cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/0001_init.sql
|
||||
var initSchema string
|
||||
|
||||
// Open dials a sqlite db with WAL + busy timeout pragmas wired in.
|
||||
// dbPath is the on-disk file path; passing ":memory:" works for tests.
|
||||
func Open(dbPath string) (*sql.DB, error) {
|
||||
dsn := buildDSN(dbPath)
|
||||
d, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
if err := d.PingContext(context.Background()); err != nil {
|
||||
_ = d.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func buildDSN(dbPath string) string {
|
||||
q := url.Values{}
|
||||
q.Add("_pragma", "journal_mode(WAL)")
|
||||
q.Add("_pragma", "busy_timeout(5000)")
|
||||
q.Add("_pragma", "foreign_keys(1)")
|
||||
return dbPath + "?" + q.Encode()
|
||||
}
|
||||
|
||||
// Bootstrap runs the embedded init schema in a single transaction.
|
||||
// Idempotent: every CREATE TABLE uses IF NOT EXISTS.
|
||||
func Bootstrap(ctx context.Context, d *sql.DB) error {
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin bootstrap tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, initSchema); err != nil {
|
||||
return fmt.Errorf("exec init schema: %w", err)
|
||||
}
|
||||
// Additive migration for DBs created before clipboard_state.version existed:
|
||||
// CREATE TABLE IF NOT EXISTS won't add the column to an existing table, and
|
||||
// SQLite has no ADD COLUMN IF NOT EXISTS, so guard it with a pragma check.
|
||||
if err := ensureColumn(ctx, tx, "clipboard_state", "version",
|
||||
"ALTER TABLE clipboard_state ADD COLUMN version INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return fmt.Errorf("migrate clipboard_state.version: %w", err)
|
||||
}
|
||||
if err := ensureColumn(ctx, tx, "clipboard_state", "origin_ts",
|
||||
"ALTER TABLE clipboard_state ADD COLUMN origin_ts INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return fmt.Errorf("migrate clipboard_state.origin_ts: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit bootstrap: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureColumn runs addSQL only when table lacks the named column — an idempotent
|
||||
// additive migration for already-created tables.
|
||||
func ensureColumn(ctx context.Context, tx *sql.Tx, table, column, addSQL string) error {
|
||||
has, err := columnExists(ctx, tx, table, column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, addSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
func columnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
|
||||
// table is a trusted compile-time constant, not user input.
|
||||
rows, err := tx.QueryContext(ctx, "PRAGMA table_info("+table+")")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid, notnull, pk int
|
||||
name, ctype string
|
||||
dflt sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootstrapCreatesAllTables(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "test.db")
|
||||
d, err := Open(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if err := Bootstrap(context.Background(), d); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions"}
|
||||
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("query tables: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
got = append(got, name)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("table count: got %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("table[%d]: got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapIsIdempotent(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "test.db")
|
||||
d, err := Open(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := Bootstrap(context.Background(), d); err != nil {
|
||||
t.Fatalf("bootstrap iter %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEnablesWALMode(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "test.db")
|
||||
d, err := Open(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
var mode string
|
||||
if err := d.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
|
||||
t.Fatalf("pragma: %v", err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Errorf("journal_mode: got %q, want %q", mode, "wal")
|
||||
}
|
||||
}
|
||||
|
||||
// Simulates the prod upgrade: a clipboard_state created before the version
|
||||
// column existed must gain it (defaulting to 0) on bootstrap, idempotently.
|
||||
func TestBootstrapAddsClipboardVersionToLegacyDB(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "legacy.db")
|
||||
d, err := Open(tmp)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
_, err = d.Exec(`CREATE TABLE clipboard_state (
|
||||
user_id TEXT PRIMARY KEY, content_type TEXT NOT NULL, content TEXT,
|
||||
source_device TEXT, updated_at INTEGER NOT NULL)`)
|
||||
if err != nil {
|
||||
t.Fatalf("legacy schema: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`INSERT INTO clipboard_state (user_id, content_type, content, updated_at)
|
||||
VALUES ('u', 'text/plain', 'hi', 100)`); err != nil {
|
||||
t.Fatalf("seed row: %v", err)
|
||||
}
|
||||
|
||||
if err := Bootstrap(context.Background(), d); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
var version int64
|
||||
if err := d.QueryRow(`SELECT version FROM clipboard_state WHERE user_id='u'`).Scan(&version); err != nil {
|
||||
t.Fatalf("select version (column missing?): %v", err)
|
||||
}
|
||||
if version != 0 {
|
||||
t.Errorf("legacy row version: got %d, want 0", version)
|
||||
}
|
||||
// Second bootstrap must be a no-op (column already present).
|
||||
if err := Bootstrap(context.Background(), d); err != nil {
|
||||
t.Fatalf("second bootstrap: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: clipboard.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const clearExpiredClipboards = `-- name: ClearExpiredClipboards :execrows
|
||||
UPDATE clipboard_state
|
||||
SET content =
|
||||
`
|
||||
|
||||
// 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
|
||||
// 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
|
||||
func (q *Queries) ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, clearExpiredClipboards, updatedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getClipboard = `-- name: GetClipboard :one
|
||||
SELECT user_id, content_type, content, source_device, updated_at, version, origin_ts
|
||||
FROM clipboard_state
|
||||
WHERE user_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetClipboard(ctx context.Context, userID string) (ClipboardState, error) {
|
||||
row := q.db.QueryRowContext(ctx, getClipboard, userID)
|
||||
var i ClipboardState
|
||||
err := row.Scan(
|
||||
&i.UserID,
|
||||
&i.ContentType,
|
||||
&i.Content,
|
||||
&i.SourceDevice,
|
||||
&i.UpdatedAt,
|
||||
&i.Version,
|
||||
&i.OriginTs,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertClipboard = `-- name: UpsertClipboard :one
|
||||
INSERT INTO clipboard_state (user_id, content_type, content, source_device, updated_at, version, origin_ts)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
content_type = excluded.content_type,
|
||||
content = excluded.content,
|
||||
source_device = excluded.source_device,
|
||||
updated_at = excluded.updated_at,
|
||||
version = clipboard_state.version + 1,
|
||||
origin_ts = excluded.origin_ts
|
||||
WHERE excluded.origin_ts > clipboard_state.origin_ts
|
||||
RETURNING version
|
||||
`
|
||||
|
||||
type UpsertClipboardParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
ContentType string `json:"content_type"`
|
||||
Content *string `json:"content"`
|
||||
SourceDevice *string `json:"source_device"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
OriginTs int64 `json:"origin_ts"`
|
||||
}
|
||||
|
||||
// New rows start version 1; conflicts bump version by 1 (per-user monotonic).
|
||||
// LWW: the DO UPDATE only runs when the new origin_ts is strictly greater than
|
||||
// the stored one, so a delayed / out-of-order older copy changes nothing and
|
||||
// RETURNING yields no row (the service reads sql.ErrNoRows as "stale, rejected").
|
||||
func (q *Queries) UpsertClipboard(ctx context.Context, arg UpsertClipboardParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertClipboard,
|
||||
arg.UserID,
|
||||
arg.ContentType,
|
||||
arg.Content,
|
||||
arg.SourceDevice,
|
||||
arg.UpdatedAt,
|
||||
arg.OriginTs,
|
||||
)
|
||||
var version int64
|
||||
err := row.Scan(&version)
|
||||
return version, err
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: devices.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const deleteDevice = `-- name: DeleteDevice :execrows
|
||||
DELETE FROM devices
|
||||
WHERE user_id = ? AND name = ?
|
||||
`
|
||||
|
||||
type DeleteDeviceParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteDevice(ctx context.Context, arg DeleteDeviceParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteDevice, arg.UserID, arg.Name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteStaleDevices = `-- name: DeleteStaleDevices :exec
|
||||
DELETE FROM devices
|
||||
WHERE last_seen < ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteStaleDevices(ctx context.Context, lastSeen int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteStaleDevices, lastSeen)
|
||||
return err
|
||||
}
|
||||
|
||||
const listDevicesByUser = `-- name: ListDevicesByUser :many
|
||||
SELECT user_id, name, type, last_seen
|
||||
FROM devices
|
||||
WHERE user_id = ?
|
||||
ORDER BY name
|
||||
`
|
||||
|
||||
func (q *Queries) ListDevicesByUser(ctx context.Context, userID string) ([]Device, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listDevicesByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Device
|
||||
for rows.Next() {
|
||||
var i Device
|
||||
if err := rows.Scan(
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Type,
|
||||
&i.LastSeen,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertDevice = `-- name: UpsertDevice :exec
|
||||
INSERT INTO devices (user_id, name, type, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, name) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
last_seen = excluded.last_seen
|
||||
`
|
||||
|
||||
type UpsertDeviceParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertDevice(ctx context.Context, arg UpsertDeviceParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertDevice,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.Type,
|
||||
arg.LastSeen,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
-- cdrop MVP schema (PROJECT_BRIEF.md §4)
|
||||
-- 以 IF NOT EXISTS 形式由 internal/db/bootstrap.go 启动期单事务执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shortcut_tokens (
|
||||
jti TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transfer_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
sender_name TEXT NOT NULL,
|
||||
receiver_name TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
mode TEXT,
|
||||
file_name TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
file_sha256 TEXT,
|
||||
bytes_transferred INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
fail_reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clipboard_state (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
content TEXT,
|
||||
source_device TEXT,
|
||||
updated_at INTEGER NOT NULL,
|
||||
-- version 是每用户严格单调递增的版本号(每次写 +1),变更探测 / ETag 用;
|
||||
-- updated_at 仍是墙钟(TTL / 展示)。既有库由 bootstrap 的幂等 ALTER 补列。
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
-- origin_ts 是内容在源设备被复制的时刻(ms)。冲突收敛按它做 LWW——写入仅在
|
||||
-- origin_ts 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的。
|
||||
origin_ts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package db
|
||||
|
||||
type ClipboardState struct {
|
||||
UserID string `json:"user_id"`
|
||||
ContentType string `json:"content_type"`
|
||||
Content *string `json:"content"`
|
||||
SourceDevice *string `json:"source_device"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
OriginTs int64 `json:"origin_ts"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
}
|
||||
|
||||
type ShortcutToken struct {
|
||||
Jti string `json:"jti"`
|
||||
UserID string `json:"user_id"`
|
||||
Label string `json:"label"`
|
||||
Scopes string `json:"scopes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
LastUsedAt *int64 `json:"last_used_at"`
|
||||
Revoked int64 `json:"revoked"`
|
||||
}
|
||||
|
||||
type TransferSession struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
ReceiverName string `json:"receiver_name"`
|
||||
State string `json:"state"`
|
||||
Mode *string `json:"mode"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileSha256 *string `json:"file_sha256"`
|
||||
BytesTransferred int64 `json:"bytes_transferred"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
FinishedAt *int64 `json:"finished_at"`
|
||||
FailReason *string `json:"fail_reason"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-- name: UpsertClipboard :one
|
||||
-- New rows start version 1; conflicts bump version by 1 (per-user monotonic).
|
||||
-- LWW: the DO UPDATE only runs when the new origin_ts is strictly greater than
|
||||
-- the stored one, so a delayed / out-of-order older copy changes nothing and
|
||||
-- RETURNING yields no row (the service reads sql.ErrNoRows as "stale, rejected").
|
||||
INSERT INTO clipboard_state (user_id, content_type, content, source_device, updated_at, version, origin_ts)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
content_type = excluded.content_type,
|
||||
content = excluded.content,
|
||||
source_device = excluded.source_device,
|
||||
updated_at = excluded.updated_at,
|
||||
version = clipboard_state.version + 1,
|
||||
origin_ts = excluded.origin_ts
|
||||
WHERE excluded.origin_ts > clipboard_state.origin_ts
|
||||
RETURNING version;
|
||||
|
||||
-- name: GetClipboard :one
|
||||
SELECT user_id, content_type, content, source_device, updated_at, version, origin_ts
|
||||
FROM clipboard_state
|
||||
WHERE user_id = ?;
|
||||
|
||||
-- name: ClearExpiredClipboards :execrows
|
||||
-- 清除 updated_at 早于 cutoff 的剪贴板内容(content / source_device 置空),
|
||||
-- 由 sweeper 周期调用实现短 TTL;保留行本身、仅抹掉内容。
|
||||
UPDATE clipboard_state
|
||||
SET content = NULL, source_device = NULL
|
||||
WHERE content IS NOT NULL AND updated_at < ?;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- name: UpsertDevice :exec
|
||||
INSERT INTO devices (user_id, name, type, last_seen)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, name) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
last_seen = excluded.last_seen;
|
||||
|
||||
-- name: ListDevicesByUser :many
|
||||
SELECT user_id, name, type, last_seen
|
||||
FROM devices
|
||||
WHERE user_id = ?
|
||||
ORDER BY name;
|
||||
|
||||
-- name: DeleteStaleDevices :exec
|
||||
DELETE FROM devices
|
||||
WHERE last_seen < ?;
|
||||
|
||||
-- name: DeleteDevice :execrows
|
||||
DELETE FROM devices
|
||||
WHERE user_id = ? AND name = ?;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
|
||||
-- iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
|
||||
-- user_id to block cross-user revocation; Touch records last use asynchronously.
|
||||
|
||||
-- name: InsertShortcutToken :exec
|
||||
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetShortcutToken :one
|
||||
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
|
||||
FROM shortcut_tokens
|
||||
WHERE jti = ?;
|
||||
|
||||
-- name: ListShortcutTokensByUser :many
|
||||
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
|
||||
FROM shortcut_tokens
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: CountActiveShortcutTokensByUser :one
|
||||
SELECT COUNT(*) FROM shortcut_tokens
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > ?;
|
||||
|
||||
-- name: RevokeShortcutToken :execrows
|
||||
UPDATE shortcut_tokens
|
||||
SET revoked = 1
|
||||
WHERE jti = ? AND user_id = ?;
|
||||
|
||||
-- name: TouchShortcutTokenUsed :exec
|
||||
UPDATE shortcut_tokens
|
||||
SET last_used_at = ?
|
||||
WHERE jti = ?;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- name: CreateTransferSession :exec
|
||||
INSERT INTO transfer_sessions (
|
||||
id, user_id, sender_name, receiver_name, state,
|
||||
file_name, file_size, file_sha256, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, ?);
|
||||
|
||||
-- name: GetTransferSession :one
|
||||
SELECT id, user_id, sender_name, receiver_name, state, mode,
|
||||
file_name, file_size, file_sha256, bytes_transferred,
|
||||
created_at, finished_at, fail_reason
|
||||
FROM transfer_sessions
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: UpdateTransferState :execrows
|
||||
UPDATE transfer_sessions
|
||||
SET state = sqlc.arg('state'),
|
||||
mode = COALESCE(sqlc.narg('mode'), mode),
|
||||
fail_reason = COALESCE(sqlc.narg('fail_reason'), fail_reason),
|
||||
finished_at = COALESCE(sqlc.narg('finished_at'), finished_at),
|
||||
bytes_transferred = COALESCE(sqlc.narg('bytes_transferred'), bytes_transferred)
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- name: ExpirePendingSessions :many
|
||||
UPDATE transfer_sessions
|
||||
SET state = 'CANCELLED',
|
||||
fail_reason = 'pending_timeout',
|
||||
finished_at = sqlc.arg('finished_at')
|
||||
WHERE state = 'PENDING' AND created_at < sqlc.arg('cutoff')
|
||||
RETURNING id, user_id, sender_name, receiver_name;
|
||||
@@ -0,0 +1,153 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: shortcut_tokens.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const countActiveShortcutTokensByUser = `-- name: CountActiveShortcutTokensByUser :one
|
||||
SELECT COUNT(*) FROM shortcut_tokens
|
||||
WHERE user_id = ? AND revoked = 0 AND expires_at > ?
|
||||
`
|
||||
|
||||
type CountActiveShortcutTokensByUserParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CountActiveShortcutTokensByUser(ctx context.Context, arg CountActiveShortcutTokensByUserParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countActiveShortcutTokensByUser, arg.UserID, arg.ExpiresAt)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const getShortcutToken = `-- name: GetShortcutToken :one
|
||||
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
|
||||
FROM shortcut_tokens
|
||||
WHERE jti = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetShortcutToken(ctx context.Context, jti string) (ShortcutToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getShortcutToken, jti)
|
||||
var i ShortcutToken
|
||||
err := row.Scan(
|
||||
&i.Jti,
|
||||
&i.UserID,
|
||||
&i.Label,
|
||||
&i.Scopes,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.Revoked,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertShortcutToken = `-- name: InsertShortcutToken :exec
|
||||
|
||||
INSERT INTO shortcut_tokens (jti, user_id, label, scopes, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type InsertShortcutTokenParams struct {
|
||||
Jti string `json:"jti"`
|
||||
UserID string `json:"user_id"`
|
||||
Label string `json:"label"`
|
||||
Scopes string `json:"scopes"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// shortcut_tokens: long-lived, scope-limited, revocable HS256 tokens for the
|
||||
// iOS Shortcut clipboard sync. Count backs the per-user cap; Revoke is scoped by
|
||||
// user_id to block cross-user revocation; Touch records last use asynchronously.
|
||||
func (q *Queries) InsertShortcutToken(ctx context.Context, arg InsertShortcutTokenParams) error {
|
||||
_, err := q.db.ExecContext(ctx, insertShortcutToken,
|
||||
arg.Jti,
|
||||
arg.UserID,
|
||||
arg.Label,
|
||||
arg.Scopes,
|
||||
arg.CreatedAt,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listShortcutTokensByUser = `-- name: ListShortcutTokensByUser :many
|
||||
SELECT jti, user_id, label, scopes, created_at, expires_at, last_used_at, revoked
|
||||
FROM shortcut_tokens
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListShortcutTokensByUser(ctx context.Context, userID string) ([]ShortcutToken, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShortcutTokensByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ShortcutToken
|
||||
for rows.Next() {
|
||||
var i ShortcutToken
|
||||
if err := rows.Scan(
|
||||
&i.Jti,
|
||||
&i.UserID,
|
||||
&i.Label,
|
||||
&i.Scopes,
|
||||
&i.CreatedAt,
|
||||
&i.ExpiresAt,
|
||||
&i.LastUsedAt,
|
||||
&i.Revoked,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const revokeShortcutToken = `-- name: RevokeShortcutToken :execrows
|
||||
UPDATE shortcut_tokens
|
||||
SET revoked = 1
|
||||
WHERE jti = ? AND user_id = ?
|
||||
`
|
||||
|
||||
type RevokeShortcutTokenParams struct {
|
||||
Jti string `json:"jti"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RevokeShortcutToken(ctx context.Context, arg RevokeShortcutTokenParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, revokeShortcutToken, arg.Jti, arg.UserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const touchShortcutTokenUsed = `-- name: TouchShortcutTokenUsed :exec
|
||||
UPDATE shortcut_tokens
|
||||
SET last_used_at = ?
|
||||
WHERE jti = ?
|
||||
`
|
||||
|
||||
type TouchShortcutTokenUsedParams struct {
|
||||
LastUsedAt *int64 `json:"last_used_at"`
|
||||
Jti string `json:"jti"`
|
||||
}
|
||||
|
||||
func (q *Queries) TouchShortcutTokenUsed(ctx context.Context, arg TouchShortcutTokenUsedParams) error {
|
||||
_, err := q.db.ExecContext(ctx, touchShortcutTokenUsed, arg.LastUsedAt, arg.Jti)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: transfers.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createTransferSession = `-- name: CreateTransferSession :exec
|
||||
INSERT INTO transfer_sessions (
|
||||
id, user_id, sender_name, receiver_name, state,
|
||||
file_name, file_size, file_sha256, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'PENDING', ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateTransferSessionParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
ReceiverName string `json:"receiver_name"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileSha256 *string `json:"file_sha256"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateTransferSession(ctx context.Context, arg CreateTransferSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createTransferSession,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.SenderName,
|
||||
arg.ReceiverName,
|
||||
arg.FileName,
|
||||
arg.FileSize,
|
||||
arg.FileSha256,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const expirePendingSessions = `-- name: ExpirePendingSessions :many
|
||||
UPDATE transfer_sessions
|
||||
SET state = 'CANCELLED',
|
||||
fail_reason = 'pending_timeout',
|
||||
finished_at = ?1
|
||||
WHERE state = 'PENDING' AND created_at < ?2
|
||||
RETURNING id, user_id, sender_name, receiver_name
|
||||
`
|
||||
|
||||
type ExpirePendingSessionsParams struct {
|
||||
FinishedAt *int64 `json:"finished_at"`
|
||||
Cutoff int64 `json:"cutoff"`
|
||||
}
|
||||
|
||||
type ExpirePendingSessionsRow struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
ReceiverName string `json:"receiver_name"`
|
||||
}
|
||||
|
||||
func (q *Queries) ExpirePendingSessions(ctx context.Context, arg ExpirePendingSessionsParams) ([]ExpirePendingSessionsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, expirePendingSessions, arg.FinishedAt, arg.Cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ExpirePendingSessionsRow
|
||||
for rows.Next() {
|
||||
var i ExpirePendingSessionsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SenderName,
|
||||
&i.ReceiverName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTransferSession = `-- name: GetTransferSession :one
|
||||
SELECT id, user_id, sender_name, receiver_name, state, mode,
|
||||
file_name, file_size, file_sha256, bytes_transferred,
|
||||
created_at, finished_at, fail_reason
|
||||
FROM transfer_sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetTransferSession(ctx context.Context, id string) (TransferSession, error) {
|
||||
row := q.db.QueryRowContext(ctx, getTransferSession, id)
|
||||
var i TransferSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.SenderName,
|
||||
&i.ReceiverName,
|
||||
&i.State,
|
||||
&i.Mode,
|
||||
&i.FileName,
|
||||
&i.FileSize,
|
||||
&i.FileSha256,
|
||||
&i.BytesTransferred,
|
||||
&i.CreatedAt,
|
||||
&i.FinishedAt,
|
||||
&i.FailReason,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateTransferState = `-- name: UpdateTransferState :execrows
|
||||
UPDATE transfer_sessions
|
||||
SET state = ?1,
|
||||
mode = COALESCE(?2, mode),
|
||||
fail_reason = COALESCE(?3, fail_reason),
|
||||
finished_at = COALESCE(?4, finished_at),
|
||||
bytes_transferred = COALESCE(?5, bytes_transferred)
|
||||
WHERE id = ?6
|
||||
`
|
||||
|
||||
type UpdateTransferStateParams struct {
|
||||
State string `json:"state"`
|
||||
Mode *string `json:"mode"`
|
||||
FailReason *string `json:"fail_reason"`
|
||||
FinishedAt *int64 `json:"finished_at"`
|
||||
BytesTransferred *int64 `json:"bytes_transferred"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTransferState(ctx context.Context, arg UpdateTransferStateParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, updateTransferState,
|
||||
arg.State,
|
||||
arg.Mode,
|
||||
arg.FailReason,
|
||||
arg.FinishedAt,
|
||||
arg.BytesTransferred,
|
||||
arg.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
Reference in New Issue
Block a user