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:
2026-06-15 21:38:28 +08:00
commit f21fa5b5e8
239 changed files with 29010 additions and 0 deletions
+103
View File
@@ -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, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
+116
View File
@@ -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)
}
}
+87
View File
@@ -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
}
+31
View File
@@ -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,
}
}
+98
View File
@@ -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
}
+51
View File
@@ -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
);
+49
View File
@@ -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"`
}
+28
View File
@@ -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 < ?;
+20
View File
@@ -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 = ?;
+32
View File
@@ -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 = ?;
+30
View File
@@ -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;
+153
View File
@@ -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
}
+155
View File
@@ -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()
}