commit f21fa5b5e808f652c9cda31988f8aa9334101643 Author: commilitia Date: Mon Jun 15 21:38:28 2026 +0800 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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..90c5ce6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 100 + +[*.{md,markdown}] +trim_trailing_whitespace = false + +[*.{json,yaml,yml,html}] +indent_size = 2 + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8fd390d --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# dev 模式(本机开发,brief 实施原则 5) +# 二者必须同时设置;后端启动期校验,缺一即 panic +CDROP_AUTH_MODE=dev +CDROP_DEV_TOKEN=replace-with-32-byte-random-base64 + +# prod 模式所需(填入你的 OIDC provider,如自建 Casdoor / Keycloak) +# CDROP_AUTH_MODE=prod +# CDROP_OIDC_AUTHORIZE_URL=https://your-idp.example/login/oauth/authorize +# CDROP_OIDC_TOKEN_URL=https://your-idp.example/api/login/oauth/access_token +# CDROP_OIDC_JWKS_URL=https://your-idp.example/.well-known/jwks +# CDROP_OIDC_ISSUER=https://your-idp.example/ +# prod 强制非空:填你的 OAuth client_id(多值逗号分隔,web + 桌面端共用时填同一个) +# CDROP_OIDC_AUDIENCE=your-client-id +# CDROP_OIDC_CLIENT_ID=your-client-id +# CDROP_OIDC_REDIRECT_URI=https://your-domain.example/oauth/callback +# CDROP_OIDC_SCOPES=openid profile email +# CDROP_HS256_SECRET= +# CDROP_TURN_URL=stun:your-stun.example:3478 + +# 通用 +CDROP_DB_PATH=./cdrop.db +CDROP_LISTEN=:8080 +# Device row max age (sliding via auth-middleware UPSERT and SSE keepalive). +# Bound by brief §2 refresh_token sliding window (196h ≈ 8 days). Default 196. +CDROP_DEVICE_TTL_HOURS=196 + +# Cloudflare Realtime TURN(可选;不配则 /api/calls/credentials 返回 STUN-only fallback)。 +# 申请:dash.cloudflare.com → Realtime → TURN → "Create TURN App",拿到 Key ID 与 API Token。 +# CDROP_CF_TURN_KEY_ID= +# CDROP_CF_TURN_API_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22868dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +# Backend build artifacts +/cdrop +/cdropd +/dist/ + +# Go test / coverage / profile output +coverage.out +coverage.html +*.test +*.prof + +# SQLite runtime files +*.db +*.db-shm +*.db-wal + +# Frontend +/web/node_modules/ +/web/dist/ +/web/.vite/ +/web/*.tsbuildinfo +/web/vite.config.js +/web/vite.config.d.ts +/web/src/routeTree.gen.ts + +# Embedded frontend bundle (Dockerfile populates this) +/internal/webui/dist/* +!/internal/webui/dist/.gitkeep + +# Editor / OS +**/.DS_Store +.idea/ +.vscode/ +*.swp +*.swo +*~ +*.orig +*.bak + +# Env files — track only the .example templates +/.env* +!/.env.example +/web/.env* +!/web/.env.example +/data/ + +# Deploy mirror (M13 pull-before-modify baseline;never入库) +/docker/.remote/ + +# Desktop client (Wails) 产物 +/desktop/build/bin/ +/desktop/frontend/node_modules/ +/desktop/frontend/dist/ +/desktop/frontend/wailsjs/ + +# Scratch / ad-hoc artifacts +/.scratch/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..1f43af6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "web/src/lib/cjk-autospace"] + path = web/src/lib/cjk-autospace + url = https://git.commilitia.net/admin/cjk-autospace.git diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..adb143d --- /dev/null +++ b/Justfile @@ -0,0 +1,83 @@ +set dotenv-load := true + +default: + @just --list + +dev-back: + go run ./cmd/cdropd + +dev-front: + cd web && npm run dev + +build-back: + mkdir -p dist + go build -o dist/cdropd ./cmd/cdropd + +build-front: + cd web && npm run build + +build: build-back build-front + +test: + go test ./... + +typecheck-front: + cd web && npm run typecheck + +fmt: + gofmt -w . + +vet: + go vet ./... + +# ---- desktop client (Wails · macOS 主开发,Windows 在 Parallels 验证) ---- + +# wails CLI 在 go install 后落在 $(go env GOPATH)/bin/wails, +# 用绝对路径避免依赖 shell PATH 配置。 +wails := `go env GOPATH` + "/bin/wails" + +# 起 wails dev 热重载,前端走 Vite dev server。 +desktop-dev: + cd desktop && {{wails}} dev + +# 构建 macOS universal .app(含 darwin/arm64 + darwin/amd64)。 +# 不加 -clean,让 mac/win 产物在 build/bin/ 共存;清理走 desktop-clean。 +# +# ⚠️ 先手动构建前端再 `wails build -s`(跳过 wails 自带前端步骤):wails build 的 +# frontend:build 在本仓库结构下不可靠(不实际重建 web/ → 嵌入旧 JS)。显式构建 +# web → desktop/frontend/dist,再用 -s 让 go:embed 嵌入这份新 dist。 +desktop-build-mac: + cd web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir + cd desktop && {{wails}} build -s -platform darwin/universal + +# 交叉编译 Windows amd64 .exe。macOS 原生即可——Windows 侧无 cgo(darwin 的 +# NSStatusBar/NSPasteboard CGO 走 build tag 排除,WebView2 是纯 Go),不需 mingw。 +# 与 mac 同:先手动构建前端再 -s(wails 自带 frontend:build 在本仓库不可靠)。 +# 产物 build/bin/desktop.exe;真测试经 RDP 到 Windows 机器跑。 +desktop-build-win: + cd web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir + cd desktop && {{wails}} build -s -platform windows/amd64 + +# 清空 build/bin/ 产物。 +desktop-clean: + rm -rf desktop/build/bin + +# 环境自检。 +desktop-doctor: + {{wails}} doctor + +# ---- deploy plumbing ---- + +# 一次性预热:把 Go modules + node_modules 烤进 cdrop-base:latest。 +# 仅 go.mod/go.sum 或 web/package*.json 改动时需要重跑。 +# 用宿主原生 arch(macOS arm64 → linux/arm64),让 BUILDPLATFORM 跨编译走原生。 +docker-base: + docker buildx build -f docker/Dockerfile.base -t cdrop-base:latest --load . + +# 应用镜像:从 cdrop-base 起步只编译源码,最终落 linux/amd64 distroless。 +# dev mode 部署时 export VITE_CDROP_DEV_TOKEN=<同后端 token> 把它烤进前端 bundle。 +docker-image: + docker buildx build --platform linux/amd64 \ + --build-arg VITE_CDROP_DEV_TOKEN="${VITE_CDROP_DEV_TOKEN:-}" \ + --build-arg VITE_CDROP_STUN_URLS="${VITE_CDROP_STUN_URLS:-}" \ + -f docker/Dockerfile -t cdrop:latest --load . diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c0158d --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# cdrop — Commilitia Drop + +跨 OS 剪贴板与文件传输服务。架构文档(`PROJECT_BRIEF.md` / `FRONTEND_DESIGN.md`)与 Changelog 位于 **`docs` 分支**: + +```sh +git worktree add ../cdrop-docs docs # 并列检出,main / docs 互不扰动 +open ../cdrop-docs/PROJECT_BRIEF.md +open ../cdrop-docs/CHANGELOG.html +``` + +## 克隆 + +前端 `web/src/lib/cjk-autospace/` 为 submodule(聚珍 / Juzhen 2.0:CJK 综合排版——中西间隙 / 长英文断词 / 标点挤压 / 禁则 / justify,跨 Claude / Nvim / cdrop 共享)。首次 clone 须带 `--recurse-submodules`,否则 `vite build` 会因找不到 import 而失败: + +```sh +git clone --recurse-submodules cdrop +# 已 clone 但未拉 submodule: +git submodule update --init --recursive +``` + +## 当前阶段 + +MVP(M0–M13)已上线 prod(OIDC PKCE 接入自建 Casdoor,Cloudflare Realtime TURN over TLS)。其后进入阶段二,已落地: + +- **剪贴板同步**:单条覆写式云剪贴板(短 TTL 限暴露面)+ 文本消息一次性通道 +- **桌面客户端**:Wails v2(macOS universal `.app` + Windows `.exe`)—— 瘦客户端复用 web,业务全走后端 API;refresh_token 存系统密钥库(信封加密) +- **iOS Shortcut**:HS256 scoped token 手动签发路径(网页签发 UI 暂停) +- **安全加固**:中继会话防耗尽、HS256 强制 jti、prod 强制 audience、TURN 短 TTL、OAuth 端点限流(详见 CHANGELOG) + +MVP 里程碑(历史记录): + +| 里程碑 | 内容 | 状态 | +|---|---|---| +| M0–M5 | 后端:chi / sqlite WAL / dev+prod auth 中间件 / SSE Hub / signaling + 状态机 / Relay ring buffer | 完成 | +| M6–M10 | 前端:Mantine + TanStack Router + Zustand(其后叠加自定义设计 token 与 Theme B 重塑)/ 自实现 SSE / WebRTC P2P / Relay 回退 / SSE 重连 + chunk 重试 | 完成 | +| M11 | 本机双 tab 端到端验收(dev mock auth)+ 多轮 bug 修复(race / 状态机 / DOM nesting / 状态着色) | 完成 | +| M12 | OIDC PKCE:后端 /api/auth/{config,exchange,refresh} 代理 token endpoint,前端 PKCE redirect + callback;Casdoor confidential client + client_secret | 完成 | +| M13 | 部署:cdrop-base 缓存层 + 应用镜像(BUILDPLATFORM 跨编译,6 MB distroless),Cloudflare Realtime TURN App,反代 reverse_proxy + flush_interval -1(SSE 友好),h3 已启 | 完成 | + +## 本机开发:两 tab 验收流程(M11) + +### 1. 准备 dev token + +把同一个随机 base64 串同时写到两份 env 文件: + +```bash +TOKEN=$(openssl rand -hex 32) + +cp .env.example .env +sed -i '' "s|replace-with-32-byte-random-base64|$TOKEN|" .env + +cp web/.env.example web/.env.local +sed -i '' "s|replace-with-32-byte-random-base64|$TOKEN|" web/.env.local +``` + +`.env` 会被根目录的 Justfile 自动加载(`set dotenv-load := true`)。 + +### 2. 起后端(dev 模式) + +```bash +just dev-back +# 监听 :8080,json 日志到 stdout +``` + +后端要求 `CDROP_AUTH_MODE=dev` 与 `CDROP_DEV_TOKEN` 同时设置;缺一会 panic 拒启动(防止误带到生产)。`.env` 文件由后端自动加载(koanf)。 + +### 3. 起前端(vite dev) + +另开一个终端: + +```bash +just dev-front +# 默认 http://localhost:5173,/api 自动代理到 :8080 +``` + +### 4. 打开两个浏览器 tab + +| tab | URL | 期望 | +|---|---|---| +| tab-1 | `http://localhost:5173/login?dev_user=alice` | 输入 `Continue` → Setup 输入设备名 `tab-1` → Home | +| tab-2 | `http://localhost:5173/login?dev_user=alice` | 同上但设备名填 `tab-2` | + +两 tab 的 Home 页应在 1–2 秒内互相看到对方在线,顶部 `● online` 亮绿。 + +### 5. 验收 brief §7 五条 + +| # | 步骤 | 通过标准 | +|---|---|---| +| 1 | 双 tab Casdoor 登录 | M11 阶段使用 dev mock 等价:两 tab 都进 Home | +| 2 | Home 页看到对方在线 | DeviceStrip 互相显示对方卡片,绿点 | +| 3 | 拖拽文件 → P2P 直传 | tab-1 选 tab-2 + 拖文件 → Send;tab-2 浏览器自动下载,sha256 与原文件一致 | +| 4 | 模拟 NAT 阻塞 → fallback Relay | DevTools console 注入 `RTCPeerConnection.prototype._origCreateOffer = RTCPeerConnection.prototype.createOffer; RTCPeerConnection.prototype.createOffer = function() { return new Promise(()=>{}) }`(永远 pending),30s 后看 transfer 状态切到 `RELAY_ACTIVE`,依然下载成功 | +| 5 | 传输中关闭一方 → 重连续传 | M11 范围内可手动 kill backend 几秒再起;或断网卡 30s | + +`> 100 MB` 文件不会自动接受(brief §2 阈值);当前 MVP 还没接 UI 二次确认,可暂停在大文件场景验证。 + +### 6. 单元测试 + +```bash +just test # go 测试 +just typecheck # 后端 go vet 由 just vet 触发 +cd web && npm run typecheck && npm run build +``` + +## 部署 + +后端编译进单个 distroless 镜像(前端经 Dockerfile 构建后 `go:embed` 进二进制)。镜像约 6 MB。 + +### 构建镜像 + +```bash +# 依赖预热(仅 go.mod/go.sum 或 web/package*.json 变动时需重跑):把 Go modules +# 与 node_modules 烤进 cdrop-base:latest,加速后续源码层构建。 +just docker-base + +# 应用镜像:从 cdrop-base 起步编前端 + 跨编译 Go,落 linux/amd64 distroless。 +just docker-image # → cdrop:latest +``` + +宿主原生 arch 即可(`BUILDPLATFORM` 让 Go 跨编译走原生,无需 qemu 执行 amd64)。 + +### 上线 + +镜像送到目标主机(二选一): + +```bash +# A. 本地构建 → 传镜像(无需在服务器装 Node/Go) +docker save cdrop:latest | gzip | ssh @ 'docker load' + +# B. 或把源码同步到服务器,在服务器上 docker build +``` + +服务条目模板见 [`docker/compose.snippet.yaml`](docker/compose.snippet.yaml):填进你的 `docker-compose.yaml`,挂一个目录到 `/data`(存 SQLite 库),不开放端口、由反代访问 `:8080`。 + +```bash +docker compose up -d +curl -s https:///healthz # {"status":"ok","auth_mode":"prod","db_ok":true} +``` + +### 反代(SSE 要点) + +cdrop 用 SSE 推事件,反代**必须关闭响应缓冲**否则事件不实时。Caddy 示例: + +```caddyfile +your-domain.example { + reverse_proxy :8080 { + flush_interval -1 # 关闭缓冲,SSE 即时下发 + } +} +``` + +### prod 配置 + +env 见 [`.env.example`](.env.example) / `compose.snippet.yaml`。要点: + +1. `CDROP_AUTH_MODE=prod`,删掉 `CDROP_DEV_TOKEN` +2. 填 `CDROP_OIDC_*`(你的 OIDC provider,如自建 Casdoor / Keycloak);在 IdP 建 confidential + PKCE client,回调白名单加 `https:///oauth/callback` +3. **`CDROP_OIDC_AUDIENCE` 必须非空**(你的 client_id,多值逗号分隔)—— 留空会跳过 audience 校验,同一 JWKS 下其他应用的 token 也能验过;后端 prod 启动期强制此项,缺则拒启动 +4. 设 `CDROP_HS256_SECRET`(iOS Shortcut scoped token 用,不用可留空) +5. 可选 `CDROP_CF_TURN_KEY_ID` + `CDROP_CF_TURN_API_TOKEN` 启用 Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底) + diff --git a/cmd/cdropd/main.go b/cmd/cdropd/main.go new file mode 100644 index 0000000..0390046 --- /dev/null +++ b/cmd/cdropd/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "commilitia.net/cdrop/internal/calls" + "commilitia.net/cdrop/internal/clipboard" + "commilitia.net/cdrop/internal/config" + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/httpapi" + "commilitia.net/cdrop/internal/hub" + "commilitia.net/cdrop/internal/jwtauth" + "commilitia.net/cdrop/internal/relay" + "commilitia.net/cdrop/internal/transfer" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + cfg, err := config.Load() + if err != nil { + slog.Error("config load failed", "err", err) + os.Exit(1) + } + slog.Info("cdropd booting", + "auth_mode", cfg.AuthMode, + "listen", cfg.Listen, + "db_path", cfg.DBPath, + ) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + dbConn, err := db.Open(cfg.DBPath) + if err != nil { + slog.Error("db open failed", "err", err) + os.Exit(1) + } + defer dbConn.Close() + + if err := db.Bootstrap(ctx, dbConn); err != nil { + slog.Error("db bootstrap failed", "err", err) + os.Exit(1) + } + + queries := db.New(dbConn) + auth := jwtauth.New(cfg, queries) + h := hub.New(queries) + defer h.Close() + relayMgr := relay.NewManager(relay.DefaultMaxSessions, relay.DefaultSessionCap) + transfers := transfer.NewService(queries, h, relayMgr) + + var cfTurn *calls.Provider + if cfg.CFTurnKeyID != "" && cfg.CFTurnAPIToken != "" { + cfTurn = calls.NewProvider(cfg.CFTurnKeyID, cfg.CFTurnAPIToken, calls.DefaultTTL) + slog.Info("cloudflare realtime TURN enabled") + } else { + slog.Info("cloudflare TURN not configured; falling back to STUN-only") + } + + clip := clipboard.New( + queries, + h, + cfg.ClipboardMaxBytes, + time.Duration(cfg.ClipboardDebounceSec)*time.Second, + time.Duration(cfg.ClipboardTTLSec)*time.Second, + ) + + go transfer.RunSweeper(ctx, transfers) + go relayMgr.RunReaper(ctx) + go jwtauth.RunDeviceSweeper(ctx, queries, time.Duration(cfg.DeviceTTLHours)*time.Hour) + if cfg.ClipboardTTLSec > 0 { + go clipboard.RunSweeper(ctx, clip) + } + + srv := &http.Server{ + Addr: cfg.Listen, + Handler: httpapi.New(cfg, dbConn, queries, auth, h, transfers, relayMgr, cfTurn, clip).Handler(), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + slog.Info("http server listening", "addr", cfg.Listen) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("http server error", "err", err) + cancel() + } + }() + + <-ctx.Done() + slog.Info("shutdown initiated") + + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + if err := srv.Shutdown(shutCtx); err != nil { + slog.Error("http shutdown error", "err", err) + } + slog.Info("cdropd stopped") +} diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..129d522 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,3 @@ +build/bin +node_modules +frontend/dist diff --git a/desktop/PLAN.md b/desktop/PLAN.md new file mode 100644 index 0000000..97982fa --- /dev/null +++ b/desktop/PLAN.md @@ -0,0 +1,403 @@ +# cdrop 桌面客户端实施计划 + +> Wails v2 · macOS 优先 + Windows 已落地 +> 状态:**macOS 与 Windows 双端均已实现并 RDP/真机实测通过**——A2 前端复用 + 托盘/菜单栏 + 剪贴板双向同步 + 桌面设置 + session 持久化 + 开机自启(含自启静默驻留)+ 设备类型登记。macOS 见 §10,Windows 落地(含「custom-scheme 不支持流式」核心坑与 127.0.0.1 本地代理解法)见 §11。计划已据原生平台研究 sweep(`desktop/RESEARCH.md`)校正。 +> 关联:根 `README.md`、`desktop/`(脚手架)、`desktop/RESEARCH.md`(调研依据),记忆 `cdrop-desktop-client` / `cdrop-deploy-pointers` / `cdrop-state`。 + +--- + +## 实现进度(截至 2026-06-13) + +| 部分 | 状态 | commit | +|---|---|---| +| 后端 OIDC audience 多值(R1) | ✅ 已提交(现非必需、保留无害) | `36abec1` | +| 后端 `/api/auth/config` 发布 `token_url` | ✅ 已提交 + **已部署 prod** | `a393b5d` | +| **D1 OAuth loopback 登录**(复用 web client + PKCE + Refresh) | ✅ 已实现,**登录真机 e2e 通过** | `c876413` | +| D4 剪贴板双向策略层 + 认证 API 客户端 | ✅ 已实现(23 单测全绿,尚未接入 app) | `516d3b2` | +| **桌面 app 主体(§10)**:A2 复用 + 菜单栏/dock + 剪贴板接入 + 设置 + 本地配置 | ✅ 已实现(本地全绿,待真机勾验菜单栏/剪贴板/自启) | 本轮待提交 | +| **登录 session 本地持久化(§10.7.1)**:Go 文件 + 启动注入 + 同步水合 | ✅ 已实现,**重启免登真机跑通** | 本轮待提交 | +| **凭据安全一致化(§10.8)**:refresh_token 全平台不进 JS | ✅ 已实现(绑定层 grep 零命中) | 本轮待提交 | + +**复用 web client(暂行决策)**:桌面端不建独立 Casdoor client,复用 web 的 confidential + PKCE client(与 web 同一 client_id)做 loopback PKCE(不送 secret),靠长寿命 refresh token 维持。token 的 aud = web client_id = 后端 `OIDCAudience`,故 R1 多值非必需。 + +**待实现 / 待验证**:① 真机勾验(本环境无 GUI 不可达)——菜单栏三项、dock 动态隐藏、剪贴板跨设备上下行、开机自启、refresh,清单见 §10.6;② D3 系统通知(未做);文件落盘已落地(桌面收文件写入可配置目录,默认 `~/Downloads`,见 §D3 落盘);③ D5 全局快捷键 + Quick Send 浮窗(受 v2/v3 决策闸,未做);④ token 持久化升级 Keychain(推迟到 Developer-ID 签名后,§10.8);⑤ Windows/Linux 原生 Source / 托盘 / 自启目前为 stub。 + +--- + +## 0. 范围、非范围与本轮关键决策 + +**范围**:常驻桌面客户端,补浏览器做不到的——后台常驻、剪贴板自动监听上传、文件落盘、系统通知、全局快捷键。平台 **macOS(Apple Silicon + Intel)优先**,**Windows(x64)保持兼容**(同源、同分支,平台差异用 Go build tag 隔离)。 + +**非范围(推后)**:Linux 打包、iOS / Android 原生、自更新(仅版本检查提示)、产品化 / 多租户、Admin、Symmetric NAT 的 mDNS 直连、本地内容缓存 / 离线。 + +**定位**:桌面端不是“另一份 UI”,而是“带原生外壳的同一份 UI”——UI 复用 `web/src` 的 React 应用,价值全在 Go 外壳提供的原生能力。 + +### 决策 C:先做框架无关里程碑,推迟 v2/v3 取舍(2026-06-13 定) + +sweep 发现 Wails v2 缺两样桌面核心能力:**无内建系统托盘**、**单原生窗口**(见 §4 D2 / D5、§9)。是否升级 Wails v3-alpha 是悬而未决的架构岔路。**本轮决定:先在 v2 上推进与窗口框架无关的 D1(OAuth)+ D3(通知)+ D4(剪贴板),把 v2/v3 取舍推迟到真正动 D2(托盘)/ D5(多窗)之前。** 理由:这三个里程碑在 v2 / v3 上实现差异不大,能先产出价值,同时让 v3 再成熟一截。 + +### 签名决策:暂不申请 Developer 证书(2026-06-13 定) + +暂不申请 Apple Developer 证书。macOS 产物仅 **未签名 / ad-hoc 签名的 `.app`**,**不公证、不封装 DMG**。下游影响(须正视): + +- 分发侧 macOS Gatekeeper 会拦“未知开发者”,自用需右键打开或 `xattr -dr com.apple.quarantine`。 +- **D3 macOS 系统通知(`UNUserNotificationCenter`)要求 app 有 `CFBundleIdentifier` 且“已签名”**——未签名不工作。无 Developer 证书时须用 **ad-hoc 签名**(`codesign -s - --force --deep YourApp.app`)补一个本地有效签名 + bundle id;ad-hoc 是否足以让本机通知工作**须真机实测**(§4 D3)。 +- `desktop/RESEARCH.md` 的“打包·签名·公证手册”留作**未来若申请证书**时的参考;当前路径以本文 D6 为准。 + +--- + +## 1. 技术栈与总体架构 + +### 1.1 选型 + +| 维度 | 决定 | 理由 | +|---|---|---| +| 框架 | **Wails v2.12.0**(Go + 系统 WebView) | 系统 WebView 省内存;Go 单语言写原生;单 codebase | +| 待评估 | **Wails v3-alpha**(见决策 C / §9) | 原生覆盖 systray + 多窗口 + ActivationPolicy;D2/D5 前定夺 | +| 淘汰 | Electron / Tauri / 原生双栈 / PWA | Electron 200+ MB;Tauri 多一门 Rust;原生双 codebase;PWA 无后台常驻 | +| 资源预算 | macOS idle 80–120 MB;Windows idle 100–150 MB;包体 10–20 MB | WebView 复用系统组件 | + +### 1.2 瘦客户端架构 + +- **全部业务走 `drop.commilitia.net`**:桌面端沿用 Web 的 API 客户端,本地只存 token + 少量设置。 +- **Go 外壳只做四类原生事**:①把 OS 事件源(剪贴板变更、热键、OAuth 回调)翻译成事件推给 WebView;②把 WebView 的请求(开系统浏览器、写文件、发通知)翻译成 OS 调用;③后台常驻;④打包。 + +### 1.3 与后端的契约(桌面端用到的端点) + +沿用 Web 现有 API(`web/src/net/api.ts` 注入 `Authorization: Bearer`,401 懒刷新): + +| 端点 | 用途 | 里程碑 | +|---|---|---| +| `GET /api/auth/config`、`POST /api/auth/exchange`、`POST /api/auth/refresh` | OIDC 配置 / 换 token / 刷新 | D1 | +| `GET/PUT /api/clipboard` | 云剪贴板读写 | D4 | +| `GET /api/hub/events`(SSE) | 服务端推送(新剪贴板 / 消息 / presence) | D3 / D4 | +| `POST /api/message` | 文本消息 | D5 Quick Send | +| `POST /api/transfer/initiate`(+ `/api/hub/signal`、`/api/calls/credentials`) | 文件传输 / WebRTC | 后续(非 MVP 主线) | +| `POST /api/me/disconnect` | 登出即时下线 | D2 退出 | + +> D1 例外:桌面端**不复用** `/api/auth/exchange`(它是 confidential 代理、附 web client_secret),改为直连 Casdoor token 端点做 public-client PKCE。详见 §3。 + +--- + +## 2. 前端复用 + 平台抽象层 + +### 2.1 前端复用(方案 A2) + +D0 脚手架的 `desktop/frontend` 是 Wails 默认 vanilla-ts 模板,**尚未指向 `web/src`**。D1 第一步改为复用现有 React 应用: + +- **A2(采用)**:`desktop/frontend` 作薄入口,import `web` 为本地包(npm workspace / `file:` 依赖)。`web` 暴露可复用入口 `mountApp(root, platform)`,desktop 入口传 `platform: "desktop"` 适配器。隔离清晰、桌面专属代码有家、web 不被污染。 +- 否决:A1(`frontend` 直指 `../web`,桌面专属代码无处落脚)、A3(复制 web,分叉地狱)。 + +### 2.2 平台抽象层(Platform 接口) + +Web 与桌面在四处行为不同,UI 只依赖 `Platform` 接口,web / desktop 各给一份实现: + +| 能力 | Web 实现 | Desktop 实现(经 Wails bind 调 Go) | +|---|---|---| +| **登录** | `window.location.href` 整页跳 authorize | 系统浏览器 + loopback 回调 + 注入(§3,D1) | +| **token 存储** | zustand store | Go 侧 Keychain / DPAPI,启动注入 | +| **通知** | Web Notification(前台限定) | 系统通知中心(后台也行,D3) | +| **文件落盘** | 浏览器下载 | Go 写可配置目录(默认 `~/Downloads`)+ 揭示 Finder(✅ 已落地) | + +> 铁律:**WebView 内绝不 `window.location.href` 跳外部 authorize**(会把 WebView 自身导航走、应用白屏,且违反 RFC 8252 §8.12 嵌入式 user-agent 禁令)。登录走系统浏览器 + 原生回调桥。 + +--- + +## 3. D1:OAuth 登录闭环(已据 sweep 定型) + +sweep 把 D1 从“最高风险未知”变成“路径已明、含一处后端改动”。完整客户端机制 + 代码骨架见 `desktop/RESEARCH.md` 的“OAuth 原生回调通道——Wails 客户端机制”。要点: + +### 3.1 回调通道:loopback(已定,弃 scheme) + +采用 **RFC 8252 loopback**:Go 起临时 `127.0.0.1:0`(必须 IPv4 字面量,不用 `localhost`)HTTP server 捕获 `code`。相对自定义 scheme `cdrop://` 的优势:免单实例锁、免 `Info.plist` / 注册表登记、免深链解析、跨平台代码一致。scheme 留作未来“浏览器点 cdrop 链接唤起桌面端”的另一用例。 + +### 3.2 token 交换放 Go 侧(已定) + +PKCE `verifier` 与换得的 token 全程不进 WebView / JS 上下文(防注入脚本窃取),并由 Go 直接持久化进 Keychain / DPAPI。前端只收“登录成功 / 失败”事件(`runtime.EventsEmit` → 前端 `EventsOn`)。 + +### 3.3 R1:后端 / Casdoor 协调(最小一处改动,已解) + +sweep 源码级确认:Casdoor 支持 public client(PKCE 时 `client_secret` 可空)、对 `http://127.0.0.1` 任意端口自动放行(`IsValidOrigin` 短路,需 Casdoor ≥ ~v3.3)。桌面直连 Casdoor 端点:authorize=`/login/oauth/authorize`,token=`/api/login/oauth/access_token`(**非**标准 `/oauth/token`)。 + +**唯一后端改动(方案 A,推荐)**:`CDROP_OIDC_AUDIENCE` 从单值扩成逗号分隔多值(web + desktop 两个 client_id),`internal/jwtauth/middleware.go:150` 展开进 `AnyAudience`(go-jose 语义“命中其一即可”)。issuer / JWKS / 签名校验与 web 完全一致,不动。 + +**前置核实**:prod 自建 Casdoor 版本须 ≥ ~v3.3.0;低于则升近版。 + +### 3.4 D1 任务清单 + +- [ ] 前端复用落地(§2.1 A2):`web` 暴露 `mountApp(root, platform)`;`desktop/frontend` 薄入口。 +- [ ] `Platform` 接口 + web / desktop 双实现(先只做 login 一处)。 +- [ ] Casdoor 新建 public application `cdrop-desktop`(JWT、无 secret、不勾 shared、登记 loopback redirect 兜底)。 +- [ ] 后端:`CDROP_OIDC_AUDIENCE` 改多值(方案 A)。 +- [ ] Go:loopback server + `runtime.BrowserOpenURL` + state 校验 + token 交换 + `EventsEmit`;token 持久化(Keychain / DPAPI)。 +- **验收**:冷启动 → 点登录 → 系统浏览器授权 → 自动回应用已登录;杀进程重启仍登录;refresh 跑通。dev 模式(`VITE_CDROP_DEV_TOKEN`)可先绕 Casdoor 打通壳。 + +--- + +## 4. D2–D6 详解(含 sweep 校正) + +### D2 · 菜单栏 + 窗口生命周期 ⚠️ 受 v2/v3 决策影响 +- **sweep 校正(refuted)**:**Wails v2 无任何可用内建托盘 API**(`options.App` 无 `Tray`、runtime 无托盘函数、`pkg/menu/tray.go` 是 onhold 死代码、Issue #4990 官方 closed not planned)。原计划“Wails 原生托盘”不成立。 +- **窗口生命周期(v2 原生可做)**:`StartHidden:true` + `HideWindowOnClose:true` 实现“启动驻留、关闭隐藏不退出”(与 `OnBeforeClose` 钩子二者互斥,不可同设)。 +- **托盘图标 + 菜单(必须第三方 / 自写)**:macOS 先验证 `github.com/ra1phdd/systray-on-wails`(非阻塞 `Register`、规避 NSApplication 冲突),不达标则自写 ObjC `NSStatusBar + NSMenu`;`getlantern/systray` 在 macOS+Wails 有 objc 链接冲突,排除。Windows 用 `fyne.io/systray`。 +- **隐藏 dock 图标**:`mac.ActivationPolicy` 在 v2.12 不可用;改在 `desktop/build/darwin/Info.plist` 手加 `LSUIElement`(启动瞬间 dock 闪现约 10ms 是系统通病、无法消除)。 +- **决策闸**:D2 是 v2/v3 取舍的触发点(见 §9)。若决定上 v3,D2 用 v3 原生 systray 重做。 + +### D3 · 系统通知 + 文件落盘 + SSE 接入 +- **SSE 由 Go 持有**(非 WebView):后台常驻、窗口隐藏仍收推送、免 WebView 休眠断流。事件分流:要弹通知的走 Go 通知 API,要更新 UI 的经 `EventsEmit` 推 WebView。 +- **macOS 通知**:`UNUserNotificationCenter`(cgo + ObjC)。三按钮 = `UNNotificationCategory` 含 3 个 `UNNotificationAction`,点击经 delegate `didReceiveResponse` 区分。**硬门槛(与签名决策冲突,须正视)**:要求已签名 bundle + `CFBundleIdentifier`,且须从 `.app` 运行——无 Developer 证书时须 ad-hoc 签名补救,**ad-hoc 能否让本机通知工作须真机实测**;不行则 macOS 通知降级(仅前台 toast)或推迟到将来获证书。 +- **Windows 通知**:`go-toast/v2`(已在 go.mod)。`Notification.Actions` 加 3 按钮、`SetActivationCallback` 按 `Arguments` 区分。坑:须先 `SetAppData` 注册 AUMID + stub CLSID,否则 COM 激活失效、回调收不到。 +- **落地节奏**:先做“单击整条通知拉主窗 + 打开文件”基础版,三按钮作增量。 +- **落盘** ✅ 已落地(2026-06-14,Go 绑定写盘):单文件 `platform/download.go`(非按平台分),`SaveDownload` 写入可配置目录(`DesktopConfig.DownloadDir` 空=系统 `~/Downloads`),`sanitizeFileName` 剥目录成分防路径穿越、`uniquePath` 重名加 ` (n)`、`revealInFileManager`(`open -R` / `explorer /select`)。桌面端 web 的 `downloadBlob` 改走绑定 `SaveDownload`(blob→base64 过桥),失败回退浏览器下载;设置页可改目录(`ChooseDownloadDir` 原生选择框)。 + +### D4 · 剪贴板自动同步(核心价值) +- **统一底层 `golang.design/x/clipboard` v0.8.0**(macOS 已 purego/objc **无 cgo**;Windows / Linux 纯 syscall)。**sweep 校正 PLAN 旧假设**:① macOS 不是 3s,是该库写死 1s 轮询 `changeCount`;② **Windows 也是轮询**(`GetClipboardSequenceNumber` 1s),**不是** `AddClipboardFormatListener` 事件——两端机制其实一致。若坚持 Windows 真事件,可自写 syscall message-only window,但建议先轮询上线(剪贴板同步对 1s 不敏感)、按真机反馈再定。 +- **自写一层 `ClipboardSource` 接口**做库不负责的三件事: + 1. **敏感内容过滤**(库不做,直接用会同步密码):macOS 读 `-types` 比对 `org.nspasteboard.ConcealedType` / `TransientType` / `AutoGeneratedType` 及私有 UTI;Windows 读后 `EnumClipboardFormats` 查 `ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`,命中跳过上传。 + 2. **回环防护**(R4):自写剪贴板会自增计数触发自己的 Watch 风暴;写入前记 hash + 自写序号,Watch 收变更先比对跳过。 + 3. **去重 + debounce**:复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。 +- **写回本机剪贴板**优先用库的 `Write`(规避 Wails #4132:macOS LANG 为空时编码 bug)。 +- **go 版本**:该库 go.mod 要求 go 1.24,`desktop/go.mod` 声明 1.23——引入需把模块声明升 1.24+(本机 toolchain 已满足)。 +- **三按钮通知**:新剪贴板到达 →“复制到本机 / 忽略 / 打开”。 +- **⚠️ D4 最大不确定项(须真机实测)**:**macOS 26(Tahoe)剪贴板隐私授权**——非用户 paste UI 触发的程序化读取会弹授权 Alert,正中“后台自动监听上传”要害。Apple 新增 `detectPatterns/detectValues`(只探测类型不读内容、不弹窗)但该库未适配。须在 macOS 26 真机验证:纯 `changeCount` 轮询 vs 读内容 各自的弹窗行为。 + +### D5 · 全局快捷键 + Quick Send 浮窗 ⚠️ 受 v2/v3 决策影响 +- **热键(已定)**:`golang.design/x/hotkey` **pin v0.4.1**(Carbon `RegisterEventHotKey`,对 `Cmd+Shift+V` 零授权弹窗)。**勿用最新 v0.6.x**——它改用 CGEventTap,每个热键都强制 Accessibility 授权,开机弹窗劣化体验;且 v0.5.0+ 要 go 1.24,v0.4.1 仅 go 1.17。**不调 `mainthread.Init`**(Wails 已占主线程 run loop)。Windows 走 `RegisterHotKey`,零 cgo、无权限。 +- **Quick Send 浮窗(sweep 校正)**:**Wails v2 单原生窗口**,开不了第二个原生窗。v2 内只能“同窗 HTML 浮层 / 路由切换”近似;“失焦自隐”v2 无现成钩子、要自写 ObjC `NSWindowDelegate windowDidResignKey`(v2 路线最脏一块)。**这是上 v3 的最强理由**(v3 多窗口每窗一等对象)。 +- 产品化时建议热键可重绑(规避撞车)。 + +### D6 · 打包(无证书:仅 `.app`) +- **macOS(当前路径)**:`wails build -platform darwin/universal` 产出 `build/bin/.app`(`lipo` 合并 arm64 + amd64)。**不公证、不 DMG、无 Developer ID**。为满足 D3 通知与稳定身份,做 **ad-hoc 签名** `codesign -s - --force --deep .app` + `Info.plist` 带 `CFBundleIdentifier`。分发靠手动(右键打开 / 去 quarantine)。 +- **entitlements(若 ad-hoc 也带)**:非沙箱只需 `com.apple.security.network.client`;purego dlopen 系统 framework **不需** `disable-library-validation`;Carbon 热键不需 entitlement。(不要列通知 / 剪贴板 / 热键 entitlement——过度声明。) +- **Windows**:`wails build -platform windows/amd64 [-nsis]` 产出 `.exe` / NSIS 安装器;当前**不签名**(SmartScreen 会提示),获代码签名证书后再 `signtool`。 +- **版本检查**:启动 / 定时拉版本端点,过期提示去下载,**不自更新**。 +- **将来获证书**:完整 codesign(Developer ID)+ notarytool + DMG + CI 流程见 `desktop/RESEARCH.md` D6 手册,届时直接套用。 +- **CI**:D6 后 GitHub Actions 双 runner(macos-latest / windows-latest)跑构建 + 防回归(无证书阶段仅构建 + ad-hoc,不跑公证)。 + +--- + +## 5. 跨平台代码组织(防分叉硬约束) + +``` +desktop/ + main.go # Wails 入口(窗口 / bind / 托盘装配) + app.go # App struct + 暴露给 WebView 的方法(bind:StartLogin 等) + platform/ + oauth.go # loopback server + PKCE(平台无关) + clipboard.go # ClipboardSource 接口 + 公共逻辑(过滤 / 去重 / 回环防护) + clipboard_darwin.go //go:build darwin + clipboard_windows.go //go:build windows + tray_darwin.go / tray_windows.go + hotkey_darwin.go / hotkey_windows.go + notify_darwin.go / notify_windows.go + download_darwin.go / download_windows.go + secrets_darwin.go # Keychain + secrets_windows.go # DPAPI / Credential Manager + frontend/ # 薄入口 + Platform desktop 实现,UI import 自 web +``` + +**铁律**:①平台代码全用 build tag,同 monorepo 同分支,**绝不开 windows-branch**;②Windows(Parallels Win11 ARM VM)只 `git pull`、**从不 commit**,Windows-only 操作封进 `just` target;③ARM Win11 跑 x64 emulator 性能 -20–40%,**不能跑资源 / 性能基准**,D6 借真 x64 PC 锁数字;④D6 后 CI 防退化。 + +--- + +## 6. 安全审查 + +| 面 | 风险 | 缓解 | +|---|---|---| +| **token 存储** | 明文落盘即被盗即接管 | macOS Keychain / Windows DPAPI;不写明文文件 / localStorage | +| **OAuth 回调** | scheme 劫持 / CSRF | 用 loopback(无 scheme 抢注面);强制 PKCE S256 + state 校验;token 交换走 HTTPS 直连 Casdoor | +| **剪贴板隐私** | 自动上传密码 / 验证码 | 自写 ClipboardSource 做敏感类型过滤(§4 D4);默认策略待定;提供暂停 + 排除 | +| **更新** | 无自更新 = 不引入更新通道劫持面 | 仅版本检查提示 | +| **无签名分发** | 用户绕过 Gatekeeper 习惯被培养、易被仿冒 | 自用 / 小范围可接受;公开分发前应申请 Developer 证书签名公证(见 RESEARCH.md) | +| **WebView ↔ Go 桥** | 暴露的 Go 方法被 XSS 滥用 | bind 方法最小化 + 参数校验;只加载本地 embed 资源 + 固定 API origin | + +--- + +## 7. 验证与测试策略 + +- **每里程碑**:`just desktop-build-mac` 冷 / 增量、`desktop-build-win` cross-compile 双绿,再真机手测(各 D 的“验收”)。 +- **真机实测清单(sweep 标注,资料无法定论)**:① macOS 26 剪贴板隐私授权对轮询的行为(D4);② ad-hoc 签名能否让 `UNUserNotificationCenter` 本机通知工作(D3);③ hotkey v0.4.1 在 Wails 主线程下稳定收事件(D5);④ Casdoor loopback 任意端口端到端不报 `redirect_uri_mismatch`(D1)。 +- **资源基准**:idle 内存在真机测(mac 本机、Windows 借真 x64 PC,ARM VM 不算数),D6 锁定。 + +--- + +## 8. 风险登记表 + +| # | 风险 | 影响 | 缓解 / 状态 | +|---|---|---|---| +| R1 | OAuth 后端协调 | D1 | **已降级**:sweep 给出最小路径(多值 audience 一处改动 + Casdoor ≥ v3.3),不再是未知阻塞 | +| R2 | 前端复用污染 web 构建 | 维护 / 分叉 | §2.1 A2 隔离 + Platform 接口 + CI 校验 | +| R3 | **Wails v2 缺托盘 + 单窗口** | D2 / D5 | 决策 C:推迟 v2/v3 至 D2/D5 前;v2 走第三方 systray + 自写 NSWindowDelegate,或升 v3 | +| R4 | 剪贴板回环(自写触发自传) | 上传风暴 | ClipboardSource 层来源标记 + hash 去重 + debounce | +| R5 | **macOS 26 剪贴板隐私授权** | D4 自动同步可能被弹窗打断 | 真机实测 changeCount 轮询是否触发;必要时改用 detect API | +| R6 | **无证书 → D3 通知 / Gatekeeper** | D3 通知可能不工作、分发受阻 | ad-hoc 签名 + 真机实测;不行则通知降级 / 推迟;公开分发前补证书 | +| R7 | ARM VM 测不了性能 / 真签名 | 数字不可信 | D6 借真 x64 PC | + +--- + +## 9. 执行顺序与 v2/v3 决策闸 + +**顺序(决策 C)**: + +1. **R1 后端协调**(非代码,可并行起草)+ Casdoor 建桌面 client。 +2. **D1**(前端复用 + loopback OAuth)——框架无关,最高价值。 +3. **D3**(SSE 常驻 + 通知 + 落盘)+ **D4**(剪贴板核心价值)——框架无关。 +4. **⟵ v2/v3 决策闸**:动 D2 / D5 前,正式评估三选一: + - **A) 留 v2**:稳定、文档全;D2 托盘 + D5 多窗靠第三方 / 自写 ObjC,技术债重。 + - **B) 升 v3-alpha**:原生 systray + 多窗口 + ActivationPolicy;但 alpha 有风险、文档薄、需重做 D0 脚手架与 D1–D4 的少量框架接缝。 + - **C) 混合**:v2 上把 D2/D5 做到“可用即可”(不追求完美失焦自隐等),待 v3 稳定再迁移。 +5. **D2**(托盘 + 生命周期)→ **D5**(热键 + Quick Send)。 +6. **D6**(打包:当前无证书仅 `.app`;CI)。 + +**判据**:若 menubar 常驻 + Quick Send 浮窗被确认为产品核心体验、且 v3-alpha 在评估时已足够稳,倾向 B;否则 A/C 先交付。 + +--- + +## 10. 当前执行计划:桌面 app(A2 复用 + 菜单栏 + 剪贴板 + 设置,2026-06-13 定) + +> 用户要求:实现 web 全部功能、复用大部分界面与设计逻辑(A2)+ 剪贴板同步;**无活动窗口时从 dock 隐藏 + 有 menu bar item**;**可能需独立的桌面设置界面**。 + +### 10.0 架构决策:留 v2(单窗菜单栏) + +本需求是单窗口形态(一个主窗 + 菜单栏,无 Quick Send 多窗),v2 可达:菜单栏 `fyne.io/systray`、dock 动态隐藏 purego objc `setActivationPolicy`、关闭即隐藏 v2 `OnBeforeClose`。复用 D1 全部成果,不冒 v3-alpha 迁移;Quick Send 多窗若将来要,再议 v3。 + +### 10.1 A2 前端复用(地基) + +Wails 构建并嵌入 web/ 的 React 应用,加薄平台 seam(web 默认行为不变): + +- **API base**:`net/api.ts` 用 `API_BASE = import.meta.env.VITE_CDROP_API_BASE ?? ""` 前缀 fetch 的 URL;web 留空(相对、不变),desktop 构建设 `https://drop.commilitia.net`(WebView 内相对 `/api` 到不了后端)。 +- **登录桥**:login 路由检测 desktop(Wails 注入 `window.runtime`)→ 调 Go `StartLogin` → Go 直连 Casdoor 换 token + 解 access_token JWT 取 sub/name → `EventsEmit {access_token, refresh_token, user}` → JS `setAuth`。即 **token 进 JS store**(A2 必需、本机可接受,放弃「token 只在 Go」)。 +- **refresh**:desktop 的 `refreshTokens` 走 Go `Flow.Refresh`(绕开 `/api/auth/refresh` 后端代理)→ emit 新 token。 +- **设备名**:desktop 取主机名(`os.Hostname`)填 `selfDeviceName`。 +- **构建接线**:desktop/frontend 引用 web(npm workspace / `file:` 或 vite 指 `web/src`),保留 wailsjs 绑定;以能 `wails build` 为准、方式待落地时定。 + +### 10.2 剪贴板同步(D4) + +- **上行**:darwin native Source(NSPasteboard changeCount 轮询,purego,clipprobe 已证可跑)→ Monitor(去重 / 回环防护;`Sensitive` 过滤 best-effort、非主防线)→ `api.PutClipboard`。 +- **下行**:Go 持 SSE `/api/hub/events` → `clipboard:update` → `ApplyRemote` → `Source.Write`(loop-guard 防自传)。 +- **安全**:服务端 5 min TTL 已上线兜底(密码即使上传,5 分钟即逝)。 +- **开关**:受桌面设置「自动同步」控制(建议默认开 + 可关)。 + +### 10.3 菜单栏 + dock 隐藏(D2,单窗) + +- **菜单栏**:`fyne.io/systray`,菜单项:显示主窗 / 设置 / 退出。 +- **dock 动态**:purego objc `NSApp setActivationPolicy:`——窗口显示 = regular(有 dock);隐藏 = accessory(无 dock、仅菜单栏)。 +- **生命周期**:v2 `OnBeforeClose` → `WindowHide` + accessory;菜单栏「显示主窗」→ `WindowShow` + regular;「退出」才真正退出(触发 `/api/me/disconnect`)。 + +### 10.4 桌面独立设置界面 + +- **桌面专属设置**(web 无):剪贴板自动同步开关、开机自启、设备名、菜单栏 / dock 行为、(TTL 只读展示)。 +- **形态**:desktop-only 路由 / 页,复用 web 设计系统(Mantine + tokens + Field / Switch / Panel 组件)保持视觉一致;由菜单栏「设置」打开。 +- **存储**:Go 侧本地配置文件(`~/Library/Application Support/cdrop/config.json`)+ 绑定方法读写;前端经 Wails binding 读写。 + +### 10.5 执行顺序 + +A2 复用(地基,先让真 web UI 在桌面跑起来 + 登录走 Go 桥)→ 菜单栏 + dock 隐藏 → 剪贴板同步 → 桌面设置。每步 `wails build` 可验;登录 / 同步 / 菜单栏需真机测。 + +### 10.6 实现状态(2026-06-13 实现,本地全绿) + +`go build ./... / go test -race ./... / web tsc / wails build` 全部通过,产出 `build/bin/cdrop-desktop.app`(内嵌真 web 应用,8 个绑定就绪)。 + +落地与对 §10.1–10.4 的关键决策: + +1. **同源反向代理替代 API_BASE 跨域**(§10.1 偏差):`platform.NewAPIProxy` 在 Wails `AssetServer.Handler` 反代 `/api/*` → backend,前端相对 URL 原样不变。理由:后端无 CORS,跨域方案要加 CORS + 重新部署(不可达);代理同源、零 CORS、零后端改动。SSE 流式已核实可行并单测(darwin `URLSchemeTaskDidReceiveData` 逐 write 推流 + `FlushInterval=-1`;`proxy_test.go` TestProxyStreamsSSE)。 +2. **登录桥 token 入 JS store**:`StartLogin` 解码 JWT(`UserFromToken`,镜像后端 `extractUser`:id_token 优先、preferred_username→name→email→sub、avatar→picture)发 `oauth:success{access_token,refresh_token,user}`;`Refresh` 走 public-client(不带 secret);`DeviceName`=主机名跳过 /setup。web `net/desktop.ts` 全程 `isDesktop()` 门控,浏览器零影响。 +3. **菜单栏 + dock:自写 CGO/ObjC `NSStatusBar` 桥,不碰 app delegate**(§10.3 落地):核实 energye/systray 与 systray-on-wails 的 external-loop 路径仍会 `setDelegate:owner` 抢占 Wails 的 NSApplication delegate → 破坏窗口/运行时,**全部排除**。`statusbar_darwin.m` 在 main queue 建 `NSStatusItem` + 菜单(显示主窗 / 设置 / 退出),dock 经 `setActivationPolicy` 动态切(regular↔accessory);`OnBeforeClose` 隐藏不退、菜单「退出」置 `quitting` 真退。CGO/ObjC 而非 purego:clang 编译期校验 selector,盲写更稳。 +4. **剪贴板 JS-centric**(§10.2 落地):Go 只做原生读写(`clipboard_darwin.m` NSPasteboard 轮询 changeCount + 读写)+ 发 `clipboard:native` 事件 / `ApplyRemoteClipboard` 绑定;上行复用 web `uploadClipboard`、下行复用 `applyRemoteClipboard`,**单 SSE**(JS 的)。回环防护用既有 `Monitor`(已加 mutex 并发安全,-race 通过)。窗口隐藏后 WebView 仍存活 → menubar 模式下行有效。Sensitive(ConcealedType 等)best-effort 过滤 + 服务端 5min TTL 兜底。 +5. **设置 + 本地配置**(§10.4 落地):`appsettings.go` 读写 `~/Library/Application Support/cdrop/config.json`;`launchagent_darwin.go` 用 LaunchAgent plist 实现开机自启(纯文件 I/O,已单测)。web `features/desktop/DesktopSettings.tsx`(复用 Panel + Mantine Switch)挂在 /settings、`isDesktop()` 门控;菜单栏「设置」→ `menu:settings` 事件 → 路由跳转。 + +新增/改动(desktop/platform):proxy.go、identity.go、statusbar{.go,_darwin.go,_darwin.m,_other.go}、clipboard_darwin.{go,m}、clipboard_source_other.go、appsettings.go、launchagent_{darwin,other}.go(+ proxy/identity/appsettings/launchagent 4 测试);clipboard.go 加 mutex;oauth.go 加 IDToken 字段。desktop:app.go(登录桥 / 生命周期 / 剪贴板 / 设置绑定)、main.go(proxy + OnBeforeClose)、wails.json(frontend 指向 web 构建)。web:net/desktop.ts + DesktopSettings.tsx 新增;login.tsx / auth.ts / clipboard.ts / __root.tsx / settings.tsx / main.tsx 加 guarded 桌面分支。 + +**待真机 + 鉴权验证(本环境无 GUI、无法登录,以下不可本地达成):** + +- [ ] 登录 e2e:loopback PKCE → token 入 store → 进首页(含设备名自动取主机名) +- [ ] refresh:API 401 → Go public-client 刷新成功 +- [ ] 菜单栏 item 出现 + 三项菜单可点 +- [ ] dock 动态隐藏:关窗 → 仅菜单栏;菜单「显示主窗口」→ 窗口与 dock 复现 +- [ ] 剪贴板跨设备:上行(复制即传)/ 下行(远端更新写回本机)/ 回环不抖 +- [ ] 开机自启:勾选后写入 LaunchAgent、重登录系统自动起 + +**已知后续:** 桌面设置 UI 已接入 `t()`(zh-CN / zh-TW / en-US 三语);菜单栏标签仍 Go 侧硬编码简体(需启动期把 locale 传入 Go,待后续);`Greet`/`LoggedIn` 为脚手架残留绑定可清理;Windows/Linux 的原生 Source / 托盘 / 自启目前为 stub。 + +### 10.7 用户首轮真机反馈修复(2026-06-13,本地全绿) + +真机实测「功能基本正常」,三处调整已修: + +1. **登录 session 本地持久化(Go 文件 + 启动注入;已真机跑通)**: + + **两个独立根因,第二个掩盖了第一个、害得多轮误判:** + + - **根因 A(真正的持久化障碍)**:Wails 从 `wails://` 自定义 scheme 提供页面,**WKWebView 对 custom-scheme origin 不持久化 localStorage / sessionStorage**,故任何「前端存储」跨重启即丢。→ 持久化必须交给 Go。 + - **根因 B(构建管线,长期误导)**:`wails build` 的 `frontend:build` 在本仓库结构下**实际不重建 `web/`**(无 vite 输出、dist 不更新),导致 `.app` 嵌入的是**数小时前的旧 JS**。所以连续多轮「仍无效」其实是在测**根本不含本轮代码的 stale bundle**——save/load/注入逻辑一直是对的,只是 JS 从未真正进过包。已用源码 marker 定位证实(marker 进不了 dist)。**修法:先手动 `npm run build` 再 `wails build -s`(跳过 wails 自带前端步),Justfile `desktop-build-mac` 已改成两步。** + + **终方案(最简、已真机验证落首页)**:① Go 持久化——`platform/session.go` 把 `LoginResult` 写 `~/Library/Application Support/cdrop/session.json`(0600),`StartLogin`/`Refresh` 即存、登出 `ClearSession` 删;② **启动注入**——`platform/sessioninject.go` 的 `AssetServer.Middleware` 在 index.html `` 前注入 ``(启动读文件、`json.Marshal` 默认 HTML 转义防 `` 逃逸、只改文档请求、assets 与 /api SSE 直通、加 `no-store`+删条件头防 304/缓存);③ 内联脚本在 deferred module bundle 前同步执行,**`store/helpers.ts` 初始化即同步读 `window.__CDROP_BOOT__` 水合 session + 设备名**——无 IPC、无异步 boot、不依赖 window.go。设备名同样不能靠 WebView 存储:`DesktopConfig` 加 `DeviceName` + `ResolveDeviceName()`(config 优先否则 hostname),重命名经 `SetDeviceName` 绑定持久化,devices slice 初始化 `readInjectedDeviceName() ?? readSelfDevice()`。 + + 真机诊断证据:`[js] render: injected=true user=Commilitia device=…` + 随后加载首页组件(DeviceStrip / clipboard / SSE)。曾试过的 localStorage(根因 A 挡)、IPC 绑定 / 异步 boot(为绕「以为是缓存」加的冗余)均已移除——它们「无效」只因根因 B 让它们从没真正跑过。Keychain 加固仍留后续。 +2. **常用快捷键**:`app.go buildMenu()` 装应用菜单——`menu.EditMenu()` 给 ⌘C/⌘V/⌘X/⌘A/⌘Z,自写「窗口」菜单 ⌘W=隐藏 / ⌘M=最小化。**关键坑**:Wails 把窗口关闭按钮与 ⌘Q **都**经同一 `OnBeforeClose("Q")` 通道(`windowShouldClose` 与 `applicationShouldTerminate` 都 `processMessage("Q")`),不可区分 → 用 role `AppMenu()` 的 ⌘Q 会被 hide 逻辑吞掉永不退出。故自写应用菜单 + 自定义 ⌘Q 退出项(先置 `quitting` 再退),实现「关闭按钮 / ⌘W 隐藏,⌘Q / 菜单退出 真退」。 +3. **菜单栏图标 + 设置崩溃**:状态项改用 SF Symbol `doc.on.clipboard`(template image,随明暗着色,旧系统回退文字)。崩溃根因:原生菜单动作回调在 AppKit 主线程同步调 Wails runtime(`WindowShow` / `EventsEmit`)→ 主 run loop 重入崩溃;修为状态项三个回调 `go` 异步派发(Wails 再安全调度回主线程)。 + +### 10.8 凭据安全策略(2026-06-13 确立,一致化) + +**原则:`refresh_token`(长寿命密钥)是受保护对象,绝不暴露给 WebView/JS;`access_token`(短寿命)是工作凭据,是 WebView 唯一持有的凭据。** 此前不一致——Web 把 refresh_token 仅留内存(XSS 最小化),桌面却把整套 token(含 refresh)既写明文文件又灌进 JS(注入 + store)。统一为: + +- **refresh_token 全平台都不进 WebView/JS**:Web 仅内存(页面生命周期)、不落盘,刷新走后端代理(持 client secret);桌面只在 **Go 进程内存 + 受限文件**,刷新走 **Go 内部**——`Refresh()` **无参**,Go 用自己的副本换新 access_token,绝不注入、不返回 JS。已在绑定边界核实:生成的 TS `SessionView` 无 `refresh_token` 字段、整个 wailsjs 生成层 `grep refresh_token` 零命中。 +- **access_token 在 JS store**(API 调用必需),短寿命、由 refresh 续期;桌面随 boot 注入 + 落盘(offline-tolerant),401 时 Go 刷新。 +- **静态存储(桌面)**:`session.json` 文件 0600、目录 0700(仅当前用户)。威胁模型:防其他本地用户 + 随手访问;**不防**同用户身份运行的恶意程序。**加固路线=macOS Keychain,推迟到 Developer-ID 正式签名后**——ad-hoc 签名每次构建都改签名,会导致每次重建都弹 Keychain 授权(且当前无法干净验证)。 +- 实现:`platform.SessionView`(`LoginResult.View()` 投影去掉 refresh)用于登录 emit / 刷新返回 / 注入;`StartLogin` 存全量到文件、emit View;`Refresh()` 无参用 `a.token.RefreshToken`(startup 时从文件载入 Go 内存,且容忍 provider 不轮换 refresh);登出 `ClearSession` 删文件 + 清 Go 内存。 + +### 10.9 品牌资产接入 + 桌面打磨(2026-06-13,web 已部署 prod) + +吉祥物品牌资产(源见记忆 `cdrop-brand-assets`)接入。web 与 desktop 共享同一前端,故多数改动两端通吃。 + +**Web(已部署 drop.commilitia.net):** + +- **站点图标**:`web/public/` favicon-16/32/48/96(源 avatar-head)+ apple-touch-180 + PWA icon-192/512(源 app-icon 金底)+ `site.webmanifest`;`index.html` 替换 emoji 占位。Google 搜索要求 favicon 为 **48px 整数倍**,故必挂 48/96。 +- **品牌字标** `ui/brand/Wordmark`:吉祥物 logo(`logo/logo-icon.png`→1024² 870KB)+ Fredoka 600 混排双色(浅 Ink/Amber、深 白/`#F8C637`,主题三态)。Fredoka 经 `gfonts.commilitia.net` 镜像、`--font-display`。用于 AuthShell 顶栏 / `__root` header。规范:**页面禁止纯文字呈现品牌**(须 logo 图或图+文字)。 +- **登录两坑**:① 跳转帧「两个 logo」(顶栏 logo 与 header logo 重叠)→ 过渡屏 `oauth.callback`/`setup` 传 `hideBrand` 隐顶栏 logo;② 图片重载闪烁(embed.FS 无 modtime、`http.FileServer` 不发缓存头)→ `internal/webui/embed.go` 补 `Cache-Control`(`/assets/*` immutable、index `no-cache`、logo/favicon `max-age=604800`)+ index.html `preload`。 + +**Desktop:** + +- **App 图标**:`build/appicon.png`←`app-icon/icon-1024.png`,构建期转 `iconfile.icns`。**bundle ID `com.wails.{{.Name}}` 占位致 LaunchServices 缓存陈旧(一直显默认图)→ 改 `net.commilitia.cdrop`**(与 launchAgentLabel 一致)+ `lsregister -f` 重注册。 +- **菜单栏图标**:SF Symbol → **彩色品牌头像**(`platform/menubar-icon.png` avatar-head 256px,`go:embed`→NSImage,`template=NO`,尺寸取 NSStatusBar `thickness`)。 +- **菜单栏交互(未来 Windows 托盘须对齐)**:**左键单击→打开主窗口;右键/⌃单击→菜单**。不挂 `item.menu`(否则 mousedown 即弹菜单),改 `item.button` action + `sendActionOn:LeftMouseUp|RightMouseUp` 手动路由。 + +**待办**:Keychain 加固仍待签名后;菜单栏标签仍 Go 侧硬编码简体(locale 注入待办)。下一步:Windows 落地,见 §11。 + +## 11. Windows 端落地(2026-06-14,RDP 实测通过) + +macOS 优先做完后接 Windows。业务逻辑全跨平台纯 Go,平台差异用 build tag 隔离(`_windows.go` / `_darwin.go` / `_other.go`)。 + +### 11.1 交叉编译:macOS 原生即可,无需 mingw + +Windows 侧无 cgo——darwin 的 NSStatusBar/NSPasteboard CGO 走 build tag 排除,托盘/剪贴板/自启均纯 syscall,WebView2 是纯 Go(`go-webview2`)。`just desktop-build-win` = 手动构建前端 + `wails build -s -platform windows/amd64`,从 macOS 直接出 `.exe`。`os.UserConfigDir()` 跨平台 → session/config 落 `%AppData%\cdrop\`。 + +### 11.2 核心坑:Wails 的 Windows custom-scheme AssetServer 不支持流式 + +**根因**:Wails 的 Windows AssetServer 把 `http.Handler` 的响应**攒到 handler 返回才交给 WebView2**。长连 SSE(`/api/hub/events`,handler 永不返回)→ WebView2 永远拿不到响应 → fetch 一直 pending(连 `resp.ok` 都不触发)→ 设备列表加载不出、状态卡「连接中」。短请求(剪贴板 fetch 等 handler 返回即送达)正常,故迷惑性强。两个同名进程互相 kick → 连接频繁**结束** → 每次结束才冲刷,是「开两个就好」的假象。 + +**排错历程**:先加 2KB SSE padding(标准抗缓冲技术)→ 无效,**反证是 buffer-until-return 而非流式缓冲**(padding 已撤)。 + +**修法**:`platform/localproxy.go` 仅 Windows 在 `127.0.0.1:0` 起**真 HTTP 代理**处理 `/api`——WebView2 的正常网络栈对 loopback 源真流式。前端经注入的 `api_base` 前缀 `/api`(`net/api.ts`)。CORS 回显 `Access-Control-Request-Headers`(覆盖 Authorization / If-None-Match / X-Device-Type,否则剪贴板刷新的 `If-None-Match` 预检失败=「Failed to fetch」)+ Private-Network 应答头。macOS 维持 custom scheme(WKWebView 流式正常,零改动)。 + +### 11.3 原生能力(`_windows.go`) + +- **托盘** `statusbar_windows.go`:`fyne.io/systray`(goroutine + LockOSThread 跑自有消息循环),品牌 `tray-icon.ico`,`SetOnTapped` 左键开窗 / 右键默认菜单(显示主窗·设置·退出)。关窗→`WindowHide`(任务栏按钮随之消失),托盘常驻。 +- **剪贴板** `clipboard_windows.go`:`golang.design/x/clipboard`(`GetClipboardSequenceNumber` 轮询,纯 syscall),上下行经既有 `Monitor`/`Run`。敏感内容过滤暂缺(`Sensitive` 恒 false,靠服务端 5min TTL 兜底;`EnumClipboardFormats` 检查待后续)。 +- **开机自启** `launchagent_windows.go`:注册表 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`(per-user 免提权),值为带引号的 exe 路径(尾带 `--hidden`,见 §11.5)。 + +### 11.4 跨端打磨 + +- **设备类型**:客户端发 `X-Device-Type`(桌面按 `runtime.GOOS` 注入 macos/windows),后端中间件白名单登记(替代写死 `browser`),前端 i18n 本地化展示(浏览器 / macOS 客户端 / Windows 客户端…)。 +- **设备名持久化**修复:`SaveSettings` 合并保留 `DeviceName`(设置页 `DesktopConfig` 无 device_name 字段,原先存设置会用空值覆盖)。 +- **统一软件名** `Commilitia Drop Desktop`(`.app` / `.exe`);品牌 `.icns`(macOS)/ `.ico`(Windows,无 ImageMagick 时用一次性 Go 工具封 PNG-in-ICO);bundle id `net.commilitia.cdrop`。 +- **Cmd+,(Ctrl+,)→ 设置**:`main.tsx` 全局 keydown,桌面与浏览器通用。 + +### 11.5 自启静默驻留(autostart → 不弹窗,直接驻留菜单栏 / 托盘) + +自启拉起的进程不该抢焦点弹窗——用户没主动开 app。机制:自启项在启动命令尾部追加 `--hidden`,进程 `launchedHidden()`(扫 `os.Args`)命中即 `options.StartHidden`(无窗口起);macOS 再 `SetDockVisible(false)` 降 accessory(隐 dock,仅留菜单栏项)。前端照常加载 → 剪贴板同步后台照跑。手动启动(Finder / 任务栏 / 终端)不带标志 → 正常显示窗口。 + +- macOS:LaunchAgent `open --args --hidden`(裸 dev 二进制 `exe --hidden`)。 +- Windows:注册表值 `"exe" --hidden`。 +- **自启项自愈**:`startup` 时若自启已开,重写一次条目——旧版本(无 `--hidden`)或迁移过位置的 bundle 自动刷新到当前路径+标志,无需手动重勾。 + +**仍待办**:Windows 剪贴板敏感过滤;菜单栏/托盘标签 i18n(仍硬编码简体);Keychain(待 Developer-ID 签名);Windows 代码签名 / SmartScreen。 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..4d7bcd3 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,19 @@ +# README + +## About + +This is the official Wails Vanilla-TS template. + +You can configure the project by editing `wails.json`. More information about the project settings can be found +here: https://wails.io/docs/reference/project-config + +## Live Development + +To run in live development mode, run `wails dev` in the project directory. This will run a Vite development +server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser +and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect +to this in your browser, and you can call your Go code from devtools. + +## Building + +To build a redistributable, production mode package, use `wails build`. diff --git a/desktop/RESEARCH.md b/desktop/RESEARCH.md new file mode 100644 index 0000000..4e360e8 --- /dev/null +++ b/desktop/RESEARCH.md @@ -0,0 +1,694 @@ +# cdrop 桌面端 D1 前决策就绪简报 + +> 框架基线:Wails v2.12.0(Go 1.23 模块声明,本机 toolchain 1.26.3)+ 系统 WebView,瘦客户端,业务全走 `drop.commilitia.net`。平台优先级 macOS(Apple Silicon + Intel universal),Windows x64 兼容。 +> 已对四项关键论断做对抗式核验:托盘能力 **refuted**、热键库可用性 **supported(附 pin 条件)**、Casdoor/R1 **mixed(需一处后端改动 + 版本前提)**、签名/公证 entitlement **mixed(核心子句成立,捆绑表述需修正)**。 + +--- + +## R1:OAuth 后端 / Casdoor 协调(解锁 D1 的最小路径) + +核验结论:**mixed**。「public client + PKCE + loopback」成立,但「后端零改动」为假——需且仅需一处后端 audience 改动,且依赖 Casdoor 版本 ≥ ~v3.3。 + +**已确定(源码级):** +- Casdoor 支持 public client:PKCE(`CodeChallenge` 非空)时 `client_secret` 可空;传了则须正确(`token_oauth.go` / `token_oauth_util.go`)。 +- loopback 短路放行:`IsValidOrigin()`(`util/validation.go`)用 `url.Hostname()` 剥端口后比对白名单 `http://127.0.0.1` / `http://localhost` / ...,命中即放行,`http://127.0.0.1:<任意端口>/<任意路径>` 无需进 `RedirectUris` 列表。 +- aud 默认 = 签发 application 的 `client_id`(`token_jwt.go:544`)。 +- 本仓库已核实根因:`internal/jwtauth/middleware.go:150` 当前是单值 `expected.AnyAudience = jwt.Audience{a.cfg.OIDCAudience}`;`docker/compose.snippet.yaml:28` 把 `CDROP_OIDC_AUDIENCE` 设为 web `cdrop` client_id。桌面新 client 的 token `aud=` 会被这条按 audience 100% 拒(401)。 +- `internal/httpapi/auth.go:92-93`、`148-149` 已核实:`/api/auth/exchange` 在 `OIDCClientSecret` 非空时附 `client_secret`(confidential 代理)——桌面端**不应复用**。 + +**最小落地路径(解锁 D1):** +1. Casdoor 新建独立 application `cdrop-desktop`:token format = JWT,`client_secret` 留空,**不可**勾选 shared application(否则 aud 被改写成 `clientId-org-owner`)。`RedirectUris` 建议登记一条 `http://127.0.0.1/callback` 作文档兜底(loopback 短路理论上登记与否都放行,待实测确认顺序)。 +2. 桌面端 Go 起临时 loopback server(**必须 bind IPv4 字面量 `127.0.0.1`**,白名单不含 IPv6 `[::1]`),直连 Casdoor 做 public PKCE 换 token,**绕过** `/api/auth/exchange`。Casdoor 专有路径:authorize = `/login/oauth/authorize`,token = `/api/login/oauth/access_token`(非标准 `/oauth/token`)。authorize 与 token 两步的 `redirect_uri` 须逐字一致(先定端口、全程同一 URL 串)。 +3. **后端唯一改动(方案 A,推荐)**:把 `CDROP_OIDC_AUDIENCE` 从单值扩成逗号分隔多值(web + desktop 两个 client_id),`middleware.go:150` 展开进 `AnyAudience`(go-jose 的 `AnyAudience` 已是「命中其一即可」语义)。issuer + JWKS + 签名校验与 web 完全一致,无需动。 + - 备选 B:桌面带 RFC 8707 `resource` 参数令 aud 收敛——依赖 Casdoor 较新版本且 authorize/token 两步须带同一 resource,不确定性更高,不推荐。 + - 备选 C:`CDROP_OIDC_AUDIENCE` 置空关 audience 校验——最弱,仅保留 issuer + 签名。 + +**版本前提:** loopback 短路 2026-04-05(commit `ea56cfec`)才接入 redirect 校验,首个含此改动的 release 约 v3.3.0(2026-04-12)。早于此的 Casdoor 会拒未注册的 `127.0.0.1:`。prod 自建 Casdoor 版本未锁定,须先核实 ≥ v3.3(建议直接升近版,最新 v3.89.0 / 2026-06-13)。 + +--- + +## D2:menubar / tray + 窗口生命周期 + +核验结论:托盘论断 **refuted**——Wails v2.12.0 **无任何可用内建托盘 API**。已核实 `options.App` 无 `Tray` 字段,runtime 包无托盘函数,`pkg/menu/tray.go` 等是 2022 废弃分支遗留、仅被 devserver 引用的孤儿死代码(README 在 `onhold/` 目录)。Issue #4990 已被官方 closed as not planned。维护者明确「Systray will not be supported in v2」。 + +**决定:** +- **窗口生命周期(v2 原生可做)**:`StartHidden:true` + `HideWindowOnClose:true` 实现「启动即驻留、关闭隐藏不退出」;或 `OnBeforeClose(ctx) bool` 内 `runtime.WindowHide(ctx)` 并 `return true`。**二者互斥**(`OnBeforeClose` 仅在 `HideWindowOnClose=false` 时触发),不可同设。 +- **托盘图标 + 菜单 + 点击回调(必须第三方/自写)**:macOS 首选先验证 `github.com/ra1phdd/systray-on-wails` 的非阻塞 `Register()`(让 Wails 接管主线程事件循环,规避 NSApplication 冲突);不达标则回退自写 CGO/ObjC `NSStatusBar+NSMenu` 绑定(最可控)。`getlantern/systray` 在 macOS+Wails 有 objc linking conflict,**排除**。Windows 侧用 `fyne.io/systray`(活跃维护 fork)。 +- **隐藏 dock 图标(accessory)**:`mac.Options.ActivationPolicy` 在 v2.12 仍被注释掉、不可用。已核实本仓库 `desktop/build/darwin/Info.plist` 有 `CFBundleIdentifier` 但**无 `LSUIElement`**——需手动加 `LSUIElement`。accessory 有「启动瞬间 dock 图标闪现约 10ms」系统通病,无法消除。 + +**v3 决策闸:** v2 的 D2(托盘)+ D5(多窗口)需要堆 2-3 套 CGO workaround。Wails v3(alpha.92,API 已稳定、有生产案例)原生覆盖跨平台 systray + 多窗口 + ActivationPolicy。若 menubar + Quick Send 是产品核心体验,应把「直接上 v3-alpha」作为正式决策选项评估,而非在 v2 上累积 workaround。 + +--- + +## D3:系统通知(三按钮:复制到本机 / 忽略 / 打开) + +核验结论:v2 **无内置通知 API**(所有 `runtime.RequestNotificationAuthorization` 等是 v3 特性)。按平台分实现、统一 Go 接口(build tag 隔离 `notify_darwin.go` / `notify_windows.go`)。 + +**决定:** +- **macOS**:`UNUserNotificationCenter`(cgo + ObjC)。三按钮 = 注册 `UNNotificationCategory`(含 3 个 `UNNotificationAction`,「忽略」可设 `destructive`),发通知设 `categoryIdentifier`;点击经 `UNUserNotificationCenterDelegate didReceiveResponse` 回调,`response.actionIdentifier` 区分。**硬门槛**:app 须有 `CFBundleIdentifier` 且已签名(Developer ID 或 Apple Development),dev/未签名构建下 API 不工作,且须从 `.app` bundle 运行(裸二进制抛 `NSInternalInconsistencyException`)。最低 macOS 11.0。→ **D3 验收与 D6 签名强绑定**。 +- **Windows**:已在 go.mod 的 `git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3`(2025-01-17,仍维护)。`Notification.Actions` 加 3 个 `toast.Action{Type: Foreground, Content, Arguments}`,`toast.SetActivationCallback` 按 `Arguments` 区分。**坑**:须先 `wintoast.SetAppData{AppID, GUID, ...}` 注册 AUMID + stub CLSID,否则 COM 激活路径失效、回调收不到(回退 PowerShell 路径时 `SetActivationCallback` 完全失效)。 +- **不直接依赖 v3 notifications service**(绑 v3 `application.ServiceOptions` 生命周期 + alpha bug #4449);借鉴其 API 形态移植到 v2。 +- **落地节奏**:先实现「单击整条通知拉起主窗 + 打开文件」基础版打通主链路,三按钮作增量。 + +--- + +## D4:剪贴板自动同步 + +核验结论:两份调研一致——现成库都不做敏感内容过滤,会同步密码。 + +**决定:** +- **统一底层 `golang.design/x/clipboard v0.8.0`**(2026-06-07,活跃;macOS 已 purego/objc **无 cgo**,Windows/Linux 纯 syscall)覆盖 macOS(changeCount 轮询)+ Windows(`GetClipboardSequenceNumber` 1s 轮询)。**纠正 PLAN.md L167**:该库 Windows 是**轮询**不是 `AddClipboardFormatListener` 事件;两端机制其实一致(diff 计数器轮询),轮询间隔写死 1s 不可配。 +- **外包一层 `ClipboardSource` 接口做 D4 业务逻辑**(库一概不替你做): + 1. **敏感内容过滤**:macOS 读 `-types` 比对 `org.nspasteboard.ConcealedType` / `TransientType` / `AutoGeneratedType` 及私有 UTI(`com.agilebits.onepassword` 等);Windows 读到内容后 `EnumClipboardFormats` 检查 `ExcludeClipboardContentFromMonitorProcessing` / `CanUploadToCloudClipboard`,命中则跳过上传。库的 `Watch` 不检查这些,直接用 = 同步密码。 + 2. **回环防护**(PLAN R4):自写剪贴板会自增 changeCount/sequenceNumber 触发自己的 Watch 风暴。写入前记 hash + 自写时间窗/序号,Watch 收变更先比对跳过(两端都要拦)。 + 3. **去重 + debounce**:复用已在 go.mod 的 `github.com/bep/debounce` + 内容 hash。 +- **写回本机剪贴板**优先用库的 `Write`(规避 Wails #4132:macOS LANG 为空时 `ClipboardSetText` 编码 bug)。 +- **go.mod 版本**:库 go.mod 要求 go 1.24,本仓库 `desktop/go.mod` 声明 go 1.23(本机 toolchain 1.26.3 满足)——引入需把模块声明升到 1.24+;若改走自写 purego 封装则只依赖 purego v0.10.x,版本要求更宽松。 +- `atotto/clipboard` **排除**(shell out pbcopy/pbpaste,无 Watch、无 changeCount)。 + +> **macOS 剪贴板隐私授权(2025-2026 平台级变化)**:macOS 15.4 预览、预计 macOS 26 (Tahoe) 正式启用——非用户 paste UI 触发的程序化读取会弹授权 Alert。Apple 新增 `detectPatterns/detectValues` + `accessBehavior` 可只探测类型不读内容、不触发弹窗,但 `golang.design v0.8.0` 尚未适配。这正中 cdrop「后台自动监听上传」要害,是 D4 最大不确定项,须真机实测。 + +--- + +## D5:全局热键 + Quick Send 浮窗 + +核验结论:热键库可用性 **supported**,但**必须 pin v0.4.1**——这是最关键的一字之差决策。 + +**决定(热键):** +- **`golang.design/x/hotkey` pin 到 v0.4.1**(Carbon `RegisterEventHotKey`)。理由:v0.6.0(2026-06-06)已把 macOS 从 Carbon 换成 **CGEventTap**,导致**每个**全局热键都要求 App 获 Accessibility / Input Monitoring 授权,否则 `Register()` 直接返回 error(源码 `isAXTrusted`/`registerTap` 返回 NULL)。对「瘦客户端 + tray + Quick Send」产品开机弹授权是显著体验劣化。v0.4.1 的 Carbon 路径对 `Cmd+Shift+V` 这类组合键**零授权弹窗**(Electron/VS Code 同此路径)。 +- **附带收益**:v0.5.0+ 强制 go 1.24,v0.4.1 仅需 go 1.17,反而与现有 toolchain 兼容。 +- 用法:`hotkey.New([]hotkey.Modifier{hotkey.ModCmd, hotkey.ModShift}, hotkey.KeyV)`(Windows 侧 `ModCtrl`),`Cmd+Shift+V` / `Ctrl+Shift+V` 全支持。Windows 走 `RegisterHotKey`,零 cgo、无权限问题。 +- **不调 `mainthread.Init`**——Wails 已自带主线程 Cocoa run loop(同 Fyne/Ebiten/Gio 情形),调它会争夺主线程所有权。 +- **排除** `ZeronoFreya/go-hotkey`(README 自述「只适用于 windows」,非跨平台)。 +- 产品化时建议支持热键重绑(规避 `Cmd+Shift+V` 与其他 app 撞车)。 + +**决定(Quick Send 浮窗):** +- **v2 架构上单 app 单原生窗口**(issue #4413/#1480),无法开第二个原生窗口。v2 内只能用「同窗 HTML 浮层/路由切换」或「另起独立 Wails 进程」近似,都不理想。 +- 无边框 + 置顶:`Frameless:true` + `AlwaysOnTop:true` + `runtime.WindowSetAlwaysOnTop()`;拖拽用 CSS `--wails-draggable:drag`。 +- **「失焦自隐」v2 无现成窗口失焦钩子**——前端 blur/visibilitychange 在 WebView 内对 OS 级窗口失焦不可靠,正解是自写 ObjC `NSWindowDelegate windowDidResignKey`(v2 路线最脏的一块)。 +- **这一条是上 v3 的最强理由**(v3 多窗口每窗一等对象 + 独立生命周期)。 + +--- + +## D6:codesign / notarize / 打包 + CI + +核验结论:「无特殊 entitlement 公证障碍」**核心子句成立**,但捆绑表述 **mixed**,需修正两处。 + +**决定 / 修正:** +- **公证只校验结构性条件**:Hardened Runtime + secure timestamp + Developer ID 全量签名。基于 entitlement 拒绝的**唯一**情形是 `com.apple.security.get-task-allow=true`(调试 entitlement)。accessibility / input-monitoring / 通知授权都不是签名期 entitlement,而是运行期 TCC 授权,公证扫描既不检查也不会因此失败。 +- **修正 1**:全局热键是否需「辅助功能弹窗」**取决于实现路径**,非必然——按 D5 选定的 v0.4.1(Carbon)路径**零权限弹窗**;只有 CGEventTap(v0.5.0+)才强制 Accessibility。 +- **修正 2**:macOS 通知有一道硬门槛被「仅需弹窗」框架略过——`UNUserNotificationCenter` 要求 app 有 `CFBundleIdentifier` 且已签名,dev/未签名不工作。这是对签名的**运行期依赖**(不是公证 entitlement 障碍),但意味着 D3 无法完全脱离 D6 独立验收。 +- **工具链**:用 `notarytool`(`gon`/`altool` 已退役,Wails #3290 的公证失败实为工具链而非 entitlement)。非沙箱 Developer ID 分发**不必**为通知/网络/剪贴板声明专门 entitlement——PLAN.md L180 把这些列入 entitlement 有过度声明之嫌,应收敛。 +- **跨平台纪律**:平台代码用 Go build tag,单 monorepo 单分支,Windows 只 pull 不 commit。 + +--- + +## 关键来源 + +- Casdoor loopback / PKCE / aud:`util/validation.go`(`IsValidOrigin`)、`object/application_util.go`(`IsRedirectUriValid`)、`object/token_jwt.go:544`、`object/token_oauth_util.go`;PR #3301、commit `ea56cfec`;RFC 8252 §7.3、RFC 8707。 +- 本仓库根因:`internal/jwtauth/middleware.go:150`、`internal/httpapi/auth.go:92`、`docker/compose.snippet.yaml:28`、`desktop/build/darwin/Info.plist`。 +- Wails 托盘/窗口:Discussion #4514 / #1438、Issue #4990(closed not planned)、#4413 / #1480;v3.wails.io systray/multiple-windows;本地 `wails/v2@v2.12.0/pkg/options/options.go`。 +- 热键:golang-design/hotkey releases(v0.6.0 CGEventTap、v0.5.0 Go 1.24)、`hotkey_darwin.go` @v0.4.1 vs @main、electrobun #334、dev.to「macOS Global Shortcut」。 +- 剪贴板:golang-design/clipboard `clipboard_windows.go` / `clipboard_darwin.go`、v0.8.0 release;nspasteboard.org;MS Learn `AddClipboardFormatListener` / Clipboard Formats;9to5Mac / MacRumors(macOS detect API);Wails #4132。 +- 通知:Wails PR #4098(macOS 须签名+bundle id)、Issue #4449;go-toast v2 / wintoast pkg.go.dev;Apple LSUIElement 论坛 thread/749689。 +- 公证:Apple Developer News id=09032019a、Eclectic Light「Notarization: the hardened runtime」、Apple 论坛 thread/735223、Wails #3290;Wails #4592 / commit 12b9f3a(macOS 26 Tahoe webview 崩溃修复)。 + +--- + +> 以下两节为 sweep 中两个研究 agent 瞬时 API 断连失败后的补跑产物(2026-06-13 补全),分别覆盖 D1 OAuth 的客户端实现机制与 D6 的具体打包命令。引号风格用弯引号,与上方合成简报的角括号略有出入,内容以本节为准。 + +## OAuth 原生回调通道——Wails 客户端机制(D1 补充) + +本节只覆盖 Wails/Go 客户端侧的实现机制。前提已确定:public client + PKCE,回调用 `http://127.0.0.1:<临时端口>/callback`,Casdoor 对 loopback 任意端口放行,后端 audience 改多值校验。Casdoor 端点:authorize=`/login/oauth/authorize`,token=`/api/login/oauth/access_token`。 + +### 1. loopback vs 自定义 scheme `cdrop://` 的取舍(Wails 语境) + +两条路线都符合 RFC 8252 对原生应用的要求(§7.1 私有 scheme、§7.3 loopback),但在 Wails 下实现成本差异明显。 + +**loopback(`http://127.0.0.1:`):** + +- 实现完全在 Go 进程内:起一个临时 `net/http` 监听、`runtime.BrowserOpenURL` 打开授权页、回调命中本地端口即拿到 `code`。无需任何 OS 级注册。 +- 不需要单实例锁。授权流全程在同一个已运行的进程里闭环,浏览器回跳打到的是本进程开的端口,不会拉起第二个 app 实例。 +- 跨平台零差异:macOS / Windows / Linux 代码一致,只依赖 `net/http`。 +- 必须用 IP 字面量 `127.0.0.1`(或 `[::1]`),不要用 `localhost`——RFC 8252 §8.3:用 `localhost` 可能因 DNS / hosts 解析意外监听到非回环接口。端口用 OS 分配的临时端口(`:0`),符合 RFC 8252 §7.3“服务器 MUST 允许请求时指定任意端口”。 + +**自定义 scheme(`cdrop://callback`):** + +- 需要 OS 级登记:macOS 在 `Info.plist` 写 `CFBundleURLTypes` → `CFBundleURLSchemes`(值 `cdrop`);Windows 要在注册表 `HKEY_CLASSES_ROOT\cdrop`(或 per-user `HKCU\Software\Classes\cdrop`)写 `URL Protocol` 键 + `shell\open\command` 指向 exe;Linux 走 `.desktop` 的 `x-scheme-handler/cdrop`。 +- 必须配单实例锁。点 `cdrop://` 时 OS 会重新拉起 app 的“第二个实例”,深链作为命令行参数传入。需要 `options.App.SingleInstanceLock` 把这个 deep link 转发给首实例,否则 token 落在一个马上要退出的临时进程里,拿不到。 +- macOS 上单实例锁与深链协作有已知坑(见 wails issue #5089:v3 的 single instance lock 与 `OpenedWithURL` 不兼容;v2 也需自己从 `Args` 解析 URL 并 `WindowUnminimise` + `Show`)。 + +scheme 路线的 `SingleInstanceLock` 形态(作为对比,**本项目不采用**): + +```go +// 仅作对比:scheme 方案才需要这一层;loopback 方案不需要 +SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "net.commilitia.cdrop", + OnSecondInstanceLaunch: func(d options.SecondInstanceData) { + // d.Args 里含被 OS 透传的 cdrop://callback?code=...&state=... + for _, arg := range d.Args { + if strings.HasPrefix(arg, "cdrop://") { + runtime.WindowUnminimise(appCtx) // 回调不会自动聚焦窗口 + runtime.Show(appCtx) + runtime.EventsEmit(appCtx, "oauth:callback", arg) + } + } + }, +}, +``` + +**推荐:loopback。** 在 Wails 下它省掉了单实例锁、`Info.plist`/注册表登记、深链解析与跨平台分叉这一整套,授权流在单进程内自洽,代码量和真机调试面都小得多。`cdrop://` 的唯一优势是不占端口、回调 URL 更“原生”,但对瘦客户端不值这些成本。scheme 留作未来若需“浏览器里点 cdrop 链接唤起桌面端”的备选——那是另一个用例(深链唤起),与本次登录回调无关。 + +### 2. `runtime.BrowserOpenURL` 用法与注意 + +签名: + +```go +func BrowserOpenURL(ctx context.Context, url string) +``` + +它调用系统默认浏览器打开 URL,本身不阻塞、无返回值(错误只在内部记日志,见 wails issue #3261,因此打开后要靠 loopback 回调或超时判定结果,不能依赖返回值)。`ctx` 用 Wails 在 `OnStartup` 注入并由你保存的 app context。 + +**为什么不能在 WebView 内用 `window.location = authorizeURL`:** Wails 的前端跑在系统 WebView 里,`window.location` 跳转会把 **WebView 自身**导航到 Casdoor 授权页,等于在嵌入式 user-agent 里做授权——这恰恰违反 RFC 8252 §8.12(原生应用 MUST NOT 用 embedded user-agent 做授权请求)与 §5(MUST 用 external user-agent)。后果实际可见:你的 React UI 整个被 authorize 页替换掉,回跳后 WebView 停在 `127.0.0.1` 而非应用界面,且拿不到系统浏览器里已有的 SSO 会话。正确做法是 `runtime.BrowserOpenURL`,把授权放到独立的系统浏览器,WebView 原地不动,靠 loopback 回调拿结果。 + +### 3. 完整 PKCE 桥接流程 + 代码骨架 + +数据流: + +React 点登录 → 调 Wails 绑定的 Go 方法 `StartLogin()` → Go 生成 PKCE `verifier`/`challenge` 与随机 `state`,起 `127.0.0.1:0` 临时监听,拼 authorize URL → `runtime.BrowserOpenURL` 打开系统浏览器 → 用户授权 → 浏览器回跳 loopback,Go 捕获 `code` 并校验 `state` → **Go 侧**用 `verifier` 完成 token 交换 → `runtime.EventsEmit` 把 token 结果推回前端 → React `EventsOn` 收到后落 store。 + +**token 交换放 Go 侧(强烈建议)。** PKCE `verifier` 与换得的 token 全程不进 WebView/JS 上下文,避免 `verifier` 或 refresh token 暴露在前端可被注入脚本读取的内存里;Go 还能把 token 直接持久化进 OS keychain(后续里程碑)。前端只收一个“登录成功/失败 + 必要的展示信息”事件。 + +Go 端骨架(loopback server 起停、超时、state 校验): + +```go +package backend + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" +) + +const ( + AUTHORIZE_ENDPOINT = "https:///login/oauth/authorize" + TOKEN_ENDPOINT = "https:///api/login/oauth/access_token" + DESKTOP_CLIENT_ID = "" + LOGIN_TIMEOUT = 3 * time.Minute +) + +type App struct +{ + ctx context.Context +} + +// OnStartup 时保存 ctx,供 runtime.* 调用 +func (a *App) OnStartup(ctx context.Context) +{ + a.ctx = ctx +} + +// StartLogin 由前端通过 Wails 绑定调用;整个授权流在 goroutine 里跑, +// 结果通过 EventsEmit 推回,不在此方法的返回值里阻塞前端。 +func (a *App) StartLogin() +{ + go a.runLoginFlow() +} + +func (a *App) runLoginFlow() +{ + verifier, challenge, err := newPKCE() + if err != nil + { + a.emitErr("PKCE 生成失败", err) + return + } + state, err := randString(24) + if err != nil + { + a.emitErr("state 生成失败", err) + return + } + + // :0 让 OS 分配临时端口;只绑回环 + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil + { + a.emitErr("无法监听回环端口", err) + return + } + port := ln.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port) + + // 用 channel 把回调结果带出 HTTP handler + type result struct + { + code string + err error + } + resCh := make(chan result, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) + { + q := r.URL.Query() + if e := q.Get("error"); e != "" + { + writeClosePage(w, false) // 见第 4 节 + resCh <- result{err: fmt.Errorf("授权被拒绝:%s", e)} + return + } + if q.Get("state") != state + { + writeClosePage(w, false) + resCh <- result{err: fmt.Errorf("state 不匹配,疑似 CSRF")} + return + } + writeClosePage(w, true) + resCh <- result{code: q.Get("code")} + }) + + srv := &http.Server{Handler: mux} + go srv.Serve(ln) + defer srv.Close() + + authURL := AUTHORIZE_ENDPOINT + "?" + url.Values{ + "response_type": {"code"}, + "client_id": {DESKTOP_CLIENT_ID}, + "redirect_uri": {redirectURI}, + "scope": {"openid profile groups"}, + "state": {state}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + }.Encode() + + runtime.BrowserOpenURL(a.ctx, authURL) + + select + { + case res := <-resCh: + { + if res.err != nil + { + a.emitErr("授权失败", res.err) + return + } + // token 交换在 Go 侧;verifier 不入 WebView + tok, err := exchangeToken(res.code, verifier, redirectURI) + if err != nil + { + a.emitErr("token 交换失败", err) + return + } + // 持久化(keychain 在后续里程碑)后,只把展示所需信息推前端 + runtime.EventsEmit(a.ctx, "oauth:success", map[string]any{ + "expiresIn": tok.ExpiresIn, + }) + } + case <-time.After(LOGIN_TIMEOUT): + { + a.emitErr("登录超时", fmt.Errorf("%s 内未完成授权", LOGIN_TIMEOUT)) + } + } +} + +func (a *App) emitErr(msg string, err error) +{ + runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{ + "message": msg, + "detail": err.Error(), + }) +} + +// --- PKCE 工具 --- + +func newPKCE() (verifier, challenge string, err error) +{ + verifier, err = randString(64) // RFC 7636:43-128 字符 + if err != nil + { + return "", "", err + } + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +func randString(n int) (string, error) +{ + b := make([]byte, n) + if _, err := rand.Read(b); err != nil + { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b)[:n], nil +} +``` + +前端桥骨架(`EventsOn` 落 store;`EventsOn` 返回取消函数,组件卸载时调用): + +```ts +import { EventsOn } from "../wailsjs/runtime/runtime"; +import { StartLogin } from "../wailsjs/go/backend/App"; + +// 在 app 初始化处注册一次;data 即 Go 端 EventsEmit 的 optionalData(JSON 反序列化后逐个作为参数) +export function wireOAuthEvents(store: AuthStore): () => void +{ + const offSuccess = EventsOn("oauth:success", (payload: { expiresIn: number }) => + { + store.setLoggedIn(payload); + }); + + const offError = EventsOn("oauth:error", (payload: { message: string; detail: string }) => + { + store.setError(payload.message); + }); + + // 返回组合取消函数 + return () => + { + offSuccess(); + offError(); + }; +} + +// 登录按钮 onClick +export async function onLoginClick(): Promise +{ + await StartLogin(); // 仅触发流程;真正结果走上面的事件 +} +``` + +要点:`EventsEmit(ctx, name, data...)` 的 `optionalData` 经 `JSON.stringify` 传输,前端回调以 `callback.apply(null, data)` 接收——发单个对象即对应回调首个参数。`wails dev` 下事件走 WebSocket、生产走原生 IPC,协议一致,无需区分。 + +### 4. loopback server 收尾(回浏览器页 + 关监听) + +拿到 `code` 后给浏览器返回一段静态 HTML,提示已登录并尝试自关闭标签页(`window.close()` 仅对脚本打开的窗口可靠,授权跳转打开的标签页常关不掉,故同时给出可读文案兜底)。随后 server 凭 `defer srv.Close()` 关闭监听释放临时端口。 + +```go +func writeClosePage(w http.ResponseWriter, ok bool) +{ + w.Header().Set("Content-Type", "text/html; charset=utf-8") + msg := "已登录,可关闭本页返回 cdrop。" + if !ok + { + msg = "登录未完成,可关闭本页返回 cdrop 重试。" + } + fmt.Fprintf(w, ``+ + `cdrop`+ + `

%s

`+ + ``, msg) +} +``` + +`srv.Close()` 立即断开监听与活动连接;因为响应已写完且 `code` 已通过 channel 送出,在 `select` 分支返回后由 `defer` 触发关闭即可,不必等浏览器读完。 + +### 已确定 vs 待真机实测(OAuth) + +**已确定(来自官方 API 签名 / RFC):** + +- `runtime.BrowserOpenURL(ctx, url)`、`runtime.EventsEmit(ctx, name, data...)`、前端 `EventsOn(name, cb)` 返回取消函数——签名稳定。 +- loopback 必须用 `127.0.0.1` 而非 `localhost`,端口用临时端口;public client 必须 PKCE;必须用系统浏览器而非 WebView——均为 RFC 8252 明文要求。 +- 选 loopback 即无需 `SingleInstanceLock` / `Info.plist` / 注册表。 + +**待真机实测:** + +- Casdoor 对 `redirect_uri` 的 loopback 匹配是否真的“任意端口”放行(前提已声明,仍需端到端验一次端口变动下不报 `redirect_uri_mismatch`)。 +- `scope` 取值(示例写 `openid profile groups`)需与 Casdoor 实际 scope / groups claim 配置对齐。 +- token 交换接口 `/api/login/oauth/access_token` 的请求体格式(form vs JSON)与 `exchangeToken` 实现需按 Casdoor 实测确定。 +- macOS WebView(WKWebView)与 Windows WebView2 下 `BrowserOpenURL` 是否都正确落到“系统默认浏览器”而非内置浏览器,需各平台各验一次。 +- 浏览器标签页 `window.close()` 在 Chrome / Safari / Edge 上的实际可关闭性(多数授权跳转开的标签关不掉,文案兜底是否够友好)。 + +### 来源(OAuth) + +- Wails Runtime(BrowserOpenURL / Events 签名):https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/runtime +- Wails options(SingleInstanceLock / SecondInstanceData 结构):https://pkg.go.dev/github.com/wailsapp/wails/v2/pkg/options +- Wails Single Instance Lock 指南:https://wails.io/docs/guides/single-instance-lock/ +- Wails Custom Protocol Schemes 指南:https://wails.io/docs/guides/custom-protocol-schemes/ +- Wails Browser 运行时参考:https://wails.io/docs/reference/runtime/browser/ +- macOS 单实例锁与深链不兼容 issue:https://github.com/wailsapp/wails/issues/5089 +- BrowserOpenURL 错误不记录 issue:https://github.com/wailsapp/wails/issues/3261 +- RFC 8252 OAuth 2.0 for Native Apps(§5/§6/§7.1/§7.3/§8.3/§8.12):https://datatracker.ietf.org/doc/html/rfc8252 +- RFC 7636 PKCE:https://datatracker.ietf.org/doc/html/rfc7636 + +--- + +## 打包·签名·公证操作手册(D6 补充) + +本节只补**具体命令与 CI 配置**;entitlement/公证“会不会被拒”的判断已在前文完成。环境基准:Wails v2.12.0(Go),macOS universal 优先 + Windows x64,瘦客户端。 + +> 重要前提:Wails 官方 signing 指南至今仍推荐 `gon`,但该工具已停止维护、且依赖已废弃的 `altool` 语义。本手册一律改用 Apple 现行的 `codesign` + `notarytool` + `stapler` 链路,这是已确定的正确路径。 + +### 1. macOS + +#### 1.1 构建产物与已知坑(已确定) + +```bash +wails build -platform darwin/universal -clean +``` + +- 产物:`build/bin/.app`(universal `.app` bundle,`lipo` 已合并 arm64 + amd64 两份 Go 二进制)。`` 来自 `wails.json` 的 `outputfilename`/项目名。 +- 项目脚手架在 `build/darwin/` 下生成 `Info.plist`(模板 `Info.plist`/`Info.dev.plist`),这是 `.app` 内 `Contents/Info.plist` 的来源。改 bundle ID、版本号、`LSMinimumSystemVersion` 等都改这里。 +- 已知坑: + - universal 构建要求**本机同时具备 arm64 与 amd64 的 CGO 工具链**。在 Apple Silicon 的 `macos-latest`(macos-14/15)runner 上原生满足;不要尝试从 Linux 交叉编译 darwin(CGO + macOS SDK 缺失,社区反复确认不可行)。 + - `wails build` 本身**不签名、不公证 macOS 产物**(与 Windows 的 `-nsis` 不同,没有内建签名参数)。签名/公证完全是构建后的独立步骤。 + - Wails 没有内建 DMG 封装,需自己做(见 1.4)。 + +#### 1.2 codesign(Developer ID Application,启用 hardened runtime) + +公证的前置硬性要求:**hardened runtime(`--options runtime`)+ secure timestamp(`--timestamp`)+ Developer ID Application 证书**。 + +```bash +APP="build/bin/YourApp.app" +IDENTITY="Developer ID Application: Your Name (TEAMID1234)" + +# 先确认本机/keychain 里的签名身份 +security find-identity -v -p codesigning + +codesign --force --deep \ + --options runtime \ + --timestamp \ + --entitlements build/darwin/entitlements.plist \ + --sign "$IDENTITY" \ + "$APP" + +# 校验 +codesign --verify --deep --strict --verbose=2 "$APP" +codesign --display --entitlements - "$APP" +``` + +`--deep` 取舍(已确定):瘦客户端的 `.app` 里只有一个主可执行文件,`--deep` 加不加都能签上;但 `--deep` 是 Apple 明确**不推荐**用于发布签名的(盲签所有嵌套项、掩盖结构问题)。当前瘦客户端单条命令即可;若将来加入嵌套二进制,改为从内到外逐个签。 + +#### 1.3 notarytool 提交 + stapler 装订(已确定) + +`notarytool` **不接受裸 `.app`**,必须先打成 zip(`ditto`,保留 bundle 结构)或 DMG 再提交。 + +```bash +# 1) 用 ditto 打 zip(--keepParent 保留 .app 顶层目录) +ditto -c -k --keepParent "build/bin/YourApp.app" "YourApp.zip" + +# 2A) Apple ID + app-specific password +xcrun notarytool submit "YourApp.zip" \ + --apple-id "you@example.com" \ + --password "$APP_SPECIFIC_PASSWORD" \ + --team-id "TEAMID1234" \ + --wait + +# 2B) 或用 App Store Connect API key(CI 首选) +xcrun notarytool submit "YourApp.zip" \ + --key "AuthKey_KEYID.p8" \ + --key-id "KEYID12345" \ + --issuer "ISSUER-UUID" \ + --wait + +# 失败时拉日志 +xcrun notarytool log \ + --key "AuthKey_KEYID.p8" --key-id "KEYID12345" --issuer "ISSUER-UUID" + +# 3) 公证通过后把 ticket 装订进 .app(staple 的是 .app,不是 zip) +xcrun stapler staple "build/bin/YourApp.app" +xcrun stapler validate "build/bin/YourApp.app" +``` + +#### 1.4 DMG 封装(已确定) + +最终分发媒介里**只放已公证 + 已装订的 `.app`**,再对 DMG 本身签名,然后对 DMG 单独走一遍公证 + staple(推荐)。 + +```bash +# 方式 A:create-dmg(brew install create-dmg) +create-dmg \ + --volname "YourApp" \ + --app-drop-link 450 190 \ + --codesign "Developer ID Application: Your Name (TEAMID1234)" \ + "YourApp.dmg" \ + "build/bin/" # 源目录,里面放已公证的 YourApp.app + +# 方式 B:纯 hdiutil + codesign(无额外依赖) +hdiutil create -volname "YourApp" -srcfolder "build/bin/YourApp.app" -ov -format UDZO "YourApp.dmg" +codesign --force --timestamp --sign "Developer ID Application: Your Name (TEAMID1234)" "YourApp.dmg" + +# 对 DMG 再公证 + staple +xcrun notarytool submit "YourApp.dmg" --key ... --key-id ... --issuer ... --wait +xcrun stapler staple "YourApp.dmg" +``` + +#### 1.5 最小 entitlements.plist(已确定) + +位置:`build/darwin/entitlements.plist`(Wails 约定路径,由 `--entitlements` 引用)。瘦客户端(Developer ID 渠道、**非** Mac App Store)的最小集合只需网络客户端: + +```xml + + + + + com.apple.security.network.client + + + +``` + +- **不要**加 `com.apple.security.app-sandbox`:App Sandbox 只有上架 Mac App Store 才强制;Developer ID 渠道开启沙盒只会平添文件访问麻烦。 +- **purego `dlopen` 系统 framework 在 hardened runtime 下:不需要任何额外 entitlement**。Library Validation 只拦“非 Apple 签名、且非同 Team ID 签名”的代码;系统 framework(Carbon/Cocoa/CoreFoundation 等)由 Apple 签名,`dlopen` 它们**不触发**拦截。因此**不需要** `com.apple.security.cs.disable-library-validation`,也**不需要** `allow-dyld-environment-variables`(这两个键只在加载第三方/未签名 dylib 时才需要,加了反而削弱 Gatekeeper 评估)。 +- **Carbon 全局热键(RegisterEventHotKey)不需要任何 entitlement**——普通系统 API,不涉及输入监控类隐私权限。 + +> 待真机实测:purego 在 hardened runtime 下 `dlopen` 系统 framework 应无碍,但请在“签名 + 公证后的 `.app`”上跑一次,确认热键与网络功能正常、没有因 `dlopen` 路径写法意外触发限制。 + +#### 1.6 Wails 对 darwin 签名的内建支持(已确定) + +`wails build -platform darwin/universal` **无签名参数**,不做 codesign/notarize;签名、公证、DMG 全是构建后的外部步骤。`build/darwin/` 下有 `Info.plist`/`Info.dev.plist`;`entitlements.plist` 非自动生成,需手动放进该路径。universal 由 Wails 内部 `lipo` 合并,无需手动 `lipo`。 + +### 2. Windows + +#### 2.1 构建产物(已确定) + +```bash +wails build -platform windows/amd64 -clean # 仅 exe +wails build -platform windows/amd64 -nsis -clean # 附 NSIS 安装器 +``` + +NSIS 配置在 `build/windows/installer/`(`info.json` + `.nsi`),数据取自 `wails.json` 的 `Info` 段,安装器输出到 `build/bin/`。瘦客户端用内建 NSIS 已足够,不必引入 WiX。 + +#### 2.2 signtool 签名(已确定) + +证书用标准代码签名证书即可(**不需要 EV**)。注:2023 年起 OV 证书私钥须存硬件 token/HSM 或云签名服务,CI 多走云签名(SSL.com eSigner/DigiCert KeyLocker);下面是本地 `.pfx` 经典流程。 + +```powershell +# /fd 文件摘要; /tr RFC3161 时间戳(必须,证书过期后签名仍有效); /td 时间戳摘要 +signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 /f certificate.pfx /p "%CERT_PASSWORD%" .\build\bin\YourApp.exe +``` + +**NSIS 的已知坑(已确定)**:`wails build -nsis` 只签**安装器和卸载器**,**不签**安装后落到 `Program Files` 的主程序 `.exe`。正确顺序:先 signtool 签主 `.exe` → 再生成安装器 → 再签安装器(或在 `project.nsi` 用 `!finalize`/`!uninstfinalize` 调 signtool)。 + +### 3. GitHub Actions + +#### 3.1 macOS job 骨架(已确定) + +`apple-actions/import-codesign-certs`(v7,把 base64 的 `.p12` 导入临时 keychain)+ App Store Connect API key 跑 notarytool。secrets:`APPLE_CERT_P12_BASE64`、`APPLE_CERT_PASSWORD`、`AC_API_KEY_P8`、`AC_API_KEY_ID`、`AC_API_ISSUER_ID`、`APPLE_SIGN_IDENTITY`。 + +```yaml +jobs: + macos: + runs-on: macos-latest # Apple Silicon,原生支持 universal CGO + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive } # cjk-autospace 等 submodule + - uses: actions/setup-go@v5 + with: { go-version: '1.22' } # Wails v2.12 要求 Go >= 1.21 + - uses: actions/setup-node@v4 + with: { node-version: '20' } + - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + - run: wails build -platform darwin/universal -clean + - uses: apple-actions/import-codesign-certs@v7 + with: + p12-file-base64: ${{ secrets.APPLE_CERT_P12_BASE64 }} + p12-password: ${{ secrets.APPLE_CERT_PASSWORD }} + - name: Codesign + run: | + codesign --force --options runtime --timestamp \ + --entitlements build/darwin/entitlements.plist \ + --sign "${{ secrets.APPLE_SIGN_IDENTITY }}" "build/bin/YourApp.app" + codesign --verify --deep --strict --verbose=2 "build/bin/YourApp.app" + - name: Notarize + staple + env: { AC_API_KEY_P8: '${{ secrets.AC_API_KEY_P8 }}' } + run: | + echo "$AC_API_KEY_P8" > /tmp/AuthKey.p8 + ditto -c -k --keepParent "build/bin/YourApp.app" "/tmp/YourApp.zip" + xcrun notarytool submit "/tmp/YourApp.zip" \ + --key /tmp/AuthKey.p8 --key-id "${{ secrets.AC_API_KEY_ID }}" \ + --issuer "${{ secrets.AC_API_ISSUER_ID }}" --wait + xcrun stapler staple "build/bin/YourApp.app" + rm /tmp/AuthKey.p8 + - name: Package DMG + run: | + brew install create-dmg + create-dmg --volname "YourApp" --app-drop-link 450 190 \ + --codesign "${{ secrets.APPLE_SIGN_IDENTITY }}" "YourApp.dmg" "build/bin/" + - uses: actions/upload-artifact@v4 + with: { name: YourApp-macos, path: YourApp.dmg } +``` + +#### 3.2 Windows job 骨架(已确定) + +```yaml + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: { submodules: recursive } + - uses: actions/setup-go@v5 + with: { go-version: '1.22' } + - uses: actions/setup-node@v4 + with: { node-version: '20' } + - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + - run: wails build -platform windows/amd64 -clean + - name: Restore signing certificate + shell: pwsh + run: | + $bytes = [Convert]::FromBase64String("${{ secrets.WIN_SIGNING_CERT }}") + [IO.File]::WriteAllBytes("$env:RUNNER_TEMP\cert.pfx", $bytes) + - name: Sign main exe + shell: pwsh + run: | + $signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" ` + | Sort-Object FullName -Descending | Select-Object -First 1).FullName + & $signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 ` + /f "$env:RUNNER_TEMP\cert.pfx" /p "${{ secrets.WIN_SIGNING_CERT_PASSWORD }}" .\build\bin\YourApp.exe + - run: wails build -platform windows/amd64 -nsis -skipbindings + - name: Sign installer + shell: pwsh + run: | + $signtool = (Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" ` + | Sort-Object FullName -Descending | Select-Object -First 1).FullName + & $signtool sign /fd sha256 /tr http://ts.ssl.com /td sha256 ` + /f "$env:RUNNER_TEMP\cert.pfx" /p "${{ secrets.WIN_SIGNING_CERT_PASSWORD }}" ` + .\build\bin\YourApp-amd64-installer.exe + - uses: actions/upload-artifact@v4 + with: { name: YourApp-windows, path: build/bin/*installer.exe } +``` + +### 已确定 vs 待真机实测(D6) + +- **已确定**:所有命令标志语义、产物路径(`build/bin/.app`、`build/darwin/entitlements.plist`、`build/windows/installer/`)、最小 entitlements(仅 `network.client`)、purego dlopen 系统 framework 不需 `disable-library-validation`、Carbon 热键不需 entitlement、notarytool 必须先 zip/DMG、NSIS 不签主 exe 的坑。 +- **待真机实测**:① 签名+公证后的 `.app` 在真机上 purego dlopen + 全局热键 + 网络全链路实跑;② Windows 安装器实际产物文件名;③ `signtool` 路径随 runner SDK 版本变化、通配是否仍命中。 + +### 来源(D6) + +- Wails Code Signing 指南:https://wails.io/docs/guides/signing/ +- Wails Mac App Store 指南(entitlements/codesign --options=runtime、build/darwin 路径):https://wails.io/docs/guides/mac-appstore/ +- Wails NSIS installer 指南(build/windows/installer、-nsis):https://wails.io/docs/guides/windows-installer/ +- Wails NSIS 不签主 exe 的 issue #3716:https://github.com/wailsapp/wails/issues/3716 +- Wails Crossplatform build(GitHub Actions、darwin/universal 矩阵):https://wails.io/docs/guides/crossplatform-build/ +- notarytool man page:https://keith.github.io/xcode-man-pages/notarytool.1.html +- Apple TN3147 迁移到新公证工具:https://developer.apple.com/documentation/technotes/tn3147-migrating-to-the-latest-notarization-tool +- Apple Disable Library Validation entitlement:https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_cs_disable-library-validation +- Apple 论坛「系统 framework 由 Apple 签名不触发 LV」:https://developer.apple.com/forums/thread/115451 +- apple-actions/import-codesign-certs:https://github.com/Apple-Actions/import-codesign-certs +- create-dmg:https://github.com/create-dmg/create-dmg diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 0000000..50d02c7 --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,372 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" + "github.com/wailsapp/wails/v2/pkg/runtime" + + "cdrop-desktop/platform" +) + +// loginResultFromToken decodes the identity out of the exchanged tokens and +// assembles the payload the WebView store consumes. Shared by login + refresh. +func loginResultFromToken(tok *platform.TokenResult) (*platform.LoginResult, error) { + user, err := platform.UserFromToken(tok.IDToken, tok.AccessToken) + if err != nil { + return nil, err + } + return &platform.LoginResult{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresIn: tok.ExpiresIn, + User: user, + }, nil +} + +// App is the Wails application context bound to the WebView. +type App struct { + ctx context.Context + apiBase string + token *platform.TokenResult // in-memory copy; refresh_token persists in the OS secret store (see session.go) + quitting bool // set by the menu bar "Quit" so beforeClose allows the real exit + startHidden bool // set by main when launched via autostart: come up to the menu bar, no window + + clipSource platform.Source + clipMonitor *platform.Monitor +} + +// NewApp creates a new App application struct. +func NewApp() *App { + return &App{apiBase: envOr("CDROP_API_BASE", "https://drop.commilitia.net")} +} + +// startup is called when the app starts. The context is saved so we can call +// the runtime methods, and the menu bar item is installed (macOS; no-op +// elsewhere). The app launches as a regular Dock app with the window visible. +func (a *App) startup(ctx context.Context) { + a.ctx = ctx + // Load the persisted refresh_token into the Go process so refresh works after + // a restart. The refresh_token stays here — it's never handed to the WebView. + if s, err := platform.LoadSession(); err == nil && s != nil { + a.token = &platform.TokenResult{ + AccessToken: s.AccessToken, + RefreshToken: s.RefreshToken, + ExpiresIn: s.ExpiresIn, + } + } + // Keep the autostart entry in sync with this binary: when it's enabled, + // re-apply so an entry written by an older build (no --hidden flag) or one + // pointing at a since-moved bundle is refreshed to the current path + flags. + // Idempotent and cheap — a single plist / registry write. + if platform.IsLaunchAtLoginEnabled() { + _ = platform.SetLaunchAtLogin(true) + } + a.startClipboardSync(ctx) + platform.InstallStatusBar( + platform.StatusBarMenu{ + Title: "cdrop", + Show: "显示主窗口", + Settings: "设置…", + Quit: "退出 cdrop", + }, + // The native menu-action callbacks fire on the AppKit main thread; calling + // Wails runtime methods synchronously there can re-enter the main run loop + // and crash. Hand off to a goroutine — Wails then dispatches to the main + // thread safely. + platform.StatusBarHandler{ + OnShowWindow: func() { go a.revealWindow() }, + OnOpenSettings: func() { go a.openSettings() }, + OnQuit: func() { go a.requestQuit() }, + }, + ) + // Autostart launch: the window already came up hidden (options.StartHidden). + // On macOS also drop the Dock icon to accessory so only the menu bar item + // remains — the same resting state as close-to-menu-bar. (Windows: this is a + // no-op; the tray is installed above and the hidden window keeps the taskbar + // clear. The menu bar item / tray is the way back in.) + if a.startHidden { + platform.SetDockVisible(false) + } +} + +// buildMenu returns the macOS application menu. Without it the standard +// shortcuts (⌘C/⌘V/⌘X/⌘A/⌘Z and ⌘W/⌘M) don't work. +// +// Quit is a custom ⌘Q item rather than the role AppMenu's, because Wails routes +// both the window close button and ⌘Q through the same OnBeforeClose("Q") +// channel — indistinguishable there. The custom item sets the quitting flag +// first, so OnBeforeClose lets ⌘Q quit while the close button hides. ⌘W likewise +// hides (close-to-menu-bar), matching the lifecycle. +func (a *App) buildMenu() *menu.Menu { + m := menu.NewMenu() + + appSub := menu.NewMenu() + appSub.Append(menu.Text("退出 cdrop", keys.CmdOrCtrl("q"), func(*menu.CallbackData) { + a.requestQuit() + })) + m.Append(menu.SubMenu("cdrop", appSub)) // macOS renders the first menu as the app menu + + m.Append(menu.EditMenu()) // Undo/Redo/Cut/Copy/Paste/SelectAll + + window := menu.NewMenu() + window.Append(menu.Text("最小化", keys.CmdOrCtrl("m"), func(*menu.CallbackData) { + runtime.WindowMinimise(a.ctx) + })) + window.Append(menu.Text("关闭窗口", keys.CmdOrCtrl("w"), func(*menu.CallbackData) { + a.hideWindow() + })) + m.Append(menu.SubMenu("窗口", window)) + return m +} + +// hideWindow tucks the window away to the menu bar (window hidden, Dock icon +// gone). Shared by ⌘W and the window's close button (via beforeClose). +func (a *App) hideWindow() { + runtime.WindowHide(a.ctx) + platform.SetDockVisible(false) +} + +// beforeClose intercepts the window close. Instead of quitting, it hides the +// window and drops to accessory (Dock icon gone, menu bar item remains the way +// back in). The real exit only happens after the menu bar "Quit" sets quitting. +func (a *App) beforeClose(_ context.Context) bool { + if a.quitting { + return false // allow the quit to proceed + } + a.hideWindow() + return true // prevent the close +} + +// revealWindow brings the main window back and restores the Dock icon. Bound to +// the menu bar "Show" item. +func (a *App) revealWindow() { + if a.ctx == nil { + return + } + platform.SetDockVisible(true) + runtime.WindowShow(a.ctx) + runtime.WindowUnminimise(a.ctx) +} + +// openSettings reveals the window and asks the WebView to route to settings. +func (a *App) openSettings() { + a.revealWindow() + runtime.EventsEmit(a.ctx, "menu:settings") +} + +// requestQuit flags an intentional quit so beforeClose lets it through, then +// asks Wails to terminate. +func (a *App) requestQuit() { + a.quitting = true + runtime.Quit(a.ctx) +} + +// startClipboardSync starts the native pasteboard watcher. Local copies are +// emitted to the WebView as "clipboard:native"; the JS layer owns the upload +// decision (auth + the user's sync toggle) and reuses the web app's existing +// upload + dedup path. The download direction is driven by the WebView calling +// ApplyRemoteClipboard. The shared Monitor enforces the loop guard across both +// directions. +func (a *App) startClipboardSync(ctx context.Context) { + a.clipSource = platform.NewClipboardSource() + a.clipMonitor = platform.NewMonitor(func(text string) { + runtime.EventsEmit(a.ctx, "clipboard:native", text) + }) + go platform.Run(ctx, a.clipSource, a.clipMonitor) +} + +// ApplyRemoteClipboard is bound to the WebView. It writes a remote clipboard +// update to the local pasteboard (download direction). The Monitor arms its loop +// guard before the write so the resulting change isn't re-uploaded, and skips +// this device's own echoes. content is plain text at this stage. +func (a *App) ApplyRemoteClipboard(content, sourceDevice string) { + if a.clipMonitor == nil || a.clipSource == nil { + return + } + a.clipMonitor.ApplyRemote(a.clipSource, platform.ClipboardContent{ + Content: content, + ContentType: "text/plain", + SourceDevice: sourceDevice, + }, a.DeviceName()) +} + +// StartLogin is bound to the WebView. It runs the loopback PKCE flow in the +// background and reports the outcome via Wails events ("oauth:success" / +// "oauth:error"). On success it persists the full session (with refresh_token) +// Go-side and emits only the refresh-free SessionView to JS — the refresh_token +// never enters the WebView (see the credential policy in session.go). +func (a *App) StartLogin() { + go func() { + cfg, err := a.resolveOAuthConfig() + if err != nil { + runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) + return + } + flow := platform.NewFlow(cfg, func(u string) { + runtime.BrowserOpenURL(a.ctx, u) + }) + tok, err := flow.Login(a.ctx) + if err != nil { + runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) + return + } + res, err := loginResultFromToken(tok) + if err != nil { + runtime.EventsEmit(a.ctx, "oauth:error", map[string]string{"detail": err.Error()}) + return + } + a.token = tok + if err := platform.SaveSession(*res); err != nil { + runtime.LogWarningf(a.ctx, "persist session failed: %v", err) + } + view := res.View() + runtime.EventsEmit(a.ctx, "oauth:success", &view) + }() +} + +// Refresh is bound to the WebView and takes NO argument: the refresh_token never +// crosses into JS, so Go uses its own in-memory copy. It mints a fresh +// access_token (public-client loopback flow, no client_secret), persists the +// rotated session, and returns only the refresh-free SessionView. The WebView +// calls this when an API request comes back 401. +func (a *App) Refresh() (*platform.SessionView, error) { + if a.token == nil || a.token.RefreshToken == "" { + return nil, errors.New("no refresh token") + } + cfg, err := a.resolveOAuthConfig() + if err != nil { + return nil, err + } + flow := platform.NewFlow(cfg, func(string) {}) + tok, err := flow.Refresh(a.ctx, a.token.RefreshToken) + if err != nil { + return nil, err + } + if tok.RefreshToken == "" { + tok.RefreshToken = a.token.RefreshToken // providers that don't rotate: keep the old one + } + res, err := loginResultFromToken(tok) + if err != nil { + return nil, err + } + a.token = tok + if err := platform.SaveSession(*res); err != nil { + runtime.LogWarningf(a.ctx, "persist refreshed session failed: %v", err) + } + view := res.View() + return &view, nil +} + +// ClearSession is bound to the WebView; called on logout to drop the persisted +// session file (and the Go-side token copy) so the next launch starts logged out. +func (a *App) ClearSession() error { + a.token = nil + return platform.ClearSession() +} + +// GetSettings is bound to the WebView. It returns the persisted desktop config, +// with LaunchAtLogin reflecting the actual LaunchAgent state (the plist is the +// source of truth — the user may have toggled it elsewhere). +func (a *App) GetSettings() platform.DesktopConfig { + cfg, _ := platform.LoadConfig() + cfg.LaunchAtLogin = platform.IsLaunchAtLoginEnabled() + return cfg +} + +// SaveSettings is bound to the WebView. It persists the settings the page owns +// and applies the launch-at-login side effect (install / remove the LaunchAgent). +// +// The page's DesktopConfig has no device_name field, so the incoming cfg always +// carries DeviceName="". We merge onto the current config (preserving DeviceName, +// which is owned by SetDeviceName) instead of overwriting — otherwise saving any +// setting would wipe the persisted device name. Config is written before the +// launch-agent side effect so its failure doesn't lose the other preferences. +func (a *App) SaveSettings(cfg platform.DesktopConfig) error { + cur, _ := platform.LoadConfig() + cur.ClipboardSyncEnabled = cfg.ClipboardSyncEnabled + cur.LaunchAtLogin = cfg.LaunchAtLogin + cur.DownloadDir = cfg.DownloadDir + if err := platform.SaveConfig(cur); err != nil { + return err + } + return platform.SetLaunchAtLogin(cur.LaunchAtLogin) +} + +// DeviceName is bound to the WebView. Returns the persisted device name, or the +// hostname when the user hasn't renamed it — so the desktop skips the per-device +// naming step the browser shows. +func (a *App) DeviceName() string { + return platform.ResolveDeviceName() +} + +// SetDeviceName is bound to the WebView; called when the user renames this device +// so the name survives restart (the WebView's localStorage doesn't, on wails://). +func (a *App) SetDeviceName(name string) error { + cfg, _ := platform.LoadConfig() + cfg.DeviceName = name + return platform.SaveConfig(cfg) +} + +// SaveDownload is bound to the WebView. The transfer pipeline hands a received +// file's bytes (base64 over the bridge) to Go, which writes them into the user's +// configured download directory (default ~/Downloads) without overwriting. The +// absolute path is returned so the page can tell the user where it landed. +func (a *App) SaveDownload(name string, data []byte) (string, error) { + return platform.SaveDownload(platform.ResolveDownloadDir(), name, data) +} + +// ChooseDownloadDir opens a native directory picker (title localised by the +// caller) and returns the chosen path, or "" when the user cancels. The page +// persists the result via SaveSettings. +func (a *App) ChooseDownloadDir(title string) (string, error) { + return runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{Title: title}) +} + +// EffectiveDownloadDir returns the directory downloads currently land in — the +// configured override or the system default — for display in the settings page. +func (a *App) EffectiveDownloadDir() string { + return platform.ResolveDownloadDir() +} + +// resolveOAuthConfig prefers explicit env overrides (dev / a pinned client); +// otherwise it fetches /api/auth/config from the backend and reuses the web +// app's client_id for the desktop's loopback PKCE flow (see desktop/PLAN.md §3). +func (a *App) resolveOAuthConfig() (platform.OAuthConfig, error) { + if env := oauthConfigFromEnv(); env.ClientID != "" { + return env, nil + } + return platform.FetchOAuthConfig(a.ctx, a.apiBase) +} + +func oauthConfigFromEnv() platform.OAuthConfig { + return platform.OAuthConfig{ + AuthorizeURL: os.Getenv("CDROP_OAUTH_AUTHORIZE_URL"), + TokenURL: os.Getenv("CDROP_OAUTH_TOKEN_URL"), + ClientID: os.Getenv("CDROP_OAUTH_CLIENT_ID"), + Scopes: os.Getenv("CDROP_OAUTH_SCOPES"), + } +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// LoggedIn reports whether a token is currently held; bound so the WebView can +// query auth state on startup. +func (a *App) LoggedIn() bool { + return a.token != nil +} + +// Greet returns a greeting for the given name (scaffold demo, kept until the +// shared web UI replaces the default frontend). +func (a *App) Greet(name string) string { + return fmt.Sprintf("Hello %s, It's show time!", name) +} diff --git a/desktop/build/README.md b/desktop/build/README.md new file mode 100644 index 0000000..1ae2f67 --- /dev/null +++ b/desktop/build/README.md @@ -0,0 +1,35 @@ +# Build Directory + +The build directory is used to house all the build files and assets for your application. + +The structure is: + +* bin - Output directory +* darwin - macOS specific files +* windows - Windows specific files + +## Mac + +The `darwin` directory holds files specific to Mac builds. +These may be customised and used as part of the build. To return these files to the default state, simply delete them +and +build with `wails build`. + +The directory contains the following files: + +- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`. +- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`. + +## Windows + +The `windows` directory contains the manifest and rc files used when building with `wails build`. +These may be customised for your application. To return these files to the default state, simply delete them and +build with `wails build`. + +- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to + use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file + will be created using the `appicon.png` file in the build directory. +- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`. +- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer, + as well as the application itself (right click the exe -> properties -> details) +- `wails.exe.manifest` - The main application manifest file. \ No newline at end of file diff --git a/desktop/build/appicon.png b/desktop/build/appicon.png new file mode 100644 index 0000000..13f0724 Binary files /dev/null and b/desktop/build/appicon.png differ diff --git a/desktop/build/darwin/Info.dev.plist b/desktop/build/darwin/Info.dev.plist new file mode 100644 index 0000000..dd09a71 --- /dev/null +++ b/desktop/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + net.commilitia.cdrop + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/desktop/build/darwin/Info.plist b/desktop/build/darwin/Info.plist new file mode 100644 index 0000000..a7409f7 --- /dev/null +++ b/desktop/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + net.commilitia.cdrop + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/desktop/build/windows/icon.ico b/desktop/build/windows/icon.ico new file mode 100644 index 0000000..a88fc93 Binary files /dev/null and b/desktop/build/windows/icon.ico differ diff --git a/desktop/build/windows/info.json b/desktop/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/desktop/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "{{.Info.ProductVersion}}" + }, + "info": { + "0000": { + "ProductVersion": "{{.Info.ProductVersion}}", + "CompanyName": "{{.Info.CompanyName}}", + "FileDescription": "{{.Info.ProductName}}", + "LegalCopyright": "{{.Info.Copyright}}", + "ProductName": "{{.Info.ProductName}}", + "Comments": "{{.Info.Comments}}" + } + } +} \ No newline at end of file diff --git a/desktop/build/windows/installer/project.nsi b/desktop/build/windows/installer/project.nsi new file mode 100644 index 0000000..654ae2e --- /dev/null +++ b/desktop/build/windows/installer/project.nsi @@ -0,0 +1,114 @@ +Unicode true + +#### +## Please note: Template replacements don't work in this file. They are provided with default defines like +## mentioned underneath. +## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo. +## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually +## from outside of Wails for debugging and development of the installer. +## +## For development first make a wails nsis build to populate the "wails_tools.nsh": +## > wails build --target windows/amd64 --nsis +## Then you can call makensis on this file with specifying the path to your binary: +## For a AMD64 only installer: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe +## For a ARM64 only installer: +## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe +## For a installer with both architectures: +## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe +#### +## The following information is taken from the ProjectInfo file, but they can be overwritten here. +#### +## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}" +## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}" +## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}" +## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}" +## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}" +### +## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe" +## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +#### +## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html +#### +## Include the wails tools +#### +!include "wails_tools.nsh" + +# The version information for this two must consist of 4 parts +VIProductVersion "${INFO_PRODUCTVERSION}.0" +VIFileVersion "${INFO_PRODUCTVERSION}.0" + +VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}" +VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer" +VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}" +VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}" +VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}" + +# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware +ManifestDPIAware true + +!include "MUI.nsh" + +!define MUI_ICON "..\icon.ico" +!define MUI_UNICON "..\icon.ico" +# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314 +!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps +!define MUI_ABORTWARNING # This will warn the user if they exit from the installer. + +!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page. +# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer +!insertmacro MUI_PAGE_DIRECTORY # In which folder install page. +!insertmacro MUI_PAGE_INSTFILES # Installing page. +!insertmacro MUI_PAGE_FINISH # Finished installation page. + +!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page + +!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer + +## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1 +#!uninstfinalize 'signtool --file "%1"' +#!finalize 'signtool --file "%1"' + +Name "${INFO_PRODUCTNAME}" +OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file. +InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder). +ShowInstDetails show # This will always show the installation details. + +Function .onInit + !insertmacro wails.checkArchitecture +FunctionEnd + +Section + !insertmacro wails.setShellContext + + !insertmacro wails.webview2runtime + + SetOutPath $INSTDIR + + !insertmacro wails.files + + CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}" + + !insertmacro wails.associateFiles + !insertmacro wails.associateCustomProtocols + + !insertmacro wails.writeUninstaller +SectionEnd + +Section "uninstall" + !insertmacro wails.setShellContext + + RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath + + RMDir /r $INSTDIR + + Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" + Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk" + + !insertmacro wails.unassociateFiles + !insertmacro wails.unassociateCustomProtocols + + !insertmacro wails.deleteUninstaller +SectionEnd diff --git a/desktop/build/windows/installer/wails_tools.nsh b/desktop/build/windows/installer/wails_tools.nsh new file mode 100644 index 0000000..2f6d321 --- /dev/null +++ b/desktop/build/windows/installer/wails_tools.nsh @@ -0,0 +1,249 @@ +# DO NOT EDIT - Generated automatically by `wails build` + +!include "x64.nsh" +!include "WinVer.nsh" +!include "FileFunc.nsh" + +!ifndef INFO_PROJECTNAME + !define INFO_PROJECTNAME "{{.Name}}" +!endif +!ifndef INFO_COMPANYNAME + !define INFO_COMPANYNAME "{{.Info.CompanyName}}" +!endif +!ifndef INFO_PRODUCTNAME + !define INFO_PRODUCTNAME "{{.Info.ProductName}}" +!endif +!ifndef INFO_PRODUCTVERSION + !define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}" +!endif +!ifndef INFO_COPYRIGHT + !define INFO_COPYRIGHT "{{.Info.Copyright}}" +!endif +!ifndef PRODUCT_EXECUTABLE + !define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe" +!endif +!ifndef UNINST_KEY_NAME + !define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}" +!endif +!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}" + +!ifndef REQUEST_EXECUTION_LEVEL + !define REQUEST_EXECUTION_LEVEL "admin" +!endif + +RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}" + +!ifdef ARG_WAILS_AMD64_BINARY + !define SUPPORTS_AMD64 +!endif + +!ifdef ARG_WAILS_ARM64_BINARY + !define SUPPORTS_ARM64 +!endif + +!ifdef SUPPORTS_AMD64 + !ifdef SUPPORTS_ARM64 + !define ARCH "amd64_arm64" + !else + !define ARCH "amd64" + !endif +!else + !ifdef SUPPORTS_ARM64 + !define ARCH "arm64" + !else + !error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY" + !endif +!endif + +!macro wails.checkArchitecture + !ifndef WAILS_WIN10_REQUIRED + !define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later." + !endif + + !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED + !define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}" + !endif + + ${If} ${AtLeastWin10} + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + Goto ok + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + Goto ok + ${EndIf} + !endif + + IfSilent silentArch notSilentArch + silentArch: + SetErrorLevel 65 + Abort + notSilentArch: + MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}" + Quit + ${else} + IfSilent silentWin notSilentWin + silentWin: + SetErrorLevel 64 + Abort + notSilentWin: + MessageBox MB_OK "${WAILS_WIN10_REQUIRED}" + Quit + ${EndIf} + + ok: +!macroend + +!macro wails.files + !ifdef SUPPORTS_AMD64 + ${if} ${IsNativeAMD64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}" + ${EndIf} + !endif + + !ifdef SUPPORTS_ARM64 + ${if} ${IsNativeARM64} + File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}" + ${EndIf} + !endif +!macroend + +!macro wails.writeUninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + SetRegView 64 + WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}" + WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}" + WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S" + + ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 + IntFmt $0 "0x%08X" $0 + WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0" +!macroend + +!macro wails.deleteUninstaller + Delete "$INSTDIR\uninstall.exe" + + SetRegView 64 + DeleteRegKey HKLM "${UNINST_KEY}" +!macroend + +!macro wails.setShellContext + ${If} ${REQUEST_EXECUTION_LEVEL} == "admin" + SetShellVarContext all + ${else} + SetShellVarContext current + ${EndIf} +!macroend + +# Install webview2 by launching the bootstrapper +# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment +!macro wails.webview2runtime + !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT + !define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime" + !endif + + SetRegView 64 + # If the admin key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + + ${If} ${REQUEST_EXECUTION_LEVEL} == "user" + # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + ${If} $0 != "" + Goto ok + ${EndIf} + ${EndIf} + + SetDetailsPrint both + DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}" + SetDetailsPrint listonly + + InitPluginsDir + CreateDirectory "$pluginsdir\webview2bootstrapper" + SetOutPath "$pluginsdir\webview2bootstrapper" + File "tmp\MicrosoftEdgeWebview2Setup.exe" + ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install' + + SetDetailsPrint both + ok: +!macroend + +# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b +!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0" + + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}" + + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open" + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}` + WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}` +!macroend + +!macro APP_UNASSOCIATE EXT FILECLASS + ; Backup the previously associated file class + ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup` + WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0" + + DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}` +!macroend + +!macro wails.associateFiles + ; Create file associations + {{range .Info.FileAssociations}} + !insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + File "..\{{.IconName}}.ico" + {{end}} +!macroend + +!macro wails.unassociateFiles + ; Delete app associations + {{range .Info.FileAssociations}} + !insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}" + + Delete "$INSTDIR\{{.IconName}}.ico" + {{end}} +!macroend + +!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" "" + WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}" +!macroend + +!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL + DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}" +!macroend + +!macro wails.associateCustomProtocols + ; Create custom protocols associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\"" + + {{end}} +!macroend + +!macro wails.unassociateCustomProtocols + ; Delete app custom protocol associations + {{range .Info.Protocols}} + !insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}" + {{end}} +!macroend diff --git a/desktop/build/windows/wails.exe.manifest b/desktop/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/desktop/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/desktop/cmd/clipprobe/main.go b/desktop/cmd/clipprobe/main.go new file mode 100644 index 0000000..0982552 --- /dev/null +++ b/desktop/cmd/clipprobe/main.go @@ -0,0 +1,101 @@ +//go:build darwin + +// clipprobe 是一个只读诊断工具:监听本机剪贴板变化,打印每次变化时 +// NSPasteboard 上挂的全部类型(UTI)。它不读取也不上传任何剪贴板内容, +// 只看「类型」,用于在真机上经验性地确定各应用(尤其密码管理器)复制时 +// 打哪些 UTI,从而让 D4 的敏感内容过滤基于实测数据而非猜测。 +// +// 用法:go run ./cmd/clipprobe(或 go build 后运行二进制),然后依次从 +// 普通文本编辑器 / 1Password / Bitwarden / 浏览器密码自动填充 等复制,观察 +// 各自打印的 UTI。含 org.nspasteboard.ConcealedType / TransientType / +// AutoGeneratedType 或厂商私有 UTI(如 com.agilebits.onepassword)者,即为 +// D4 应跳过上传的敏感内容。 +// +// 实现用 ebitengine/purego + objc 运行时直调 AppKit,无 cgo、可随 Wails +// 交叉编译;调用模式照搬 golang.design/x/clipboard 的 darwin 实现。 +package main + +import ( + "fmt" + "runtime" + "time" + "unsafe" + + "github.com/ebitengine/purego" + "github.com/ebitengine/purego/objc" +) + +func main() { + // objc 调用必须钉在同一 OS 线程上(autorelease pool 的语义要求)。 + runtime.LockOSThread() + + // 加载 AppKit / Foundation,使 NSPasteboard / NSAutoreleasePool 等类可解析。 + for _, fw := range []string{ + "/System/Library/Frameworks/AppKit.framework/AppKit", + "/System/Library/Frameworks/Foundation.framework/Foundation", + } { + if _, err := purego.Dlopen(fw, purego.RTLD_NOW|purego.RTLD_GLOBAL); err != nil { + panic(fmt.Sprintf("clipprobe: 无法加载 %s: %v", fw, err)) + } + } + + pbClass := objc.GetClass("NSPasteboard") + poolClass := objc.GetClass("NSAutoreleasePool") + var ( + selGeneral = objc.RegisterName("generalPasteboard") + selChangeCount = objc.RegisterName("changeCount") + selTypes = objc.RegisterName("types") + selCount = objc.RegisterName("count") + selObjectAt = objc.RegisterName("objectAtIndex:") + selUTF8 = objc.RegisterName("UTF8String") + selAlloc = objc.RegisterName("alloc") + selInit = objc.RegisterName("init") + selRelease = objc.RegisterName("release") + ) + + fmt.Println("clipprobe:监听剪贴板,打印每次变化的 pasteboard 类型(UTI)。Ctrl+C 退出。") + fmt.Println("依次从 普通文本 / 1Password / Bitwarden / 浏览器密码自动填充 复制,观察各自的 UTI。") + fmt.Println("含 org.nspasteboard.ConcealedType / TransientType / AutoGeneratedType") + fmt.Println("或厂商私有 UTI(com.agilebits.onepassword 等)者 = 应跳过上传的敏感内容。") + fmt.Println("(本工具只读类型、不读内容、不上传。)") + fmt.Println("--------") + + last := -1 + for { + pool := objc.ID(poolClass).Send(selAlloc).Send(selInit) + + general := objc.ID(pbClass).Send(selGeneral) + cc := int(general.Send(selChangeCount)) + if cc != last { + last = cc + types := general.Send(selTypes) + n := int(types.Send(selCount)) + fmt.Printf("[changeCount=%d] %d 个类型:\n", cc, n) + for i := 0; i < n; i++ { + item := types.Send(selObjectAt, i) + cstr := item.Send(selUTF8) + fmt.Printf(" - %s\n", cString(uintptr(cstr))) + } + } + + objc.ID(pool).Send(selRelease) + time.Sleep(500 * time.Millisecond) + } +} + +// cString reads a NUL-terminated C string (from -[NSString UTF8String]) into a +// Go string. +func cString(p uintptr) string { + if p == 0 { + return "(nil)" + } + var b []byte + for i := 0; ; i++ { + c := *(*byte)(unsafe.Pointer(p + uintptr(i))) + if c == 0 { + break + } + b = append(b, c) + } + return string(b) +} diff --git a/desktop/frontend/index.html b/desktop/frontend/index.html new file mode 100644 index 0000000..e6fdeed --- /dev/null +++ b/desktop/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + cdrop-desktop + + +
+ + + diff --git a/desktop/frontend/package-lock.json b/desktop/frontend/package-lock.json new file mode 100644 index 0000000..f7e21de --- /dev/null +++ b/desktop/frontend/package-lock.json @@ -0,0 +1,679 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "devDependencies": { + "typescript": "^4.5.4", + "vite": "^3.0.7" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json new file mode 100644 index 0000000..c57eb86 --- /dev/null +++ b/desktop/frontend/package.json @@ -0,0 +1,14 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^4.5.4", + "vite": "^3.0.7" + } +} \ No newline at end of file diff --git a/desktop/frontend/package.json.md5 b/desktop/frontend/package.json.md5 new file mode 100755 index 0000000..33e2d8c --- /dev/null +++ b/desktop/frontend/package.json.md5 @@ -0,0 +1 @@ +f8412e249716e9530567e4bc92d8331c \ No newline at end of file diff --git a/desktop/frontend/src/app.css b/desktop/frontend/src/app.css new file mode 100644 index 0000000..59d06f6 --- /dev/null +++ b/desktop/frontend/src/app.css @@ -0,0 +1,54 @@ +#logo { + display: block; + width: 50%; + height: 50%; + margin: auto; + padding: 10% 0 0; + background-position: center; + background-repeat: no-repeat; + background-size: 100% 100%; + background-origin: content-box; +} + +.result { + height: 20px; + line-height: 20px; + margin: 1.5rem auto; +} + +.input-box .btn { + width: 60px; + height: 30px; + line-height: 30px; + border-radius: 3px; + border: none; + margin: 0 0 0 20px; + padding: 0 8px; + cursor: pointer; +} + +.input-box .btn:hover { + background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%); + color: #333333; +} + +.input-box .input { + border: none; + border-radius: 3px; + outline: none; + height: 30px; + line-height: 30px; + padding: 0 10px; + background-color: rgba(240, 240, 240, 1); + -webkit-font-smoothing: antialiased; +} + +.input-box .input:hover { + border: none; + background-color: rgba(255, 255, 255, 1); +} + +.input-box .input:focus { + border: none; + background-color: rgba(255, 255, 255, 1); +} \ No newline at end of file diff --git a/desktop/frontend/src/assets/fonts/OFL.txt b/desktop/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..9cac04c --- /dev/null +++ b/desktop/frontend/src/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com), + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 0000000..2f9cc59 Binary files /dev/null and b/desktop/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/desktop/frontend/src/assets/images/logo-universal.png b/desktop/frontend/src/assets/images/logo-universal.png new file mode 100644 index 0000000..d63303b Binary files /dev/null and b/desktop/frontend/src/assets/images/logo-universal.png differ diff --git a/desktop/frontend/src/main.ts b/desktop/frontend/src/main.ts new file mode 100644 index 0000000..b89cf63 --- /dev/null +++ b/desktop/frontend/src/main.ts @@ -0,0 +1,50 @@ +import "./style.css"; +import "./app.css"; +import { StartLogin, LoggedIn } from "../wailsjs/go/main/App"; +import { EventsOn } from "../wailsjs/runtime/runtime"; + +// 临时登录验证 UI:在共享 web 界面接入(A2)前,用它端到端验证 D1 +// loopback OAuth 流程。点登录 → Go 起 loopback + 开系统浏览器 → 授权回跳 → +// Go 换 token → oauth:success / oauth:error 事件回到这里。 +document.querySelector("#app")!.innerHTML = ` +
+

cdrop 桌面端

+

检查登录状态……

+ +

登录会在系统浏览器中打开授权页,完成后自动返回。

+
+`; + +const statusEl = document.getElementById("status") as HTMLElement; +const loginBtn = document.getElementById("loginBtn") as HTMLButtonElement; + +function setStatus(text: string): void { + statusEl.innerText = text; +} + +loginBtn.addEventListener("click", () => { + setStatus("已在系统浏览器中打开授权页,请完成登录……"); + loginBtn.disabled = true; + StartLogin(); +}); + +EventsOn("oauth:success", (payload: { expiresIn: number }) => { + setStatus(`登录成功,access token 有效期约 ${payload.expiresIn} 秒。`); + loginBtn.innerText = "重新登录"; + loginBtn.disabled = false; +}); + +EventsOn("oauth:error", (payload: { detail: string }) => { + setStatus(`登录失败:${payload.detail}`); + loginBtn.disabled = false; +}); + +LoggedIn() + .then((yes) => { + setStatus(yes ? "已登录。" : "尚未登录。"); + loginBtn.disabled = false; + }) + .catch(() => { + setStatus("尚未登录。"); + loginBtn.disabled = false; + }); diff --git a/desktop/frontend/src/style.css b/desktop/frontend/src/style.css new file mode 100644 index 0000000..3940d6c --- /dev/null +++ b/desktop/frontend/src/style.css @@ -0,0 +1,26 @@ +html { + background-color: rgba(27, 38, 54, 1); + text-align: center; + color: white; +} + +body { + margin: 0; + color: white; + font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", + "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; +} + +@font-face { + font-family: "Nunito"; + font-style: normal; + font-weight: 400; + src: local(""), + url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2"); +} + +#app { + height: 100vh; + text-align: center; +} diff --git a/desktop/frontend/src/vite-env.d.ts b/desktop/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/desktop/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/frontend/tsconfig.json b/desktop/frontend/tsconfig.json new file mode 100644 index 0000000..6264574 --- /dev/null +++ b/desktop/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": [ + "ESNext", + "DOM" + ], + "moduleResolution": "Node", + "strict": true, + "sourceMap": true, + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "noEmit": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true + }, + "include": [ + "src" + ] +} diff --git a/desktop/go.mod b/desktop/go.mod new file mode 100644 index 0000000..34e5537 --- /dev/null +++ b/desktop/go.mod @@ -0,0 +1,49 @@ +module cdrop-desktop + +go 1.24 + +require ( + fyne.io/systray v1.12.2 + github.com/ebitengine/purego v0.10.1 + github.com/wailsapp/wails/v2 v2.12.0 + github.com/zalando/go-keyring v0.2.8 + golang.design/x/clipboard v0.8.0 + golang.org/x/sys v0.33.0 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.design/x/x11 v0.2.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 // indirect + golang.org/x/image v0.28.0 // indirect + golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/text v0.26.0 // indirect +) + +// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/commilitia/go/pkg/mod diff --git a/desktop/go.sum b/desktop/go.sum new file mode 100644 index 0000000..df7a571 --- /dev/null +++ b/desktop/go.sum @@ -0,0 +1,103 @@ +fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA= +fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.design/x/clipboard v0.8.0 h1:6VEcH28wwcSgKc+vnxHHDWiRjrakTQIAJnPUrt3aOgg= +golang.design/x/clipboard v0.8.0/go.mod h1:s0pwrtA3Q9fgnVtGDmP5ZK/pp55cQKB23esKsjwWhWM= +golang.design/x/x11 v0.2.0 h1:Uiwu2guGihsJX/ZCzpoDPFz5gR/Qntm08mvoBCmRydo= +golang.design/x/x11 v0.2.0/go.mod h1:/5q1mFkdc1rL8mvB7DsQFi6as4tIkBv4FXjcP07mrkE= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476 h1:Wdx0vgH5Wgsw+lF//LJKmWOJBLWX6nprsMqnf99rYDE= +golang.org/x/exp/shiny v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f h1:/n+PL2HlfqeSiDCuhdBbRNlGS/g2fM4OHufalHaTVG8= +golang.org/x/mobile v0.0.0-20250606033058-a2a15c67f36f/go.mod h1:ESkJ836Z6LpG6mTVAhA48LpfW/8fNR0ifStlH2axyfg= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..d1b25d5 --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "embed" + "os" + "runtime" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + + "cdrop-desktop/platform" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + // Create an instance of the app structure + app := NewApp() + + // Reverse-proxy /api/* to the backend so the embedded web UI can keep its + // same-origin relative URLs (see platform.NewAPIProxy). SSE streams through. + apiProxy, err := platform.NewAPIProxy(app.apiBase) + if err != nil { + println("Error:", err.Error()) + return + } + + // Windows: Wails' custom-scheme AssetServer buffers a handler's response until + // it returns, so the long-lived SSE never reaches the WebView (device list + // never loads, status stuck on "connecting"). Serve /api from a loopback HTTP + // origin instead — WebView2's normal network stack streams it — and point the + // page there via the injected api_base. macOS streams the custom scheme fine, + // so it keeps api_base="" (same-origin relative /api through apiProxy). + webAPIBase := "" + if runtime.GOOS == "windows" { + if base, lerr := platform.StartLocalAPIServer(app.apiBase); lerr == nil { + webAPIBase = base + } else { + println("local api server:", lerr.Error()) + } + } + + // Tell the backend this is a native desktop client, not a browser, so the + // device list shows the right kind. The page forwards it as X-Device-Type. + deviceType := "" + switch runtime.GOOS { + case "darwin": + deviceType = "macos" + case "windows": + deviceType = "windows" + case "linux": + deviceType = "linux" + } + + // Restore the persisted login + device name by injecting them into index.html + // at load — the wails:// scheme has no persistent WebView storage (see + // session.go). + session, _ := platform.LoadSession() + + // When the OS starts us at login, the autostart entry appends a --hidden flag + // (see SetLaunchAtLogin). Come up straight to the menu bar / tray with no + // window — the user didn't open the app, so don't steal focus. The frontend + // still loads, so clipboard sync runs in the background. A manual launch has + // no flag and shows the window as usual. + hidden := launchedHidden() + app.startHidden = hidden + + // Create application with options + err = wails.Run(&options.App{ + Title: "cdrop", + Width: 1024, + Height: 768, + StartHidden: hidden, + AssetServer: &assetserver.Options{ + Assets: assets, + Handler: apiProxy, + Middleware: platform.SessionInjectMiddleware(session, platform.ResolveDeviceName(), webAPIBase, deviceType), + }, + BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, + Menu: app.buildMenu(), + OnStartup: app.startup, + OnBeforeClose: app.beforeClose, + Bind: []interface{}{ + app, + }, + }) + + if err != nil { + println("Error:", err.Error()) + } +} + +// launchedHidden reports whether this process was started by the login autostart +// entry, which appends a --hidden flag to the launch command (see +// SetLaunchAtLogin on each platform). Manual launches never carry it. +func launchedHidden() bool { + for _, arg := range os.Args[1:] { + if arg == "--hidden" { + return true + } + } + return false +} diff --git a/desktop/platform/api.go b/desktop/platform/api.go new file mode 100644 index 0000000..4b47032 --- /dev/null +++ b/desktop/platform/api.go @@ -0,0 +1,108 @@ +package platform + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// ClipboardMaxBytes mirrors the backend's CDROP_CLIPBOARD_MAX_BYTES (default +// 64 KiB). Uploads larger than this are rejected client-side, matching the web. +const ClipboardMaxBytes = 64 * 1024 + +// Client is an authenticated client for the cdrop backend API. The access token +// is held here in Go (never in the WebView); deviceName identifies this device +// as the source of clipboard writes via the X-Device-Name header. +type Client struct { + baseURL string + token string + deviceName string + http *http.Client +} + +// NewClient builds a Client. baseURL is the backend origin, e.g. +// https://drop.commilitia.net. +func NewClient(baseURL, accessToken, deviceName string) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: accessToken, + deviceName: deviceName, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ClipboardContent is the cloud clipboard state. UpdatedAt is the server's +// authoritative timestamp (also the ETag); 0 means never written. +type ClipboardContent struct { + Content string `json:"content"` + ContentType string `json:"content_type"` + SourceDevice string `json:"source_device"` + UpdatedAt int64 `json:"updated_at"` +} + +func (c *Client) authReq(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + if c.deviceName != "" { + req.Header.Set("X-Device-Name", c.deviceName) + } + return req, nil +} + +// Clipboard fetches the current cloud clipboard content. +func (c *Client) Clipboard(ctx context.Context) (ClipboardContent, error) { + req, err := c.authReq(ctx, http.MethodGet, "/api/clipboard", nil) + if err != nil { + return ClipboardContent{}, fmt.Errorf("api: build clipboard request: %w", err) + } + resp, err := c.http.Do(req) + if err != nil { + return ClipboardContent{}, fmt.Errorf("api: clipboard request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return ClipboardContent{}, fmt.Errorf("api: GET clipboard status %d", resp.StatusCode) + } + var cc ClipboardContent + if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil { + return ClipboardContent{}, fmt.Errorf("api: decode clipboard: %w", err) + } + return cc, nil +} + +// PutClipboard uploads plain text to the cloud clipboard. +func (c *Client) PutClipboard(ctx context.Context, text string) error { + if len(text) > ClipboardMaxBytes { + return fmt.Errorf("api: clipboard content %d bytes exceeds limit %d", len(text), ClipboardMaxBytes) + } + payload, err := json.Marshal(map[string]string{ + "content": text, + "content_type": "text/plain", + }) + if err != nil { + return fmt.Errorf("api: marshal clipboard: %w", err) + } + req, err := c.authReq(ctx, http.MethodPut, "/api/clipboard", bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("api: build clipboard request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("api: clipboard upload: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return fmt.Errorf("api: PUT clipboard status %d", resp.StatusCode) + } + return nil +} diff --git a/desktop/platform/api_test.go b/desktop/platform/api_test.go new file mode 100644 index 0000000..6ef8760 --- /dev/null +++ b/desktop/platform/api_test.go @@ -0,0 +1,90 @@ +package platform + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPutClipboard(t *testing.T) { + var gotAuth, gotDevice, gotCT string + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/api/clipboard" { + t.Errorf("method/path = %s %s", r.Method, r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + gotDevice = r.Header.Get("X-Device-Name") + gotCT = r.Header.Get("Content-Type") + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok-abc", "my-mac") + if err := c.PutClipboard(context.Background(), "hello world"); err != nil { + t.Fatalf("PutClipboard: %v", err) + } + if gotAuth != "Bearer tok-abc" { + t.Errorf("Authorization = %q, want Bearer tok-abc", gotAuth) + } + if gotDevice != "my-mac" { + t.Errorf("X-Device-Name = %q, want my-mac", gotDevice) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q", gotCT) + } + if gotBody["content"] != "hello world" || gotBody["content_type"] != "text/plain" { + t.Errorf("body = %v", gotBody) + } +} + +func TestPutClipboard_TooLarge(t *testing.T) { + c := NewClient("https://example", "tok", "dev") + big := strings.Repeat("x", ClipboardMaxBytes+1) + if err := c.PutClipboard(context.Background(), big); err == nil { + t.Fatal("want error for oversized content, got nil") + } +} + +func TestClipboard_Get(t *testing.T) { + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ClipboardContent{ + Content: "from cloud", + ContentType: "text/plain", + SourceDevice: "other-device", + UpdatedAt: 1700000000, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, "tok-xyz", "my-mac") + cc, err := c.Clipboard(context.Background()) + if err != nil { + t.Fatalf("Clipboard: %v", err) + } + if gotAuth != "Bearer tok-xyz" { + t.Errorf("Authorization = %q", gotAuth) + } + if cc.Content != "from cloud" || cc.SourceDevice != "other-device" || cc.UpdatedAt != 1700000000 { + t.Errorf("clipboard = %+v", cc) + } +} + +func TestClipboard_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := NewClient(srv.URL, "bad", "dev") + if _, err := c.Clipboard(context.Background()); err == nil { + t.Fatal("want error for 401, got nil") + } +} diff --git a/desktop/platform/appsettings.go b/desktop/platform/appsettings.go new file mode 100644 index 0000000..b703653 --- /dev/null +++ b/desktop/platform/appsettings.go @@ -0,0 +1,104 @@ +package platform + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +// DesktopConfig is the desktop-only preference set, persisted as JSON under the +// user config dir. json tags are the contract with the WebView settings page. +type DesktopConfig struct { + ClipboardSyncEnabled bool `json:"clipboard_sync_enabled"` + LaunchAtLogin bool `json:"launch_at_login"` + DeviceName string `json:"device_name"` // empty = use the hostname + DownloadDir string `json:"download_dir"` // empty = system Downloads dir +} + +// ResolveDeviceName returns the persisted device name, or the hostname when the +// user hasn't renamed it. Persisting it Go-side is required because the WebView's +// localStorage doesn't survive restart on the wails:// scheme (see session.go). +func ResolveDeviceName() string { + cfg, _ := LoadConfig() + if cfg.DeviceName != "" { + return cfg.DeviceName + } + if h, err := os.Hostname(); err == nil && h != "" { + return h + } + return "cdrop-desktop" +} + +// DefaultConfig is what a fresh install gets: clipboard sync on, no autostart. +func DefaultConfig() DesktopConfig { + return DesktopConfig{ClipboardSyncEnabled: true, LaunchAtLogin: false} +} + +// ResolveDownloadDir returns the directory received files land in: the user's +// configured override, or the system Downloads dir when unset. +func ResolveDownloadDir() string { + cfg, _ := LoadConfig() + if cfg.DownloadDir != "" { + return cfg.DownloadDir + } + return DefaultDownloadDir() +} + +// DefaultDownloadDir is ~/Downloads (the OS default download folder; its path is +// stable across locales on macOS/Windows). Falls back to the cwd if the home +// dir can't be resolved. +func DefaultDownloadDir() string { + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, "Downloads") + } + return "." +} + +// configPath is ~/Library/Application Support/cdrop/config.json on macOS +// (os.UserConfigDir resolves the per-OS base). +func configPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cdrop", "config.json"), nil +} + +// LoadConfig reads the persisted config, falling back to defaults when the file +// is absent or unreadable so the app always has a usable preference set. +func LoadConfig() (DesktopConfig, error) { + p, err := configPath() + if err != nil { + return DefaultConfig(), err + } + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return DefaultConfig(), nil + } + if err != nil { + return DefaultConfig(), err + } + cfg := DefaultConfig() + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultConfig(), err + } + return cfg, nil +} + +// SaveConfig writes the config atomically-ish (mkdir + write) under the user +// config dir. +func SaveConfig(cfg DesktopConfig) error { + p, err := configPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0o644) +} diff --git a/desktop/platform/appsettings_test.go b/desktop/platform/appsettings_test.go new file mode 100644 index 0000000..b44d27a --- /dev/null +++ b/desktop/platform/appsettings_test.go @@ -0,0 +1,55 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigRoundTrip(t *testing.T) { + // Redirect the user config dir into a temp HOME so the test never touches + // the real ~/Library/Application Support. + t.Setenv("HOME", t.TempDir()) + + // Absent file → defaults. + got, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig (absent): %v", err) + } + if got != DefaultConfig() { + t.Errorf("absent config should be defaults, got %+v", got) + } + + want := DesktopConfig{ClipboardSyncEnabled: false, LaunchAtLogin: true} + if err := SaveConfig(want); err != nil { + t.Fatalf("SaveConfig: %v", err) + } + got, err = LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if got != want { + t.Errorf("round-trip: got %+v, want %+v", got, want) + } +} + +func TestLoadConfigToleratesGarbage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + p, err := configPath() + if err != nil { + t.Fatalf("configPath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + got, err := LoadConfig() + if err == nil { + t.Error("expected an error for malformed json") + } + if got != DefaultConfig() { + t.Errorf("malformed config should fall back to defaults, got %+v", got) + } +} diff --git a/desktop/platform/clipboard.go b/desktop/platform/clipboard.go new file mode 100644 index 0000000..1192135 --- /dev/null +++ b/desktop/platform/clipboard.go @@ -0,0 +1,151 @@ +package platform + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "sync" +) + +// ClipboardEvent is one observed change of the local clipboard. +type ClipboardEvent struct { + Text string + // Sensitive is set by the platform Source when the pasteboard carries a + // privacy marker — on macOS org.nspasteboard.ConcealedType / TransientType / + // AutoGeneratedType or a known password-manager UTI; on Windows the + // ExcludeClipboardContentFromMonitorProcessing / CanUploadToCloudClipboard + // formats. Sensitive events are never uploaded. + Sensitive bool +} + +// Sink receives clipboard text that the policy decided should be uploaded. +type Sink func(text string) + +// Source is the platform-specific clipboard backend: it watches for local +// changes and can write text back. Implementations live in clipboard_darwin.go +// / clipboard_windows.go. The upstream golang.design/x/clipboard library only +// surfaces the changed text (no change-count, no pasteboard types), so the +// Sensitive flag and the upload policy below are this package's responsibility, +// not the library's. +type Source interface { + // Watch emits an event on every observed local clipboard change until ctx + // is cancelled, then closes the channel. The implementation sets Sensitive + // from the pasteboard's privacy markers. + Watch(ctx context.Context) <-chan ClipboardEvent + // Write replaces the local clipboard text. + Write(text string) error +} + +// Monitor applies cdrop's upload policy on top of a raw clipboard event stream: +// it drops sensitive content, de-duplicates repeats, and suppresses the echo of +// our own writes (the loop guard, PLAN.md R4). Everything here is pure and +// host-independent, so it is unit-tested without a real clipboard; the +// platform Source produces the raw events and performs the OS writes. +// +// Debounce of rapid successive copies is intentionally left to the upload +// callback (wrap it with github.com/bep/debounce in the app wiring) so the +// policy stays free of timers and trivially testable. +type Monitor struct { + upload Sink + + mu sync.Mutex + lastHash string // hash of the last text we accepted (dedup) + selfHash string // hash of the text we most recently wrote ourselves (loop guard) +} + +// NewMonitor builds a Monitor that forwards accepted text to upload. +func NewMonitor(upload Sink) *Monitor { + return &Monitor{upload: upload} +} + +// Observe runs the policy for one raw event. It returns true when the event was +// accepted and forwarded to the sink, false when it was filtered out. Safe to +// call concurrently with ApplyRemote — the watch goroutine and the download +// path share one Monitor. +func (m *Monitor) Observe(ev ClipboardEvent) bool { + if ev.Sensitive || ev.Text == "" { + return false + } + h := hashText(ev.Text) + + m.mu.Lock() + if h == m.selfHash { + // Echo of our own write: consume it once (don't re-upload) and disarm, + // so a genuine later re-copy of the same text still passes. + m.selfHash = "" + m.lastHash = h + m.mu.Unlock() + return false + } + if h == m.lastHash { + m.mu.Unlock() + return false // unchanged content re-observed + } + m.lastHash = h + m.mu.Unlock() + + m.upload(ev.Text) // outside the lock: the sink may block (network / IPC) + return true +} + +// WillWrite must be called immediately before the Source writes text to the +// local clipboard, so the resulting change notification (carrying the same +// text) is recognised as our own echo and not re-uploaded. +func (m *Monitor) WillWrite(text string) { + m.mu.Lock() + m.selfHash = hashText(text) + m.mu.Unlock() +} + +// ApplyRemote writes a remote clipboard change to the local clipboard (the +// download direction), skipping our own uploads echoed back by the server and +// content we already hold. It arms the loop guard before writing, so the +// Source's resulting change event is not re-uploaded. Returns true if it wrote. +func (m *Monitor) ApplyRemote(src Source, cc ClipboardContent, selfDevice string) bool { + if cc.Content == "" { + return false + } + if selfDevice != "" && cc.SourceDevice == selfDevice { + return false // our own upload, echoed back by the server + } + h := hashText(cc.Content) + + m.mu.Lock() + if h == m.lastHash { + m.mu.Unlock() + return false // already the current local content + } + m.selfHash = h // arm loop guard inline (avoid re-locking via WillWrite) + m.mu.Unlock() + + if err := src.Write(cc.Content); err != nil { + return false + } + + m.mu.Lock() + m.lastHash = h + m.mu.Unlock() + return true +} + +// Run drives src's events through m until ctx is cancelled or the source closes. +// It is the glue the app uses; all policy decisions live in Monitor. +func Run(ctx context.Context, src Source, m *Monitor) { + ch := src.Watch(ctx) + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-ch: + if !ok { + return + } + m.Observe(ev) + } + } +} + +func hashText(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} diff --git a/desktop/platform/clipboard_darwin.go b/desktop/platform/clipboard_darwin.go new file mode 100644 index 0000000..279b6e9 --- /dev/null +++ b/desktop/platform/clipboard_darwin.go @@ -0,0 +1,80 @@ +//go:build darwin + +package platform + +/* +#cgo darwin CFLAGS: -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Cocoa +#include + +long cdropPasteboardChangeCount(void); +char *cdropPasteboardReadText(int *sensitive); +void cdropPasteboardWriteText(const char *text); +*/ +import "C" + +import ( + "context" + "time" + "unsafe" +) + +// pasteboardPollInterval is how often the watcher samples the change counter. +// 400ms keeps copies feeling instant without busy-polling. +const pasteboardPollInterval = 400 * time.Millisecond + +// darwinClipboard is the macOS Source: it polls NSPasteboard's changeCount for +// local copies and writes plain text back. NSPasteboard read/write off the main +// thread is the same approach the common clipboard libraries take. +type darwinClipboard struct { + poll time.Duration +} + +// NewClipboardSource returns the platform clipboard Source (macOS). +func NewClipboardSource() Source { + return &darwinClipboard{poll: pasteboardPollInterval} +} + +func (d *darwinClipboard) Watch(ctx context.Context) <-chan ClipboardEvent { + ch := make(chan ClipboardEvent) + go func() { + defer close(ch) + last := C.cdropPasteboardChangeCount() + ticker := time.NewTicker(d.poll) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cc := C.cdropPasteboardChangeCount() + if cc == last { + continue + } + last = cc + + var sensitive C.int + cstr := C.cdropPasteboardReadText(&sensitive) + if cstr == nil { + continue // non-text payload (image / file) — ignore + } + text := C.GoString(cstr) + C.free(unsafe.Pointer(cstr)) + + select { + case ch <- ClipboardEvent{Text: text, Sensitive: sensitive != 0}: + case <-ctx.Done(): + return + } + } + } + }() + return ch +} + +func (d *darwinClipboard) Write(text string) error { + c := C.CString(text) + defer C.free(unsafe.Pointer(c)) + C.cdropPasteboardWriteText(c) + return nil +} diff --git a/desktop/platform/clipboard_darwin.m b/desktop/platform/clipboard_darwin.m new file mode 100644 index 0000000..5b2185f --- /dev/null +++ b/desktop/platform/clipboard_darwin.m @@ -0,0 +1,42 @@ +#import +#include +#include + +// cdropPasteboardChangeCount returns the general pasteboard's monotonic change +// counter. Polling it is the cheap way to detect a copy without a callback API. +long cdropPasteboardChangeCount(void) { + return (long)[[NSPasteboard generalPasteboard] changeCount]; +} + +// cdropPasteboardReadText returns a malloc'd UTF-8 copy of the pasteboard's +// plain-text payload (caller frees), or NULL when there is no text (image / +// file only). It sets *sensitive to 1 when a privacy marker type is present — +// org.nspasteboard.{Concealed,Transient,AutoGenerated}Type — so the upload +// policy can skip it. (Most password managers set no marker; this is a +// best-effort secondary guard behind the server's short TTL.) +char *cdropPasteboardReadText(int *sensitive) { + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + *sensitive = 0; + for (NSString *type in pb.types) { + if ([type isEqualToString:@"org.nspasteboard.ConcealedType"] || + [type isEqualToString:@"org.nspasteboard.TransientType"] || + [type isEqualToString:@"org.nspasteboard.AutoGeneratedType"]) { + *sensitive = 1; + break; + } + } + NSString *s = [pb stringForType:NSPasteboardTypeString]; + if (s == nil) { return NULL; } + const char *utf8 = [s UTF8String]; + if (utf8 == NULL) { return NULL; } + return strdup(utf8); +} + +// cdropPasteboardWriteText replaces the pasteboard's contents with plain text. +void cdropPasteboardWriteText(const char *text) { + NSString *s = [NSString stringWithUTF8String:text]; + if (s == nil) { return; } + NSPasteboard *pb = [NSPasteboard generalPasteboard]; + [pb clearContents]; + [pb setString:s forType:NSPasteboardTypeString]; +} diff --git a/desktop/platform/clipboard_source_other.go b/desktop/platform/clipboard_source_other.go new file mode 100644 index 0000000..7b21cfa --- /dev/null +++ b/desktop/platform/clipboard_source_other.go @@ -0,0 +1,24 @@ +//go:build !darwin && !windows + +package platform + +import "context" + +// noopClipboard is the non-darwin Source: it never emits and silently drops +// writes. Windows/Linux native clipboard backends land when those platforms are +// targeted; until then the rest of the sync wiring compiles and stays inert. +type noopClipboard struct{} + +// NewClipboardSource returns an inert Source outside macOS. +func NewClipboardSource() Source { return noopClipboard{} } + +func (noopClipboard) Watch(ctx context.Context) <-chan ClipboardEvent { + ch := make(chan ClipboardEvent) + go func() { + <-ctx.Done() + close(ch) + }() + return ch +} + +func (noopClipboard) Write(string) error { return nil } diff --git a/desktop/platform/clipboard_test.go b/desktop/platform/clipboard_test.go new file mode 100644 index 0000000..1501711 --- /dev/null +++ b/desktop/platform/clipboard_test.go @@ -0,0 +1,199 @@ +package platform + +import ( + "context" + "testing" + "time" +) + +// collect records everything the monitor decides to upload. +func newCollector() (*[]string, Sink) { + var got []string + return &got, func(text string) { got = append(got, text) } +} + +func TestMonitor_UploadsNewText(t *testing.T) { + got, sink := newCollector() + m := NewMonitor(sink) + + if !m.Observe(ClipboardEvent{Text: "hello"}) { + t.Error("first new text should be accepted") + } + if len(*got) != 1 || (*got)[0] != "hello" { + t.Errorf("uploads = %v, want [hello]", *got) + } +} + +func TestMonitor_DropsSensitive(t *testing.T) { + got, sink := newCollector() + m := NewMonitor(sink) + + if m.Observe(ClipboardEvent{Text: "hunter2", Sensitive: true}) { + t.Error("sensitive content must not be accepted") + } + if len(*got) != 0 { + t.Errorf("uploads = %v, want none", *got) + } +} + +func TestMonitor_DropsEmpty(t *testing.T) { + got, sink := newCollector() + m := NewMonitor(sink) + if m.Observe(ClipboardEvent{Text: ""}) { + t.Error("empty text must not be accepted") + } + if len(*got) != 0 { + t.Errorf("uploads = %v, want none", *got) + } +} + +func TestMonitor_DedupsRepeats(t *testing.T) { + got, sink := newCollector() + m := NewMonitor(sink) + + m.Observe(ClipboardEvent{Text: "same"}) + if m.Observe(ClipboardEvent{Text: "same"}) { + t.Error("identical repeat should be dropped") + } + if len(*got) != 1 { + t.Errorf("uploads = %v, want one", *got) + } + // a different value re-arms uploads + if !m.Observe(ClipboardEvent{Text: "other"}) { + t.Error("changed text should be accepted") + } +} + +func TestMonitor_LoopGuardSuppressesOwnEcho(t *testing.T) { + got, sink := newCollector() + m := NewMonitor(sink) + + // We write "from-cloud" locally, then the OS reports that same change. + m.WillWrite("from-cloud") + if m.Observe(ClipboardEvent{Text: "from-cloud"}) { + t.Error("our own write echo must not be re-uploaded") + } + if len(*got) != 0 { + t.Errorf("uploads = %v, want none (echo suppressed)", *got) + } + + // A genuinely new copy afterwards still uploads. + if !m.Observe(ClipboardEvent{Text: "typed-by-user"}) { + t.Error("subsequent real change should be accepted") + } + if len(*got) != 1 || (*got)[0] != "typed-by-user" { + t.Errorf("uploads = %v, want [typed-by-user]", *got) + } +} + +// fakeSource feeds a fixed list of events into Watch then closes. +type fakeSource struct { + events []ClipboardEvent +} + +func (f *fakeSource) Watch(ctx context.Context) <-chan ClipboardEvent { + ch := make(chan ClipboardEvent) + go func() { + defer close(ch) + for _, ev := range f.events { + select { + case <-ctx.Done(): + return + case ch <- ev: + } + } + }() + return ch +} + +func (f *fakeSource) Write(string) error { return nil } + +func TestRun_DrivesSourceThroughMonitor(t *testing.T) { + got, sink := newCollector() + m := NewMonitor(sink) + src := &fakeSource{events: []ClipboardEvent{ + {Text: "a"}, + {Text: "secret", Sensitive: true}, + {Text: "a"}, // dedup + {Text: "b"}, + }} + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + Run(ctx, src, m) + + want := []string{"a", "b"} + if len(*got) != len(want) || (*got)[0] != "a" || (*got)[1] != "b" { + t.Errorf("uploads = %v, want %v", *got, want) + } +} + +// recordingSource records Write calls; Watch is unused for the download tests. +type recordingSource struct { + writes []string + err error +} + +func (r *recordingSource) Watch(context.Context) <-chan ClipboardEvent { + ch := make(chan ClipboardEvent) + close(ch) + return ch +} + +func (r *recordingSource) Write(text string) error { + if r.err != nil { + return r.err + } + r.writes = append(r.writes, text) + return nil +} + +func TestApplyRemote_WritesNewContent(t *testing.T) { + src := &recordingSource{} + m := NewMonitor(func(string) {}) + if !m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "other"}, "me") { + t.Error("new remote content should be written locally") + } + if len(src.writes) != 1 || src.writes[0] != "hi" { + t.Errorf("writes = %v, want [hi]", src.writes) + } +} + +func TestApplyRemote_SkipsSelfSource(t *testing.T) { + src := &recordingSource{} + m := NewMonitor(func(string) {}) + if m.ApplyRemote(src, ClipboardContent{Content: "hi", SourceDevice: "me"}, "me") { + t.Error("our own echoed upload must be skipped") + } + if len(src.writes) != 0 { + t.Errorf("writes = %v, want none", src.writes) + } +} + +func TestApplyRemote_SkipsEmptyAndCurrent(t *testing.T) { + src := &recordingSource{} + m := NewMonitor(func(string) {}) + if m.ApplyRemote(src, ClipboardContent{Content: "", SourceDevice: "other"}, "me") { + t.Error("empty remote content must be skipped") + } + m.Observe(ClipboardEvent{Text: "X"}) // lastHash now = hash(X) + if m.ApplyRemote(src, ClipboardContent{Content: "X", SourceDevice: "other"}, "me") { + t.Error("content equal to current must be skipped") + } +} + +// The full loop guard across both directions: apply a remote change, then the +// Source's resulting local change event must not be re-uploaded. +func TestApplyRemote_LoopGuardAcrossObserve(t *testing.T) { + src := &recordingSource{} + var uploads []string + m := NewMonitor(func(text string) { uploads = append(uploads, text) }) + + m.ApplyRemote(src, ClipboardContent{Content: "cloud", SourceDevice: "other"}, "me") + if m.Observe(ClipboardEvent{Text: "cloud"}) { + t.Error("echo of an applied-remote write must be suppressed") + } + if len(uploads) != 0 { + t.Errorf("uploads = %v, want none", uploads) + } +} diff --git a/desktop/platform/clipboard_windows.go b/desktop/platform/clipboard_windows.go new file mode 100644 index 0000000..62a8e18 --- /dev/null +++ b/desktop/platform/clipboard_windows.go @@ -0,0 +1,67 @@ +//go:build windows + +package platform + +import ( + "context" + + "golang.design/x/clipboard" +) + +// windowsClipboard is the Windows Source backed by golang.design/x/clipboard +// (pure syscall, no cgo): it watches the clipboard for local copies via +// GetClipboardSequenceNumber polling and writes plain text back. +type windowsClipboard struct { + ready bool +} + +// NewClipboardSource returns the platform clipboard Source (Windows). If the +// clipboard can't be initialised it degrades to an inert source so the rest of +// the sync wiring still runs. +func NewClipboardSource() Source { + return &windowsClipboard{ready: clipboard.Init() == nil} +} + +func (c *windowsClipboard) Watch(ctx context.Context) <-chan ClipboardEvent { + out := make(chan ClipboardEvent) + go func() { + defer close(out) + if !c.ready { + <-ctx.Done() + return + } + changes := clipboard.Watch(ctx, clipboard.FmtText) + for { + select { + case <-ctx.Done(): + return + case data, ok := <-changes: + if !ok { + return + } + if data.Format != clipboard.FmtText || len(data.Bytes) == 0 { + continue + } + // golang.design/x/clipboard surfaces only the text — the Windows + // privacy formats (ExcludeClipboardContentFromMonitorProcessing / + // CanUploadToCloudClipboard) aren't exposed, so Sensitive stays + // false and the server's 5-min TTL is the backstop. A raw + // EnumClipboardFormats check is a possible follow-up. + select { + case out <- ClipboardEvent{Text: string(data.Bytes)}: + case <-ctx.Done(): + return + } + } + } + }() + return out +} + +func (c *windowsClipboard) Write(text string) error { + if !c.ready { + return nil + } + clipboard.Write(clipboard.FmtText, []byte(text)) + return nil +} diff --git a/desktop/platform/config.go b/desktop/platform/config.go new file mode 100644 index 0000000..d4acccb --- /dev/null +++ b/desktop/platform/config.go @@ -0,0 +1,56 @@ +package platform + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +// FetchOAuthConfig pulls the provider coordinates from the cdrop backend's +// /api/auth/config. The desktop reuses the same OAuth client as the web app +// (the client_id published there) and runs its own loopback PKCE flow against +// the provider, instead of the browser's in-page redirect + /api/auth/exchange +// proxy. apiBase is the backend origin, e.g. https://drop.commilitia.net. +func FetchOAuthConfig(ctx context.Context, apiBase string) (OAuthConfig, error) { + endpoint := strings.TrimRight(apiBase, "/") + "/api/auth/config" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return OAuthConfig{}, fmt.Errorf("oauth: build config request: %w", err) + } + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return OAuthConfig{}, fmt.Errorf("oauth: fetch config: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return OAuthConfig{}, fmt.Errorf("oauth: config endpoint status %d", resp.StatusCode) + } + + var c struct { + AuthorizeURL string `json:"authorize_url"` + TokenURL string `json:"token_url"` + ClientID string `json:"client_id"` + Scopes string `json:"scopes"` + } + if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { + return OAuthConfig{}, fmt.Errorf("oauth: decode config: %w", err) + } + + cfg := OAuthConfig{ + AuthorizeURL: c.AuthorizeURL, + TokenURL: c.TokenURL, + ClientID: c.ClientID, + Scopes: c.Scopes, + } + if cfg.AuthorizeURL == "" || cfg.TokenURL == "" || cfg.ClientID == "" { + return OAuthConfig{}, errors.New("oauth: backend config missing authorize_url / token_url / client_id") + } + return cfg, nil +} diff --git a/desktop/platform/config_test.go b/desktop/platform/config_test.go new file mode 100644 index 0000000..bf6d92e --- /dev/null +++ b/desktop/platform/config_test.go @@ -0,0 +1,69 @@ +package platform + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestFetchOAuthConfig_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/auth/config" { + t.Errorf("path = %s, want /api/auth/config", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "auth_mode": "prod", + "authorize_url": "https://casdoor.example/login/oauth/authorize", + "token_url": "https://casdoor.example/api/login/oauth/access_token", + "client_id": "web-client-id", + "redirect_uri": "https://drop.example/oauth/callback", + "scopes": "openid profile groups", + }) + })) + defer srv.Close() + + // trailing slash on apiBase must be tolerated + cfg, err := FetchOAuthConfig(context.Background(), srv.URL+"/") + if err != nil { + t.Fatalf("FetchOAuthConfig: %v", err) + } + if cfg.ClientID != "web-client-id" { + t.Errorf("client_id = %q, want web-client-id (reused web client)", cfg.ClientID) + } + if cfg.TokenURL != "https://casdoor.example/api/login/oauth/access_token" { + t.Errorf("token_url = %q", cfg.TokenURL) + } + if cfg.Scopes != "openid profile groups" { + t.Errorf("scopes = %q", cfg.Scopes) + } +} + +func TestFetchOAuthConfig_Incomplete(t *testing.T) { + // backend missing token_url / client_id (e.g. dev mode) must be rejected. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "auth_mode": "dev", + "authorize_url": "https://casdoor.example/login/oauth/authorize", + }) + })) + defer srv.Close() + + if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil { + t.Fatal("want error for incomplete backend config, got nil") + } +} + +func TestFetchOAuthConfig_BadStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + if _, err := FetchOAuthConfig(context.Background(), srv.URL); err == nil { + t.Fatal("want error for 500 status, got nil") + } +} diff --git a/desktop/platform/download.go b/desktop/platform/download.go new file mode 100644 index 0000000..7fcad34 --- /dev/null +++ b/desktop/platform/download.go @@ -0,0 +1,112 @@ +package platform + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// SaveDownload writes a received file's bytes into dir (empty → system Downloads), +// returning the absolute path written. The name comes from a transfer peer (U2, +// untrusted), so it is sanitized to a bare filename before use — no path +// components survive, so a crafted name like "../../x" cannot escape dir. On a +// name collision a " (n)" suffix is appended rather than overwriting. After a +// successful write the file is best-effort revealed in the OS file manager. +// +// If the configured dir is unwritable (deleted, no permission, read-only), the +// write is retried into the system Downloads dir before giving up — the WebView +// has no usable download fallback (WKWebView ignores blob a[download]), so a +// received file must never be silently lost to a bad setting. The returned path +// reflects where the file actually landed, so the UI can show it. +func SaveDownload(dir, name string, data []byte) (string, error) { + if dir == "" { + dir = DefaultDownloadDir() + } + path, err := writeInto(dir, name, data) + if err == nil { + return path, nil + } + if def := DefaultDownloadDir(); def != dir { + if p, ferr := writeInto(def, name, data); ferr == nil { + return p, nil + } + } + return "", err // report the original (configured-dir) failure +} + +// writeInto sanitizes the name, resolves a non-colliding path under dir, writes +// the bytes, and reveals the result. Returns the absolute path written. +func writeInto(dir, name string, data []byte) (string, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + safe := sanitizeFileName(name) + if safe == "" { + safe = "cdrop-download" + } + target := uniquePath(dir, safe) + if err := os.WriteFile(target, data, 0o644); err != nil { + return "", err + } + revealInFileManager(target) + return target, nil +} + +// sanitizeFileName reduces a peer-supplied name to a single safe filename: +// directory components are dropped (path-traversal defense), control chars are +// removed, and characters reserved by common filesystems are replaced with "_". +func sanitizeFileName(name string) string { + name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts + name = strings.TrimSpace(name) + if name == "." || name == ".." { + return "" + } + + var b strings.Builder + for _, r := range name { + switch { + case r < 0x20: + continue // control characters + case strings.ContainsRune(`/\:*?"<>|`, r): + b.WriteRune('_') // filesystem-reserved + default: + b.WriteRune(r) + } + } + out := strings.TrimSpace(b.String()) + if len(out) > 200 { + out = strings.TrimSpace(out[:200]) + } + return out +} + +// uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does +// not yet exist, so a repeated transfer never silently overwrites a prior file. +func uniquePath(dir, name string) string { + target := filepath.Join(dir, name) + if _, err := os.Stat(target); os.IsNotExist(err) { + return target + } + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + for i := 1; ; i += 1 { + cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext)) + if _, err := os.Stat(cand); os.IsNotExist(err) { + return cand + } + } +} + +// revealInFileManager opens the OS file manager with the saved file selected. +// Best-effort: the save already succeeded, so any error here is ignored. +func revealInFileManager(path string) { + switch runtime.GOOS { + case "darwin": + _ = exec.Command("open", "-R", path).Start() + case "windows": + _ = exec.Command("explorer", "/select,"+path).Start() + } +} diff --git a/desktop/platform/identity.go b/desktop/platform/identity.go new file mode 100644 index 0000000..7dd8d5f --- /dev/null +++ b/desktop/platform/identity.go @@ -0,0 +1,104 @@ +package platform + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// UserInfo is the display identity the WebView store needs (mirrors the web +// app's User shape: id / name / avatar). +type UserInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar"` +} + +// LoginResult is the full result Go keeps internally: tokens + identity. The +// refresh_token never crosses into the WebView — see SessionView and the +// credential policy in session.go. +type LoginResult struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + User UserInfo `json:"user"` +} + +// SessionView is the refresh-token-free projection handed to the WebView (login +// emit, refresh return, boot injection). The refresh_token — the long-lived +// secret — is deliberately omitted: it lives only in the Go process and the +// on-disk session, never in JS or the injected HTML. +type SessionView struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + User UserInfo `json:"user"` +} + +// View projects a LoginResult to the refresh-free SessionView. +func (r LoginResult) View() SessionView { + return SessionView{AccessToken: r.AccessToken, ExpiresIn: r.ExpiresIn, User: r.User} +} + +// UserFromToken extracts the display identity from OIDC tokens WITHOUT verifying +// the signature. This mirrors the backend's extractUser (internal/httpapi/auth.go) +// exactly: prefer the id_token's richer claims, fall back to the access_token; +// name priority preferred_username → name → email → sub; avatar avatar → picture. +// +// Skipping verification is safe for the same reason it is on the backend: the +// auth middleware verifies the access_token on every API call via JWKS, so any +// tamper surfaces there. These claims only drive local display. +func UserFromToken(idToken, accessToken string) (UserInfo, error) { + pick := idToken + if pick == "" { + pick = accessToken + } + claims, err := decodeJWTClaims(pick) + if err != nil { + return UserInfo{}, err + } + + u := UserInfo{ID: claimString(claims, "sub")} + for _, k := range []string{"preferred_username", "name", "email"} { + if v := claimString(claims, k); v != "" { + u.Name = v + break + } + } + if u.Name == "" { + u.Name = u.ID + } + for _, k := range []string{"avatar", "picture"} { + if v := claimString(claims, k); v != "" { + u.Avatar = v + break + } + } + return u, nil +} + +// decodeJWTClaims base64url-decodes the JWT payload segment and unmarshals it. +// No signature check (see UserFromToken). +func decodeJWTClaims(token string) (map[string]any, error) { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return nil, errors.New("oauth: token is not a JWT") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("oauth: decode jwt payload: %w", err) + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("oauth: parse jwt claims: %w", err) + } + return claims, nil +} + +func claimString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/desktop/platform/identity_test.go b/desktop/platform/identity_test.go new file mode 100644 index 0000000..f1b4def --- /dev/null +++ b/desktop/platform/identity_test.go @@ -0,0 +1,94 @@ +package platform + +import ( + "encoding/base64" + "encoding/json" + "testing" +) + +// makeJWT builds a syntactically valid (unsigned) JWT carrying the given claims. +// UserFromToken never verifies the signature, so a dummy header + sig suffice. +func makeJWT(claims map[string]any) string { + payload, _ := json.Marshal(claims) + seg := base64.RawURLEncoding.EncodeToString(payload) + return "eyJhbGciOiJSUzI1NiJ9." + seg + ".sig" +} + +func TestUserFromTokenPrefersIDToken(t *testing.T) { + idTok := makeJWT(map[string]any{"sub": "u-1", "preferred_username": "alice"}) + accessTok := makeJWT(map[string]any{"sub": "u-1", "name": "bob"}) + + u, err := UserFromToken(idTok, accessTok) + if err != nil { + t.Fatalf("UserFromToken: %v", err) + } + if u.ID != "u-1" { + t.Errorf("id: got %q, want u-1", u.ID) + } + if u.Name != "alice" { + t.Errorf("name should come from id_token preferred_username, got %q", u.Name) + } +} + +func TestUserFromTokenNamePriority(t *testing.T) { + cases := []struct { + name string + claims map[string]any + want string + }{ + {"preferred_username wins", map[string]any{"sub": "s", "preferred_username": "p", "name": "n", "email": "e"}, "p"}, + {"name when no preferred", map[string]any{"sub": "s", "name": "n", "email": "e"}, "n"}, + {"email when no name", map[string]any{"sub": "s", "email": "e@x"}, "e@x"}, + {"sub as last resort", map[string]any{"sub": "s"}, "s"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u, err := UserFromToken(makeJWT(c.claims), "") + if err != nil { + t.Fatalf("UserFromToken: %v", err) + } + if u.Name != c.want { + t.Errorf("name: got %q, want %q", u.Name, c.want) + } + }) + } +} + +func TestUserFromTokenAvatarPriority(t *testing.T) { + u, _ := UserFromToken(makeJWT(map[string]any{ + "sub": "s", "avatar": "a.png", "picture": "p.png", + }), "") + if u.Avatar != "a.png" { + t.Errorf("avatar should prefer 'avatar' claim, got %q", u.Avatar) + } + + u2, _ := UserFromToken(makeJWT(map[string]any{"sub": "s", "picture": "p.png"}), "") + if u2.Avatar != "p.png" { + t.Errorf("avatar should fall back to 'picture', got %q", u2.Avatar) + } + + u3, _ := UserFromToken(makeJWT(map[string]any{"sub": "s"}), "") + if u3.Avatar != "" { + t.Errorf("avatar should be empty when no claim, got %q", u3.Avatar) + } +} + +func TestUserFromTokenFallsBackToAccessToken(t *testing.T) { + accessTok := makeJWT(map[string]any{"sub": "u-9", "name": "carol"}) + u, err := UserFromToken("", accessTok) + if err != nil { + t.Fatalf("UserFromToken: %v", err) + } + if u.ID != "u-9" || u.Name != "carol" { + t.Errorf("fallback to access_token failed: %+v", u) + } +} + +func TestUserFromTokenRejectsGarbage(t *testing.T) { + if _, err := UserFromToken("not-a-jwt", ""); err == nil { + t.Error("expected error for non-JWT token") + } + if _, err := UserFromToken("a.!!!notbase64!!!.c", ""); err == nil { + t.Error("expected error for undecodable payload") + } +} diff --git a/desktop/platform/launchagent_darwin.go b/desktop/platform/launchagent_darwin.go new file mode 100644 index 0000000..3f33c96 --- /dev/null +++ b/desktop/platform/launchagent_darwin.go @@ -0,0 +1,117 @@ +//go:build darwin + +package platform + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +// launchAgentLabel is the reverse-DNS label + plist filename for the per-user +// LaunchAgent that starts cdrop at login. +const launchAgentLabel = "net.commilitia.cdrop" + +func launchAgentPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist"), nil +} + +// launchTarget returns the program the LaunchAgent should run. For a packaged +// .app it launches the bundle via `open` (proper LaunchServices activation); +// in a bare dev binary it runs the executable directly. Either way it appends +// --hidden so a login launch comes up to the menu bar with no window (see +// launchedHidden in main); `open --args` forwards the rest as the app's argv. +func launchTarget() ([]string, error) { + exe, err := os.Executable() + if err != nil { + return nil, err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + if bundle := appBundlePath(exe); bundle != "" { + return []string{"/usr/bin/open", bundle, "--args", "--hidden"}, nil + } + return []string{exe, "--hidden"}, nil +} + +// appBundlePath returns the enclosing .app path if exe lives inside one +// (…/Foo.app/Contents/MacOS/binary), else "". +func appBundlePath(exe string) string { + const marker = ".app/Contents/MacOS/" + if i := strings.LastIndex(exe, marker); i != -1 { + return exe[:i+len(".app")] + } + return "" +} + +// SetLaunchAtLogin installs or removes the LaunchAgent plist. Removing a missing +// agent is a no-op success. +func SetLaunchAtLogin(enabled bool) error { + p, err := launchAgentPath() + if err != nil { + return err + } + if !enabled { + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + args, err := launchTarget() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + return os.WriteFile(p, []byte(buildLaunchAgentPlist(launchAgentLabel, args)), 0o644) +} + +// IsLaunchAtLoginEnabled reports whether the LaunchAgent plist is present. +func IsLaunchAtLoginEnabled() bool { + p, err := launchAgentPath() + if err != nil { + return false + } + _, err = os.Stat(p) + return err == nil +} + +func buildLaunchAgentPlist(label string, programArgs []string) string { + var args strings.Builder + for _, a := range programArgs { + fmt.Fprintf(&args, "\n %s", xmlEscape(a)) + } + return fmt.Sprintf(` + + + + Label + %s + ProgramArguments + %s + + RunAtLoad + + + +`, xmlEscape(label), args.String()) +} + +func xmlEscape(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(s) +} diff --git a/desktop/platform/launchagent_darwin_test.go b/desktop/platform/launchagent_darwin_test.go new file mode 100644 index 0000000..1fcf1dd --- /dev/null +++ b/desktop/platform/launchagent_darwin_test.go @@ -0,0 +1,66 @@ +//go:build darwin + +package platform + +import ( + "os" + "strings" + "testing" +) + +func TestSetLaunchAtLoginInstallsAndRemoves(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + if IsLaunchAtLoginEnabled() { + t.Fatal("should start disabled") + } + + if err := SetLaunchAtLogin(true); err != nil { + t.Fatalf("enable: %v", err) + } + if !IsLaunchAtLoginEnabled() { + t.Error("should be enabled after install") + } + + p, _ := launchAgentPath() + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read plist: %v", err) + } + plist := string(data) + if !strings.Contains(plist, "Label") || + !strings.Contains(plist, launchAgentLabel) || + !strings.Contains(plist, "RunAtLoad") { + t.Errorf("plist missing expected keys:\n%s", plist) + } + + // Disabling removes the file; removing again is a no-op success. + if err := SetLaunchAtLogin(false); err != nil { + t.Fatalf("disable: %v", err) + } + if IsLaunchAtLoginEnabled() { + t.Error("should be disabled after removal") + } + if err := SetLaunchAtLogin(false); err != nil { + t.Errorf("removing an absent agent should be a no-op, got %v", err) + } +} + +func TestAppBundlePath(t *testing.T) { + cases := map[string]string{ + "/Apps/cdrop.app/Contents/MacOS/desktop": "/Apps/cdrop.app", + "/usr/local/bin/desktop": "", + } + for in, want := range cases { + if got := appBundlePath(in); got != want { + t.Errorf("appBundlePath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestXMLEscape(t *testing.T) { + got := xmlEscape(`a&b"d"`) + if !strings.Contains(got, "&") || !strings.Contains(got, "<") || !strings.Contains(got, """) { + t.Errorf("xmlEscape did not escape specials: %q", got) + } +} diff --git a/desktop/platform/launchagent_other.go b/desktop/platform/launchagent_other.go new file mode 100644 index 0000000..16ba56f --- /dev/null +++ b/desktop/platform/launchagent_other.go @@ -0,0 +1,13 @@ +//go:build !darwin && !windows + +package platform + +// Launch-at-login on Windows/Linux uses different mechanisms (registry Run key / +// XDG autostart .desktop) and lands with those platform targets. Stubbed so the +// settings wiring compiles everywhere. + +// SetLaunchAtLogin is a no-op outside macOS. +func SetLaunchAtLogin(bool) error { return nil } + +// IsLaunchAtLoginEnabled always reports false outside macOS. +func IsLaunchAtLoginEnabled() bool { return false } diff --git a/desktop/platform/launchagent_windows.go b/desktop/platform/launchagent_windows.go new file mode 100644 index 0000000..a6a57f0 --- /dev/null +++ b/desktop/platform/launchagent_windows.go @@ -0,0 +1,53 @@ +//go:build windows + +package platform + +import ( + "errors" + "os" + + "golang.org/x/sys/windows/registry" +) + +const ( + // runKeyPath is the per-user autostart key — entries here launch at sign-in + // without elevation (HKLM would need admin). + runKeyPath = `Software\Microsoft\Windows\CurrentVersion\Run` + runValueName = "Commilitia Drop" +) + +// SetLaunchAtLogin adds or removes the per-user Run registry entry that starts +// the app at sign-in. The value is the quoted executable path (so a Program +// Files path with spaces stays one argument) followed by --hidden, so a login +// launch comes up to the tray with no window (see launchedHidden in main). +func SetLaunchAtLogin(enabled bool) error { + k, _, err := registry.CreateKey(registry.CURRENT_USER, runKeyPath, registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + + if !enabled { + if err := k.DeleteValue(runValueName); err != nil && !errors.Is(err, registry.ErrNotExist) { + return err + } + return nil + } + + exe, err := os.Executable() + if err != nil { + return err + } + return k.SetStringValue(runValueName, `"`+exe+`" --hidden`) +} + +// IsLaunchAtLoginEnabled reports whether the Run entry is present. +func IsLaunchAtLoginEnabled() bool { + k, err := registry.OpenKey(registry.CURRENT_USER, runKeyPath, registry.QUERY_VALUE) + if err != nil { + return false + } + defer k.Close() + _, _, err = k.GetStringValue(runValueName) + return err == nil +} diff --git a/desktop/platform/localproxy.go b/desktop/platform/localproxy.go new file mode 100644 index 0000000..7f488c2 --- /dev/null +++ b/desktop/platform/localproxy.go @@ -0,0 +1,68 @@ +package platform + +import ( + "fmt" + "net" + "net/http" +) + +// StartLocalAPIServer runs the /api reverse proxy on a loopback HTTP listener and +// returns its base URL (e.g. http://127.0.0.1:54321). +// +// Why: on Windows, Wails' custom-scheme AssetServer buffers a handler's response +// until the handler RETURNS, so a long-lived SSE (/api/hub/events, whose handler +// never returns) never reaches the WebView — the device list never loads and the +// status sticks on "connecting". A real loopback HTTP origin is served by +// WebView2's normal network stack, which streams response bodies incrementally. +// +// The page reaches this via the injected api_base; since its origin differs from +// the listener, CORS is added. No credentials/cookies are used (auth is a Bearer +// header), so a permissive policy is safe. macOS (WKWebView) streams the custom +// scheme fine and doesn't use this. +func StartLocalAPIServer(apiBase string) (string, error) { + proxy, err := NewAPIProxy(apiBase) + if err != nil { + return "", err + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", fmt.Errorf("local api: listen: %w", err) + } + srv := &http.Server{Handler: corsWrap(proxy)} + go func() { _ = srv.Serve(ln) }() + return "http://" + ln.Addr().String(), nil +} + +// corsWrap adds permissive CORS so the WebView page (a different origin than this +// loopback listener) can fetch /api, and answers the preflight. Includes the +// Private Network Access ack since the page origin is also loopback. +func corsWrap(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + h.Add("Vary", "Access-Control-Request-Headers") + h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + // Echo the requested headers so every client header is allowed without + // maintaining a list — the app sends Authorization, X-Device-Name, + // X-Device-Type, and conditional headers like If-None-Match (clipboard + // caching). Missing one fails the preflight as a "Failed to fetch". + if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + h.Set("Access-Control-Allow-Headers", reqHeaders) + } else { + h.Set("Access-Control-Allow-Headers", + "Authorization, Content-Type, X-Device-Name, X-Device-Type, X-Dev-User, If-None-Match") + } + h.Set("Access-Control-Allow-Private-Network", "true") + h.Set("Access-Control-Max-Age", "600") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/desktop/platform/menubar-icon.png b/desktop/platform/menubar-icon.png new file mode 100644 index 0000000..b161480 Binary files /dev/null and b/desktop/platform/menubar-icon.png differ diff --git a/desktop/platform/oauth.go b/desktop/platform/oauth.go new file mode 100644 index 0000000..1d6b654 --- /dev/null +++ b/desktop/platform/oauth.go @@ -0,0 +1,264 @@ +// Package platform implements cdrop desktop's native capabilities — the Go +// shell that the shared web UI drives over Wails bindings. +// +// This file owns the OAuth login flow: an RFC 8252 loopback redirect against +// Casdoor with PKCE as a public client (no secret). The authorization code +// exchange happens here in Go on purpose, so the PKCE verifier and the +// resulting tokens never enter the WebView / JS context. +package platform + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// OAuthConfig carries the provider coordinates. For Casdoor the token endpoint +// is /api/login/oauth/access_token (non-standard) and authorize is +// /login/oauth/authorize; the desktop registers as its own public client. +type OAuthConfig struct { + AuthorizeURL string + TokenURL string + ClientID string + Scopes string // space-separated; empty defaults to "openid profile groups" +} + +// TokenResult is the subset of the token response the shell keeps. IDToken is +// captured so the identity can be decoded from the richer id_token claims (it's +// present when the openid scope is granted — our default scopes include it). +type TokenResult struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// Flow runs one interactive login. openURL is injected so production uses +// Wails' runtime.BrowserOpenURL while tests substitute a fake user-agent. +type Flow struct { + cfg OAuthConfig + openURL func(string) + client *http.Client + timeout time.Duration +} + +// NewFlow builds a Flow with sane defaults: a 30s HTTP client for the token +// exchange and a 3-minute ceiling on the whole interactive login. +func NewFlow(cfg OAuthConfig, openURL func(string)) *Flow { + return &Flow{ + cfg: cfg, + openURL: openURL, + client: &http.Client{Timeout: 30 * time.Second}, + timeout: 3 * time.Minute, + } +} + +// Login performs the loopback PKCE flow and returns the exchanged tokens. +// +// Steps: generate verifier/challenge/state → bind an ephemeral 127.0.0.1 +// listener → open the system browser at the authorize URL → wait for the +// browser to hit the loopback /callback with the code → validate state → +// exchange code+verifier at the token endpoint. The verifier never leaves Go. +func (f *Flow) Login(ctx context.Context) (*TokenResult, error) { + if f.cfg.AuthorizeURL == "" || f.cfg.TokenURL == "" || f.cfg.ClientID == "" { + return nil, errors.New("oauth: incomplete config (authorize_url / token_url / client_id)") + } + + verifier, challenge, err := newPKCE() + if err != nil { + return nil, fmt.Errorf("oauth: pkce: %w", err) + } + state, err := randString(24) + if err != nil { + return nil, fmt.Errorf("oauth: state: %w", err) + } + + // RFC 8252 §7.3: bind the IPv4 loopback literal (not "localhost") on an + // OS-assigned ephemeral port. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("oauth: loopback listen: %w", err) + } + defer ln.Close() + redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", ln.Addr().(*net.TCPAddr).Port) + + type cbResult struct { + code string + err error + } + resCh := make(chan cbResult, 1) + send := func(r cbResult) { + // non-blocking: only the first callback wins, later ones are dropped. + select { + case resCh <- r: + default: + } + } + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if e := q.Get("error"); e != "" { + writeClosePage(w, false) + send(cbResult{err: fmt.Errorf("oauth: authorization denied: %s", e)}) + return + } + if q.Get("state") != state { + writeClosePage(w, false) + send(cbResult{err: errors.New("oauth: state mismatch (possible CSRF)")}) + return + } + code := q.Get("code") + if code == "" { + writeClosePage(w, false) + send(cbResult{err: errors.New("oauth: callback missing code")}) + return + } + writeClosePage(w, true) + send(cbResult{code: code}) + }) + + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(ln) }() + defer srv.Close() + + authURL := f.cfg.AuthorizeURL + "?" + url.Values{ + "response_type": {"code"}, + "client_id": {f.cfg.ClientID}, + "redirect_uri": {redirectURI}, + "scope": {f.scopes()}, + "state": {state}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + }.Encode() + f.openURL(authURL) + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(f.timeout): + return nil, errors.New("oauth: login timed out") + case res := <-resCh: + if res.err != nil { + return nil, res.err + } + return f.exchange(ctx, res.code, verifier, redirectURI) + } +} + +func (f *Flow) scopes() string { + if s := strings.TrimSpace(f.cfg.Scopes); s != "" { + return s + } + return "openid profile groups" +} + +// exchange trades the authorization code + PKCE verifier for tokens. Casdoor +// accepts form-encoded public-client requests (no client_secret). authorize and +// token must carry the identical redirect_uri, so we pass the same value. +func (f *Flow) exchange(ctx context.Context, code, verifier, redirectURI string) (*TokenResult, error) { + return f.postToken(ctx, url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "code_verifier": {verifier}, + "client_id": {f.cfg.ClientID}, + "redirect_uri": {redirectURI}, + }) +} + +// Refresh exchanges a refresh_token for a fresh token (public client, no +// secret). Call it when the access token nears expiry; the access token never +// leaves Go, so refresh is driven from the shell, not the WebView. +func (f *Flow) Refresh(ctx context.Context, refreshToken string) (*TokenResult, error) { + if f.cfg.TokenURL == "" || f.cfg.ClientID == "" { + return nil, errors.New("oauth: incomplete config (token_url / client_id)") + } + if refreshToken == "" { + return nil, errors.New("oauth: empty refresh token") + } + return f.postToken(ctx, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {refreshToken}, + "client_id": {f.cfg.ClientID}, + "scope": {f.scopes()}, + }) +} + +// postToken POSTs a form-encoded request to the token endpoint and decodes the +// response. Shared by the authorization-code exchange and refresh. +func (f *Flow) postToken(ctx context.Context, form url.Values) (*TokenResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.cfg.TokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("oauth: build token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("oauth: token request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oauth: token endpoint status %d", resp.StatusCode) + } + var tok TokenResult + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil { + return nil, fmt.Errorf("oauth: decode token: %w", err) + } + if tok.AccessToken == "" { + return nil, errors.New("oauth: token response missing access_token") + } + return &tok, nil +} + +// --- PKCE (RFC 7636) --- + +func newPKCE() (verifier, challenge string, err error) { + verifier, err = randString(64) // 43–128 chars required; 64 is comfortable + if err != nil { + return "", "", err + } + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +// randString returns n characters of base64url-encoded crypto-random data. The +// base64url alphabet (A–Z a–z 0–9 - _) is a subset of the PKCE unreserved set, +// so the output is a valid code_verifier / state. +func randString(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + s := base64.RawURLEncoding.EncodeToString(b) + if len(s) > n { + s = s[:n] + } + return s, nil +} + +// writeClosePage is what the browser tab lands on after the redirect. The tab +// usually can't be closed by script (it wasn't script-opened), so the text is +// the real affordance; the close attempt is best-effort. +func writeClosePage(w http.ResponseWriter, ok bool) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + msg := "已登录,可关闭本页返回 cdrop。" + if !ok { + msg = "登录未完成,可关闭本页返回 cdrop 重试。" + } + fmt.Fprintf(w, `cdrop`+ + ``+ + `

%s

`, msg) +} diff --git a/desktop/platform/oauth_test.go b/desktop/platform/oauth_test.go new file mode 100644 index 0000000..f3f15cd --- /dev/null +++ b/desktop/platform/oauth_test.go @@ -0,0 +1,187 @@ +package platform + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestNewPKCE(t *testing.T) { + v, c, err := newPKCE() + if err != nil { + t.Fatalf("newPKCE: %v", err) + } + if len(v) != 64 { + t.Errorf("verifier length = %d, want 64", len(v)) + } + sum := sha256.Sum256([]byte(v)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if c != want { + t.Errorf("challenge = %q, want S256(verifier) = %q", c, want) + } + // two calls must differ + v2, _, _ := newPKCE() + if v == v2 { + t.Error("two verifiers collided") + } +} + +// TestLogin_Success drives the whole loopback flow with a fake Casdoor token +// endpoint and a fake browser, with no real network or prod dependency. +func TestLogin_Success(t *testing.T) { + var gotForm url.Values + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("token endpoint ParseForm: %v", err) + } + gotForm = r.PostForm + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-123", + "refresh_token": "rt-456", + "expires_in": 3600, + "token_type": "Bearer", + }) + })) + defer tokenSrv.Close() + + cfg := OAuthConfig{ + AuthorizeURL: "https://casdoor.example/login/oauth/authorize", + TokenURL: tokenSrv.URL, + ClientID: "cdrop-desktop", + } + + // Fake browser: parse the authorize URL, assert it carries PKCE, then GET + // the loopback redirect with a code + the same state (authorized). + openURL := func(authURL string) { + u, err := url.Parse(authURL) + if err != nil { + t.Errorf("parse authURL: %v", err) + return + } + q := u.Query() + if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { + t.Errorf("authorize request missing PKCE challenge: %v", q) + } + if q.Get("client_id") != "cdrop-desktop" { + t.Errorf("authorize client_id = %q", q.Get("client_id")) + } + cb := q.Get("redirect_uri") + "?code=auth-code-xyz&state=" + url.QueryEscape(q.Get("state")) + resp, err := http.Get(cb) + if err != nil { + t.Errorf("callback GET: %v", err) + return + } + _ = resp.Body.Close() + } + + tok, err := NewFlow(cfg, openURL).Login(context.Background()) + if err != nil { + t.Fatalf("Login: %v", err) + } + if tok.AccessToken != "at-123" { + t.Errorf("access_token = %q, want at-123", tok.AccessToken) + } + if tok.RefreshToken != "rt-456" { + t.Errorf("refresh_token = %q, want rt-456", tok.RefreshToken) + } + if tok.ExpiresIn != 3600 { + t.Errorf("expires_in = %d, want 3600", tok.ExpiresIn) + } + + // The exchange must carry the code + PKCE verifier and, as a public client, + // no client_secret. + if gotForm.Get("grant_type") != "authorization_code" { + t.Errorf("grant_type = %q", gotForm.Get("grant_type")) + } + if gotForm.Get("code") != "auth-code-xyz" { + t.Errorf("code = %q", gotForm.Get("code")) + } + if gotForm.Get("code_verifier") == "" { + t.Error("token exchange missing code_verifier") + } + if gotForm.Get("client_secret") != "" { + t.Error("public client must not send client_secret") + } +} + +func TestLogin_StateMismatch(t *testing.T) { + cfg := OAuthConfig{ + AuthorizeURL: "https://casdoor.example/login/oauth/authorize", + TokenURL: "https://casdoor.example/token", + ClientID: "cdrop-desktop", + } + openURL := func(authURL string) { + u, _ := url.Parse(authURL) + redirect := u.Query().Get("redirect_uri") + resp, err := http.Get(redirect + "?code=c&state=WRONG-STATE") + if err == nil { + _ = resp.Body.Close() + } + } + _, err := NewFlow(cfg, openURL).Login(context.Background()) + if err == nil || !strings.Contains(err.Error(), "state mismatch") { + t.Fatalf("want state mismatch error, got %v", err) + } +} + +func TestLogin_IncompleteConfig(t *testing.T) { + _, err := NewFlow(OAuthConfig{ClientID: "only-id"}, func(string) {}).Login(context.Background()) + if err == nil { + t.Fatal("want error for incomplete config, got nil") + } +} + +func TestRefresh_Success(t *testing.T) { + var gotForm url.Values + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + gotForm = r.PostForm + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "new-at", + "refresh_token": "new-rt", + "expires_in": 3600, + }) + })) + defer tokenSrv.Close() + + cfg := OAuthConfig{ + AuthorizeURL: "https://casdoor.example/login/oauth/authorize", + TokenURL: tokenSrv.URL, + ClientID: "cdrop-desktop", + } + tok, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), "old-rt") + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if tok.AccessToken != "new-at" { + t.Errorf("access_token = %q, want new-at", tok.AccessToken) + } + if gotForm.Get("grant_type") != "refresh_token" { + t.Errorf("grant_type = %q", gotForm.Get("grant_type")) + } + if gotForm.Get("refresh_token") != "old-rt" { + t.Errorf("refresh_token = %q", gotForm.Get("refresh_token")) + } + if gotForm.Get("client_secret") != "" { + t.Error("public client must not send client_secret on refresh") + } +} + +func TestRefresh_EmptyToken(t *testing.T) { + cfg := OAuthConfig{ + AuthorizeURL: "https://x/authorize", + TokenURL: "https://x/token", + ClientID: "c", + } + if _, err := NewFlow(cfg, func(string) {}).Refresh(context.Background(), ""); err == nil { + t.Fatal("want error for empty refresh token") + } +} diff --git a/desktop/platform/proxy.go b/desktop/platform/proxy.go new file mode 100644 index 0000000..ae92118 --- /dev/null +++ b/desktop/platform/proxy.go @@ -0,0 +1,55 @@ +package platform + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +// NewAPIProxy returns an http.Handler suitable for Wails' AssetServer.Handler. +// It reverse-proxies every /api/* request to the remote cdrop backend so the +// embedded web UI keeps using same-origin relative URLs — no CORS, no API-base +// rewrite, no backend change. +// +// Why a proxy instead of cross-origin fetch: the WebView serves the bundle from +// a custom scheme, so a relative /api request never reaches the backend, and a +// cross-origin fetch to https://drop.commilitia.net would need server-side CORS +// the backend doesn't send. Proxying keeps the request same-origin end to end. +// +// SSE (/api/hub/events) streams correctly: FlushInterval=-1 flushes each chunk, +// and the darwin WebView ResponseWriter forwards every Write to the +// WKURLSchemeTask immediately (didReceiveData per write), so frames arrive live. +// +// apiBase is the backend origin, e.g. https://drop.commilitia.net. Requests +// that are not under /api/ get a 404 — desktop navigation is client-side, so the +// asset FS already answers "/" and deep-link misses don't happen in normal use. +func NewAPIProxy(apiBase string) (http.Handler, error) { + target, err := url.Parse(apiBase) + if err != nil { + return nil, fmt.Errorf("proxy: parse api base %q: %w", apiBase, err) + } + if target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("proxy: api base %q missing scheme or host", apiBase) + } + + proxy := httputil.NewSingleHostReverseProxy(target) + base := proxy.Director + proxy.Director = func(r *http.Request) { + base(r) + // The backend is vhosted behind Caddy; the Host header drives TLS SNI + // and request routing, so it must be the backend host, not the WebView's + // synthetic origin. + r.Host = target.Host + } + proxy.FlushInterval = -1 // immediate flush — SSE frames / streamed relay bodies + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + proxy.ServeHTTP(w, r) + return + } + http.NotFound(w, r) + }), nil +} diff --git a/desktop/platform/proxy_test.go b/desktop/platform/proxy_test.go new file mode 100644 index 0000000..8a4fd86 --- /dev/null +++ b/desktop/platform/proxy_test.go @@ -0,0 +1,145 @@ +package platform + +import ( + "bufio" + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestProxyForwardsAPIWithBackendHost(t *testing.T) { + var gotPath, gotHost, gotAuth string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotHost = r.Host + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "clip-body") + })) + defer backend.Close() + + proxy, err := NewAPIProxy(backend.URL) + if err != nil { + t.Fatalf("NewAPIProxy: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil) + req.Header.Set("Authorization", "Bearer tok-123") + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", rec.Code) + } + if gotPath != "/api/clipboard" { + t.Errorf("backend path: got %q", gotPath) + } + // Host must be rewritten to the backend (TLS SNI + vhost routing), not the + // WebView's synthetic origin. + if gotHost != backendHost(backend.URL) { + t.Errorf("backend Host header: got %q, want %q", gotHost, backendHost(backend.URL)) + } + if gotAuth != "Bearer tok-123" { + t.Errorf("Authorization not forwarded: got %q", gotAuth) + } + if body := rec.Body.String(); body != "clip-body" { + t.Errorf("body: got %q", body) + } +} + +func TestProxyNonAPIReturns404(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("backend should not be hit for non-/api path") + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + proxy, err := NewAPIProxy(backend.URL) + if err != nil { + t.Fatalf("NewAPIProxy: %v", err) + } + + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("non-/api status: got %d, want 404", rec.Code) + } +} + +func TestProxyForwardsPUT(t *testing.T) { + var gotMethod, gotBody string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + buf := make([]byte, r.ContentLength) + _, _ = r.Body.Read(buf) + gotBody = string(buf) + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + proxy, _ := NewAPIProxy(backend.URL) + req := httptest.NewRequest(http.MethodPut, "/api/clipboard", strings.NewReader("hello")) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if gotMethod != http.MethodPut { + t.Errorf("method: got %q", gotMethod) + } + if gotBody != "hello" { + t.Errorf("body: got %q", gotBody) + } +} + +// TestProxyStreamsSSE checks that the proxy relays an event-stream incrementally +// rather than buffering until the backend closes the connection. The backend +// holds the connection open after the first frame; the client must see that +// frame before the deadline. +func TestProxyStreamsSSE(t *testing.T) { + release := make(chan struct{}) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "event: clipboard:update\ndata: {}\n\n") + w.(http.Flusher).Flush() + <-release // keep the stream open like a real SSE channel + })) + defer backend.Close() + defer close(release) + + proxy, _ := NewAPIProxy(backend.URL) + srv := httptest.NewServer(proxy) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/hub/events", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET sse: %v", err) + } + defer resp.Body.Close() + + done := make(chan string, 1) + go func() { + line, _ := bufio.NewReader(resp.Body).ReadString('\n') + done <- line + }() + + select { + case line := <-done: + if !strings.Contains(line, "clipboard:update") { + t.Errorf("first streamed frame: got %q", line) + } + case <-time.After(1500 * time.Millisecond): + t.Fatal("SSE frame did not arrive incrementally (proxy buffered the stream)") + } +} + +func backendHost(rawURL string) string { + // httptest URLs are http://host:port — strip the scheme. + return strings.TrimPrefix(rawURL, "http://") +} diff --git a/desktop/platform/session.go b/desktop/platform/session.go new file mode 100644 index 0000000..94b71c4 --- /dev/null +++ b/desktop/platform/session.go @@ -0,0 +1,258 @@ +package platform + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + + keyring "github.com/zalando/go-keyring" +) + +// Session persistence lives in a Go-owned file, NOT WebView storage: Wails serves +// the UI from the wails:// custom scheme, and WKWebView does not persist +// localStorage / sessionStorage for custom-scheme origins, so any in-page store +// is wiped on every restart. The persisted session is injected back into the +// page at load time (see SessionInjectMiddleware) so it's available synchronously +// without a binding round-trip. +// +// Credential policy (consistent with the browser): the refresh_token is the +// long-lived secret and is never exposed to the WebView/JS context. It lives only +// in the Go process and at rest; the WebView only ever receives the short-lived +// access_token (via SessionView). Refresh is Go-internal — the WebView calls +// Refresh() with no argument and Go uses its own copy. +// +// At rest the refresh_token is encrypted, not plaintext (R2). It is the one +// credential that lets a holder mint fresh access tokens indefinitely, so +// same-user malware must not be able to read it off disk. We can't store it in +// the OS secret store directly: Casdoor issues JWT refresh_tokens that run to +// ~15 KB, far past go-keyring's per-item limits (macOS ~3 KB command, Windows +// 2560 B). Instead we keep a 32-byte AES key in the secret store (tiny, no size +// issue on any platform) and write the AES-256-GCM ciphertext to the 0600 file. +// Reading the file yields only ciphertext; decrypting it needs the key, which is +// protected exactly as a directly-stored token would be — same posture, no size +// cap. The key goes through go-keyring's system tooling rather than this app's +// code signature, so an ad-hoc / per-build re-sign doesn't invalidate it. +// +// The short-lived access_token and display identity stay as plaintext in the +// file: the access_token is already handed to the WebView and expires fast, so +// it is not the asset R2 protects. + +const ( + keyringService = "cdrop" + // keyringKeyAccount holds the 32-byte AES key (base64) that encrypts the + // refresh_token at rest — NOT the token itself. + keyringKeyAccount = "session_key" +) + +// persistedSession is the on-disk shape. RefreshTokenEnc holds +// base64(nonce||ciphertext) under AES-256-GCM. RefreshToken is only ever set on +// a legacy file (written before R2) or as a degraded fallback when the secret +// store is unavailable; the happy path leaves it empty. +type persistedSession struct { + AccessToken string `json:"access_token"` + RefreshTokenEnc string `json:"refresh_token_enc,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in"` + User UserInfo `json:"user"` +} + +// sessionPath is ~/Library/Application Support/cdrop/session.json on macOS. +func sessionPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "cdrop", "session.json"), nil +} + +// loadOrCreateKey returns the AES-256 key from the OS secret store, minting and +// storing a fresh random one on first use. +func loadOrCreateKey() ([]byte, error) { + if enc, err := keyring.Get(keyringService, keyringKeyAccount); err == nil { + if key, derr := base64.StdEncoding.DecodeString(enc); derr == nil && len(key) == 32 { + return key, nil + } + // A corrupt / wrong-sized value is unusable; fall through and regenerate. + // (Any ciphertext encrypted under the old value becomes undecryptable — + // the user re-logs in, which is acceptable for a corrupt store.) + } else if !errors.Is(err, keyring.ErrNotFound) { + return nil, err + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + if err := keyring.Set(keyringService, keyringKeyAccount, base64.StdEncoding.EncodeToString(key)); err != nil { + return nil, err + } + return key, nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// encryptToken seals the refresh_token under the secret-store key, returning +// base64(nonce||ciphertext). +func encryptToken(plaintext string) (string, error) { + key, err := loadOrCreateKey() + if err != nil { + return "", err + } + gcm, err := newGCM(key) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(sealed), nil +} + +// decryptToken opens a refresh_token ciphertext using the secret-store key. It +// never mints a key: a missing key means the ciphertext is unrecoverable (the +// caller falls back to requiring a fresh login). +func decryptToken(enc string) (string, error) { + keyB64, err := keyring.Get(keyringService, keyringKeyAccount) + if err != nil { + return "", err + } + key, err := base64.StdEncoding.DecodeString(keyB64) + if err != nil || len(key) != 32 { + return "", errors.New("session: malformed key in secret store") + } + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", err + } + gcm, err := newGCM(key) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("session: ciphertext too short") + } + nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +// SaveSession persists the login session. The refresh_token is encrypted at rest +// under a key held in the OS secret store; everything else (short-lived +// access_token + identity) is plaintext in the 0600 file. If the secret store is +// unavailable (e.g. a headless Linux box with no Secret Service), we fall back to +// writing the refresh_token in plaintext so login still works — logging the +// downgrade rather than failing auth outright. +func SaveSession(res LoginResult) error { + p, err := sessionPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + + rec := persistedSession{ + AccessToken: res.AccessToken, + ExpiresIn: res.ExpiresIn, + User: res.User, + } + if res.RefreshToken != "" { + if enc, err := encryptToken(res.RefreshToken); err == nil { + rec.RefreshTokenEnc = enc + } else { + slog.Warn("cdrop: refresh_token kept in session file; OS secret store unavailable", "err", err) + rec.RefreshToken = res.RefreshToken + } + } + + data, err := json.Marshal(rec) + if err != nil { + return err + } + return os.WriteFile(p, data, 0o600) +} + +// LoadSession returns the persisted session, or nil when none is stored / the +// file is unreadable or empty. The refresh_token is decrypted from the file +// using the secret-store key. A legacy file that still embeds the plaintext +// refresh_token (written before R2, or by the fallback above) is re-encrypted on +// first load and stripped of its plaintext. +func LoadSession() (*LoginResult, error) { + p, err := sessionPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var rec persistedSession + if err := json.Unmarshal(data, &rec); err != nil { + return nil, err + } + if rec.AccessToken == "" { + return nil, nil + } + + res := &LoginResult{ + AccessToken: rec.AccessToken, + ExpiresIn: rec.ExpiresIn, + User: rec.User, + } + switch { + case rec.RefreshToken != "": + // Plaintext on disk (legacy or fallback): keep it in memory and re-save, + // which encrypts it and strips the plaintext (no-ops if the store is + // still unavailable, leaving the file as-is). + res.RefreshToken = rec.RefreshToken + if err := SaveSession(*res); err != nil { + slog.Warn("cdrop: refresh_token migration to secret store failed", "err", err) + } + case rec.RefreshTokenEnc != "": + if rt, err := decryptToken(rec.RefreshTokenEnc); err == nil { + res.RefreshToken = rt + } else { + // Key gone / ciphertext corrupt: drop to an access-token-only session; + // the app will require a fresh login once the access_token lapses. + slog.Warn("cdrop: decrypt refresh_token failed; re-login will be required", "err", err) + } + } + return res, nil +} + +// ClearSession removes the persisted session (logout): both the file and the +// encryption key held in the OS secret store. Absent entries are a no-op. +func ClearSession() error { + p, err := sessionPath() + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := keyring.Delete(keyringService, keyringKeyAccount); err != nil && + !errors.Is(err, keyring.ErrNotFound) { + slog.Warn("cdrop: clear session key from secret store failed", "err", err) + } + return nil +} diff --git a/desktop/platform/session_test.go b/desktop/platform/session_test.go new file mode 100644 index 0000000..8cbcdef --- /dev/null +++ b/desktop/platform/session_test.go @@ -0,0 +1,197 @@ +package platform + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + keyring "github.com/zalando/go-keyring" +) + +// withTempSession isolates session storage: an in-memory keyring mock (so tests +// never touch the real Keychain / Credential Manager) plus a temp HOME so +// sessionPath resolves under t.TempDir(). +func withTempSession(t *testing.T) { + t.Helper() + keyring.MockInit() + dir := t.TempDir() + // os.UserConfigDir honours XDG_CONFIG_HOME on Linux and HOME on macOS. + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("HOME", dir) +} + +// readSessionFile returns the parsed on-disk record plus its raw bytes. +func readSessionFile(t *testing.T) (persistedSession, []byte) { + t.Helper() + p, err := sessionPath() + if err != nil { + t.Fatalf("sessionPath: %v", err) + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return persistedSession{}, nil + } + t.Fatalf("read session file: %v", err) + } + var rec persistedSession + if err := json.Unmarshal(data, &rec); err != nil { + t.Fatalf("unmarshal session file: %v", err) + } + return rec, data +} + +// The refresh_token must be encrypted at rest (R2): the plaintext never appears +// in the file, the keyring holds only the small AES key, and the round-trip +// returns the token in memory. +func TestSaveSession_RefreshTokenEncryptedAtRest(t *testing.T) { + withTempSession(t) + + res := LoginResult{ + AccessToken: "access-abc", + RefreshToken: "refresh-xyz-secret", + ExpiresIn: 3600, + User: UserInfo{ID: "u1", Name: "Tester"}, + } + if err := SaveSession(res); err != nil { + t.Fatalf("save: %v", err) + } + + rec, raw := readSessionFile(t) + if rec.RefreshToken != "" { + t.Errorf("plaintext refresh_token must not be in the file, got %q", rec.RefreshToken) + } + if rec.RefreshTokenEnc == "" { + t.Error("refresh_token_enc must be present") + } + if strings.Contains(string(raw), "refresh-xyz-secret") { + t.Error("the plaintext token must not appear anywhere in the file bytes") + } + // The keyring holds the AES key, not the token. + if k, err := keyring.Get(keyringService, keyringKeyAccount); err != nil || k == "" { + t.Errorf("keyring must hold the session key: %q err %v", k, err) + } + + loaded, err := LoadSession() + if err != nil || loaded == nil { + t.Fatalf("load: %v (nil=%v)", err, loaded == nil) + } + if loaded.RefreshToken != "refresh-xyz-secret" || loaded.AccessToken != "access-abc" { + t.Errorf("loaded tokens: rt=%q at=%q", loaded.RefreshToken, loaded.AccessToken) + } +} + +// A large JWT refresh_token (Casdoor issues ~15 KB) must round-trip — this is +// exactly the case go-keyring can't store directly (its per-item limit is a few +// KB), and the reason the token is encrypted into the file instead. +func TestSaveSession_LargeTokenRoundTrips(t *testing.T) { + withTempSession(t) + + big := strings.Repeat("a", 16000) + res := LoginResult{AccessToken: "acc", RefreshToken: big, ExpiresIn: 3600, User: UserInfo{ID: "u"}} + if err := SaveSession(res); err != nil { + t.Fatalf("save large token: %v", err) + } + rec, raw := readSessionFile(t) + if rec.RefreshToken != "" { + t.Error("large token must not be stored in plaintext") + } + if strings.Contains(string(raw), big) { + t.Error("large plaintext token must not appear in the file bytes") + } + loaded, err := LoadSession() + if err != nil || loaded == nil || loaded.RefreshToken != big { + t.Fatalf("large token did not round-trip: err %v", err) + } +} + +// A legacy file with a plaintext refresh_token (written before R2) must be +// re-encrypted on first load and stripped of its plaintext. +func TestLoadSession_MigratesLegacyPlaintext(t *testing.T) { + withTempSession(t) + + p, _ := sessionPath() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Seed a legacy file: plaintext refresh_token, no ciphertext field. + data, _ := json.Marshal(persistedSession{ + AccessToken: "access-abc", + RefreshToken: "legacy-plain", + ExpiresIn: 3600, + User: UserInfo{ID: "u1"}, + }) + if err := os.WriteFile(p, data, 0o600); err != nil { + t.Fatalf("seed legacy file: %v", err) + } + + loaded, err := LoadSession() + if err != nil || loaded == nil { + t.Fatalf("load: %v", err) + } + if loaded.RefreshToken != "legacy-plain" { + t.Errorf("migrated session must carry the token in memory, got %q", loaded.RefreshToken) + } + rec, raw := readSessionFile(t) + if rec.RefreshToken != "" { + t.Error("plaintext must be stripped from disk after migration") + } + if rec.RefreshTokenEnc == "" || strings.Contains(string(raw), "legacy-plain") { + t.Error("migrated token must be encrypted in the file") + } +} + +func TestSessionRoundTripAndClear(t *testing.T) { + withTempSession(t) + + if got, err := LoadSession(); err != nil || got != nil { + t.Fatalf("absent session should be nil; got %+v err %v", got, err) + } + + want := LoginResult{ + AccessToken: "acc", + RefreshToken: "ref", + ExpiresIn: 3600, + User: UserInfo{ID: "u-1", Name: "alice", Avatar: "a.png"}, + } + if err := SaveSession(want); err != nil { + t.Fatalf("SaveSession: %v", err) + } + got, err := LoadSession() + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + if got == nil || *got != want { + t.Errorf("round-trip: got %+v, want %+v", got, want) + } + + if err := ClearSession(); err != nil { + t.Fatalf("ClearSession: %v", err) + } + if got, _ := LoadSession(); got != nil { + t.Error("session should be gone after ClearSession") + } + // The key is dropped too — no orphaned secret-store entry. + if _, err := keyring.Get(keyringService, keyringKeyAccount); err != keyring.ErrNotFound { + t.Errorf("session key should be gone after clear, got err %v", err) + } + if err := ClearSession(); err != nil { + t.Errorf("ClearSession on absent should be no-op, got %v", err) + } +} + +func TestLoadSessionIgnoresEmptyToken(t *testing.T) { + withTempSession(t) + if err := SaveSession(LoginResult{RefreshToken: "ref"}); err != nil { + t.Fatalf("SaveSession: %v", err) + } + got, err := LoadSession() + if err != nil { + t.Fatalf("LoadSession: %v", err) + } + if got != nil { + t.Errorf("empty-access-token session should load as nil, got %+v", got) + } +} diff --git a/desktop/platform/sessioninject.go b/desktop/platform/sessioninject.go new file mode 100644 index 0000000..91d6e0a --- /dev/null +++ b/desktop/platform/sessioninject.go @@ -0,0 +1,116 @@ +package platform + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +// bootPayload is the state injected into the page at load so the WebView store +// can hydrate synchronously: the restored session (refresh-free — see +// SessionView), the device name (both of which the wails:// scheme can't persist +// in WebView storage), and the API base the page should fetch from. +type bootPayload struct { + Session *SessionView `json:"session"` + DeviceName string `json:"device_name"` + // APIBase points the page at a loopback HTTP origin for /api on Windows, + // where the custom-scheme AssetServer buffers streaming responses (so SSE + // never arrives). Empty on macOS — the page then uses same-origin relative + // /api through the custom scheme, which streams fine there. + APIBase string `json:"api_base,omitempty"` + // DeviceType is the native client kind (macos / windows / linux); the page + // forwards it as X-Device-Type so the backend lists it correctly instead of + // defaulting every device to "browser". + DeviceType string `json:"device_type,omitempty"` +} + +// SessionInjectMiddleware injects the boot state into the served index.html as +// `window.__CDROP_BOOT__`, so the WebView store hydrates synchronously at startup +// — no WebView storage (which doesn't survive restart for the wails:// scheme) +// and no binding round-trip (window.go isn't ready that early). Read once at app +// launch. device_name + api_base are injected always (needed even logged out); +// the session is included only when one was restored. +// +// Only the top-level HTML document is rewritten; assets and the /api proxy +// (including the SSE stream) pass straight through untouched. The JSON is +// produced by encoding/json with default HTML escaping, so a display name +// containing "" can't break out of the inline script. +func SessionInjectMiddleware(session *LoginResult, deviceName, apiBase, deviceType string) assetserver.Middleware { + boot := bootPayload{DeviceName: deviceName, APIBase: apiBase, DeviceType: deviceType} + if session != nil { + view := session.View() // strip the refresh_token before it ever reaches the page + boot.Session = &view + } + payload, _ := json.Marshal(boot) + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !isDocumentRequest(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + // Force a full 200 (never a 304): strip conditional headers so + // http.ServeContent always returns the whole document for us to + // inject. Without this, a WebView that cached the first (logged-out) + // page could send If-Modified-Since on the next launch, get a 304 with + // an empty body, reuse its stale page, and the session wouldn't restore. + r.Header.Del("If-Modified-Since") + r.Header.Del("If-None-Match") + r.Header.Del("If-Range") + + rec := &captureWriter{header: http.Header{}} + next.ServeHTTP(rec, r) + body := rec.buf.Bytes() + + if idx := bytes.Index(body, []byte("")); idx != -1 { + script := []byte("") + out := make([]byte, 0, len(body)+len(script)) + out = append(out, body[:idx]...) + out = append(out, script...) + out = append(out, body[idx:]...) + body = out + } + + h := w.Header() + for k, vs := range rec.header { + for _, v := range vs { + h.Add(k, v) + } + } + // Never let the WebView cache the document — the injected session + // changes between launches, so a cached copy must not be reused. + h.Del("Last-Modified") + h.Del("ETag") + h.Set("Cache-Control", "no-store, no-cache, must-revalidate") + h.Set("Pragma", "no-cache") + h.Set("Content-Length", strconv.Itoa(len(body))) + code := rec.code + if code == 0 { + code = http.StatusOK + } + w.WriteHeader(code) + _, _ = w.Write(body) + }) + } +} + +// isDocumentRequest matches the top-level HTML document the WebView loads. +func isDocumentRequest(path string) bool { + return path == "" || path == "/" || path == "/index.html" +} + +// captureWriter buffers a handler's response so the middleware can rewrite the +// HTML body before forwarding it. Only used for the small index.html document. +type captureWriter struct { + header http.Header + buf bytes.Buffer + code int +} + +func (c *captureWriter) Header() http.Header { return c.header } +func (c *captureWriter) WriteHeader(code int) { c.code = code } +func (c *captureWriter) Write(b []byte) (int, error) { return c.buf.Write(b) } diff --git a/desktop/platform/sessioninject_test.go b/desktop/platform/sessioninject_test.go new file mode 100644 index 0000000..4252e72 --- /dev/null +++ b/desktop/platform/sessioninject_test.go @@ -0,0 +1,109 @@ +package platform + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const htmlDoc = "cdrop" + +func htmlHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(htmlDoc)) + }) +} + +func TestSessionInjectIntoDocument(t *testing.T) { + session := &LoginResult{ + AccessToken: "acc-token", + RefreshToken: "ref-token", + User: UserInfo{ID: "u-1", Name: "alice"}, + } + h := SessionInjectMiddleware(session, "my-host", "", "macos")(htmlHandler()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + body := rec.Body.String() + + if !strings.Contains(body, "window.__CDROP_BOOT__") { + t.Fatalf("boot global not injected:\n%s", body) + } + if !strings.Contains(body, `"access_token":"acc-token"`) { + t.Errorf("access token not in injected payload:\n%s", body) + } + // Credential policy: the refresh_token must NEVER reach the page. + if strings.Contains(body, "ref-token") || strings.Contains(body, "refresh_token") { + t.Errorf("refresh_token must not be injected into the page:\n%s", body) + } + if !strings.Contains(body, `"device_name":"my-host"`) { + t.Errorf("device name not in injected payload:\n%s", body) + } + if !strings.Contains(body, `"device_type":"macos"`) { + t.Errorf("device type not in injected payload:\n%s", body) + } + // Injected before so it runs before the deferred module bundle. + if strings.Index(body, "__CDROP_BOOT__") > strings.Index(body, "") { + t.Error("injection must precede ") + } +} + +func TestSessionInjectPassesThroughNonDocument(t *testing.T) { + apiBody := "streamed-api-body" + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(apiBody)) + }) + h := SessionInjectMiddleware(&LoginResult{AccessToken: "x"}, "h", "", "")(next) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/hub/events", nil)) + if rec.Body.String() != apiBody { + t.Errorf("non-document request must pass through unchanged, got %q", rec.Body.String()) + } +} + +func TestSessionInjectNilSessionStillInjectsDeviceBoot(t *testing.T) { + // Logged out, the boot state is still injected — the page needs the device + // name / api_base / device_type before login — but with a null session, and + // no token of any kind leaks. + h := SessionInjectMiddleware(nil, "my-host", "http://127.0.0.1:5000", "windows")(htmlHandler()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + body := rec.Body.String() + + if !strings.Contains(body, "window.__CDROP_BOOT__") { + t.Fatalf("boot global must be injected even when logged out:\n%s", body) + } + if !strings.Contains(body, `"session":null`) { + t.Errorf("logged-out boot must carry a null session:\n%s", body) + } + if !strings.Contains(body, `"device_name":"my-host"`) || + !strings.Contains(body, `"device_type":"windows"`) || + !strings.Contains(body, `"api_base":"http://127.0.0.1:5000"`) { + t.Errorf("device boot fields not injected:\n%s", body) + } + if strings.Contains(body, "access_token") { + t.Errorf("no token must appear when logged out:\n%s", body) + } +} + +func TestSessionInjectEscapesScriptBreakout(t *testing.T) { + // A display name containing must not break out of the inline tag. + session := &LoginResult{AccessToken: "acc", User: UserInfo{Name: "x"}} + h := SessionInjectMiddleware(session, "h", "", "")(htmlHandler()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + body := rec.Body.String() + + // The literal closing tag from the name must NOT appear verbatim; encoding/json + // HTML-escapes < / > to < / > by default. + if strings.Contains(body, "x") { + t.Errorf("script breakout not escaped:\n%s", body) + } + if !strings.Contains(body, ``) { + t.Errorf("expected HTML-escaped name in payload:\n%s", body) + } +} diff --git a/desktop/platform/statusbar.go b/desktop/platform/statusbar.go new file mode 100644 index 0000000..d489878 --- /dev/null +++ b/desktop/platform/statusbar.go @@ -0,0 +1,23 @@ +package platform + +// StatusBarHandler carries the three menu-bar actions as closures so the App can +// supply them without exposing extra Wails-bound methods on its struct. +type StatusBarHandler struct { + OnShowWindow func() + OnOpenSettings func() + OnQuit func() +} + +// StatusBarMenu is the set of localized labels for the menu bar item + its menu. +// The icon itself is the embedded brand mascot (see statusbar_darwin); Title is +// only the accessibility label + text fallback when the image can't be loaded. +type StatusBarMenu struct { + Title string // accessibility label + text fallback if the icon fails to load + Show string + Settings string + Quit string +} + +// statusBarHandler holds the active closures; set by InstallStatusBar and read +// by the native menu-action callbacks (darwin) — a no-op elsewhere. +var statusBarHandler StatusBarHandler diff --git a/desktop/platform/statusbar_darwin.go b/desktop/platform/statusbar_darwin.go new file mode 100644 index 0000000..4d23c4b --- /dev/null +++ b/desktop/platform/statusbar_darwin.go @@ -0,0 +1,80 @@ +//go:build darwin + +package platform + +/* +#cgo darwin CFLAGS: -x objective-c -fobjc-arc +#cgo darwin LDFLAGS: -framework Cocoa +#include + +void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title, + const char *showLabel, const char *settingsLabel, + const char *quitLabel); +void cdropSetActivationPolicy(int policy); +*/ +import "C" + +import ( + _ "embed" + "unsafe" +) + +// menuBarIconPNG 是嵌入二进制的品牌吉祥物头像(avatar-head,256px)。菜单栏图标 +// 用彩色图而非 SF Symbol 模板图——彩色更有品牌辨识度(代价是不随明暗自适应)。 +// 256px 提供高清 rep,运行时按菜单栏 thickness(逻辑点)显示,retina 下采样保持清晰。 +// +//go:embed menubar-icon.png +var menuBarIconPNG []byte + +// InstallStatusBar creates the menu bar (NSStatusBar) item with a Show / +// Settings / Quit menu. Safe to call from any goroutine — the native work is +// dispatched to the main thread. Idempotent on the native side. +func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) { + statusBarHandler = h + + ct := C.CString(m.Title) + cs := C.CString(m.Show) + cg := C.CString(m.Settings) + cq := C.CString(m.Quit) + defer C.free(unsafe.Pointer(ct)) + defer C.free(unsafe.Pointer(cs)) + defer C.free(unsafe.Pointer(cg)) + defer C.free(unsafe.Pointer(cq)) + + // 把 PNG 字节复制进 C 缓冲;ObjC 侧在 dispatch 前同步拷进 NSData,故调用返回后即可释放。 + cIcon := C.CBytes(menuBarIconPNG) + defer C.free(cIcon) + + C.cdropStatusBarInstall(cIcon, C.int(len(menuBarIconPNG)), ct, cs, cg, cq) +} + +// SetDockVisible toggles the Dock icon: true → regular (icon shown), false → +// accessory (Dock hidden, only the menu bar item remains). Main-thread safe. +func SetDockVisible(visible bool) { + p := C.int(1) // accessory + if visible { + p = C.int(0) // regular + } + C.cdropSetActivationPolicy(p) +} + +//export cdropOnShowWindow +func cdropOnShowWindow() { + if statusBarHandler.OnShowWindow != nil { + statusBarHandler.OnShowWindow() + } +} + +//export cdropOnOpenSettings +func cdropOnOpenSettings() { + if statusBarHandler.OnOpenSettings != nil { + statusBarHandler.OnOpenSettings() + } +} + +//export cdropOnQuit +func cdropOnQuit() { + if statusBarHandler.OnQuit != nil { + statusBarHandler.OnQuit() + } +} diff --git a/desktop/platform/statusbar_darwin.m b/desktop/platform/statusbar_darwin.m new file mode 100644 index 0000000..7aa1a45 --- /dev/null +++ b/desktop/platform/statusbar_darwin.m @@ -0,0 +1,133 @@ +//go:build darwin + +#import +#include "_cgo_export.h" + +// CdropStatusBarController owns the menu bar item and routes the three menu +// actions back into Go. It is deliberately NOT the NSApplication delegate — +// Wails owns that, and stealing it would break Wails' window / runtime / custom +// scheme handling. A status item can be created independently after launch, so +// we never touch the delegate. +@interface CdropStatusBarController : NSObject +@property (strong) NSStatusItem *item; +@property (strong) NSMenu *menu; +- (void)showWindow:(id)sender; +- (void)openSettings:(id)sender; +- (void)quit:(id)sender; +- (void)statusItemClicked:(id)sender; +@end + +@implementation CdropStatusBarController +- (void)showWindow:(id)sender { cdropOnShowWindow(); } +- (void)openSettings:(id)sender { cdropOnOpenSettings(); } +- (void)quit:(id)sender { cdropOnQuit(); } + +// showMenu pops the menu now. Temporarily attaching item.menu and performClick +// shows it and BLOCKS until dismissed (menu tracking is modal); detaching after +// keeps left-clicks flowing through statusItemClicked: (so left-click opens the +// window directly instead of popping the menu). +- (void)showMenu { + self.item.menu = self.menu; + [self.item.button performClick:nil]; + self.item.menu = nil; +} + +// statusItemClicked fires on left/right mouse-up. Click routing (the Windows tray +// should mirror this once implemented): +// - left-click → open the main window +// - right-click / control-click → menu (Show / Settings / Quit) +// We route clicks manually instead of setting item.menu — item.menu would pop the +// menu on left-click too, leaving no way for left-click to open the window. +- (void)statusItemClicked:(id)sender { + NSEvent *e = [NSApp currentEvent]; + BOOL secondary = (e.type == NSEventTypeRightMouseUp) || + ((e.modifierFlags & NSEventModifierFlagControl) != 0); + if (secondary) { + [self showMenu]; // right-click / control-click → menu + } else { + cdropOnShowWindow(); // left-click → open the main window + } +} +@end + +// File-scope strong reference (ARC) keeps the controller and its status item +// alive for the process lifetime. +static CdropStatusBarController *gController = nil; + +// cdropStatusBarInstall creates the menu bar item + its menu on the main thread. +// The icon is the embedded brand mascot (a colored, NON-template image — keeps +// brand color instead of tinting to the menu bar). It's displayed at the menu +// bar thickness (logical points); the high-res (256px) source rep keeps it crisp +// on retina. If decoding fails it falls back to the text title. The PNG bytes are +// copied into NSData synchronously here (before the async block) so Go can free +// its C buffer right after this call. Labels arrive as UTF-8 from Go so this +// source stays ASCII-only. Idempotent. +void cdropStatusBarInstall(const void *iconPNG, int iconLen, const char *title, + const char *showLabel, const char *settingsLabel, + const char *quitLabel) { + NSData *iconData = (iconPNG != NULL && iconLen > 0) + ? [NSData dataWithBytes:iconPNG length:(NSUInteger)iconLen] + : nil; + NSString *t = [NSString stringWithUTF8String:title]; + NSString *sl = [NSString stringWithUTF8String:showLabel]; + NSString *gl = [NSString stringWithUTF8String:settingsLabel]; + NSString *ql = [NSString stringWithUTF8String:quitLabel]; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (gController != nil) { return; } + gController = [[CdropStatusBarController alloc] init]; + + NSStatusItem *item = [[NSStatusBar systemStatusBar] + statusItemWithLength:NSVariableStatusItemLength]; + + NSImage *icon = (iconData != nil) ? [[NSImage alloc] initWithData:iconData] : nil; + if (icon != nil) { + CGFloat th = [[NSStatusBar systemStatusBar] thickness]; + [icon setSize:NSMakeSize(th, th)]; // fill bar height; retina uses 256px rep + icon.template = NO; // keep brand color, don't tint + item.button.image = icon; + item.button.accessibilityLabel = t; // image has no text → a11y label + } else { + item.button.title = t; + } + + NSMenu *menu = [[NSMenu alloc] init]; + + NSMenuItem *show = [[NSMenuItem alloc] initWithTitle:sl + action:@selector(showWindow:) keyEquivalent:@""]; + show.target = gController; + [menu addItem:show]; + + NSMenuItem *settings = [[NSMenuItem alloc] initWithTitle:gl + action:@selector(openSettings:) keyEquivalent:@""]; + settings.target = gController; + [menu addItem:settings]; + + [menu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem *quit = [[NSMenuItem alloc] initWithTitle:ql + action:@selector(quit:) keyEquivalent:@""]; + quit.target = gController; + [menu addItem:quit]; + + // Don't set item.menu (that auto-pops on mouse-down, killing double-click + // detection). Store the menu and route clicks through statusItemClicked:. + gController.item = item; + gController.menu = menu; + item.button.target = gController; + item.button.action = @selector(statusItemClicked:); + [item.button sendActionOn:(NSEventMaskLeftMouseUp | NSEventMaskRightMouseUp)]; + }); +} + +// cdropSetActivationPolicy flips the Dock presence on the main thread. +// policy: 0 = regular (Dock icon shown), 1 = accessory (Dock hidden, menu bar +// item only). +void cdropSetActivationPolicy(int policy) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSApplicationActivationPolicy p = + (policy == 1) ? NSApplicationActivationPolicyAccessory + : NSApplicationActivationPolicyRegular; + [NSApp setActivationPolicy:p]; + }); +} diff --git a/desktop/platform/statusbar_other.go b/desktop/platform/statusbar_other.go new file mode 100644 index 0000000..a19280c --- /dev/null +++ b/desktop/platform/statusbar_other.go @@ -0,0 +1,19 @@ +//go:build !darwin && !windows + +package platform + +// Non-darwin stubs. The menu bar item + Dock activation policy are macOS +// concepts; Windows/Linux equivalents (tray + window-to-tray) land when those +// platforms are targeted. Kept as no-ops so cross-platform builds compile. +// +// 当实现 Windows 托盘时,交互须与 macOS 对齐(见 statusbar_darwin.m): +// 左键单击 → 打开主窗口;右键 → 菜单(显示主窗 / 设置 / 退出)。托盘图标用同一 +// 彩色品牌图(menubar-icon.png)。 + +// InstallStatusBar is a no-op outside macOS. +func InstallStatusBar(_ StatusBarMenu, h StatusBarHandler) { + statusBarHandler = h +} + +// SetDockVisible is a no-op outside macOS. +func SetDockVisible(_ bool) {} diff --git a/desktop/platform/statusbar_windows.go b/desktop/platform/statusbar_windows.go new file mode 100644 index 0000000..50ae222 --- /dev/null +++ b/desktop/platform/statusbar_windows.go @@ -0,0 +1,74 @@ +//go:build windows + +package platform + +import ( + _ "embed" + "runtime" + + "fyne.io/systray" +) + +// trayIconICO is the brand mascot as a Windows .ico, embedded in the binary and +// shown in the notification area. +// +//go:embed tray-icon.ico +var trayIconICO []byte + +// InstallStatusBar installs the Windows system-tray icon, mirroring the macOS +// menu bar: left-click (primary tap) opens the main window, right-click shows +// the Show / Settings / Quit menu. systray owns a Win32 message loop, so it runs +// on its own OS-locked goroutine; the menu-item click channels are drained in a +// second goroutine. The handler closures already hand work to a goroutine before +// touching the Wails runtime, so invoking them from here is safe. +func InstallStatusBar(m StatusBarMenu, h StatusBarHandler) { + statusBarHandler = h + go func() { + // systray creates a window + GetMessage loop on this thread; the window + // is thread-affine, so the goroutine must not migrate. + runtime.LockOSThread() + systray.Run(func() { trayOnReady(m) }, nil) + }() +} + +func trayOnReady(m StatusBarMenu) { + systray.SetIcon(trayIconICO) + systray.SetTitle(m.Title) + systray.SetTooltip(m.Title) + + // Left-click → open the main window (matches the macOS convention). Right- + // click falls through to the menu below (Windows default). + systray.SetOnTapped(func() { + if statusBarHandler.OnShowWindow != nil { + statusBarHandler.OnShowWindow() + } + }) + + show := systray.AddMenuItem(m.Show, "") + settings := systray.AddMenuItem(m.Settings, "") + quit := systray.AddMenuItem(m.Quit, "") + + go func() { + for { + select { + case <-show.ClickedCh: + if statusBarHandler.OnShowWindow != nil { + statusBarHandler.OnShowWindow() + } + case <-settings.ClickedCh: + if statusBarHandler.OnOpenSettings != nil { + statusBarHandler.OnOpenSettings() + } + case <-quit.ClickedCh: + if statusBarHandler.OnQuit != nil { + statusBarHandler.OnQuit() + } + } + } + }() +} + +// SetDockVisible is a no-op on Windows: the app lifecycle already hides/shows the +// window (Wails WindowHide/WindowShow), which removes/adds the taskbar button, so +// there is no separate Dock-style activation policy to toggle. +func SetDockVisible(_ bool) {} diff --git a/desktop/platform/tray-icon.ico b/desktop/platform/tray-icon.ico new file mode 100644 index 0000000..7da8db6 Binary files /dev/null and b/desktop/platform/tray-icon.ico differ diff --git a/desktop/wails.json b/desktop/wails.json new file mode 100644 index 0000000..8b68d96 --- /dev/null +++ b/desktop/wails.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "Commilitia Drop Desktop", + "outputfilename": "Commilitia Drop Desktop", + "frontend:install": "cd ../../web && npm install", + "frontend:build": "cd ../../web && npm run build -- --outDir ../desktop/frontend/dist --emptyOutDir", + "frontend:dev:watcher": "cd ../../web && npm run dev", + "frontend:dev:serverUrl": "auto", + "info": { + "productName": "Commilitia Drop Desktop" + }, + "author": { + "name": "commilitia", + "email": "git.commilitia@commilitia.net" + } +} diff --git a/docker/Caddyfile.snippet b/docker/Caddyfile.snippet new file mode 100644 index 0000000..f772f0b --- /dev/null +++ b/docker/Caddyfile.snippet @@ -0,0 +1,12 @@ +# 并入你的主 Caddyfile(站点 block)。换上你的域名与服务名。 +# 关键开关: +# - flush_interval -1 :禁用 reverse_proxy 对 SSE 长响应的缓冲,让探测帧 +# 与 ping 立即送达浏览器;任何反代器忽略此项都会让 /api/hub/events 看似无事件 +# - h3 建议在 global block 启用(站点级不必重复声明) +# - NN-cdrop 中的 NN 与 compose 服务条目保持一致 + +your-domain.example { + reverse_proxy NN-cdrop:8080 { + flush_interval -1 + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..bc2fca5 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.7 +# +# cdrop:latest —— 应用镜像。基础环境(Go 工具链 + node_modules + go modules) +# 已被 cdrop-base:latest 预热(见 docker/Dockerfile.base),这里只做源码层与 +# 跨编译。 +# +# 构建(部署平台为 linux/amd64): +# docker buildx build --platform linux/amd64 \ +# -f docker/Dockerfile -t cdrop:latest --load . +# +# BUILDPLATFORM 让 build 阶段跑在宿主原生 arch(macOS arm64),跨编译产出 +# linux/amd64 的 Go 二进制;最终 runtime FROM distroless 是 linux/amd64 +# 但只有 COPY 一层,不经过 qemu 执行任何 amd64 程序。 + +# ---- build (uses cached base) ---- +FROM --platform=$BUILDPLATFORM cdrop-base:latest AS build +ARG TARGETOS=linux TARGETARCH=amd64 +# Vite 编译期 env:dev 模式 token 必须随 image 烤进 bundle;prod 部署留空即可 +ARG VITE_CDROP_DEV_TOKEN="" +ARG VITE_CDROP_STUN_URLS="" +ENV VITE_CDROP_DEV_TOKEN=$VITE_CDROP_DEV_TOKEN \ + VITE_CDROP_STUN_URLS=$VITE_CDROP_STUN_URLS +WORKDIR /src + +# 复制源码(依赖在 base 中已就位) +COPY . . + +# 前端构建(VITE_* 环境变量已 export) +RUN cd web && npm run build + +# 嵌入到 internal/webui/dist 让 //go:embed 拿到 +RUN rm -rf ./internal/webui/dist && cp -a web/dist ./internal/webui/dist + +# 跨编译 Go 到目标平台 +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 \ + go build -trimpath -ldflags="-s -w" -o /out/cdropd ./cmd/cdropd + +# ---- runtime (linux/amd64 distroless) ---- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/cdropd /cdropd +USER nonroot:nonroot +EXPOSE 8080 +ENTRYPOINT ["/cdropd"] diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base new file mode 100644 index 0000000..0ae912f --- /dev/null +++ b/docker/Dockerfile.base @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1.7 +# +# cdrop-base — 重型依赖的预热镜像。 +# 只有 go.mod/go.sum 或 web/package*.json 变动时才需要重建;改源码后构建 +# cdrop:latest 就只需要复制源 + 编译,省掉 npm ci 与 go mod download 的耗时。 +# +# 同时安装 Go 工具链与 Node 运行时,让最终的应用 Dockerfile 不再需要 multi-stage +# 切换基础镜像。 +# +# 构建(一次或随依赖更新;走宿主原生 arch 即可): +# docker buildx build -f docker/Dockerfile.base -t cdrop-base:latest --load . + +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS base + +# Node.js + npm,覆盖 web 端依赖;ca-certificates 给 go mod download 用 HTTPS +RUN apk add --no-cache nodejs npm ca-certificates git + +WORKDIR /src + +# Go modules 缓存层 +COPY go.mod go.sum ./ +RUN go mod download + +# npm 缓存层 +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci --no-audit --no-fund + +WORKDIR /src diff --git a/docker/compose.snippet.yaml b/docker/compose.snippet.yaml new file mode 100644 index 0000000..131536a --- /dev/null +++ b/docker/compose.snippet.yaml @@ -0,0 +1,38 @@ +# cdrop 在你的 docker-compose.yaml 中的服务条目片段。 +# NN 由你决定(与既有服务区分的编号 / 名称);your-proxy-network 换成你的反代所在网络。 +# +# 不显式 ports —— 走内部网络,反代(如 Caddy)通过 NN-cdrop:8080 访问。 +# 持久化挂一个目录到 /data,里面只有 cdrop.db(SQLite WAL)。 + +NN-cdrop: + image: cdrop:latest + container_name: NN-cdrop + restart: unless-stopped + networks: [your-proxy-network] + ports: [] + volumes: + - ./NN-cdrop/data:/data + environment: + # M13 首轮 dev 模式(验证 Caddy / H3 管道),稳定后切 prod 见下方注释 + CDROP_AUTH_MODE: dev + CDROP_DEV_TOKEN: REPLACE_WITH_RANDOM_HEX64 + CDROP_DB_PATH: /data/cdrop.db + CDROP_LISTEN: ":8080" + CDROP_DEVICE_TTL_HOURS: "196" + + # ---- prod 切换时启用以下,并把 CDROP_AUTH_MODE 改成 prod、删掉 DEV_TOKEN ---- + # CDROP_OIDC_AUTHORIZE_URL: https://your-idp.example/login/oauth/authorize + # CDROP_OIDC_TOKEN_URL: https://your-idp.example/api/login/oauth/access_token + # JWKS 可走 IdP 内网地址省一跳;也可用公网 https。 + # CDROP_OIDC_JWKS_URL: https://your-idp.example/.well-known/jwks + # CDROP_OIDC_ISSUER: https://your-idp.example/ + # prod 强制非空(空则跳过 audience 校验 = 同 JWKS 其他应用可冒充) + # CDROP_OIDC_AUDIENCE: <你的 OAuth client_id> + # CDROP_OIDC_CLIENT_ID: <你的 OAuth client_id> + # CDROP_OIDC_REDIRECT_URI: https://your-domain.example/oauth/callback + # CDROP_OIDC_SCOPES: "openid profile email" + # CDROP_HS256_SECRET: REPLACE_WITH_RANDOM_HEX64 + + # ---- 可选:Cloudflare Realtime TURN over TLS(不配则前端 STUN-only 兜底)---- + # CDROP_CF_TURN_KEY_ID: + # CDROP_CF_TURN_API_TOKEN: <同 App 派发的 API Token> diff --git a/docs/ios-shortcut.md b/docs/ios-shortcut.md new file mode 100644 index 0000000..af04639 --- /dev/null +++ b/docs/ios-shortcut.md @@ -0,0 +1,148 @@ +# iOS 快捷指令剪贴板同步 + +> **状态(2026-06-14):暂停。** 网页端「签发 token」界面已移除(组件 `web/src/features/shortcut/ShortcutTokens.tsx` 保留休眠、后端端点/令牌系统保留,便于将来重启)。本文余下内容为暂停前的设计与搭建规格,存档参考。 +> +> **暂停原因(两条硬伤,源自轮询模型)**: +> 1. **频繁鉴权**——自动指令每 INTERVAL 轮询一次,每次都走一遍完整鉴权(HS256 验签 + 设备 upsert + 异步 touch)。 +> 2. **设备列表被「Unknown Device」刷屏**——轮询打的 `GET /api/clipboard/version` **没带 `X-Device-Name`**,鉴权中间件遂用 UA 兜底名(含随机十六进制)登记设备,每次轮询生成一条新设备行。 +> +> 重启此路径前必须先解决:给所有快捷指令请求统一带 `X-Device-Name`(或服务端对 scoped 令牌固定设备名/跳过 upsert),并评估轮询频率对鉴权的压力(如令牌侧加轻量校验、或换长连/推送方案)。 + +借鉴 [SyncClipboard](https://github.com/Jeric-X/SyncClipboard) 的形态:两个 iCloud 快捷指令——**手动**与**自动**。本阶段先落地**手动**路径并真机验证,自动轮询指令在此基础上追加(见文末)。 + +## 鉴权模型(长期方案) + +为快捷指令签发**长效、仅限剪贴板、可随时吊销**的专用 HS256 token,**不**采用 SyncClipboard 的账号密码 Basic Auth。 + +理由出自信任模型(不信任本机其他软件):token 会明文存在快捷指令配置里、同机其他 App 可读。 + +- Basic Auth 一旦泄漏的是整账号、无法单独吊销、改密即全端失效——正是要避开的反模式。 +- 本方案 token 只携带 `scope=clipboard`,经 `requireScope` 中间件**只能命中 `/api/clipboard`**;可单独**吊销**(服务端查 `revoked`,下次使用即失效)、带 **TTL**(默认 365 天)、记 `last_used_at` 审计。泄漏面仅限剪贴板、可定点吊销、不碰账号密码。 + +token 用 `CDROP_HS256_SECRET` 派生的 32 字节 HMAC 密钥签发(SHA-256 派生,任意长度密钥都可用)。快捷指令登记的设备名取自请求的 `X-Device-Name`——**设备名全局限制为 ASCII**:名字要塞进 HTTP 头,非 ASCII 无法可靠传输(浏览器 fetch 还会直接抛错),故输入处校验 + 服务端 `sanitizeDeviceName` 兜底剥离。`ios` 设备类型保留给未来真正的原生客户端,快捷指令不发该类型。 + +## 签发 token + +设置页 →「iOS 快捷指令」→ 填备注名 →「签发」。弹窗**一次性**展示 token 与服务器地址,立即复制保存;关闭后无法再次查看。每用户活跃 token 上限默认 10(`CDROP_SHORTCUT_MAX_PER_USER`)。 + +需服务端配置 `CDROP_HS256_SECRET` 才启用,否则签发返回 503。 + +## 同步模型(version + origin_ts) + +服务端权威,每条剪贴板带: +- **`version`**:每用户严格单调递增的版本号。判断「变没变」的唯一依据,**不比内容**。 +- **`origin_ts`**:内容在源设备被复制的时刻(ms)。冲突收敛的 LWW 键——写入仅在 `origin_ts` 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的(异步容忍)。 +- `source_device`:来源,抑制回声 + 展示。 + +客户端:记 `lastVersion` + 自己的设备名。轮询**轻量 `/version`** 探测变更(不取 content),版本变了且 `source ≠ 自己` 才取全量;推送带 `origin_ts`。 + +## 端点 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/clipboard/version` | **廉价探测**:只返 `{version, source_device, origin_ts, content_type, updated_at}`,**不含 content** | +| GET | `/api/clipboard` | 取全量;返回上面字段 + `content` | +| PUT | `/api/clipboard` | 写入;体 `{"content": "...", "content_type": "text/plain", "origin_ts": <复制时刻 ms>}`,上限 64 KB。被 LWW 拒(有更晚的复制)回 `409` + 当前胜出态;省略 `origin_ts` 时服务端用 now | + +三者都需请求头 `Authorization: Bearer `。 + +## 手动快捷指令搭建 + +在 iPhone「快捷指令」App 中手工新建两个指令,首次运行填入服务器地址与 token(用「文本」+「设定变量」持久化,后续动作引用变量)。两个指令都加请求头 `X-Device-Name: <你的 ASCII 设备名>`(仅 ASCII,如 `iPhone`),让它在设备列表里统一显示为一台设备。 + +### 拉取(云端 → 本机剪贴板) + +1. 「获取 URL 内容」:地址 `<服务器地址>/api/clipboard`,方法 GET,请求头加 `Authorization: Bearer `。 +2. 「获取词典值」:取键 `content`。 +3. 内容非空 →「拷贝到剪贴板」;可加「显示通知」提示已拉取。 + +### 上传(本机剪贴板 → 云端) + +1. 「获取剪贴板」;若为空则「停止并输出」。 +2. 「获取 URL 内容」:地址同上,方法 PUT,请求头加 `Authorization: Bearer ` 与 `Content-Type: application/json`,请求体选 JSON:`{"content": 剪贴板, "content_type": "text/plain"}`。 +3. 可加「显示通知」提示已上传。 + +将两个指令加到主屏 / 共享表单 / 轻点背面即可手动触发。 + +## 网络容错 + +单次运行遇网络波动可能失败,重试即可——长效 token 不会因短暂离线失效。Web / 桌面端的实时通道(SSE)已改为**永不放弃的指数退避重连**(1s→3s→9s→18s,封顶 30s),短期断网自动恢复、不报错不退出。 + +## 吊销 + +设置页「iOS 快捷指令」列表 →「吊销」。服务端在每次请求时校验 `revoked`,吊销后该 token 下次使用立即失效。 + +## iCloud 分发:合并指令搭建规格(手动同步) + +做**一个合并指令**(运行时菜单选 拉取 / 上传),配 **Import Questions** 在安装时问参数——服务器地址、设备名给默认值,安装者只需粘贴一次令牌。在 iPhone「快捷指令」App 里照下面搭,再「拷贝 iCloud 链接」分发;分享出的 iCloud 链接由 Apple 签名,安装无需开启「不受信任的快捷指令」。 + +### 顶部:三个配置变量 + +1. 「文本」→ 内容 `https://drop.commilitia.net` →「设定变量」`BASE`。 +2. 「文本」→ 内容占位(如 `token`)→「设定变量」`TOKEN`。 +3. 「文本」→ 内容 `iPhone` →「设定变量」`NAME`。 + +(这三条「文本」的内容随后设为 Import Questions,安装者填 / 确认。) + +### 菜单 + +4. 「从菜单选取」→ 两项:`从云端拉取`、`上传到云端`。 + +### 「从云端拉取」分支 + +5. 「获取 URL 内容」: + - 网址:插入变量 `BASE`,紧跟文本 `/api/clipboard`。 + - 方法:`GET`。 + - 标头:`Authorization` = 文本 `Bearer ` 后插变量 `TOKEN`;`X-Device-Name` = 变量 `NAME`。 +6. 「获取词典值」:键 `content`,输入为上一步结果。 +7. 「如果」上一步「有任何值」: + - 「拷贝到剪贴板」= 上一步值;(可选)「显示通知」`已从 cdrop 拉取`。 + - 否则:(可选)「显示通知」`云端剪贴板为空`。 + +### 「上传到云端」分支 + +8. 「获取剪贴板」。 +9. 「如果」剪贴板「有任何值」: + - 「获取 URL 内容」: + - 网址:变量 `BASE` 紧跟文本 `/api/clipboard`。 + - 方法:`PUT`。 + - 请求体:`JSON`,两字段——`content`(文本)= 变量「剪贴板」;`content_type`(文本)= `text/plain`。 + - 标头:`Authorization` = `Bearer ` + 变量 `TOKEN`;`X-Device-Name` = 变量 `NAME`。(请求体选 JSON 时通常自动带 `Content-Type: application/json`,没有就手动加。) + - (可选)「显示通知」`已上传到 cdrop`。 + - 否则:(可选)「显示通知」`本机剪贴板为空`。 + +### 配置 Import Questions(分享前) + +打开指令详情(设置)→「导入问题」区,添加三个,对应顶部三条「文本」: +- 服务器地址 → 问题「服务器地址」,默认答案 `https://drop.commilitia.net`。 +- 令牌 → 问题「粘贴 cdrop 令牌」,无默认。 +- 设备名 → 问题「设备名(仅 ASCII)」,默认 `iPhone`。 + +### 分发 + +指令详情 →「分享」→「拷贝 iCloud 链接」→ 把链接发来,接进网站签发面板(安装链接 + 二维码 + 复制令牌)。 + +要点:设备名仅 ASCII(与全局策略一致);不发 `X-Device-Type`——快捷指令在设备列表显示为浏览器类型 + 你填的名字,`ios` 类型保留给未来原生客户端;两个分支都带 `X-Device-Name`,统一折成一台设备。 + +## 自动同步:双向轮询指令 + +一个无限循环指令,按 version + origin_ts 模型做**双向**同步:本机被复制就推、远端更新就拉,单一 `LAST_VERSION` 防回环。每轮只读一次本机剪贴板 + 一次轻量 `/version`,仅在真有新内容时才取全量。 + +配置同合并指令(BASE_URL / TOKEN / NAME + Import Questions / INTERVAL)。持久变量:`LAST_VERSION`、`BASELINE`(本机当前内容)。初始化:`BASELINE`=获取剪贴板、`LAST_VERSION`=0。「重复」次数 `99999`(无真无限循环,停靠手动结束运行)。 + +循环体: + +1. `L` = 「获取剪贴板」。 +2. **如果 `L` ≠ `BASELINE`(本机被复制)→ 推送**: + - 「获取 URL 内容」PUT `BASE_URL/api/clipboard`,体 JSON `{content: L, content_type: "text/plain", origin_ts: <当前时间 Unix 毫秒>}`,标头 `Authorization`/`X-Device-Name`。 + - 取响应 `version` → `LAST_VERSION`;取响应 `content`:**等于 `L`** → `BASELINE`=L(被接受);**不等** → 「拷贝到剪贴板」=响应 content、`BASELINE`=响应 content(被拒,采纳胜出内容)。 + - (iOS 读不了 409 状态码,故靠「响应 content 是否等于我发的」判接受/落败。) +3. **否则 → 廉价探测上游**: + - 「获取 URL 内容」GET `BASE_URL/api/clipboard/version`(无 content)→ `{version, source_device}`。 + - 如果 `version` ≠ `LAST_VERSION` **且** `source_device` ≠ `NAME`:GET 全量 `/api/clipboard` → 「拷贝到剪贴板」=content、`BASELINE`=content、`LAST_VERSION`=version。 + - 否则如果 `version` ≠ `LAST_VERSION`(自己的回声/同值):只 `LAST_VERSION`=version,不回写。 +4. 「等待」`INTERVAL` 秒。 + +`<当前时间 Unix 毫秒>` 用「当前日期」→ 格式化为 Unix 毫秒时间戳。 + +**局限(如实交代)**:iOS 无法监听剪贴板变化,「自动」本质是前台/驻留轮询、不保证真后台、需手动停止;完全没网时「获取 URL 内容」报错会中断循环(iOS 无 try/catch),需重新运行或配「连上 Wi-Fi 时」自动化重启。真正无感同步只能靠原生 App。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d510298 --- /dev/null +++ b/go.mod @@ -0,0 +1,34 @@ +module commilitia.net/cdrop + +go 1.26.2 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/httprate v0.15.0 + github.com/go-jose/go-jose/v4 v4.1.4 + github.com/knadh/koanf/parsers/yaml v1.1.0 + github.com/knadh/koanf/providers/env/v2 v2.0.0 + github.com/knadh/koanf/providers/file v1.2.1 + github.com/knadh/koanf/v2 v2.3.4 + modernc.org/sqlite v1.50.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..848bada --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env/v2 v2.0.0 h1:Ad5H3eun722u+FvchiIcEIJZsZ2M6oxCkgZfWN5B5KY= +github.com/knadh/koanf/providers/env/v2 v2.0.0/go.mod h1:1g01PE+Ve1gBfWNNw2wmULRP0tc8RJrjn5p2N/jNCIc= +github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM= +github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc= +github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/calls/cloudflare.go b/internal/calls/cloudflare.go new file mode 100644 index 0000000..93d691e --- /dev/null +++ b/internal/calls/cloudflare.go @@ -0,0 +1,161 @@ +// Package calls integrates Cloudflare Realtime TURN. +// +// Cloudflare Realtime issues short-lived ICE-server credentials via: +// +// POST https://rtc.live.cloudflare.com/v1/turn/keys/{KEY_ID}/credentials/generate-ice-servers +// Authorization: Bearer {API_TOKEN} +// {"ttl": 86400} +// +// Response 201: +// +// { "iceServers": [ +// { "urls": ["stun:stun.cloudflare.com:3478", ...] }, +// { "urls": ["turn:turn.cloudflare.com:3478?transport=udp", +// "turn:turn.cloudflare.com:3478?transport=tcp", +// "turns:turn.cloudflare.com:5349?transport=tcp"], +// "username": "...", "credential": "..." } +// ]} +// +// Free of charge for cdrop's egress volume (1 TB/mo soft cap is enough). +package calls + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" +) + +const ( + endpointTmpl = "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers" + // DefaultTTL caps how long an issued TURN credential stays valid (R3). The + // same bundle is shared by every client of this instance, so a user who + // extracts it could relay traffic on cdrop's Cloudflare quota until it + // expires; a short TTL keeps that window small. 1h is ample to set up a + // transfer (creds authenticate the Allocate; the allocation outlives them). + DefaultTTL = 1 * time.Hour + requestTimeout = 10 * time.Second +) + +type ICEServer struct { + URLs []string `json:"urls"` + Username string `json:"username,omitempty"` + Credential string `json:"credential,omitempty"` +} + +type ICEResponse struct { + ICEServers []ICEServer `json:"iceServers"` +} + +// Provider is concurrency-safe and caches generated credentials so multiple +// concurrent web clients share one upstream call to Cloudflare. +type Provider struct { + keyID string + apiToken string + ttl time.Duration + refreshMargin time.Duration + client *http.Client + + mu sync.Mutex + cached *ICEResponse + expiresAt time.Time +} + +// NewProvider returns a Provider configured to mint creds with the given TTL. +// ttl ≤ 0 falls back to DefaultTTL. The cache refreshes once a served credential +// drops below refreshMargin (a quarter of the TTL, floored at 5m) of remaining +// life, so every credential handed out still has a comfortable validity window +// even with a short TTL. +func NewProvider(keyID, apiToken string, ttl time.Duration) *Provider { + if ttl <= 0 { + ttl = DefaultTTL + } + margin := ttl / 4 + if margin < 5*time.Minute { + margin = 5 * time.Minute + } + if margin >= ttl { + // Pathologically short TTLs: keep margin under the TTL so the cache can + // still serve at least briefly rather than refetching on every call. + margin = ttl / 2 + } + return &Provider{ + keyID: keyID, + apiToken: apiToken, + ttl: ttl, + refreshMargin: margin, + client: &http.Client{Timeout: requestTimeout}, + } +} + +// Get returns a fresh ICE-server bundle. Repeated calls within the TTL window +// (minus refreshMargin) return the cached value; otherwise a new fetch happens. +// On upstream failure, falls back to the most recent cached value if any. +func (p *Provider) Get(ctx context.Context) (*ICEResponse, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.cached != nil && time.Until(p.expiresAt) > p.refreshMargin { + return p.cached, nil + } + + creds, err := p.fetch(ctx) + if err != nil { + if p.cached != nil { + slog.Warn("cf turn refresh failed; using cached creds", + "err", err, "expires_in", time.Until(p.expiresAt)) + return p.cached, nil + } + return nil, err + } + p.cached = creds + p.expiresAt = time.Now().Add(p.ttl) + slog.Info("cf turn creds refreshed", + "servers", len(creds.ICEServers), "ttl", p.ttl) + return creds, nil +} + +func (p *Provider) fetch(ctx context.Context) (*ICEResponse, error) { + body := bytes.NewReader([]byte(fmt.Sprintf(`{"ttl":%d}`, int(p.ttl.Seconds())))) + url := fmt.Sprintf(endpointTmpl, p.keyID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+p.apiToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("cloudflare unreachable: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("cloudflare turn status %d: %s", + resp.StatusCode, truncate(string(raw), 200)) + } + var r ICEResponse + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if len(r.ICEServers) == 0 { + return nil, errors.New("cloudflare returned empty iceServers") + } + return &r, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/calls/cloudflare_test.go b/internal/calls/cloudflare_test.go new file mode 100644 index 0000000..142258a --- /dev/null +++ b/internal/calls/cloudflare_test.go @@ -0,0 +1,33 @@ +package calls + +import ( + "testing" + "time" +) + +// The refresh margin scales with the TTL (a quarter, floored at 5m) so a short +// R3 TTL still leaves every served credential a comfortable validity window +// without refetching on every call. +func TestNewProvider_RefreshMargin(t *testing.T) { + cases := []struct { + name string + ttl time.Duration + want time.Duration + }{ + {"default when zero", 0, DefaultTTL / 4}, // ttl<=0 → DefaultTTL (1h) → 15m + {"one hour", time.Hour, 15 * time.Minute}, // quarter + {"quarter floored at 5m", 10 * time.Minute, 5 * time.Minute}, // 2.5m → floor 5m, 5m<10m + {"pathologically short", 4 * time.Minute, 2 * time.Minute}, // floor 5m >= 4m ttl → ttl/2 + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := NewProvider("k", "tok", c.ttl) + if p.refreshMargin != c.want { + t.Errorf("refreshMargin: got %v, want %v", p.refreshMargin, c.want) + } + if p.refreshMargin >= p.ttl { + t.Errorf("margin %v must stay under ttl %v so the cache can serve", p.refreshMargin, p.ttl) + } + }) + } +} diff --git a/internal/clipboard/service.go b/internal/clipboard/service.go new file mode 100644 index 0000000..13d72a1 --- /dev/null +++ b/internal/clipboard/service.go @@ -0,0 +1,261 @@ +// Package clipboard 实现单条覆写式的剪贴板同步:DB 立即写入(GET 永远反映 +// 最新真相),SSE 广播按 generation 计数节流——同窗口内的连续 PUT 只让最后 +// 一次广播出去,避免下游设备被中间值抖动。 +package clipboard + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/hub" +) + +// originFutureToleranceMs caps how far ahead of server time a client's origin_ts +// may be. Beyond it we assume a runaway clock and clamp to server-now, so one +// mis-set device can't set an origin_ts no honest device could beat for a while. +const originFutureToleranceMs = 5 * 60 * 1000 + +const ( + // ContentTypeText 是 MVP 阶段唯一允许写入的 content_type。前端 HTML→text + // fallback 在客户端完成,后端只接 text/plain。 + ContentTypeText = "text/plain" + + // EventType 是 SSE 推送给在线设备的事件名(brief §5 风格的 namespace:verb)。 + EventType = "clipboard:update" +) + +// Errors that the HTTP layer needs to distinguish. +var ( + ErrUnsupportedType = errors.New("unsupported content_type") + ErrTooLarge = errors.New("content exceeds limit") +) + +// Hub is the subset of *hub.Hub that the service needs. Declared as interface +// so unit tests can swap in a fake. +type Hub interface { + Broadcast(userID string, ev hub.Event) +} + +// Querier 是 sqlc 生成的 *db.Queries 的子集,便于测试桩替换。 +type Querier interface { + UpsertClipboard(ctx context.Context, arg db.UpsertClipboardParams) (int64, error) + GetClipboard(ctx context.Context, userID string) (db.ClipboardState, error) + ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error) +} + +// Service 持有内存里的 pending 表,每个 userID 对应一个最新待广播的 generation。 +type Service struct { + q Querier + hub Hub + max int + window time.Duration + ttl time.Duration // 0 = 内容不过期 + + mu sync.Mutex + pending map[string]uint64 // userID → 当前最新 generation + nextGen uint64 +} + +// New constructs a Service. window 是稳定窗口(PUT 收到后等待无新写入的时长, +// 到期才广播);maxBytes 是单次写入上限(UTF-8 字节);ttl 是云端内容存活时长 +// (0 = 不过期)——见 Get 的惰性过期与 RunSweeper 的主动清除。 +func New(q Querier, h Hub, maxBytes int, window, ttl time.Duration) *Service { + if maxBytes <= 0 { + maxBytes = 65536 + } + if window <= 0 { + window = 3 * time.Second + } + return &Service{ + q: q, + hub: h, + max: maxBytes, + window: window, + ttl: ttl, + pending: map[string]uint64{}, + } +} + +// MaxBytes 暴露上限给 HTTP 层做请求体校验。 +func (s *Service) MaxBytes() int { return s.max } + +// State 是 GET 返回 / SSE 广播 payload 的统一形状。Version 是变更探测的核心—— +// 每用户严格单调递增;OriginTs 是冲突收敛的 LWW 键(源设备复制时刻 ms);客户端 +// 据 Version 判变更、据 OriginTs 决定回写与否。 +type State struct { + Content string `json:"content"` + ContentType string `json:"content_type"` + SourceDevice string `json:"source_device"` + UpdatedAt int64 `json:"updated_at"` + Version int64 `json:"version"` + OriginTs int64 `json:"origin_ts"` +} + +// Get 读取当前持久化的剪贴板状态;err == sql.ErrNoRows 时表示用户尚未写入。 +// 内容超过 TTL 即视为已过期,返回空状态(与「尚未写入」等价、ETag 0),绝不 +// 回吐过期内容;实际的 DB 清除交给 RunSweeper。 +func (s *Service) Get(ctx context.Context, userID string) (*State, error) { + row, err := s.q.GetClipboard(ctx, userID) + if err != nil { + return nil, err + } + st := rowToState(row) + if s.expired(st.Content, st.UpdatedAt) { + return &State{ContentType: ContentTypeText, UpdatedAt: 0}, nil + } + return st, nil +} + +// expired 判断一条内容是否超过 TTL。ttl<=0 或内容为空时永不过期。 +func (s *Service) expired(content string, updatedAt int64) bool { + if s.ttl <= 0 || content == "" { + return false + } + return time.Now().Unix()-updatedAt >= int64(s.ttl.Seconds()) +} + +// SweepExpired 清除所有超过 TTL 的剪贴板内容(content / source_device 置空), +// 返回清除的行数。ttl<=0 时为 no-op。 +func (s *Service) SweepExpired(ctx context.Context) (int64, error) { + if s.ttl <= 0 { + return 0, nil + } + cutoff := time.Now().Unix() - int64(s.ttl.Seconds()) + return s.q.ClearExpiredClipboards(ctx, cutoff) +} + +// SweepInterval 是 sweeper 唤醒周期。 +const SweepInterval = 30 * time.Second + +// RunSweeper 阻塞直到 ctx 取消,周期性清除过期剪贴板内容。ttl<=0 时 +// SweepExpired 为 no-op;调用方应据 cfg.ClipboardTTLSec>0 决定是否启动。 +func RunSweeper(ctx context.Context, svc *Service) { + t := time.NewTicker(SweepInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + n, err := svc.SweepExpired(ctx) + if err != nil { + slog.Error("clipboard sweeper: clear expired failed", "err", err) + continue + } + if n > 0 { + slog.Info("clipboard sweeper: cleared expired content", "count", n) + } + } + } +} + +// Put 接收一次写入:校验 → 立即 UPSERT → bump generation → 定时器到期才广播。 +// 同 userID 在窗口期内的多次 Put 只让最后一次广播。 +// Put writes a clipboard update tagged with originTs (the source device's copy +// time, ms). It is accepted only when originTs strictly exceeds the stored one +// (LWW); a delayed / out-of-order older copy is rejected with accepted=false and +// the current winning State returned (so the caller can reconcile). Only an +// accepted write bumps the version and schedules a broadcast. +func (s *Service) Put( + ctx context.Context, + userID, contentType, content, sourceDevice string, + originTs int64, +) (st *State, accepted bool, err error) { + if contentType != ContentTypeText { + return nil, false, fmt.Errorf("%w: %q", ErrUnsupportedType, contentType) + } + if len(content) > s.max { + return nil, false, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(content), s.max) + } + + now := time.Now() + if nowMs := now.UnixMilli(); originTs > nowMs+originFutureToleranceMs { + originTs = nowMs // clamp a runaway-future clock to server-now + } + + version, err := s.q.UpsertClipboard(ctx, db.UpsertClipboardParams{ + UserID: userID, + ContentType: contentType, + Content: &content, + SourceDevice: &sourceDevice, + UpdatedAt: now.Unix(), + OriginTs: originTs, + }) + if errors.Is(err, sql.ErrNoRows) { + // LWW rejected: a newer origin_ts already won. Return the current winner + // (no broadcast) so the caller pulls / reconciles instead of clobbering. + cur, gerr := s.Get(ctx, userID) + if gerr != nil { + return nil, false, gerr + } + return cur, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("upsert clipboard: %w", err) + } + + st = &State{ + Content: content, + ContentType: contentType, + SourceDevice: sourceDevice, + UpdatedAt: now.Unix(), + Version: version, + OriginTs: originTs, + } + + s.mu.Lock() + s.nextGen++ + gen := s.nextGen + s.pending[userID] = gen + s.mu.Unlock() + + time.AfterFunc(s.window, func() { s.maybeCommit(userID, gen) }) + return st, true, nil +} + +// maybeCommit 由定时器回调进入。只有当 pending[userID] 仍然是入参 gen 时(即 +// 期间没有更新写入)才真正广播;否则视为被新写入覆盖,直接丢弃。 +func (s *Service) maybeCommit(userID string, gen uint64) { + s.mu.Lock() + cur, ok := s.pending[userID] + if !ok || cur != gen { + s.mu.Unlock() + return + } + delete(s.pending, userID) + s.mu.Unlock() + + // 重新读 DB 拿权威 state——窗口结束时 DB 已是 gen 对应的内容(中间被 + // 后续覆盖的可能性已被前面的 generation 检查排除)。 + row, err := s.q.GetClipboard(context.Background(), userID) + if err != nil { + slog.Error("clipboard commit: get state failed", "err", err, "user", userID) + return + } + s.hub.Broadcast(userID, hub.Event{ + Type: EventType, + Data: rowToState(row), + }) +} + +func rowToState(row db.ClipboardState) *State { + st := &State{ + ContentType: row.ContentType, + UpdatedAt: row.UpdatedAt, + Version: row.Version, + OriginTs: row.OriginTs, + } + if row.Content != nil { + st.Content = *row.Content + } + if row.SourceDevice != nil { + st.SourceDevice = *row.SourceDevice + } + return st +} diff --git a/internal/clipboard/service_test.go b/internal/clipboard/service_test.go new file mode 100644 index 0000000..aa78f0f --- /dev/null +++ b/internal/clipboard/service_test.go @@ -0,0 +1,378 @@ +package clipboard + +import ( + "context" + "database/sql" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/hub" +) + +// fakeQuerier 在内存里模拟 clipboard_state 表,按 user_id 单条覆写。 +type fakeQuerier struct { + mu sync.Mutex + rows map[string]db.ClipboardState + gets atomic.Int32 + puts atomic.Int32 +} + +func newFakeQuerier() *fakeQuerier { + return &fakeQuerier{rows: map[string]db.ClipboardState{}} +} + +func (f *fakeQuerier) UpsertClipboard(_ context.Context, arg db.UpsertClipboardParams) (int64, error) { + f.puts.Add(1) + f.mu.Lock() + defer f.mu.Unlock() + prev, exists := f.rows[arg.UserID] + if exists && arg.OriginTs <= prev.OriginTs { + return 0, sql.ErrNoRows // conditional WHERE failed → stale, no change (RETURNING no row) + } + version := prev.Version + 1 // absent → 0 → new version 1; else prev+1 + f.rows[arg.UserID] = db.ClipboardState{ + UserID: arg.UserID, + ContentType: arg.ContentType, + Content: arg.Content, + SourceDevice: arg.SourceDevice, + UpdatedAt: arg.UpdatedAt, + Version: version, + OriginTs: arg.OriginTs, + } + return version, nil +} + +func (f *fakeQuerier) GetClipboard(_ context.Context, userID string) (db.ClipboardState, error) { + f.gets.Add(1) + f.mu.Lock() + defer f.mu.Unlock() + row, ok := f.rows[userID] + if !ok { + return db.ClipboardState{}, errNoRow + } + return row, nil +} + +// ClearExpiredClipboards 模拟 SQL:把 content 非空且 updated_at < cutoff 的行 +// 内容置 nil,返回清除行数。 +func (f *fakeQuerier) ClearExpiredClipboards(_ context.Context, cutoff int64) (int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + var n int64 + for k, row := range f.rows { + if row.Content != nil && row.UpdatedAt < cutoff { + row.Content = nil + row.SourceDevice = nil + f.rows[k] = row + n++ + } + } + return n, nil +} + +var errNoRow = errors.New("no row") + +// fakeHub 计数 Broadcast 调用并把 events 排队让测试断言。 +type fakeHub struct { + mu sync.Mutex + events []hub.Event +} + +func (h *fakeHub) Broadcast(_ string, ev hub.Event) { + h.mu.Lock() + h.events = append(h.events, ev) + h.mu.Unlock() +} + +func (h *fakeHub) snapshot() []hub.Event { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]hub.Event, len(h.events)) + copy(out, h.events) + return out +} + +func TestPutWritesDBImmediately(t *testing.T) { + q := newFakeQuerier() + h := &fakeHub{} + s := New(q, h, 1024, 50*time.Millisecond, 0) + + if _, _, err := s.Put(context.Background(), "alice", "text/plain", "hello", "tab-1", 1); err != nil { + t.Fatalf("Put: %v", err) + } + if got := q.puts.Load(); got != 1 { + t.Errorf("expected 1 DB upsert, got %d", got) + } + got, err := s.Get(context.Background(), "alice") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Content != "hello" { + t.Errorf("content: got %q, want %q", got.Content, "hello") + } +} + +func TestPutTrailingPutOnlyBroadcastsLast(t *testing.T) { + q := newFakeQuerier() + h := &fakeHub{} + s := New(q, h, 1024, 60*time.Millisecond, 0) + ctx := context.Background() + + for i, text := range []string{"a", "b", "c"} { + if _, _, err := s.Put(ctx, "alice", "text/plain", text, "tab-1", int64(i+1)); err != nil { + t.Fatalf("Put %q: %v", text, err) + } + time.Sleep(10 * time.Millisecond) // 每次写都在同窗口内 + } + + // 等待最后一次 Put 之后的窗口结束 + 余量 + time.Sleep(150 * time.Millisecond) + + evs := h.snapshot() + if len(evs) != 1 { + t.Fatalf("expected 1 broadcast, got %d", len(evs)) + } + st, ok := evs[0].Data.(*State) + if !ok { + t.Fatalf("event data type: %T", evs[0].Data) + } + if st.Content != "c" { + t.Errorf("broadcast content: got %q, want %q", st.Content, "c") + } + if evs[0].Type != EventType { + t.Errorf("event type: got %q, want %q", evs[0].Type, EventType) + } +} + +func TestPutDifferentUsersDontInterfere(t *testing.T) { + q := newFakeQuerier() + h := &fakeHub{} + s := New(q, h, 1024, 40*time.Millisecond, 0) + ctx := context.Background() + + if _, _, err := s.Put(ctx, "alice", "text/plain", "alice-data", "tab-1", 1); err != nil { + t.Fatalf("alice: %v", err) + } + if _, _, err := s.Put(ctx, "bob", "text/plain", "bob-data", "tab-1", 1); err != nil { + t.Fatalf("bob: %v", err) + } + + time.Sleep(120 * time.Millisecond) + + evs := h.snapshot() + if len(evs) != 2 { + t.Fatalf("expected 2 broadcasts (one per user), got %d", len(evs)) + } +} + +func TestPutRejectsUnsupportedType(t *testing.T) { + s := New(newFakeQuerier(), &fakeHub{}, 1024, 10*time.Millisecond, 0) + _, _, err := s.Put(context.Background(), "alice", "text/html", "hi", "tab-1", 1) + if !errors.Is(err, ErrUnsupportedType) { + t.Errorf("expected ErrUnsupportedType, got %v", err) + } +} + +func TestPutRejectsTooLarge(t *testing.T) { + s := New(newFakeQuerier(), &fakeHub{}, 8, 10*time.Millisecond, 0) + _, _, err := s.Put(context.Background(), "alice", "text/plain", "123456789", "tab-1", 1) + if !errors.Is(err, ErrTooLarge) { + t.Errorf("expected ErrTooLarge, got %v", err) + } +} + +func TestPutEmptyContentAccepted(t *testing.T) { + q := newFakeQuerier() + h := &fakeHub{} + s := New(q, h, 1024, 30*time.Millisecond, 0) + + if _, _, err := s.Put(context.Background(), "alice", "text/plain", "", "tab-1", 1); err != nil { + t.Errorf("empty content should be accepted (clear semantics): %v", err) + } + time.Sleep(80 * time.Millisecond) + evs := h.snapshot() + if len(evs) != 1 { + t.Fatalf("expected 1 broadcast, got %d", len(evs)) + } + st := evs[0].Data.(*State) + if st.Content != "" { + t.Errorf("expected empty content, got %q", st.Content) + } +} + +func TestPutWindowExtendsOnNewWrite(t *testing.T) { + q := newFakeQuerier() + h := &fakeHub{} + window := 80 * time.Millisecond + s := New(q, h, 1024, window, 0) + ctx := context.Background() + + t0 := time.Now() + _, _, _ = s.Put(ctx, "alice", "text/plain", "first", "tab-1", 1) + time.Sleep(window / 2) + _, _, _ = s.Put(ctx, "alice", "text/plain", "second", "tab-1", 2) + + // 第一窗到期点:任何广播都不该出现(被第二个 Put 重置) + time.Sleep(window/2 + 10*time.Millisecond) + if got := len(h.snapshot()); got != 0 { + t.Errorf("at t=%v, expected 0 broadcasts (window not yet elapsed for second), got %d", + time.Since(t0), got) + } + + // 第二窗到期之后 broadcast 应该出现,且内容是 "second" + time.Sleep(window/2 + 30*time.Millisecond) + evs := h.snapshot() + if len(evs) != 1 { + t.Fatalf("expected 1 broadcast after second window, got %d", len(evs)) + } + if got := evs[0].Data.(*State).Content; got != "second" { + t.Errorf("broadcast content: got %q, want %q", got, "second") + } +} + +// --- TTL(短存活)--- + +func seedRow(q *fakeQuerier, userID, content string, ageSec int64) { + c := content + src := "tab-1" + q.rows[userID] = db.ClipboardState{ + UserID: userID, + ContentType: "text/plain", + Content: &c, + SourceDevice: &src, + UpdatedAt: time.Now().Unix() - ageSec, + } +} + +func TestGetExpiredReturnsEmpty(t *testing.T) { + q := newFakeQuerier() + seedRow(q, "alice", "secret", 100) // 100s 前写入 + s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second) + + got, err := s.Get(context.Background(), "alice") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Content != "" { + t.Errorf("过期内容不应回吐,got %q", got.Content) + } + if got.UpdatedAt != 0 { + t.Errorf("过期状态 UpdatedAt 应为 0(等同未写入),got %d", got.UpdatedAt) + } +} + +func TestGetFreshWithinTTL(t *testing.T) { + q := newFakeQuerier() + seedRow(q, "alice", "recent", 5) // 5s 前 + s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second) + + got, err := s.Get(context.Background(), "alice") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Content != "recent" { + t.Errorf("TTL 内的新内容应返回,got %q", got.Content) + } +} + +func TestGetTTLDisabledNeverExpires(t *testing.T) { + q := newFakeQuerier() + seedRow(q, "alice", "old", 100000) + s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) // ttl=0 → 禁用 + + got, _ := s.Get(context.Background(), "alice") + if got.Content != "old" { + t.Errorf("ttl=0 永不过期,got %q", got.Content) + } +} + +func TestSweepExpiredClearsOldOnly(t *testing.T) { + q := newFakeQuerier() + seedRow(q, "alice", "old-secret", 100) // 过期 + seedRow(q, "bob", "recent", 5) // 未过期 + s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second) + + n, err := s.SweepExpired(context.Background()) + if err != nil { + t.Fatalf("SweepExpired: %v", err) + } + if n != 1 { + t.Errorf("应清除 1 行(alice),got %d", n) + } + if c := q.rows["alice"].Content; c != nil { + t.Errorf("alice 的内容应被清空(nil),got %v", *c) + } + if c := q.rows["bob"].Content; c == nil || *c != "recent" { + t.Errorf("bob 的新内容应存活,got %v", c) + } +} + +func TestSweepExpiredNoopWhenDisabled(t *testing.T) { + q := newFakeQuerier() + seedRow(q, "alice", "x", 100000) + s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) + + n, err := s.SweepExpired(context.Background()) + if err != nil || n != 0 { + t.Errorf("ttl=0 时 SweepExpired 应为 noop,got n=%d err=%v", n, err) + } + if q.rows["alice"].Content == nil { + t.Error("ttl=0 不应清除任何内容") + } +} + +func TestPutBumpsVersionMonotonically(t *testing.T) { + svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0) + st1, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "a", "devA", 1) + if err != nil || !ok { + t.Fatalf("put 1: ok=%v err=%v", ok, err) + } + if st1.Version != 1 { + t.Errorf("first put version: got %d, want 1", st1.Version) + } + st2, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "b", "devA", 2) + if err != nil || !ok { + t.Fatalf("put 2: ok=%v err=%v", ok, err) + } + if st2.Version != 2 { + t.Errorf("second put version: got %d, want 2", st2.Version) + } + got, err := svc.Get(context.Background(), "u") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Version != 2 || got.SourceDevice != "devA" { + t.Errorf("get: version=%d source=%q, want 2/devA", got.Version, got.SourceDevice) + } +} + +// LWW: a delayed/out-of-order write with an older-or-equal origin_ts must be +// rejected (accepted=false) and leave the current winner intact. +func TestPutRejectsStaleOrigin(t *testing.T) { + svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0) + ctx := context.Background() + + st, ok, err := svc.Put(ctx, "u", ContentTypeText, "new", "devA", 200) + if err != nil || !ok || st.Version != 1 { + t.Fatalf("first put: ok=%v ver=%v err=%v", ok, st.Version, err) + } + // Older copy arriving late → rejected, current winner returned. + cur, ok, err := svc.Put(ctx, "u", ContentTypeText, "stale", "devB", 100) + if err != nil { + t.Fatalf("stale put err: %v", err) + } + if ok { + t.Error("older origin_ts must be rejected") + } + if cur.Content != "new" || cur.Version != 1 { + t.Errorf("rejected put must return current winner; got content=%q ver=%d", cur.Content, cur.Version) + } + // Equal origin_ts is not strictly greater → rejected (idempotent duplicate). + if _, ok, _ := svc.Put(ctx, "u", ContentTypeText, "dup", "devA", 200); ok { + t.Error("equal origin_ts must be rejected (idempotent)") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..24944d5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,134 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env/v2" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +const envPrefix = "CDROP_" + +type Config struct { + AuthMode string `koanf:"auth_mode"` + DevToken string `koanf:"dev_token"` + + DBPath string `koanf:"db_path"` + Listen string `koanf:"listen"` + + // DeviceTTLHours is the maximum age of a row in `devices` before the + // background sweeper deletes it. Brief §2 sets the refresh_token sliding + // window at 196h (≈8 days) — devices must not outlive that. Default 196h. + DeviceTTLHours int `koanf:"device_ttl_hours"` + + // OIDC settings. Generic OAuth/OIDC provider compatible (Casdoor included). + OIDCAuthorizeURL string `koanf:"oidc_authorize_url"` + OIDCTokenURL string `koanf:"oidc_token_url"` + OIDCJWKSURL string `koanf:"oidc_jwks_url"` + OIDCIssuer string `koanf:"oidc_issuer"` + // OIDCAudience accepts a comma-separated list. A token passes when its `aud` + // matches any one entry — this lets one backend serve several OAuth clients + // that carry different audiences (e.g. the web app and the desktop client). + // Empty disables audience checking; a single value behaves as before. + OIDCAudience string `koanf:"oidc_audience"` + OIDCClientID string `koanf:"oidc_client_id"` + OIDCClientSecret string `koanf:"oidc_client_secret"` + OIDCRedirectURI string `koanf:"oidc_redirect_uri"` + OIDCScopes string `koanf:"oidc_scopes"` + + HS256Secret string `koanf:"hs256_secret"` + + // Shortcut tokens:iOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期 + // (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。 + // 需配 HS256Secret 才启用,否则签发端点返回 503。 + ShortcutTokenTTLDays int `koanf:"shortcut_token_ttl_days"` + ShortcutMaxPerUser int `koanf:"shortcut_max_per_user"` + + // Cloudflare Realtime TURN (https://developers.cloudflare.com/realtime/turn/). + // When both fields are set, /api/calls/credentials returns short-lived + // TLS-capable TURN creds (turns:turn.cloudflare.com:5349); otherwise the + // endpoint serves a STUN-only fallback so dev / no-CF deploys still work. + CFTurnKeyID string `koanf:"cf_turn_key_id"` + CFTurnAPIToken string `koanf:"cf_turn_api_token"` + + // Clipboard:单条覆写式同步。MaxBytes 限制单次写入字节数;DebounceSec 是 + // 收到 PUT 后等待"无新写入稳定窗口"的秒数,到期才广播 SSE。同窗口内的 + // 后续 PUT 直接刷新 generation 让旧广播取消。 + ClipboardMaxBytes int `koanf:"clipboard_max_bytes"` + ClipboardDebounceSec int `koanf:"clipboard_debounce_sec"` + // ClipboardTTLSec 是云剪贴板内容的存活秒数。因为无法可靠区分密码与普通 + // 文本(密码.app / 浏览器扩展复制都不打敏感标记),改以短 TTL 限制暴露面: + // 内容超过 TTL 后 GET 返回空,后台 sweeper 亦清除其 content。0 = 不过期。 + ClipboardTTLSec int `koanf:"clipboard_ttl_sec"` +} + +// Load reads config from optional ./config.yaml then overrides with CDROP_* env. +// Validates dev/prod invariants; returns error if invariants are violated. +func Load() (*Config, error) { + k := koanf.New(".") + + k.Set("auth_mode", "prod") + k.Set("db_path", "./cdrop.db") + k.Set("listen", ":8080") + k.Set("device_ttl_hours", 196) + k.Set("oidc_scopes", "openid profile email") + k.Set("clipboard_max_bytes", 65536) + k.Set("clipboard_debounce_sec", 3) + k.Set("shortcut_token_ttl_days", 365) + k.Set("shortcut_max_per_user", 10) + + if _, err := os.Stat("config.yaml"); err == nil { + if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil { + return nil, fmt.Errorf("load config.yaml: %w", err) + } + } + + envProvider := env.Provider(".", env.Opt{ + Prefix: envPrefix, + TransformFunc: func(key, value string) (string, any) { + return strings.ToLower(strings.TrimPrefix(key, envPrefix)), value + }, + }) + if err := k.Load(envProvider, nil); err != nil { + return nil, fmt.Errorf("load env: %w", err) + } + + cfg := &Config{} + if err := k.Unmarshal("", cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) validate() error { + switch c.AuthMode { + case "dev": + if c.DevToken == "" { + return errors.New("CDROP_AUTH_MODE=dev requires CDROP_DEV_TOKEN; refusing to start") + } + case "prod": + // Audience MUST be set in prod (R6). With it empty, RS256 audience + // checking is skipped entirely, so ANY token the IdP mints for ANY + // application sharing this JWKS would validate here — a user of a + // sibling Casdoor app could impersonate a cdrop user. The web + desktop + // client_ids go in CDROP_OIDC_AUDIENCE (comma-separated); refuse to boot + // without it rather than run wide open. + if c.OIDCAudience == "" { + return errors.New( + "CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " + + "(comma-separated OAuth client_ids); refusing to start") + } + default: + return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..a938ffb --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidate_DevRequiresDevToken(t *testing.T) { + c := &Config{AuthMode: "dev", DevToken: ""} + err := c.validate() + if err == nil { + t.Fatal("dev mode without DevToken must reject; got nil error") + } + if !strings.Contains(err.Error(), "CDROP_DEV_TOKEN") { + t.Errorf("error must mention CDROP_DEV_TOKEN; got %q", err.Error()) + } +} + +func TestValidate_DevWithDevTokenOK(t *testing.T) { + c := &Config{AuthMode: "dev", DevToken: "x"} + if err := c.validate(); err != nil { + t.Fatalf("dev with token should pass; got %v", err) + } +} + +func TestValidate_ProdOK(t *testing.T) { + c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop"} + if err := c.validate(); err != nil { + t.Fatalf("prod mode with audience should pass; got %v", err) + } +} + +func TestValidate_ProdRequiresAudience(t *testing.T) { + // R6: prod with an empty audience leaves RS256 audience checking off, so a + // token minted for any sibling app sharing the JWKS would validate. Refuse. + c := &Config{AuthMode: "prod", OIDCAudience: ""} + err := c.validate() + if err == nil { + t.Fatal("prod without CDROP_OIDC_AUDIENCE must reject; got nil error") + } + if !strings.Contains(err.Error(), "CDROP_OIDC_AUDIENCE") { + t.Errorf("error must mention CDROP_OIDC_AUDIENCE; got %q", err.Error()) + } +} + +func TestValidate_UnknownMode(t *testing.T) { + c := &Config{AuthMode: "rogue"} + err := c.validate() + if err == nil { + t.Fatal("unknown auth mode must reject") + } +} + +func TestLoad_EnvOverridesDefaults(t *testing.T) { + t.Setenv("CDROP_AUTH_MODE", "dev") + t.Setenv("CDROP_DEV_TOKEN", "abc") + t.Setenv("CDROP_LISTEN", ":9090") + t.Setenv("CDROP_DB_PATH", "/tmp/x.db") + + cfg, err := Load() + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.AuthMode != "dev" { + t.Errorf("AuthMode: got %q, want %q", cfg.AuthMode, "dev") + } + if cfg.DevToken != "abc" { + t.Errorf("DevToken: got %q, want %q", cfg.DevToken, "abc") + } + if cfg.Listen != ":9090" { + t.Errorf("Listen: got %q, want %q", cfg.Listen, ":9090") + } + if cfg.DBPath != "/tmp/x.db" { + t.Errorf("DBPath: got %q, want %q", cfg.DBPath, "/tmp/x.db") + } +} diff --git a/internal/db/bootstrap.go b/internal/db/bootstrap.go new file mode 100644 index 0000000..77cc746 --- /dev/null +++ b/internal/db/bootstrap.go @@ -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() +} diff --git a/internal/db/bootstrap_test.go b/internal/db/bootstrap_test.go new file mode 100644 index 0000000..0dcd517 --- /dev/null +++ b/internal/db/bootstrap_test.go @@ -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) + } +} diff --git a/internal/db/clipboard.sql.go b/internal/db/clipboard.sql.go new file mode 100644 index 0000000..2bad428 --- /dev/null +++ b/internal/db/clipboard.sql.go @@ -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 +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..f43598b --- /dev/null +++ b/internal/db/db.go @@ -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, + } +} diff --git a/internal/db/devices.sql.go b/internal/db/devices.sql.go new file mode 100644 index 0000000..72c4076 --- /dev/null +++ b/internal/db/devices.sql.go @@ -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 +} diff --git a/internal/db/migrations/0001_init.sql b/internal/db/migrations/0001_init.sql new file mode 100644 index 0000000..8ecd135 --- /dev/null +++ b/internal/db/migrations/0001_init.sql @@ -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 +); diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..b4bdbad --- /dev/null +++ b/internal/db/models.go @@ -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"` +} diff --git a/internal/db/queries/clipboard.sql b/internal/db/queries/clipboard.sql new file mode 100644 index 0000000..c39d8ed --- /dev/null +++ b/internal/db/queries/clipboard.sql @@ -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 < ?; diff --git a/internal/db/queries/devices.sql b/internal/db/queries/devices.sql new file mode 100644 index 0000000..0dfa30f --- /dev/null +++ b/internal/db/queries/devices.sql @@ -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 = ?; diff --git a/internal/db/queries/shortcut_tokens.sql b/internal/db/queries/shortcut_tokens.sql new file mode 100644 index 0000000..0e4f49d --- /dev/null +++ b/internal/db/queries/shortcut_tokens.sql @@ -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 = ?; diff --git a/internal/db/queries/transfers.sql b/internal/db/queries/transfers.sql new file mode 100644 index 0000000..6b9db66 --- /dev/null +++ b/internal/db/queries/transfers.sql @@ -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; diff --git a/internal/db/shortcut_tokens.sql.go b/internal/db/shortcut_tokens.sql.go new file mode 100644 index 0000000..d4fca0f --- /dev/null +++ b/internal/db/shortcut_tokens.sql.go @@ -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 +} diff --git a/internal/db/transfers.sql.go b/internal/db/transfers.sql.go new file mode 100644 index 0000000..89cac3b --- /dev/null +++ b/internal/db/transfers.sql.go @@ -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() +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..d10bdf4 --- /dev/null +++ b/internal/httpapi/auth.go @@ -0,0 +1,247 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" +) + +// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend +// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC +// provider; the access_token still flows back to the browser unchanged. + +type authConfigResp struct { + AuthMode string `json:"auth_mode"` + AuthorizeURL string `json:"authorize_url"` + // TokenURL is published so native clients (the desktop app) can run their + // own loopback PKCE flow directly against the provider, reusing this same + // client_id. The browser doesn't need it — it proxies through /api/auth/exchange. + TokenURL string `json:"token_url"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Scopes string `json:"scopes"` +} + +// handleAuthConfig publishes everything the frontend needs to start the PKCE +// authorize redirect. No auth required (it's all public OIDC metadata). +func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, authConfigResp{ + AuthMode: s.cfg.AuthMode, + AuthorizeURL: s.cfg.OIDCAuthorizeURL, + TokenURL: s.cfg.OIDCTokenURL, + ClientID: s.cfg.OIDCClientID, + RedirectURI: s.cfg.OIDCRedirectURI, + Scopes: s.cfg.OIDCScopes, + }) +} + +type exchangeReq struct { + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` +} + +type tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +type userResp struct { + ID string `json:"id"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` +} + +type exchangeResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + User userResp `json:"user"` +} + +func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) { + var req exchangeReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if req.Code == "" || req.CodeVerifier == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"}) + return + } + if s.cfg.OIDCTokenURL == "" { + writeJSON(w, http.StatusInternalServerError, + map[string]string{"error": "OIDC token URL not configured"}) + return + } + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", req.Code) + form.Set("code_verifier", req.CodeVerifier) + form.Set("client_id", s.cfg.OIDCClientID) + form.Set("redirect_uri", s.cfg.OIDCRedirectURI) + // Casdoor's "cdrop" application is a confidential client; PKCE is layered + // on top of the standard secret. Skip the secret if not configured to keep + // public-client OIDC providers happy. + if s.cfg.OIDCClientSecret != "" { + form.Set("client_secret", s.cfg.OIDCClientSecret) + } + + tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) + if err != nil { + slog.Warn("oidc exchange failed", "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + + user, err := extractUser(tr.IDToken, tr.AccessToken) + if err != nil { + writeJSON(w, http.StatusBadGateway, + map[string]string{"error": "extract user: " + err.Error()}) + return + } + + writeJSON(w, http.StatusOK, exchangeResp{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresIn: tr.ExpiresIn, + User: user, + }) +} + +type refreshReq struct { + RefreshToken string `json:"refresh_token"` +} + +type refreshResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { + var req refreshReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if req.RefreshToken == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"}) + return + } + if s.cfg.OIDCTokenURL == "" { + writeJSON(w, http.StatusInternalServerError, + map[string]string{"error": "OIDC token URL not configured"}) + return + } + + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", req.RefreshToken) + form.Set("client_id", s.cfg.OIDCClientID) + if s.cfg.OIDCClientSecret != "" { + form.Set("client_secret", s.cfg.OIDCClientSecret) + } + + tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form) + if err != nil { + slog.Warn("oidc refresh failed", "err", err) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, refreshResp{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresIn: tr.ExpiresIn, + }) +} + +var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) { + req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := oidcHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("oidc unreachable: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + var tr tokenResp + if err := json.Unmarshal(body, &tr); err != nil { + return nil, fmt.Errorf("oidc malformed: %w", err) + } + if tr.AccessToken == "" { + return nil, fmt.Errorf("oidc returned no access_token") + } + return &tr, nil +} + +// extractUser parses {sub, preferred_username | name} from the id_token if +// available, else falls back to access_token. We DON'T verify the signature +// here — the backend's auth middleware verifies access_token on every API +// call via the JWKS cache, so any tamper would surface there. +func extractUser(idToken, accessToken string) (userResp, error) { + pick := idToken + if pick == "" { + pick = accessToken + } + parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{ + jose.RS256, jose.RS384, jose.RS512, + jose.ES256, jose.ES384, jose.ES512, + jose.HS256, + }) + if err != nil { + return userResp{}, err + } + var std jwt.Claims + custom := map[string]any{} + if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil { + return userResp{}, err + } + u := userResp{ID: std.Subject} + if v, ok := custom["preferred_username"].(string); ok && v != "" { + u.Name = v + } else if v, ok := custom["name"].(string); ok && v != "" { + u.Name = v + } else if v, ok := custom["email"].(string); ok && v != "" { + u.Name = v + } else { + u.Name = u.ID + } + // Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段; + // 不同 IdP 实现各异,所以都查一遍。值为空字符串视为未提供,前端落到字母方块回退。 + if v, ok := custom["avatar"].(string); ok && v != "" { + u.Avatar = v + } else if v, ok := custom["picture"].(string); ok && v != "" { + u.Avatar = v + } + return u, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/httpapi/calls.go b/internal/httpapi/calls.go new file mode 100644 index 0000000..e31081b --- /dev/null +++ b/internal/httpapi/calls.go @@ -0,0 +1,30 @@ +package httpapi + +import ( + "log/slog" + "net/http" + + "commilitia.net/cdrop/internal/calls" +) + +// fallbackICEServers is what we return when Cloudflare is not configured or +// generation fails: STUN-only (Cloudflare anycast, China-friendly). +var fallbackICEServers = []calls.ICEServer{ + {URLs: []string{"stun:stun.cloudflare.com:3478"}}, +} + +// handleCallsCredentials returns ICE servers for the WebRTC client. +// Auth-required (don't expose Cloudflare creds to anonymous probes). +func (s *Server) handleCallsCredentials(w http.ResponseWriter, r *http.Request) { + if s.calls == nil { + writeJSON(w, http.StatusOK, map[string]any{"iceServers": fallbackICEServers}) + return + } + creds, err := s.calls.Get(r.Context()) + if err != nil { + slog.Warn("cf turn credentials unavailable; serving fallback STUN", "err", err) + writeJSON(w, http.StatusOK, map[string]any{"iceServers": fallbackICEServers}) + return + } + writeJSON(w, http.StatusOK, creds) +} diff --git a/internal/httpapi/clipboard.go b/internal/httpapi/clipboard.go new file mode 100644 index 0000000..b1e42bb --- /dev/null +++ b/internal/httpapi/clipboard.go @@ -0,0 +1,154 @@ +package httpapi + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "commilitia.net/cdrop/internal/clipboard" + "commilitia.net/cdrop/internal/jwtauth" +) + +type clipboardPutReq struct { + Content string `json:"content"` + ContentType string `json:"content_type"` + // OriginTs is the source device's copy time (ms). It's the LWW key: the write + // is accepted only if it strictly exceeds the stored one. Clients that omit it + // (legacy / transitional) get server-now, so they still sync. + OriginTs int64 `json:"origin_ts"` +} + +// handleClipboardPut 接受一次剪贴板写入。请求体最大字节限制(MaxBytesReader) +// 给上限 + JSON 信封冗余 (×1.5);服务层会再核一次纯内容字节数。 +func (s *Server) handleClipboardPut(w http.ResponseWriter, r *http.Request) { + if s.clipboard == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"}) + return + } + + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + device, _ := jwtauth.DeviceNameFromContext(r.Context()) + + maxBody := int64(s.clipboard.MaxBytes() + s.clipboard.MaxBytes()/2 + 256) + r.Body = http.MaxBytesReader(w, r.Body, maxBody) + + var req clipboardPutReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // MaxBytesReader 触发时返回 *http.MaxBytesError;统一映射 413。 + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "request body too large"}) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + + originTs := req.OriginTs + if originTs <= 0 { + originTs = time.Now().UnixMilli() // client didn't tag a copy time → use now + } + + st, accepted, err := s.clipboard.Put(r.Context(), claims.UserID, req.ContentType, req.Content, device, originTs) + switch { + case errors.Is(err, clipboard.ErrUnsupportedType): + writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": err.Error()}) + return + case errors.Is(err, clipboard.ErrTooLarge): + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()}) + return + case err != nil: + slog.Error("clipboard put failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + w.Header().Set("ETag", etagFor(st.Version)) + if !accepted { + // LWW rejected this write — a newer copy already won. Hand back the current + // winner so the client reconciles (pulls) instead of clobbering it. + writeJSON(w, http.StatusConflict, st) + return + } + writeJSON(w, http.StatusAccepted, st) +} + +type clipboardMeta struct { + Version int64 `json:"version"` + SourceDevice string `json:"source_device"` + ContentType string `json:"content_type"` + UpdatedAt int64 `json:"updated_at"` + OriginTs int64 `json:"origin_ts"` +} + +// handleClipboardVersion 返回剪贴板版本元信息(不含 content)。给读不了 HTTP 状态 +// 码的轮询客户端(iOS 快捷指令)做廉价变更探测:版本变了、且来源不是自己,才去 +// GET 全量内容——避免每轮都全量拉取。 +func (s *Server) handleClipboardVersion(w http.ResponseWriter, r *http.Request) { + if s.clipboard == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"}) + return + } + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + st, err := s.clipboard.Get(r.Context(), claims.UserID) + if errors.Is(err, sql.ErrNoRows) { + st = &clipboard.State{ContentType: clipboard.ContentTypeText} + } else if err != nil { + slog.Error("clipboard version get failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + etag := etagFor(st.Version) + w.Header().Set("ETag", etag) + if match := r.Header.Get("If-None-Match"); match != "" && match == etag { + w.WriteHeader(http.StatusNotModified) + return + } + writeJSON(w, http.StatusOK, clipboardMeta{ + Version: st.Version, + SourceDevice: st.SourceDevice, + ContentType: st.ContentType, + UpdatedAt: st.UpdatedAt, + OriginTs: st.OriginTs, + }) +} + +// handleClipboardGet 返回当前剪贴板内容;支持 If-None-Match: +// → 304 节省流量。用户尚未写入时返回 200 + 空 State + ETag "0",让"槽位常驻" +// 语义对前端更友好(避免 DevTools Network 红色 404)。 +func (s *Server) handleClipboardGet(w http.ResponseWriter, r *http.Request) { + if s.clipboard == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "clipboard service unavailable"}) + return + } + + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + st, err := s.clipboard.Get(r.Context(), claims.UserID) + if errors.Is(err, sql.ErrNoRows) { + st = &clipboard.State{ContentType: clipboard.ContentTypeText} + } else if err != nil { + slog.Error("clipboard get failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + etag := etagFor(st.Version) + w.Header().Set("ETag", etag) + if match := r.Header.Get("If-None-Match"); match != "" && match == etag { + w.WriteHeader(http.StatusNotModified) + return + } + writeJSON(w, http.StatusOK, st) +} + +// etagFor 用版本号的十进制字符串当 ETag——版本是严格单调的,故 ETag 精确反映 +// 「内容变了没有」(秒级 updated_at 会在同秒两写时撞号)。RFC 9110 的强 ETag。 +func etagFor(version int64) string { + return fmt.Sprintf("\"%s\"", strconv.FormatInt(version, 10)) +} diff --git a/internal/httpapi/devices.go b/internal/httpapi/devices.go new file mode 100644 index 0000000..8316cc9 --- /dev/null +++ b/internal/httpapi/devices.go @@ -0,0 +1,72 @@ +package httpapi + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/jwtauth" +) + +type deviceItem struct { + Name string `json:"name"` + Type string `json:"type"` + Online bool `json:"online"` + LastSeen int64 `json:"last_seen"` +} + +// handleDevices returns the calling user's known devices with live online flags. +// Online = currently connected to /api/events; LastSeen = epoch from devices.last_seen. +func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + + devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID) + if err != nil { + slog.Error("list devices failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + out := make([]deviceItem, 0, len(devs)) + for _, d := range devs { + out = append(out, deviceItem{ + Name: d.Name, + Type: d.Type, + Online: s.hub.Online(claims.UserID, d.Name), + LastSeen: d.LastSeen, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"devices": out}) +} + +// handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播 +// 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则 +// 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。 +func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + name := chi.URLParam(r, "name") + if name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device name"}) + return + } + + rows, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ + UserID: claims.UserID, + Name: name, + }) + if err != nil { + slog.Error("delete device failed", "err", err, "user", claims.UserID, "name", name) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + if rows == 0 { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) + return + } + + s.hub.Kick(claims.UserID, name) + s.hub.PublishPresence(r.Context(), claims.UserID) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/message.go b/internal/httpapi/message.go new file mode 100644 index 0000000..afc2a27 --- /dev/null +++ b/internal/httpapi/message.go @@ -0,0 +1,82 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "commilitia.net/cdrop/internal/hub" + "commilitia.net/cdrop/internal/jwtauth" +) + +// 4 KB caps DoS-via-paste; longer payloads should use file transfer instead. +const maxMessageBytes = 4 * 1024 + +// maxMessageBodyBytes bounds the raw request body BEFORE decode (R5): the 4 KB +// limit above checks the decoded Go string, so on its own the whole body is +// still read into memory first. The wire form can be larger than the decoded +// text — JSON escaping inflates quotes/control chars (worst case ~6×) — so the +// body cap is the content cap with headroom, not equal to it, to avoid falsely +// rejecting a legitimate 4 KB message while still bounding memory per request. +const maxMessageBodyBytes = maxMessageBytes * 8 + +type messageReq struct { + To string `json:"to"` + Text string `json:"text"` +} + +// handleMessage forwards a one-shot text payload to a peer device. Reuses the +// SSE Hub the same way /api/hub/signal does — no DB row, no transfer state +// machine. Receiver sees a `message` event; the frontend keeps it in memory +// only until the tab closes (per user spec). +// +// 204 if delivered, 410 if peer offline, 413 if oversized. +func (s *Server) handleMessage(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + from, _ := jwtauth.DeviceNameFromContext(r.Context()) + + r.Body = http.MaxBytesReader(w, r.Body, maxMessageBodyBytes) + var req messageReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + writeJSON(w, http.StatusRequestEntityTooLarge, + map[string]string{"error": "message exceeds 4 KB"}) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if req.To == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to'"}) + return + } + if req.Text == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty text"}) + return + } + if len(req.Text) > maxMessageBytes { + writeJSON(w, http.StatusRequestEntityTooLarge, + map[string]string{"error": "message exceeds 4 KB"}) + return + } + if req.To == from { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot send to self"}) + return + } + + delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{ + Type: "message", + Data: map[string]any{ + "from": from, + "text": req.Text, + "sent_at": time.Now().Unix(), + }, + }) + if !delivered { + writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/relay.go b/internal/httpapi/relay.go new file mode 100644 index 0000000..ac10a7c --- /dev/null +++ b/internal/httpapi/relay.go @@ -0,0 +1,155 @@ +package httpapi + +import ( + "errors" + "io" + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "commilitia.net/cdrop/internal/jwtauth" + "commilitia.net/cdrop/internal/relay" +) + +// Per-chunk upload limit. Senders split files into chunks of at most this size +// (plan §M5: "sender 多次 chunked POST"). Whole-chunk read into memory before +// commit lets us cleanly reject oversized chunks without partial state. +const maxRelayChunkSize = relay.DefaultChunkLimit + +func (s *Server) handleRelayChunk(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + device, _ := jwtauth.DeviceNameFromContext(r.Context()) + sessionID := chi.URLParam(r, "id") + if sessionID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing session id"}) + return + } + + // Read body fully so an oversized chunk fails *before* we touch the ring. + body, err := io.ReadAll(io.LimitReader(r.Body, maxRelayChunkSize+1)) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if len(body) > maxRelayChunkSize { + writeJSON(w, http.StatusRequestEntityTooLarge, + map[string]string{"error": "chunk exceeds 1 MiB"}) + return + } + + sess, err := s.relay.Acquire(sessionID, claims.UserID, device) + if err != nil { + mapRelayError(w, err) + return + } + + // Resume support (plan §M10): caller sends the offset of THIS chunk's + // first byte. We CAS against current bytesWritten so a retried chunk + // (network blip, sender app reload) returns 409 and the X-Bytes-Received + // header tells the client where to pick up. + if hdr := r.Header.Get("X-Resume-Offset"); hdr != "" { + want, perr := strconv.ParseInt(hdr, 10, 64) + if perr != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid X-Resume-Offset"}) + return + } + got := sess.BytesWritten() + if want != got { + w.Header().Set("X-Bytes-Received", strconv.FormatInt(got, 10)) + writeJSON(w, http.StatusConflict, map[string]any{ + "error": "offset mismatch", + "expected": got, + "received": want, + }) + return + } + } + + n, err := sess.Write(r.Context(), body) + if err != nil { + slog.Warn("relay chunk write failed", + "err", err, "session", sessionID, "user", claims.UserID, "n", n) + writeJSON(w, http.StatusGone, map[string]string{ + "error": err.Error(), + "bytes_received": strconv.FormatInt(sess.BytesWritten(), 10), + }) + return + } + if r.Header.Get("X-End") == "true" { + sess.CloseWriter() + } + w.Header().Set("X-Bytes-Received", strconv.FormatInt(sess.BytesWritten(), 10)) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleRelayStream(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + device, _ := jwtauth.DeviceNameFromContext(r.Context()) + sessionID := chi.URLParam(r, "id") + if sessionID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing session id"}) + return + } + + sess, err := s.relay.Acquire(sessionID, claims.UserID, device) + if err != nil { + mapRelayError(w, err) + return + } + + // The relay stream is strictly single-consumer: a second concurrent reader + // would drain bytes out of the ring and silently corrupt the receiver's copy + // (G2). Refuse it rather than join. ReleaseReader on return lets a legitimate + // reconnect (network blip, page reload) take over once the first has exited. + if !sess.AcquireReader() { + writeJSON(w, http.StatusConflict, map[string]string{"error": "stream already active"}) + return + } + defer sess.ReleaseReader() + + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + if flusher != nil { + flusher.Flush() + } + + buf := make([]byte, 32*1024) + for { + n, err := sess.Read(r.Context(), buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + if errors.Is(err, io.EOF) { + return + } + if err != nil { + return + } + } +} + +func mapRelayError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, relay.ErrTooManySessions): + writeJSON(w, http.StatusServiceUnavailable, + map[string]string{"error": "too many active relay sessions"}) + case errors.Is(err, relay.ErrForbidden): + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + case errors.Is(err, relay.ErrNotRegistered): + // The transfer isn't in relay mode (never fell back, or the session was + // reaped after going idle). The client should re-issue /fallback. + writeJSON(w, http.StatusConflict, map[string]string{"error": "relay session not active"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go new file mode 100644 index 0000000..5f63c5f --- /dev/null +++ b/internal/httpapi/server.go @@ -0,0 +1,202 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" + + "commilitia.net/cdrop/internal/calls" + "commilitia.net/cdrop/internal/clipboard" + "commilitia.net/cdrop/internal/config" + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/hub" + "commilitia.net/cdrop/internal/jwtauth" + "commilitia.net/cdrop/internal/relay" + "commilitia.net/cdrop/internal/transfer" + "commilitia.net/cdrop/internal/webui" +) + +type Server struct { + cfg *config.Config + db *sql.DB + queries *db.Queries + auth *jwtauth.Authenticator + hub *hub.Hub + transfers *transfer.Service + relay *relay.Manager + calls *calls.Provider // optional; nil → STUN-only fallback + clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard + mux *chi.Mux +} + +func New( + cfg *config.Config, + dbConn *sql.DB, + queries *db.Queries, + auth *jwtauth.Authenticator, + h *hub.Hub, + transfers *transfer.Service, + rm *relay.Manager, + cp *calls.Provider, + clip *clipboard.Service, +) *Server { + s := &Server{ + cfg: cfg, + db: dbConn, + queries: queries, + auth: auth, + hub: h, + transfers: transfers, + relay: rm, + calls: cp, + clipboard: clip, + mux: chi.NewRouter(), + } + s.routes() + return s +} + +func (s *Server) Handler() http.Handler { return s.mux } + +func (s *Server) routes() { + s.mux.Use(middleware.RequestID) + s.mux.Use(middleware.RealIP) + s.mux.Use(middleware.Recoverer) + + s.mux.Get("/healthz", s.handleHealth) + + // Anything not matched below falls through to the embedded frontend bundle. + // In Docker the binary serves the SPA itself; in local dev the embed FS is + // empty and this 404s — vite dev on :5173 handles the UI instead. + s.mux.NotFound(webui.Handler().ServeHTTP) + + s.mux.Route("/api", func(r chi.Router) { + // Public OIDC PKCE plumbing — no auth required (it bootstraps it). + r.Get("/auth/config", s.handleAuthConfig) + // Rate-limit the credential-bearing OAuth endpoints (G4): they proxy to + // the IdP, so without a cap cdrop is a brute-force / token-pivot relay. + // Per-IP (RealIP is mounted above); 60/min is ample for real logins and + // hourly refresh even behind a shared NAT, while choking a scripted flood. + r.Group(func(r chi.Router) { + r.Use(httprate.LimitByIP(60, time.Minute)) + r.Post("/auth/exchange", s.handleAuthExchange) + r.Post("/auth/refresh", s.handleAuthRefresh) + }) + + // Protected routes. gzip / compress is intentionally NOT mounted — + // it would buffer the SSE stream. + r.Group(func(r chi.Router) { + r.Use(s.auth.Middleware) + + // Clipboard is the one capability a scoped shortcut token may reach + // (iOS Shortcut sync) — it must carry the "clipboard" scope. Full + // login sessions pass requireScope unconditionally. + r.Group(func(r chi.Router) { + r.Use(requireScope(shortcutScope)) + r.Get("/clipboard", s.handleClipboardGet) + r.Put("/clipboard", s.handleClipboardPut) + // Lightweight version probe — no content; lets pollers (iOS + // Shortcut) detect change cheaply before fetching the full body. + r.Get("/clipboard/version", s.handleClipboardVersion) + }) + + // Everything else requires a full login session; scoped shortcut + // tokens are rejected, so a leaked token's blast radius stays the + // clipboard and nothing more (including token self-management). + r.Group(func(r chi.Router) { + r.Use(rejectScoped) + r.Get("/me", s.handleMe) + r.Post("/me/disconnect", s.handleDisconnect) + r.Get("/hub/events", s.handleEvents) + r.Post("/hub/signal", s.handleSignal) + r.Post("/message", s.handleMessage) + r.Get("/devices", s.handleDevices) + r.Delete("/devices/{name}", s.handleDeleteDevice) + r.Get("/calls/credentials", s.handleCallsCredentials) + r.Post("/shortcut/issue", s.handleShortcutIssue) + r.Get("/shortcut", s.handleShortcutList) + r.Delete("/shortcut/{jti}", s.handleShortcutRevoke) + r.Route("/transfer", func(r chi.Router) { + r.Post("/initiate", s.handleTransferInit) + r.Post("/{id}/accept", s.transitionHandler(transfer.StateAccepted, "")) + r.Post("/{id}/cancel", s.transitionHandler(transfer.StateCancelled, "")) + r.Post("/{id}/p2p", s.transitionHandler(transfer.StateP2PActive, transfer.ModeP2P)) + r.Post("/{id}/fallback", s.transitionHandler(transfer.StateRelayActive, transfer.ModeRelay)) + r.Post("/{id}/done", s.transitionHandler(transfer.StateDone, "")) + r.Post("/{id}/fail", s.transitionHandler(transfer.StateFailed, "")) + }) + r.Route("/relay/{id}", func(r chi.Router) { + r.Post("/chunk", s.handleRelayChunk) + r.Get("/stream", s.handleRelayStream) + }) + }) + }) + }) +} + +type healthResp struct { + Status string `json:"status"` + AuthMode string `json:"auth_mode"` + DBOK bool `json:"db_ok"` +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + resp := healthResp{Status: "ok", AuthMode: s.cfg.AuthMode, DBOK: true} + if err := s.db.PingContext(ctx); err != nil { + resp.Status = "degraded" + resp.DBOK = false + slog.Error("healthz db ping failed", "err", err) + } + writeJSON(w, http.StatusOK, resp) +} + +type meResp struct { + UserID string `json:"user_id"` + Groups []string `json:"groups"` + DeviceName string `json:"device_name"` +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + claims, ok := jwtauth.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) + return + } + device, _ := jwtauth.DeviceNameFromContext(r.Context()) + writeJSON(w, http.StatusOK, meResp{ + UserID: claims.UserID, + Groups: claims.Groups, + DeviceName: device, + }) +} + +// handleDisconnect 是用户主动告知"本机要下线"的轻量信号(登出 / 关页前)。 +// auth 中间件会先 UPSERT device 把 last_seen 更新,本 handler 再 Kick 关闭 +// SSE + 立刻广播 presence,让对端不必等 30s 宽限期就看到本设备离线。 +func (s *Server) handleDisconnect(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + deviceName, _ := jwtauth.DeviceNameFromContext(r.Context()) + if deviceName == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device"}) + return + } + s.hub.Kick(claims.UserID, deviceName) + s.hub.PublishPresence(r.Context(), claims.UserID) + w.WriteHeader(http.StatusNoContent) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/internal/httpapi/shortcut.go b/internal/httpapi/shortcut.go new file mode 100644 index 0000000..68a4553 --- /dev/null +++ b/internal/httpapi/shortcut.go @@ -0,0 +1,240 @@ +package httpapi + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/jwtauth" +) + +// shortcutScope is the single scope a shortcut token is granted; the route layer +// only lets a token carrying it reach the clipboard endpoints (see server.go). +const shortcutScope = "clipboard" + +// requireScope gates a route group that scoped shortcut tokens may also reach: a +// scoped token must carry the named scope, while a full login session always +// passes (it has no scope restriction). +func requireScope(scope string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := jwtauth.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) + return + } + if claims.Scoped() && !claims.HasScope(scope) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"}) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// rejectScoped blocks scoped shortcut tokens outright — for every route only a +// full login session may touch. A leaked clipboard token thus reaches nothing +// here: not the device list, transfers, signalling, nor token self-management. +func rejectScoped(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := jwtauth.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"}) + return + } + if claims.Scoped() { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"}) + return + } + next.ServeHTTP(w, r) + }) +} + +// 这些端点只由登录会话调用(路由层 rejectScoped 守门)——用 token 不能自管 token, +// 防止泄漏的快捷指令 token 自我续期或越权。 + +type shortcutIssueReq struct { + Label string `json:"label"` +} + +type shortcutIssueResp struct { + // Token 仅在签发时返回这一次,服务端不留可还原副本(只存 jti 等元数据)。 + Token string `json:"token"` + Jti string `json:"jti"` + Label string `json:"label"` + Scopes []string `json:"scopes"` + ExpiresAt int64 `json:"expires_at"` +} + +type shortcutTokenView struct { + Jti string `json:"jti"` + 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 bool `json:"revoked"` +} + +// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores +// its metadata, and returns the token string once. Disabled (503) when no HS256 +// secret is configured. +func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) { + if len(s.cfg.HS256Secret) == 0 { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"}) + return + } + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + + var req shortcutIssueReq + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + label := strings.TrimSpace(req.Label) + if label == "" { + label = "iOS Shortcut" + } + if len(label) > 64 { + label = label[:64] + } + + now := time.Now() + active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{ + UserID: claims.UserID, + ExpiresAt: now.Unix(), + }) + if err != nil { + slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + if active >= int64(s.cfg.ShortcutMaxPerUser) { + writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"}) + return + } + + jti, err := randomTokenID() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"}) + return + } + expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour) + + token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires) + if err != nil { + slog.Error("sign shortcut token failed", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"}) + return + } + + if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{ + Jti: jti, + UserID: claims.UserID, + Label: label, + Scopes: shortcutScope, + CreatedAt: now.Unix(), + ExpiresAt: expires.Unix(), + }); err != nil { + slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + + writeJSON(w, http.StatusCreated, shortcutIssueResp{ + Token: token, + Jti: jti, + Label: label, + Scopes: []string{shortcutScope}, + ExpiresAt: expires.Unix(), + }) +} + +// handleShortcutList returns the caller's tokens (metadata only, never the token +// string). +func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID) + if err != nil { + slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + out := make([]shortcutTokenView, 0, len(rows)) + for _, t := range rows { + out = append(out, shortcutTokenView{ + Jti: t.Jti, + Label: t.Label, + Scopes: strings.Fields(t.Scopes), + CreatedAt: t.CreatedAt, + ExpiresAt: t.ExpiresAt, + LastUsedAt: t.LastUsedAt, + Revoked: t.Revoked != 0, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) +} + +// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so +// one user can't revoke another's). verifyHS256 reads the flag on every request, +// so revocation takes effect on the token's next use. +func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + jti := chi.URLParam(r, "jti") + if jti == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"}) + return + } + n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{ + Jti: jti, + UserID: claims.UserID, + }) + if err != nil { + slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) + return + } + if n == 0 { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space- +// delimited OAuth-style, informational — the store row is authoritative), iat, +// exp. The shared HS256 secret is the same one verifyHS256 checks against. +func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) { + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return "", err + } + std := jwt.Claims{ + Subject: userID, + ID: jti, + IssuedAt: jwt.NewNumericDate(iat), + Expiry: jwt.NewNumericDate(exp), + } + return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize() +} + +// randomTokenID returns a 128-bit URL-safe random id for the jti. +func randomTokenID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b[:]), nil +} diff --git a/internal/httpapi/signal.go b/internal/httpapi/signal.go new file mode 100644 index 0000000..81b6b03 --- /dev/null +++ b/internal/httpapi/signal.go @@ -0,0 +1,63 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "commilitia.net/cdrop/internal/hub" + "commilitia.net/cdrop/internal/jwtauth" +) + +// maxSignalBytes caps the signaling body. WebRTC SDP offers/answers and bundled +// ICE candidates are at most a few KB; the payload is an opaque json.RawMessage +// with no other length check, so without this a single request could stream an +// unbounded body into memory (R4). 64 KiB leaves generous headroom for fat SDP. +const maxSignalBytes = 64 * 1024 + +type signalReq struct { + To string `json:"to"` + Payload json.RawMessage `json:"payload"` +} + +// handleSignal forwards WebRTC offer/answer/ICE candidates between same-user devices. +// Body: { to: deviceName, payload: any-json } +// 204 if delivered to live SSE; 410 if peer offline. +func (s *Server) handleSignal(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + from, _ := jwtauth.DeviceNameFromContext(r.Context()) + + r.Body = http.MaxBytesReader(w, r.Body, maxSignalBytes) + var req signalReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + writeJSON(w, http.StatusRequestEntityTooLarge, + map[string]string{"error": "signal payload too large"}) + return + } + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + if req.To == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to'"}) + return + } + if req.To == from { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot signal self"}) + return + } + + delivered := s.hub.SendTo(claims.UserID, req.To, hub.Event{ + Type: "signal", + Data: map[string]any{ + "from": from, + "payload": req.Payload, + }, + }) + if !delivered { + writeJSON(w, http.StatusGone, map[string]string{"error": "peer offline"}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/httpapi/sse.go b/internal/httpapi/sse.go new file mode 100644 index 0000000..dbe8aff --- /dev/null +++ b/internal/httpapi/sse.go @@ -0,0 +1,100 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/hub" + "commilitia.net/cdrop/internal/jwtauth" +) + +const ( + keepaliveInterval = 15 * time.Second + probeFrame = ": hi\n\n" +) + +// handleEvents serves the long-lived SSE channel mandated by brief §5. +// Sets X-Accel-Buffering / Cache-Control / flush to defeat reverse-proxy buffering. +// Sends a probe frame immediately so the client can detect first-byte latency. +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + claims, ok := jwtauth.ClaimsFromContext(r.Context()) + if !ok { + http.Error(w, "missing claims", http.StatusUnauthorized) + return + } + deviceName, _ := jwtauth.DeviceNameFromContext(r.Context()) + if deviceName == "" { + http.Error(w, "missing device", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + if _, err := io.WriteString(w, probeFrame); err != nil { + return + } + flusher.Flush() + + client := s.hub.Connect(r.Context(), claims.UserID, deviceName) + defer s.hub.Disconnect(client) + + slog.Debug("sse connected", "user", claims.UserID, "device", deviceName) + + ping := time.NewTicker(keepaliveInterval) + defer ping.Stop() + + for { + select { + case ev, ok := <-client.Events(): + if !ok { + return + } + if err := writeSSEEvent(w, ev); err != nil { + return + } + flusher.Flush() + case <-ping.C: + if _, err := io.WriteString(w, "event: ping\ndata: {}\n\n"); err != nil { + return + } + flusher.Flush() + // Refresh last_seen so a connection-only client (no other API calls) + // keeps its device row alive past TTL — matches the sliding + // refresh_token semantics requested for device persistence. + _ = s.queries.UpsertDevice(r.Context(), db.UpsertDeviceParams{ + UserID: claims.UserID, + Name: deviceName, + Type: jwtauth.DeviceTypeFromContext(r.Context()), + LastSeen: time.Now().Unix(), + }) + case <-r.Context().Done(): + return + } + } +} + +func writeSSEEvent(w io.Writer, ev hub.Event) error { + payload, err := json.Marshal(ev.Data) + if err != nil { + return fmt.Errorf("marshal sse data: %w", err) + } + // SSE frame: "event: \ndata: \n\n". + // JSON is single-line via json.Marshal so no `data:` continuation needed. + _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, payload) + return err +} diff --git a/internal/httpapi/transfer.go b/internal/httpapi/transfer.go new file mode 100644 index 0000000..1a4ce41 --- /dev/null +++ b/internal/httpapi/transfer.go @@ -0,0 +1,94 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "commilitia.net/cdrop/internal/jwtauth" + "commilitia.net/cdrop/internal/transfer" +) + +type initReq struct { + ReceiverName string `json:"receiver_name"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + FileSHA256 string `json:"file_sha256,omitempty"` +} + +type initResp struct { + SessionID string `json:"session_id"` +} + +func (s *Server) handleTransferInit(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + sender, _ := jwtauth.DeviceNameFromContext(r.Context()) + + var req initReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + + id, err := s.transfers.Init(r.Context(), transfer.InitParams{ + UserID: claims.UserID, + SenderName: sender, + ReceiverName: req.ReceiverName, + FileName: req.FileName, + FileSize: req.FileSize, + FileSHA256: req.FileSHA256, + }) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, initResp{SessionID: id}) +} + +// handleTransferTransition is the shared body for accept / cancel / fallback / +// done / fail. The target state and any side-fields are bound by the route. +type transitionReq struct { + Reason string `json:"reason,omitempty"` + BytesTransferred int64 `json:"bytes_transferred,omitempty"` +} + +func (s *Server) transitionHandler(target, mode string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + claims, _ := jwtauth.ClaimsFromContext(r.Context()) + id := chi.URLParam(r, "id") + if id == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing id"}) + return + } + + var req transitionReq + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) + return + } + } + + err := s.transfers.Transition( + r.Context(), claims.UserID, id, target, + mode, req.Reason, req.BytesTransferred, + ) + switch { + case err == nil: + w.WriteHeader(http.StatusNoContent) + case errors.Is(err, transfer.ErrNotFound): + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + case errors.Is(err, transfer.ErrForbidden): + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"}) + case errors.Is(err, transfer.ErrInvalidTransition): + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + case errors.Is(err, transfer.ErrRelayUnavailable): + writeJSON(w, http.StatusServiceUnavailable, + map[string]string{"error": "relay unavailable, retry later"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + } +} diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..a3000cf --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,242 @@ +package hub + +import ( + "context" + "log/slog" + "sync" + "time" + + "commilitia.net/cdrop/internal/db" +) + +// DeviceLister is the subset of *db.Queries the hub needs. +// Declared as interface so tests can inject a fake. +type DeviceLister interface { + ListDevicesByUser(ctx context.Context, userID string) ([]db.Device, error) +} + +// Event is what the hub fan-outs to SSE clients. +type Event struct { + Type string `json:"-"` + Data any `json:"-"` +} + +// PresenceDevice is the per-device entry in a `presence` event payload. +// Field names align with brief §5: { name, type, online }; last_seen 是 +// 设置页展示「上次活跃」需要的扩展字段。 +type PresenceDevice struct { + Name string `json:"name"` + Type string `json:"type"` + Online bool `json:"online"` + LastSeen int64 `json:"last_seen"` +} + +const clientBuffer = 32 + +// Client is a single live SSE connection. +type Client struct { + UserID string + DeviceID string + ch chan Event +} + +func (c *Client) Events() <-chan Event { return c.ch } + +// Hub is the in-memory presence + signaling fan-out. +// +// Concurrency: a single sync.RWMutex protects the user→device map. +// Sends to client channels are non-blocking — slow consumers drop events +// rather than stall the broadcaster. +type Hub struct { + mu sync.RWMutex + users map[string]map[string]*Client + + devices DeviceLister + grace time.Duration + + closed bool +} + +func New(devices DeviceLister) *Hub { + return &Hub{ + users: map[string]map[string]*Client{}, + devices: devices, + grace: 30 * time.Second, + } +} + +// Connect registers a new SSE client and announces presence to the user's other devices. +// If a client for (userID, deviceID) already exists (e.g., a tab refresh), its channel is closed. +func (h *Hub) Connect(ctx context.Context, userID, deviceID string) *Client { + c := &Client{ + UserID: userID, + DeviceID: deviceID, + ch: make(chan Event, clientBuffer), + } + + h.mu.Lock() + if h.users[userID] == nil { + h.users[userID] = map[string]*Client{} + } + if old, ok := h.users[userID][deviceID]; ok { + close(old.ch) + } + h.users[userID][deviceID] = c + h.mu.Unlock() + + go h.publishPresence(ctx, userID) + return c +} + +// Disconnect removes a client and, after a grace period, re-publishes presence +// to peers if the client has not reconnected. +func (h *Hub) Disconnect(c *Client) { + h.mu.Lock() + if active, ok := h.users[c.UserID][c.DeviceID]; ok && active == c { + delete(h.users[c.UserID], c.DeviceID) + if len(h.users[c.UserID]) == 0 { + delete(h.users, c.UserID) + } + } + h.mu.Unlock() + + time.AfterFunc(h.grace, func() { + h.mu.RLock() + _, stillOnline := h.users[c.UserID][c.DeviceID] + h.mu.RUnlock() + if stillOnline { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + h.publishPresence(ctx, c.UserID) + }) +} + +// SendTo routes an event to a specific (userID, deviceID). Reports whether +// the target was online and the event was queued. +func (h *Hub) SendTo(userID, deviceID string, ev Event) bool { + h.mu.RLock() + c, ok := h.users[userID][deviceID] + h.mu.RUnlock() + if !ok { + return false + } + select { + case c.ch <- ev: + return true + default: + slog.Warn("client buffer full; dropping event", + "user", userID, "device", deviceID, "type", ev.Type) + return false + } +} + +// Broadcast fans an event out to every live client of a user. +func (h *Hub) Broadcast(userID string, ev Event) { + clients := h.snapshotClients(userID) + for _, c := range clients { + select { + case c.ch <- ev: + default: + slog.Warn("client buffer full; dropping event", + "user", c.UserID, "device", c.DeviceID, "type", ev.Type) + } + } +} + +// Online reports whether a (userID, deviceID) currently has a live SSE. +func (h *Hub) Online(userID, deviceID string) bool { + h.mu.RLock() + defer h.mu.RUnlock() + _, ok := h.users[userID][deviceID] + return ok +} + +// Kick force-removes a (userID, deviceID) entry from the hub and closes its +// event channel; the SSE handler exits on the next iteration. 与 Connect 的 +// 旧通道关闭模式一致(与并发 SendTo 之间存在极窄竞态,但 Kick 罕用,可接受)。 +func (h *Hub) Kick(userID, deviceID string) { + h.mu.Lock() + c, ok := h.users[userID][deviceID] + if ok { + delete(h.users[userID], deviceID) + if len(h.users[userID]) == 0 { + delete(h.users, userID) + } + } + h.mu.Unlock() + if ok { + close(c.ch) + } +} + +// PublishPresence broadcasts the user's current device list to all live +// clients. 用于在变更设备集合后(如 DELETE /api/devices/{name})立刻广播, +// 而不是等待 Disconnect 的宽限期。 +func (h *Hub) PublishPresence(ctx context.Context, userID string) { + h.publishPresence(ctx, userID) +} + +// Close drops every connected client. Used on graceful shutdown. +func (h *Hub) Close() { + h.mu.Lock() + defer h.mu.Unlock() + if h.closed { + return + } + h.closed = true + for _, devices := range h.users { + for _, c := range devices { + close(c.ch) + } + } + h.users = map[string]map[string]*Client{} +} + +func (h *Hub) snapshotClients(userID string) []*Client { + h.mu.RLock() + defer h.mu.RUnlock() + src := h.users[userID] + out := make([]*Client, 0, len(src)) + for _, c := range src { + out = append(out, c) + } + return out +} + +func (h *Hub) publishPresence(ctx context.Context, userID string) { + devs, err := h.devices.ListDevicesByUser(ctx, userID) + if err != nil { + slog.Error("presence: list devices failed", "user", userID, "err", err) + return + } + + h.mu.RLock() + live := h.users[userID] + items := make([]PresenceDevice, 0, len(devs)) + for _, d := range devs { + _, online := live[d.Name] + items = append(items, PresenceDevice{ + Name: d.Name, Type: d.Type, Online: online, LastSeen: d.LastSeen, + }) + } + clients := make([]*Client, 0, len(live)) + for _, c := range live { + clients = append(clients, c) + } + h.mu.RUnlock() + + ev := Event{ + Type: "presence", + Data: map[string]any{"devices": items}, + } + for _, c := range clients { + select { + case c.ch <- ev: + default: + slog.Warn("presence: client buffer full; dropping", + "user", c.UserID, "device", c.DeviceID) + } + } +} diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..8774476 --- /dev/null +++ b/internal/hub/hub_test.go @@ -0,0 +1,198 @@ +package hub + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "commilitia.net/cdrop/internal/db" +) + +// fakeLister returns a fixed device list and counts calls. +type fakeLister struct { + devices []db.Device + calls atomic.Int32 +} + +func (f *fakeLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) { + f.calls.Add(1) + return f.devices, nil +} + +func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) Event { + t.Helper() + select { + case ev, ok := <-c.Events(): + if !ok { + t.Fatalf("event channel closed; wanted type=%q", want) + } + if want != "" && ev.Type != want { + t.Fatalf("event type: got %q, want %q", ev.Type, want) + } + return ev + case <-time.After(timeout): + t.Fatalf("timeout waiting for event type=%q", want) + } + return Event{} +} + +func TestConnectFiresPresenceEvent(t *testing.T) { + lister := &fakeLister{ + devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}}, + } + h := New(lister) + defer h.Close() + + c := h.Connect(context.Background(), "alice", "tab-1") + defer h.Disconnect(c) + + ev := waitForEvent(t, c, "presence", time.Second) + data, _ := ev.Data.(map[string]any) + devs, _ := data["devices"].([]PresenceDevice) + if len(devs) != 1 || devs[0].Name != "tab-1" || !devs[0].Online { + t.Errorf("presence devices: %+v", devs) + } +} + +func TestSecondClientSeesFirstAsOnline(t *testing.T) { + lister := &fakeLister{ + devices: []db.Device{ + {UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}, + {UserID: "alice", Name: "tab-2", Type: "browser", LastSeen: 2}, + }, + } + h := New(lister) + defer h.Close() + + c1 := h.Connect(context.Background(), "alice", "tab-1") + defer h.Disconnect(c1) + _ = waitForEvent(t, c1, "presence", time.Second) + + c2 := h.Connect(context.Background(), "alice", "tab-2") + defer h.Disconnect(c2) + + // c2 receives its own presence (announced on Connect). + ev := waitForEvent(t, c2, "presence", time.Second) + devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice) + if len(devs) != 2 { + t.Fatalf("expected 2 devices in presence, got %+v", devs) + } + online := map[string]bool{} + for _, d := range devs { + online[d.Name] = d.Online + } + if !online["tab-1"] || !online["tab-2"] { + t.Errorf("both tabs should be online: %+v", online) + } + + // c1 also gets presence diff for c2's arrival. + ev = waitForEvent(t, c1, "presence", time.Second) + devs, _ = ev.Data.(map[string]any)["devices"].([]PresenceDevice) + if len(devs) != 2 { + t.Errorf("c1 presence after tab-2 arrival: got %d devices, want 2", len(devs)) + } +} + +func TestSendToRoutesEvent(t *testing.T) { + lister := &fakeLister{} + h := New(lister) + defer h.Close() + + c := h.Connect(context.Background(), "alice", "tab-1") + defer h.Disconnect(c) + _ = waitForEvent(t, c, "presence", time.Second) + + if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) { + t.Fatal("SendTo to live client should report true") + } + ev := waitForEvent(t, c, "signal", time.Second) + if ev.Data.(map[string]any)["x"] != 1 { + t.Errorf("payload: %+v", ev.Data) + } +} + +func TestSendToOfflineDeviceReportsFalse(t *testing.T) { + h := New(&fakeLister{}) + defer h.Close() + if h.SendTo("ghost-user", "ghost-device", Event{Type: "signal"}) { + t.Error("SendTo to offline target should report false") + } +} + +func TestReconnectClosesOldChannel(t *testing.T) { + h := New(&fakeLister{}) + defer h.Close() + + old := h.Connect(context.Background(), "alice", "tab-1") + // drain the initial presence so we can detect close + <-old.Events() + + _ = h.Connect(context.Background(), "alice", "tab-1") + + select { + case _, ok := <-old.Events(): + if ok { + t.Error("old channel should be closed on reconnect from same (user, device)") + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for old channel close") + } +} + +func TestDisconnectRemovesFromOnlineSet(t *testing.T) { + h := New(&fakeLister{ + devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser"}}, + }) + defer h.Close() + + c := h.Connect(context.Background(), "alice", "tab-1") + if !h.Online("alice", "tab-1") { + t.Fatal("client should be online after Connect") + } + h.Disconnect(c) + if h.Online("alice", "tab-1") { + t.Error("client should not be online after Disconnect") + } +} + +// TestGracePeriod uses a tiny grace so the test runs fast. +func TestGracePeriodDelaysOfflinePresence(t *testing.T) { + h := New(&fakeLister{ + devices: []db.Device{ + {UserID: "alice", Name: "tab-1", Type: "browser"}, + {UserID: "alice", Name: "tab-2", Type: "browser"}, + }, + }) + h.grace = 100 * time.Millisecond + defer h.Close() + + c1 := h.Connect(context.Background(), "alice", "tab-1") + defer h.Disconnect(c1) + c2 := h.Connect(context.Background(), "alice", "tab-2") + // Drain initial presence frames + _ = waitForEvent(t, c1, "presence", time.Second) + _ = waitForEvent(t, c1, "presence", time.Second) + _ = waitForEvent(t, c2, "presence", time.Second) + + h.Disconnect(c2) + // Grace 100ms: c1 should receive presence after the grace window with tab-2 offline. + select { + case ev := <-c1.Events(): + if ev.Type != "presence" { + t.Fatalf("expected presence after grace, got %q", ev.Type) + } + devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice) + var tab2Online bool + for _, d := range devs { + if d.Name == "tab-2" { + tab2Online = d.Online + } + } + if tab2Online { + t.Error("tab-2 should be offline in post-grace presence") + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for post-grace presence") + } +} diff --git a/internal/jwtauth/claims.go b/internal/jwtauth/claims.go new file mode 100644 index 0000000..a687c79 --- /dev/null +++ b/internal/jwtauth/claims.go @@ -0,0 +1,56 @@ +package jwtauth + +import "context" + +type Claims struct { + UserID string + Groups []string + // JTI and Scopes are populated only for HS256 shortcut tokens; a full OIDC / + // dev session leaves them empty. A non-empty JTI marks a *scoped* token — + // one allowed to reach only the endpoints its Scopes grant (the route layer + // enforces this). This keeps a leaked shortcut token's blast radius minimal. + JTI string + Scopes []string +} + +// Scoped reports whether these claims came from a scoped shortcut token rather +// than a full login session. +func (c *Claims) Scoped() bool { return c.JTI != "" } + +// HasScope reports whether the claims grant the named scope. +func (c *Claims) HasScope(scope string) bool { + for _, s := range c.Scopes { + if s == scope { + return true + } + } + return false +} + +type ctxKey int + +const ( + claimsCtxKey ctxKey = iota + deviceCtxKey + deviceTypeCtxKey +) + +func ClaimsFromContext(ctx context.Context) (*Claims, bool) { + c, ok := ctx.Value(claimsCtxKey).(*Claims) + return c, ok +} + +func DeviceNameFromContext(ctx context.Context) (string, bool) { + n, ok := ctx.Value(deviceCtxKey).(string) + return n, ok +} + +// DeviceTypeFromContext returns the client-declared device type set by the auth +// middleware (browser / macos / windows / linux), defaulting to "browser". +func DeviceTypeFromContext(ctx context.Context) string { + t, ok := ctx.Value(deviceTypeCtxKey).(string) + if !ok || t == "" { + return "browser" + } + return t +} diff --git a/internal/jwtauth/devices.go b/internal/jwtauth/devices.go new file mode 100644 index 0000000..67d3609 --- /dev/null +++ b/internal/jwtauth/devices.go @@ -0,0 +1,38 @@ +package jwtauth + +import ( + "context" + "log/slog" + "time" + + "commilitia.net/cdrop/internal/db" +) + +// DeviceSweepInterval is how often the device sweeper wakes up. +const DeviceSweepInterval = 1 * time.Hour + +// RunDeviceSweeper deletes devices whose last_seen is older than ttl. +// Per user requirement: device registration must not outlive the longest +// valid refresh_token (brief §2: 196h sliding window). Anything ≤ TTL is fine; +// 0 disables the sweeper. +func RunDeviceSweeper(ctx context.Context, queries *db.Queries, ttl time.Duration) { + if ttl <= 0 { + slog.Info("device sweeper disabled (ttl <= 0)") + return + } + slog.Info("device sweeper running", "ttl", ttl, "interval", DeviceSweepInterval) + + t := time.NewTicker(DeviceSweepInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + cutoff := time.Now().Add(-ttl).Unix() + if err := queries.DeleteStaleDevices(ctx, cutoff); err != nil { + slog.Error("device sweeper failed", "err", err) + } + } + } +} diff --git a/internal/jwtauth/devices_test.go b/internal/jwtauth/devices_test.go new file mode 100644 index 0000000..6791c5d --- /dev/null +++ b/internal/jwtauth/devices_test.go @@ -0,0 +1,61 @@ +package jwtauth + +import ( + "context" + "path/filepath" + "testing" + "time" + + "commilitia.net/cdrop/internal/db" +) + +func openTestQueries(t *testing.T) *db.Queries { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "x.db") + conn, err := db.Open(dbPath) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.Bootstrap(context.Background(), conn); err != nil { + t.Fatalf("bootstrap: %v", err) + } + return db.New(conn) +} + +func TestDeleteStaleDevices_RemovesOnlyOld(t *testing.T) { + q := openTestQueries(t) + ctx := context.Background() + + now := time.Now() + old := now.Add(-200 * time.Hour) + fresh := now.Add(-1 * time.Hour) + + upsert := func(name string, ts time.Time) { + if err := q.UpsertDevice(ctx, db.UpsertDeviceParams{ + UserID: "alice", Name: name, Type: "browser", LastSeen: ts.Unix(), + }); err != nil { + t.Fatalf("upsert %s: %v", name, err) + } + } + upsert("old-device", old) + upsert("fresh-device", fresh) + + // Cutoff = "older than 196 hours from now" mimics RunDeviceSweeper math. + cutoff := now.Add(-196 * time.Hour).Unix() + if err := q.DeleteStaleDevices(ctx, cutoff); err != nil { + t.Fatalf("delete: %v", err) + } + + devs, _ := q.ListDevicesByUser(ctx, "alice") + got := map[string]bool{} + for _, d := range devs { + got[d.Name] = true + } + if got["old-device"] { + t.Error("old-device should have been swept") + } + if !got["fresh-device"] { + t.Error("fresh-device should remain") + } +} diff --git a/internal/jwtauth/jwks.go b/internal/jwtauth/jwks.go new file mode 100644 index 0000000..40feb9a --- /dev/null +++ b/internal/jwtauth/jwks.go @@ -0,0 +1,113 @@ +package jwtauth + +import ( + "context" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/go-jose/go-jose/v4" +) + +// jwksCache fetches & caches the Casdoor JWKS. +// +// Behavior: +// - successful fetch: refreshes the entire key set +// - kid miss: forces a single refresh attempt +// - stale-while-error: if cached entry exists, return it even when refresh fails +type jwksCache struct { + url string + ttl time.Duration + + mu sync.RWMutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time + + httpClient *http.Client +} + +func newJWKSCache(url string, ttl time.Duration) *jwksCache { + return &jwksCache{ + url: url, + ttl: ttl, + keys: map[string]*rsa.PublicKey{}, + httpClient: &http.Client{Timeout: 5 * time.Second}, + } +} + +func (j *jwksCache) GetKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + if kid == "" { + return nil, errors.New("missing kid") + } + + j.mu.RLock() + cached, found := j.keys[kid] + fresh := !j.fetchedAt.IsZero() && time.Since(j.fetchedAt) < j.ttl + j.mu.RUnlock() + + if found && fresh { + return cached, nil + } + + if err := j.refresh(ctx); err != nil { + if found { + slog.Warn("jwks refresh failed; returning stale key", + "err", err, "kid", kid) + return cached, nil + } + return nil, fmt.Errorf("jwks refresh: %w", err) + } + + j.mu.RLock() + defer j.mu.RUnlock() + if k, ok := j.keys[kid]; ok { + return k, nil + } + return nil, fmt.Errorf("kid %q not found in JWKS", kid) +} + +func (j *jwksCache) refresh(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, j.url, nil) + if err != nil { + return err + } + resp, err := j.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var set jose.JSONWebKeySet + if err := json.Unmarshal(body, &set); err != nil { + return fmt.Errorf("parse JWKS: %w", err) + } + + next := map[string]*rsa.PublicKey{} + for _, k := range set.Keys { + pk, ok := k.Key.(*rsa.PublicKey) + if !ok || k.KeyID == "" { + continue + } + next[k.KeyID] = pk + } + + j.mu.Lock() + defer j.mu.Unlock() + j.keys = next + j.fetchedAt = time.Now() + return nil +} diff --git a/internal/jwtauth/middleware.go b/internal/jwtauth/middleware.go new file mode 100644 index 0000000..29ecc8e --- /dev/null +++ b/internal/jwtauth/middleware.go @@ -0,0 +1,319 @@ +package jwtauth + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "commilitia.net/cdrop/internal/config" + "commilitia.net/cdrop/internal/db" +) + +// Store is the subset of *db.Queries the auth middleware uses: device upserts +// plus the shortcut-token lookups needed to honour revocation. Declared as an +// interface so tests can swap in a fake. +type Store interface { + UpsertDevice(ctx context.Context, arg db.UpsertDeviceParams) error + GetShortcutToken(ctx context.Context, jti string) (db.ShortcutToken, error) + TouchShortcutTokenUsed(ctx context.Context, arg db.TouchShortcutTokenUsedParams) error +} + +type Authenticator struct { + cfg *config.Config + store Store + jwks *jwksCache + hsKey []byte +} + +func New(cfg *config.Config, store Store) *Authenticator { + a := &Authenticator{ + cfg: cfg, + store: store, + } + if cfg.HS256Secret != "" { + a.hsKey = DeriveHS256Key(cfg.HS256Secret) + } + if cfg.AuthMode == "prod" && cfg.OIDCJWKSURL != "" { + a.jwks = newJWKSCache(cfg.OIDCJWKSURL, 10*time.Minute) + } + return a +} + +func (a *Authenticator) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := bearerToken(r) + if !ok { + unauthorized(w, "missing bearer token") + return + } + + claims, err := a.verify(r.Context(), token, r) + if err != nil { + slog.Warn("auth failed", "err", err, "path", r.URL.Path) + unauthorized(w, "invalid token") + return + } + + deviceName := sanitizeDeviceName(r.Header.Get("X-Device-Name")) + deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type")) + if claims.Scoped() { + // The backend authoritatively knows a scoped token is the iOS + // Shortcut, so it labels the device "shortcut" regardless of any + // header. ("ios" stays reserved for a real native client.) + deviceType = "shortcut" + } + + // Register a device only when the client names itself (X-Device-Name). + // A nameless request — e.g. a polling shortcut hitting /api/clipboard/version + // without the header — must NOT be registered: the old random UA fallback + // minted a fresh "Unknown Device" row on every request and flooded the list. + if deviceName != "" { + if err := a.store.UpsertDevice(r.Context(), db.UpsertDeviceParams{ + UserID: claims.UserID, + Name: deviceName, + Type: deviceType, + LastSeen: time.Now().Unix(), + }); err != nil { + // non-fatal: log and continue so transient DB errors don't 401 users + slog.Error("device upsert failed", + "err", err, "user", claims.UserID, "device", deviceName) + } + } + + ctx := context.WithValue(r.Context(), claimsCtxKey, claims) + ctx = context.WithValue(ctx, deviceCtxKey, deviceName) + ctx = context.WithValue(ctx, deviceTypeCtxKey, deviceType) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (a *Authenticator) verify(ctx context.Context, token string, r *http.Request) (*Claims, error) { + if a.cfg.AuthMode == "dev" { + return a.verifyDev(token, r) + } + if c, err := a.verifyHS256(ctx, token); err == nil { + return c, nil + } + return a.verifyRS256(ctx, token) +} + +func (a *Authenticator) verifyDev(token string, r *http.Request) (*Claims, error) { + if subtle.ConstantTimeCompare([]byte(token), []byte(a.cfg.DevToken)) != 1 { + return nil, errors.New("invalid dev token") + } + userID := r.Header.Get("X-Dev-User") + if userID == "" { + userID = "dev-user" + } + return &Claims{UserID: userID, Groups: []string{"dev"}}, nil +} + +func (a *Authenticator) verifyHS256(ctx context.Context, token string) (*Claims, error) { + if len(a.hsKey) == 0 { + return nil, errors.New("HS256 secret not configured") + } + parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) + if err != nil { + return nil, err + } + var std jwt.Claims + custom := map[string]any{} + if err := parsed.Claims(a.hsKey, &std, &custom); err != nil { + return nil, err + } + if err := std.ValidateWithLeeway(jwt.Expected{Time: time.Now()}, 30*time.Second); err != nil { + return nil, err + } + claims, err := claimsFromJWT(std, custom) + if err != nil { + return nil, err + } + + // HS256 is only ever used to mint scoped shortcut tokens, and those ALWAYS + // carry a jti. A validly-signed HS256 token without one must be rejected + // outright: otherwise it would fall through here as a full, unscoped account + // session with a self-declared subject — far beyond the clipboard-only + // surface HS256 is meant for. Requiring the jti keeps a leaked HS256 secret's + // blast radius pinned to the shortcut scope (clipboard, and nothing else). + if std.ID == "" { + return nil, errors.New("HS256 token missing jti") + } + + // The signature alone is not enough — the token must still be present and not + // revoked in the store, so a leaked or retired token can be killed + // server-side. The stored row is also the authoritative source of scopes + // (never trust scopes off the wire). + row, err := a.store.GetShortcutToken(ctx, std.ID) + if err != nil { + return nil, fmt.Errorf("shortcut token lookup: %w", err) + } + if row.Revoked != 0 { + return nil, errors.New("shortcut token revoked") + } + if row.UserID != claims.UserID { + return nil, errors.New("shortcut token subject mismatch") + } + claims.JTI = std.ID + claims.Scopes = strings.Fields(row.Scopes) + a.touchTokenAsync(std.ID) + return claims, nil +} + +// touchTokenAsync records a shortcut token's last-use time off the request path +// — it's audit metadata, so a slow or failing write must never delay or fail +// the request it belongs to. +func (a *Authenticator) touchTokenAsync(jti string) { + now := time.Now().Unix() + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := a.store.TouchShortcutTokenUsed(ctx, db.TouchShortcutTokenUsedParams{ + LastUsedAt: &now, + Jti: jti, + }); err != nil { + slog.Warn("touch shortcut token failed", "err", err, "jti", jti) + } + }() +} + +func (a *Authenticator) verifyRS256(ctx context.Context, token string) (*Claims, error) { + if a.jwks == nil { + return nil, errors.New("JWKS not configured") + } + parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.RS256}) + if err != nil { + return nil, err + } + if len(parsed.Headers) == 0 { + return nil, errors.New("missing token headers") + } + kid := parsed.Headers[0].KeyID + key, err := a.jwks.GetKey(ctx, kid) + if err != nil { + return nil, err + } + var std jwt.Claims + custom := map[string]any{} + if err := parsed.Claims(key, &std, &custom); err != nil { + return nil, err + } + expected := jwt.Expected{Time: time.Now()} + if a.cfg.OIDCIssuer != "" { + expected.Issuer = a.cfg.OIDCIssuer + } + if a.cfg.OIDCAudience != "" { + expected.AnyAudience = parseAudiences(a.cfg.OIDCAudience) + } + if err := std.ValidateWithLeeway(expected, 30*time.Second); err != nil { + return nil, err + } + return claimsFromJWT(std, custom) +} + +// parseAudiences splits a comma-separated OIDCAudience config into a jwt.Audience +// set. Multiple values let one backend accept tokens minted for several OAuth +// clients (the web app and the desktop client carry different `aud`); go-jose's +// AnyAudience passes when the token's audience matches any one entry. Whitespace +// around entries is trimmed and empties dropped, so a plain single value behaves +// exactly as before. +func parseAudiences(raw string) jwt.Audience { + parts := strings.Split(raw, ",") + out := make(jwt.Audience, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} + +func claimsFromJWT(std jwt.Claims, custom map[string]any) (*Claims, error) { + if std.Subject == "" { + return nil, errors.New("missing subject claim") + } + c := &Claims{UserID: std.Subject} + if g, ok := custom["groups"].([]any); ok { + for _, item := range g { + if s, ok := item.(string); ok { + c.Groups = append(c.Groups, s) + } + } + } + return c, nil +} + +func bearerToken(r *http.Request) (string, bool) { + h := r.Header.Get("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(h, prefix) { + return "", false + } + tok := strings.TrimPrefix(h, prefix) + if tok == "" { + return "", false + } + return tok, true +} + +func unauthorized(w http.ResponseWriter, reason string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("WWW-Authenticate", `Bearer realm="cdrop"`) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + "reason": reason, + }) +} + +// DeriveHS256Key turns a configured secret of any length into a fixed 32-byte +// HMAC key. HS256 requires >= 32 bytes; hashing guarantees that (and keeps the +// minting and verifying sides in lockstep) so a short CDROP_HS256_SECRET can't +// make shortcut-token signing fail. Both signing (httpapi) and verifying use it. +func DeriveHS256Key(secret string) []byte { + sum := sha256.Sum256([]byte(secret)) + return sum[:] +} + +// sanitizeDeviceName enforces the global ASCII-only device-name policy. Device +// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII +// reliably (and browser fetch rejects such header values outright), so the name +// is restricted to printable ASCII everywhere. Here we keep only printable ASCII +// (0x20–0x7E), trim, and cap the length as a server-side backstop; clients also +// validate the name up front for a clear error. Empty after sanitising → the +// caller falls back to a UA-derived default. +func sanitizeDeviceName(raw string) string { + var b strings.Builder + for _, r := range raw { + if r >= 0x20 && r <= 0x7E { + b.WriteRune(r) + } + } + name := strings.TrimSpace(b.String()) + if len(name) > 64 { + name = strings.TrimSpace(name[:64]) + } + return name +} + +// normalizeDeviceType whitelists the client-declared X-Device-Type so a device +// row only ever carries a known kind; anything unrecognised (incl. empty) falls +// back to "browser", the default web client. Desktop clients send macos/windows; +// "ios" is reserved for a future native iOS client (the Shortcut never sends it). +func normalizeDeviceType(raw string) string { + switch t := strings.ToLower(strings.TrimSpace(raw)); t { + case "macos", "windows", "linux", "ios", "browser": + return t + default: + return "browser" + } +} diff --git a/internal/jwtauth/middleware_test.go b/internal/jwtauth/middleware_test.go new file mode 100644 index 0000000..c14613e --- /dev/null +++ b/internal/jwtauth/middleware_test.go @@ -0,0 +1,490 @@ +package jwtauth + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "commilitia.net/cdrop/internal/config" + "commilitia.net/cdrop/internal/db" +) + +// fakeDeviceUpserter records UpsertDevice calls without touching SQL and serves +// shortcut-token lookups from an in-memory map (empty → "not found", so plain +// device-only tests are unaffected). +type fakeDeviceUpserter struct { + calls []db.UpsertDeviceParams + err error + tokens map[string]db.ShortcutToken +} + +func (f *fakeDeviceUpserter) UpsertDevice(_ context.Context, arg db.UpsertDeviceParams) error { + f.calls = append(f.calls, arg) + return f.err +} + +func (f *fakeDeviceUpserter) GetShortcutToken(_ context.Context, jti string) (db.ShortcutToken, error) { + if t, ok := f.tokens[jti]; ok { + return t, nil + } + return db.ShortcutToken{}, sql.ErrNoRows +} + +// TouchShortcutTokenUsed is a no-op in tests: it runs on a detached goroutine +// (touchTokenAsync), so recording into the fake here would race the test body. +func (f *fakeDeviceUpserter) TouchShortcutTokenUsed(_ context.Context, _ db.TouchShortcutTokenUsedParams) error { + return nil +} + +// echo handler that responds with the claims+device discovered in context. +func echoMe(w http.ResponseWriter, r *http.Request) { + claims, _ := ClaimsFromContext(r.Context()) + dev, _ := DeviceNameFromContext(r.Context()) + resp := map[string]any{ + "user_id": claims.UserID, + "groups": claims.Groups, + "device": dev, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func TestDevMode_AcceptsTokenAndUsesXDevUser(t *testing.T) { + cfg := &config.Config{AuthMode: "dev", DevToken: "secret-token"} + dev := &fakeDeviceUpserter{} + a := New(cfg, dev) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer secret-token") + req.Header.Set("X-Dev-User", "alice") + req.Header.Set("X-Device-Name", "tab-1") + + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got["user_id"] != "alice" { + t.Errorf("user_id: got %v, want alice", got["user_id"]) + } + if got["device"] != "tab-1" { + t.Errorf("device: got %v, want tab-1", got["device"]) + } + if len(dev.calls) != 1 { + t.Errorf("UpsertDevice calls: got %d, want 1", len(dev.calls)) + } +} + +func TestDevMode_DefaultsUserIDWhenHeaderMissing(t *testing.T) { + cfg := &config.Config{AuthMode: "dev", DevToken: "t"} + a := New(cfg, &fakeDeviceUpserter{}) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer t") + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", rr.Code) + } + var got map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &got) + if got["user_id"] != "dev-user" { + t.Errorf("user_id: got %v, want dev-user", got["user_id"]) + } +} + +func TestDevMode_RejectsWrongToken(t *testing.T) { + cfg := &config.Config{AuthMode: "dev", DevToken: "right"} + a := New(cfg, &fakeDeviceUpserter{}) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer wrong") + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401", rr.Code) + } +} + +func TestDevMode_RejectsMissingHeader(t *testing.T) { + cfg := &config.Config{AuthMode: "dev", DevToken: "x"} + a := New(cfg, &fakeDeviceUpserter{}) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401", rr.Code) + } +} + +// --- prod RS256 path --- + +func newRSAKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa keygen: %v", err) + } + return k +} + +func startJWKSServer(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server { + t.Helper() + jwk := jose.JSONWebKey{Key: pub, KeyID: kid, Algorithm: "RS256", Use: "sig"} + set := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{jwk}} + body, err := json.Marshal(set) + if err != nil { + t.Fatalf("marshal jwks: %v", err) + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) +} + +func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.Claims, custom map[string]any) string { + t.Helper() + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: priv}, + (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid), + ) + if err != nil { + t.Fatalf("new signer: %v", err) + } + tok, err := jwt.Signed(signer).Claims(claims).Claims(custom).Serialize() + if err != nil { + t.Fatalf("sign: %v", err) + } + return tok +} + +func TestProdMode_AcceptsValidRS256(t *testing.T) { + priv := newRSAKey(t) + kid := "key-1" + srv := startJWKSServer(t, kid, &priv.PublicKey) + defer srv.Close() + + cfg := &config.Config{ + AuthMode: "prod", + OIDCJWKSURL: srv.URL, + OIDCIssuer: "https://oauth.example/", + OIDCAudience: "cdrop", + } + a := New(cfg, &fakeDeviceUpserter{}) + + now := time.Now() + std := jwt.Claims{ + Issuer: "https://oauth.example/", + Subject: "user-bob", + Audience: jwt.Audience{"cdrop"}, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + } + tok := signRS256(t, priv, kid, std, map[string]any{"groups": []any{"users"}}) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + var got map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &got) + if got["user_id"] != "user-bob" { + t.Errorf("user_id: got %v, want user-bob", got["user_id"]) + } +} + +func TestProdMode_RejectsExpiredRS256(t *testing.T) { + priv := newRSAKey(t) + kid := "key-1" + srv := startJWKSServer(t, kid, &priv.PublicKey) + defer srv.Close() + + cfg := &config.Config{ + AuthMode: "prod", + OIDCJWKSURL: srv.URL, + OIDCIssuer: "https://oauth.example/", + OIDCAudience: "cdrop", + } + a := New(cfg, &fakeDeviceUpserter{}) + + past := time.Now().Add(-time.Hour) + std := jwt.Claims{ + Issuer: "https://oauth.example/", + Subject: "user-bob", + Audience: jwt.Audience{"cdrop"}, + IssuedAt: jwt.NewNumericDate(past), + Expiry: jwt.NewNumericDate(past.Add(time.Minute)), // already 59min stale + } + tok := signRS256(t, priv, kid, std, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestProdMode_RejectsWrongIssuer(t *testing.T) { + priv := newRSAKey(t) + kid := "key-1" + srv := startJWKSServer(t, kid, &priv.PublicKey) + defer srv.Close() + + cfg := &config.Config{ + AuthMode: "prod", + OIDCJWKSURL: srv.URL, + OIDCIssuer: "https://oauth.example/", + OIDCAudience: "cdrop", + } + a := New(cfg, &fakeDeviceUpserter{}) + + now := time.Now() + std := jwt.Claims{ + Issuer: "https://attacker.example/", + Subject: "user-bob", + Audience: jwt.Audience{"cdrop"}, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + } + tok := signRS256(t, priv, kid, std, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String()) + } +} + +// Multi-audience (R1): one backend serving web + desktop clients. A desktop +// token carries aud=; it must pass when OIDCAudience lists +// both client_ids comma-separated, and still be rejected when its aud is in +// neither. +func TestProdMode_AcceptsSecondAudienceInList(t *testing.T) { + priv := newRSAKey(t) + kid := "key-1" + srv := startJWKSServer(t, kid, &priv.PublicKey) + defer srv.Close() + + cfg := &config.Config{ + AuthMode: "prod", + OIDCJWKSURL: srv.URL, + OIDCIssuer: "https://oauth.example/", + OIDCAudience: "cdrop-web , cdrop-desktop", // spaces trimmed + } + a := New(cfg, &fakeDeviceUpserter{}) + + now := time.Now() + std := jwt.Claims{ + Issuer: "https://oauth.example/", + Subject: "user-bob", + Audience: jwt.Audience{"cdrop-desktop"}, // the desktop client's aud + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + } + tok := signRS256(t, priv, kid, std, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200; body=%s", rr.Code, rr.Body.String()) + } +} + +func TestProdMode_RejectsAudienceNotInList(t *testing.T) { + priv := newRSAKey(t) + kid := "key-1" + srv := startJWKSServer(t, kid, &priv.PublicKey) + defer srv.Close() + + cfg := &config.Config{ + AuthMode: "prod", + OIDCJWKSURL: srv.URL, + OIDCIssuer: "https://oauth.example/", + OIDCAudience: "cdrop-web,cdrop-desktop", + } + a := New(cfg, &fakeDeviceUpserter{}) + + now := time.Now() + std := jwt.Claims{ + Issuer: "https://oauth.example/", + Subject: "user-bob", + Audience: jwt.Audience{"some-other-client"}, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(time.Hour)), + } + tok := signRS256(t, priv, kid, std, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(echoMe)).ServeHTTP(rr, req) + + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status: got %d, want 401; body=%s", rr.Code, rr.Body.String()) + } +} + +// A nameless request (no X-Device-Name) must NOT register a device — preventing +// the random-fallback "Unknown Device" flood from polling clients. +func TestNamelessRequestSkipsDeviceUpsert(t *testing.T) { + cfg := &config.Config{AuthMode: "dev", DevToken: "t"} + store := &fakeDeviceUpserter{} + a := New(cfg, store) + + req := httptest.NewRequest(http.MethodGet, "/api/clipboard/version", nil) + req.Header.Set("Authorization", "Bearer t") // no X-Device-Name + rr := httptest.NewRecorder() + a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status: got %d, want 200", rr.Code) + } + if len(store.calls) != 0 { + t.Errorf("nameless request must not upsert a device, got %d calls", len(store.calls)) + } +} + +func signHS256(t *testing.T, secret, sub, jti, scope string, exp time.Time) string { + t.Helper() + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: DeriveHS256Key(secret)}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + t.Fatalf("signer: %v", err) + } + std := jwt.Claims{ + Subject: sub, + ID: jti, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(exp), + } + tok, err := jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize() + if err != nil { + t.Fatalf("serialize: %v", err) + } + return tok +} + +func serveBearer(a *Authenticator, tok string) (int, *Claims) { + req := httptest.NewRequest(http.MethodGet, "/api/clipboard", nil) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("X-Device-Name", "iPhone") + rr := httptest.NewRecorder() + var got *Claims + a.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, _ = ClaimsFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })).ServeHTTP(rr, req) + return rr.Code, got +} + +// A valid HS256 shortcut token authenticates and carries its jti + stored scope; +// revocation, an unknown jti, and a subject/owner mismatch are all rejected even +// though the signature is valid — the store is the authority. +func TestShortcutToken_HS256VerifyRevokeAndScope(t *testing.T) { + secret := "test-hs256-secret-at-least-32-bytes-long" // HS256 needs >= 32 bytes + cfg := &config.Config{AuthMode: "prod", HS256Secret: secret} + exp := time.Now().Add(time.Hour) + store := &fakeDeviceUpserter{tokens: map[string]db.ShortcutToken{ + "jti-1": {Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", ExpiresAt: exp.Unix()}, + }} + a := New(cfg, store) + + tok := signHS256(t, secret, "user-x", "jti-1", "clipboard", exp) + + code, claims := serveBearer(a, tok) + if code != http.StatusOK { + t.Fatalf("valid token: got %d, want 200", code) + } + if claims == nil || !claims.Scoped() || claims.JTI != "jti-1" || !claims.HasScope("clipboard") { + t.Fatalf("claims not populated as scoped clipboard token: %+v", claims) + } + // The device registers under the (ASCII) X-Device-Name the request carried. + if len(store.calls) == 0 || store.calls[len(store.calls)-1].Name != "iPhone" { + t.Errorf("expected device name from header, got %+v", store.calls) + } + + // Subject mismatch: stored row owned by a different user than the token's sub. + store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "someone-else", Scopes: "clipboard", ExpiresAt: exp.Unix()} + if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized { + t.Errorf("subject mismatch: got %d, want 401", code) + } + + // Revoked row → reject. + store.tokens["jti-1"] = db.ShortcutToken{Jti: "jti-1", UserID: "user-x", Scopes: "clipboard", Revoked: 1, ExpiresAt: exp.Unix()} + if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized { + t.Errorf("revoked token: got %d, want 401", code) + } + + // Unknown jti (signed but no stored row) → reject. + ghost := signHS256(t, secret, "user-x", "ghost", "clipboard", exp) + if code, _ := serveBearer(a, ghost); code != http.StatusUnauthorized { + t.Errorf("unknown jti: got %d, want 401", code) + } +} + +// An HS256 token WITHOUT a jti must be rejected outright (G1): the HS256 path +// only ever signs scoped shortcut tokens, which always carry a jti. A jti-less +// but validly-signed token would otherwise fall through as a full, unscoped +// account session with a self-declared subject — so a leaked HS256 secret could +// mint arbitrary-subject sessions far beyond the clipboard scope. Requiring the +// jti pins a leaked secret's blast radius to clipboard-only. +func TestShortcutToken_HS256RejectsMissingJTI(t *testing.T) { + secret := "test-hs256-secret-at-least-32-bytes-long" + cfg := &config.Config{AuthMode: "prod", HS256Secret: secret} + a := New(cfg, &fakeDeviceUpserter{}) + + tok := signHS256(t, secret, "user-x", "", "clipboard", time.Now().Add(time.Hour)) + if code, _ := serveBearer(a, tok); code != http.StatusUnauthorized { + t.Errorf("jti-less HS256 token must be rejected, got %d", code) + } +} + +func TestSanitizeDeviceName(t *testing.T) { + cases := []struct{ in, want string }{ + {"iPhone", "iPhone"}, + {" My iPad ", "My iPad"}, + {"我的iPhone", "iPhone"}, // CJK stripped + {"我的电脑", ""}, // all non-ASCII → empty (caller falls back) + {"Mac Book", "MacBook"}, // NBSP dropped + } + for _, c := range cases { + if got := sanitizeDeviceName(c.in); got != c.want { + t.Errorf("sanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/relay/manager.go b/internal/relay/manager.go new file mode 100644 index 0000000..7c56ac5 --- /dev/null +++ b/internal/relay/manager.go @@ -0,0 +1,269 @@ +package relay + +import ( + "context" + "errors" + "log/slog" + "sync" + "sync/atomic" + "time" +) + +const ( + DefaultMaxSessions = 8 + DefaultMaxSessionsPerUser = 3 + DefaultSessionCap = 64 << 20 // 64 MiB + DefaultChunkLimit = 1 << 20 // 1 MiB + + // DefaultIdleTTL is how long a registered relay session may sit without any + // chunk/stream activity before the reaper tears it down. A fallback that + // registered but never got used (both peers vanished) is reclaimed rather + // than pinning a slot — and, once bytes flowed, its 64 MiB ring — forever. + DefaultIdleTTL = 2 * time.Minute + + // DefaultReapPeriod is how often the reaper goroutine sweeps for idle sessions. + DefaultReapPeriod = 30 * time.Second +) + +var ( + ErrTooManySessions = errors.New("relay: too many active sessions") + ErrForbidden = errors.New("relay: caller is not a participant of this session") + ErrNotRegistered = errors.New("relay: session not registered") +) + +// Session pairs a Ring with the metadata that proves who is allowed to use it. +// The ring is allocated lazily on the first Acquire so a registered-but-unused +// fallback costs only bookkeeping until bytes actually flow. +type Session struct { + ID string + UserID string + Sender string // device name of the legitimate sender (POSTs chunks) + Receiver string // device name of the legitimate receiver (GETs the stream) + + capBytes int + ring *Ring // nil until first Acquire; guarded by Manager.mu + + bytesWritten atomic.Int64 + lastActivity atomic.Int64 // unix seconds; the reaper's idle clock + readerActive atomic.Bool // single-consumer guard (a second reader tears the ring) +} + +func (s *Session) touch() { s.lastActivity.Store(time.Now().Unix()) } + +func (s *Session) Write(ctx context.Context, p []byte) (int, error) { + s.touch() + n, err := s.ring.Write(ctx, p) + if n > 0 { + s.bytesWritten.Add(int64(n)) + } + return n, err +} + +func (s *Session) Read(ctx context.Context, p []byte) (int, error) { + s.touch() + return s.ring.Read(ctx, p) +} + +func (s *Session) CloseWriter() { + if s.ring != nil { + s.ring.CloseWriter() + } +} + +func (s *Session) Abort() { + if s.ring != nil { + s.ring.Abort() + } +} + +func (s *Session) BytesWritten() int64 { return s.bytesWritten.Load() } + +// AcquireReader claims the single consumer slot. The relay stream is strictly +// single-consumer: two concurrent readers would each drain bytes out of the +// ring and silently corrupt the receiver's copy (G2). A second reader gets a +// false here so the handler can 409 instead of joining. +func (s *Session) AcquireReader() bool { return s.readerActive.CompareAndSwap(false, true) } + +// ReleaseReader frees the consumer slot when the stream handler returns. +func (s *Session) ReleaseReader() { s.readerActive.Store(false) } + +// Manager owns active relay sessions. A session must be Register-ed (which the +// transfer service does when a transfer enters RELAY_ACTIVE) before either peer +// can Acquire it. This is the core of the R1/G2 hardening: the old Acquire +// minted a session for any unknown id on demand, so 8 one-byte requests could +// exhaust every slot and pin ~512 MiB. Now only legitimate, server-minted +// transfers that actually reached RELAY_ACTIVE get a slot, capped both globally +// and per-user, and reclaimed when idle. +type Manager struct { + mu sync.Mutex + sessions map[string]*Session + perUser map[string]int + maxSessions int + maxPerUser int + sessionCap int + idleTTL time.Duration +} + +func NewManager(maxSessions, sessionCapBytes int) *Manager { + if maxSessions <= 0 { + maxSessions = DefaultMaxSessions + } + if sessionCapBytes <= 0 { + sessionCapBytes = DefaultSessionCap + } + maxPerUser := DefaultMaxSessionsPerUser + if maxPerUser > maxSessions { + // A per-user cap above the global cap is meaningless; clamp so small + // global caps (tests, constrained deploys) still behave sanely. + maxPerUser = maxSessions + } + return &Manager{ + sessions: map[string]*Session{}, + perUser: map[string]int{}, + maxSessions: maxSessions, + maxPerUser: maxPerUser, + sessionCap: sessionCapBytes, + idleTTL: DefaultIdleTTL, + } +} + +// Register reserves a relay slot for a transfer that has just entered +// RELAY_ACTIVE, recording its two legitimate participants. Only a registered +// session can later be joined via Acquire. +// +// Idempotent for the same (id, userID): a duplicate Register just refreshes the +// idle clock. A mismatched userID is ErrForbidden (ids are server-minted, so +// this is defensive). Returns ErrTooManySessions when the global or per-user +// cap is reached — the caller (transfer service) surfaces that as a retryable +// failure rather than moving the transfer into a relay mode with no slot. +func (m *Manager) Register(id, userID, sender, receiver string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if s, ok := m.sessions[id]; ok { + if s.UserID != userID { + return ErrForbidden + } + s.touch() + return nil + } + if len(m.sessions) >= m.maxSessions { + return ErrTooManySessions + } + if m.perUser[userID] >= m.maxPerUser { + return ErrTooManySessions + } + + s := &Session{ + ID: id, + UserID: userID, + Sender: sender, + Receiver: receiver, + capBytes: m.sessionCap, + } + s.touch() + m.sessions[id] = s + m.perUser[userID] = m.perUser[userID] + 1 + return nil +} + +// Acquire joins an already-registered relay session as one of its two +// participants. Unlike the old implementation it never creates a session: +// - unknown id → ErrNotRegistered +// - different user → ErrForbidden +// - not sender/receiver → ErrForbidden +// +// device is the caller's X-Device-Name (set by the auth middleware). The 64 MiB +// ring is allocated here, lazily, on the first join. +func (m *Manager) Acquire(id, userID, device string) (*Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[id] + if !ok { + return nil, ErrNotRegistered + } + if s.UserID != userID { + return nil, ErrForbidden + } + if device == "" || (device != s.Sender && device != s.Receiver) { + return nil, ErrForbidden + } + if s.ring == nil { + s.ring = newRing(s.capBytes) + } + s.touch() + return s, nil +} + +// Release removes the session from the active set and aborts any blocked +// Read/Write. Safe to call multiple times (terminal transitions and the pending +// sweeper both call it). A no-op for an id that was never registered. +func (m *Manager) Release(id string) { + m.mu.Lock() + s, ok := m.sessions[id] + if ok { + delete(m.sessions, id) + m.decUserLocked(s.UserID) + } + m.mu.Unlock() + if ok { + s.Abort() + } +} + +// decUserLocked drops one from a user's active count, deleting the key at zero +// so perUser never accumulates empty entries. Caller holds m.mu. +func (m *Manager) decUserLocked(userID string) { + if n := m.perUser[userID]; n <= 1 { + delete(m.perUser, userID) + } else { + m.perUser[userID] = n - 1 + } +} + +// Active reports the current count of held sessions. +func (m *Manager) Active() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.sessions) +} + +// reapIdle aborts and removes every session whose last activity is older than +// the idle TTL, returning how many it reclaimed. now is unix seconds (injected +// so tests don't depend on the wall clock). +func (m *Manager) reapIdle(now int64) int { + cutoff := now - int64(m.idleTTL/time.Second) + var dead []*Session + m.mu.Lock() + for id, s := range m.sessions { + if s.lastActivity.Load() < cutoff { + delete(m.sessions, id) + m.decUserLocked(s.UserID) + dead = append(dead, s) + } + } + m.mu.Unlock() + for _, s := range dead { + s.Abort() + } + return len(dead) +} + +// RunReaper blocks until ctx is cancelled, periodically reclaiming idle relay +// sessions (fallbacks that registered but never completed — e.g. both peers +// went away mid-transfer). Mirrors transfer.RunSweeper; start it once at boot. +func (m *Manager) RunReaper(ctx context.Context) { + t := time.NewTicker(DefaultReapPeriod) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if n := m.reapIdle(time.Now().Unix()); n > 0 { + slog.Info("relay reaper: reclaimed idle sessions", "count", n) + } + } + } +} diff --git a/internal/relay/manager_test.go b/internal/relay/manager_test.go new file mode 100644 index 0000000..9fff246 --- /dev/null +++ b/internal/relay/manager_test.go @@ -0,0 +1,195 @@ +package relay + +import ( + "errors" + "testing" + "time" +) + +// register is a test helper: every session must be Register-ed before it can be +// Acquire-d. Sender "s" / receiver "r" unless a test needs specific names. +func register(t *testing.T, m *Manager, id, user string) { + t.Helper() + if err := m.Register(id, user, "s", "r"); err != nil { + t.Fatalf("register %s/%s: %v", id, user, err) + } +} + +func TestManager_AcquireReturnsSameSession(t *testing.T) { + m := NewManager(8, 1024) + register(t, m, "s1", "alice") + s1, err := m.Acquire("s1", "alice", "s") + if err != nil { + t.Fatalf("acquire 1: %v", err) + } + s2, err := m.Acquire("s1", "alice", "r") + if err != nil { + t.Fatalf("acquire 2: %v", err) + } + if s1 != s2 { + t.Error("expected same session pointer on repeat Acquire") + } +} + +// Acquire never mints a session: an id that was never Register-ed is rejected. +// This is the core R1 fix — the old Acquire created one on demand, so any +// authenticated client could conjure sessions for arbitrary ids and exhaust +// every slot. +func TestManager_AcquireUnregisteredRejected(t *testing.T) { + m := NewManager(8, 1024) + if _, err := m.Acquire("ghost", "alice", "s"); !errors.Is(err, ErrNotRegistered) { + t.Errorf("expected ErrNotRegistered, got %v", err) + } + if got := m.Active(); got != 0 { + t.Errorf("a rejected Acquire must not create a session; Active=%d", got) + } +} + +func TestManager_DifferentUserForbidden(t *testing.T) { + m := NewManager(8, 1024) + register(t, m, "s1", "alice") + if _, err := m.Acquire("s1", "mallory", "s"); !errors.Is(err, ErrForbidden) { + t.Errorf("expected ErrForbidden, got %v", err) + } +} + +// Only the registered sender/receiver device may join (G2). A third device of +// the same account — or a missing device name — is forbidden. +func TestManager_NonParticipantForbidden(t *testing.T) { + m := NewManager(8, 1024) + if err := m.Register("s1", "alice", "laptop", "phone"); err != nil { + t.Fatalf("register: %v", err) + } + if _, err := m.Acquire("s1", "alice", "tablet"); !errors.Is(err, ErrForbidden) { + t.Errorf("non-participant device: expected ErrForbidden, got %v", err) + } + if _, err := m.Acquire("s1", "alice", ""); !errors.Is(err, ErrForbidden) { + t.Errorf("empty device: expected ErrForbidden, got %v", err) + } + if _, err := m.Acquire("s1", "alice", "laptop"); err != nil { + t.Errorf("sender device must be allowed: %v", err) + } + if _, err := m.Acquire("s1", "alice", "phone"); err != nil { + t.Errorf("receiver device must be allowed: %v", err) + } +} + +// Register, not Acquire, now enforces the global cap. +func TestManager_GlobalCapEnforced(t *testing.T) { + m := NewManager(2, 1024) + if err := m.Register("a", "u1", "s", "r"); err != nil { + t.Fatalf("a: %v", err) + } + if err := m.Register("b", "u2", "s", "r"); err != nil { + t.Fatalf("b: %v", err) + } + if err := m.Register("c", "u3", "s", "r"); !errors.Is(err, ErrTooManySessions) { + t.Errorf("expected ErrTooManySessions, got %v", err) + } + if got := m.Active(); got != 2 { + t.Errorf("Active: got %d, want 2", got) + } +} + +// A single user can't monopolise every global slot — the per-user cap stops +// them well before the global cap, leaving room for other users. +func TestManager_PerUserCapEnforced(t *testing.T) { + m := NewManager(8, 1024) // per-user default is 3 + for i, id := range []string{"a", "b", "c"} { + if err := m.Register(id, "greedy", "s", "r"); err != nil { + t.Fatalf("register %d: %v", i, err) + } + } + if err := m.Register("d", "greedy", "s", "r"); !errors.Is(err, ErrTooManySessions) { + t.Errorf("4th session for one user: expected ErrTooManySessions, got %v", err) + } + // A different user is unaffected — global slots remain. + if err := m.Register("e", "other", "s", "r"); err != nil { + t.Errorf("other user should still register: %v", err) + } +} + +// Register is idempotent for the same owner (e.g. a retried /fallback) and does +// not consume a second slot. +func TestManager_RegisterIdempotent(t *testing.T) { + m := NewManager(8, 1024) + register(t, m, "s1", "alice") + register(t, m, "s1", "alice") + if got := m.Active(); got != 1 { + t.Errorf("duplicate Register must not add a slot; Active=%d", got) + } + if err := m.Register("s1", "mallory", "s", "r"); !errors.Is(err, ErrForbidden) { + t.Errorf("re-register under another user: expected ErrForbidden, got %v", err) + } +} + +func TestManager_ReleaseFreesSlot(t *testing.T) { + m := NewManager(1, 1024) + register(t, m, "a", "u") + m.Release("a") + if got := m.Active(); got != 0 { + t.Errorf("Active after release: got %d, want 0", got) + } + // A new session can now slot in (both global and per-user counts dropped). + if err := m.Register("b", "u", "s", "r"); err != nil { + t.Errorf("register after release: %v", err) + } +} + +func TestManager_ReleaseAbortsSession(t *testing.T) { + m := NewManager(1, 1024) + register(t, m, "a", "u") + s, err := m.Acquire("a", "u", "s") // allocates the ring + if err != nil { + t.Fatalf("acquire: %v", err) + } + m.Release("a") + if _, err := s.Write(nil, []byte("x")); !errors.Is(err, ErrClosed) { + t.Errorf("write after release: got %v, want ErrClosed", err) + } +} + +// The reaper reclaims a session whose last activity is older than the idle TTL, +// dropping both the global and per-user counts so the slot is reusable. +func TestManager_ReaperReclaimsIdle(t *testing.T) { + m := NewManager(8, 1024) + register(t, m, "a", "u") + + // Just-registered: a sweep "now" leaves it alone. + if n := m.reapIdle(time.Now().Unix()); n != 0 { + t.Errorf("fresh session reclaimed: got %d, want 0", n) + } + // Sweep far enough in the future that it's past the idle TTL. + future := time.Now().Add(DefaultIdleTTL + time.Minute).Unix() + if n := m.reapIdle(future); n != 1 { + t.Errorf("idle session not reclaimed: got %d, want 1", n) + } + if got := m.Active(); got != 0 { + t.Errorf("Active after reap: got %d, want 0", got) + } + // The per-user slot was freed too. + if err := m.Register("b", "u", "s", "r"); err != nil { + t.Errorf("register after reap: %v", err) + } +} + +// The stream is single-consumer: a second concurrent reader is refused so it +// can't drain bytes out from under the first and corrupt the receiver's copy. +func TestSession_SingleReader(t *testing.T) { + m := NewManager(8, 1024) + register(t, m, "a", "u") + s, err := m.Acquire("a", "u", "r") + if err != nil { + t.Fatalf("acquire: %v", err) + } + if !s.AcquireReader() { + t.Fatal("first AcquireReader should succeed") + } + if s.AcquireReader() { + t.Error("second AcquireReader must fail while the first holds it") + } + s.ReleaseReader() + if !s.AcquireReader() { + t.Error("AcquireReader should succeed again after release") + } +} diff --git a/internal/relay/ring.go b/internal/relay/ring.go new file mode 100644 index 0000000..cee6c6d --- /dev/null +++ b/internal/relay/ring.go @@ -0,0 +1,170 @@ +package relay + +import ( + "context" + "errors" + "io" + "sync" +) + +// ErrClosed is returned by Read/Write after Abort. +var ErrClosed = errors.New("relay: ring aborted") + +// Ring is a bounded byte buffer with single-producer / single-consumer +// blocking semantics. Plan §M5: 单 session 64 MiB. +// +// Internally it's a flat slice with head/tail wrap-around, guarded by a +// sync.Cond. Both Read and Write accept ctx so handlers can abort fast on +// client disconnect — the watchdog goroutine broadcasts the cond when ctx +// fires so blocked Wait calls re-check ctx.Err(). +type Ring struct { + mu sync.Mutex + cond *sync.Cond + + buf []byte + head int + tail int + used int + cap int + + closed bool // sender called CloseWriter; reader drains to EOF + aborted bool // both sides torn down +} + +func newRing(capacity int) *Ring { + r := &Ring{ + buf: make([]byte, capacity), + cap: capacity, + } + r.cond = sync.NewCond(&r.mu) + return r +} + +func (r *Ring) Write(ctx context.Context, p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + stop := r.watchCtx(ctx) + defer stop() + + r.mu.Lock() + defer r.mu.Unlock() + + written := 0 + for written < len(p) { + for r.used == r.cap && !r.aborted && !r.closed && ctxOK(ctx) { + r.cond.Wait() + } + if !ctxOK(ctx) { + return written, ctx.Err() + } + if r.aborted { + return written, ErrClosed + } + if r.closed { + return written, ErrClosed + } + + free := r.cap - r.used + toWrite := len(p) - written + if toWrite > free { + toWrite = free + } + + end := r.tail + toWrite + if end <= r.cap { + copy(r.buf[r.tail:end], p[written:written+toWrite]) + } else { + first := r.cap - r.tail + copy(r.buf[r.tail:], p[written:written+first]) + copy(r.buf[:toWrite-first], p[written+first:written+toWrite]) + } + r.tail = (r.tail + toWrite) % r.cap + r.used += toWrite + written += toWrite + r.cond.Broadcast() + } + return written, nil +} + +func (r *Ring) Read(ctx context.Context, p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + stop := r.watchCtx(ctx) + defer stop() + + r.mu.Lock() + defer r.mu.Unlock() + + for r.used == 0 && !r.closed && !r.aborted && ctxOK(ctx) { + r.cond.Wait() + } + if !ctxOK(ctx) { + return 0, ctx.Err() + } + if r.used == 0 { + if r.aborted { + return 0, ErrClosed + } + return 0, io.EOF + } + + n := r.used + if n > len(p) { + n = len(p) + } + end := r.head + n + if end <= r.cap { + copy(p[:n], r.buf[r.head:end]) + } else { + first := r.cap - r.head + copy(p[:first], r.buf[r.head:]) + copy(p[first:n], r.buf[:n-first]) + } + r.head = (r.head + n) % r.cap + r.used -= n + r.cond.Broadcast() + return n, nil +} + +// CloseWriter signals end-of-stream from the sender side. Reader returns +// io.EOF after draining whatever is still buffered. +func (r *Ring) CloseWriter() { + r.mu.Lock() + r.closed = true + r.cond.Broadcast() + r.mu.Unlock() +} + +// Abort tears the ring down hard. Subsequent Read/Write return ErrClosed. +func (r *Ring) Abort() { + r.mu.Lock() + r.aborted = true + r.cond.Broadcast() + r.mu.Unlock() +} + +func (r *Ring) watchCtx(ctx context.Context) func() { + if ctx == nil || ctx.Err() != nil { + return func() {} + } + stop := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + r.mu.Lock() + r.cond.Broadcast() + r.mu.Unlock() + case <-stop: + } + }() + return func() { close(stop) } +} + +func ctxOK(ctx context.Context) bool { + if ctx == nil { + return true + } + return ctx.Err() == nil +} diff --git a/internal/relay/ring_test.go b/internal/relay/ring_test.go new file mode 100644 index 0000000..ff67997 --- /dev/null +++ b/internal/relay/ring_test.go @@ -0,0 +1,177 @@ +package relay + +import ( + "bytes" + "context" + "errors" + "io" + "sync" + "testing" + "time" +) + +func TestRing_BasicWriteRead(t *testing.T) { + r := newRing(64) + want := []byte("hello, world") + if n, err := r.Write(context.Background(), want); err != nil || n != len(want) { + t.Fatalf("write: n=%d err=%v", n, err) + } + got := make([]byte, 32) + n, err := r.Read(context.Background(), got) + if err != nil { + t.Fatalf("read: %v", err) + } + if !bytes.Equal(got[:n], want) { + t.Errorf("read mismatch: got %q want %q", got[:n], want) + } +} + +func TestRing_CloseWriterSignalsEOF(t *testing.T) { + r := newRing(32) + _, _ = r.Write(context.Background(), []byte("abc")) + r.CloseWriter() + + got := make([]byte, 16) + n, err := r.Read(context.Background(), got) + if err != nil || n != 3 { + t.Fatalf("first read: n=%d err=%v", n, err) + } + n, err = r.Read(context.Background(), got) + if !errors.Is(err, io.EOF) { + t.Errorf("expected EOF after drain; got n=%d err=%v", n, err) + } +} + +func TestRing_AbortReturnsErrClosed(t *testing.T) { + r := newRing(32) + r.Abort() + if _, err := r.Write(context.Background(), []byte("x")); !errors.Is(err, ErrClosed) { + t.Errorf("write after abort: got %v want ErrClosed", err) + } + if _, err := r.Read(context.Background(), make([]byte, 1)); !errors.Is(err, ErrClosed) { + t.Errorf("read after abort: got %v want ErrClosed", err) + } +} + +func TestRing_BlocksWhenFullThenUnblocksOnRead(t *testing.T) { + r := newRing(8) + if _, err := r.Write(context.Background(), []byte("12345678")); err != nil { + t.Fatalf("fill: %v", err) + } + + done := make(chan struct{}) + go func() { + _, err := r.Write(context.Background(), []byte("X")) + if err != nil { + t.Errorf("blocked write: %v", err) + } + close(done) + }() + + // Without a reader the goroutine should block. + select { + case <-done: + t.Fatal("write should block when ring is full") + case <-time.After(50 * time.Millisecond): + } + + got := make([]byte, 4) + if _, err := r.Read(context.Background(), got); err != nil { + t.Fatalf("read: %v", err) + } + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("write did not unblock after read") + } +} + +func TestRing_CtxCancelsBlockedRead(t *testing.T) { + r := newRing(8) + ctx, cancel := context.WithCancel(context.Background()) + + type res struct { + n int + err error + } + out := make(chan res, 1) + go func() { + buf := make([]byte, 4) + n, err := r.Read(ctx, buf) + out <- res{n, err} + }() + + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case r := <-out: + if !errors.Is(r.err, context.Canceled) { + t.Errorf("expected context.Canceled, got %v", r.err) + } + case <-time.After(time.Second): + t.Fatal("blocked read did not unblock on ctx cancel") + } +} + +func TestRing_WrapsAround(t *testing.T) { + r := newRing(4) + // Cycle through enough bytes to force head/tail wrapping. + for i := 0; i < 20; i++ { + _, _ = r.Write(context.Background(), []byte{byte(i)}) + got := make([]byte, 1) + n, _ := r.Read(context.Background(), got) + if n != 1 || got[0] != byte(i) { + t.Errorf("iter %d: got %v want [%d]", i, got, i) + } + } +} + +func TestRing_StressManyChunks(t *testing.T) { + const total = 4 * 1024 + r := newRing(64) + + var wgWriter, wgReader sync.WaitGroup + wgWriter.Add(1) + wgReader.Add(1) + + src := make([]byte, total) + for i := range src { + src[i] = byte(i) + } + + go func() { + defer wgWriter.Done() + for i := 0; i < total; i += 13 { + end := i + 13 + if end > total { + end = total + } + _, _ = r.Write(context.Background(), src[i:end]) + } + r.CloseWriter() + }() + + got := make([]byte, 0, total) + go func() { + defer wgReader.Done() + buf := make([]byte, 17) + for { + n, err := r.Read(context.Background(), buf) + got = append(got, buf[:n]...) + if errors.Is(err, io.EOF) { + return + } + if err != nil { + t.Errorf("read: %v", err) + return + } + } + }() + + wgWriter.Wait() + wgReader.Wait() + if !bytes.Equal(got, src) { + t.Errorf("mismatch: got %d bytes, want %d", len(got), len(src)) + } +} diff --git a/internal/transfer/service.go b/internal/transfer/service.go new file mode 100644 index 0000000..c426aa5 --- /dev/null +++ b/internal/transfer/service.go @@ -0,0 +1,266 @@ +package transfer + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/hub" +) + +var ( + ErrNotFound = errors.New("transfer: session not found") + ErrForbidden = errors.New("transfer: not your session") + ErrInvalidTransition = errors.New("transfer: invalid state transition") + ErrRelayUnavailable = errors.New("transfer: relay unavailable") +) + +// SessionView is the session shape pushed to clients (omits internal columns). +type SessionView struct { + ID string `json:"id"` + SenderName string `json:"sender_name"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + FileSHA256 string `json:"file_sha256,omitempty"` +} + +// RelayController is the subset of *relay.Manager the transfer service drives: +// it reserves a relay slot when a transfer enters RELAY_ACTIVE (Register) and +// frees it when the transfer reaches a terminal state (Release). Declared as an +// interface so the transfer package doesn't gain a hard import of relay (avoids +// cycles in tests). +type RelayController interface { + Register(id, userID, sender, receiver string) error + Release(sessionID string) +} + +type nopRelay struct{} + +func (nopRelay) Register(_, _, _, _ string) error { return nil } +func (nopRelay) Release(string) {} + +type Service struct { + queries *db.Queries + hub *hub.Hub + relay RelayController + + now func() time.Time + newID func() string +} + +func NewService(queries *db.Queries, h *hub.Hub, r RelayController) *Service { + if r == nil { + r = nopRelay{} + } + return &Service{ + queries: queries, + hub: h, + relay: r, + now: time.Now, + newID: defaultNewID, + } +} + +func defaultNewID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand only fails if the OS RNG is broken; panicking is correct. + panic(fmt.Sprintf("crypto/rand: %v", err)) + } + return hex.EncodeToString(b[:]) +} + +// InitParams are the inputs to Init. +type InitParams struct { + UserID string + SenderName string + ReceiverName string + FileName string + FileSize int64 + FileSHA256 string +} + +// Init creates a new PENDING session and pushes transfer:incoming to the receiver. +// Returns the new session ID. Receiver offline does not fail Init — the receiver +// will see the session next time it connects via /api/devices or a fresh init. +func (s *Service) Init(ctx context.Context, p InitParams) (string, error) { + if p.SenderName == "" || p.ReceiverName == "" || p.FileName == "" { + return "", errors.New("init: sender_name, receiver_name, file_name required") + } + if p.SenderName == p.ReceiverName { + return "", errors.New("init: sender and receiver must differ") + } + // brief §2 explicitly says "不限文件大小"; 0-byte is legal (empty file + // transfer still needs control frames). Negatives are nonsense. + if p.FileSize < 0 { + return "", errors.New("init: file_size cannot be negative") + } + + id := s.newID() + createdAt := s.now().Unix() + + var sha *string + if p.FileSHA256 != "" { + v := p.FileSHA256 + sha = &v + } + + if err := s.queries.CreateTransferSession(ctx, db.CreateTransferSessionParams{ + ID: id, + UserID: p.UserID, + SenderName: p.SenderName, + ReceiverName: p.ReceiverName, + FileName: p.FileName, + FileSize: p.FileSize, + FileSha256: sha, + CreatedAt: createdAt, + }); err != nil { + return "", fmt.Errorf("create session: %w", err) + } + + view := SessionView{ + ID: id, + SenderName: p.SenderName, + FileName: p.FileName, + FileSize: p.FileSize, + FileSHA256: p.FileSHA256, + } + autoAccept := p.FileSize <= AutoAcceptThreshold + + s.hub.SendTo(p.UserID, p.ReceiverName, hub.Event{ + Type: "transfer:incoming", + Data: map[string]any{ + "session": view, + "auto_accept": autoAccept, + }, + }) + + return id, nil +} + +// Transition validates and applies a state change, then broadcasts transfer:state +// to both sender and receiver. When entering RELAY_ACTIVE, additionally emits +// transfer:relay_ready with the chunk/stream URLs (brief §5). When entering a +// terminal state, releases the relay session if any. +func (s *Service) Transition( + ctx context.Context, + userID, sessionID, target string, + mode, reason string, + bytes int64, +) error { + sess, err := s.queries.GetTransferSession(ctx, sessionID) + if err != nil { + return ErrNotFound + } + if sess.UserID != userID { + return ErrForbidden + } + if !IsValidTransition(sess.State, target) { + return fmt.Errorf("%w: %s→%s", ErrInvalidTransition, sess.State, target) + } + + // Reserve the relay slot BEFORE committing the state change: if the relay is + // full (global or per-user cap) the transfer must not advance into a relay + // mode it has no slot for — surface a retryable error instead. Registering + // here also records the two legitimate participants, which is what later lets + // the relay endpoints reject anyone who isn't the sender or receiver (R1/G2). + if target == StateRelayActive { + if err := s.relay.Register(sessionID, userID, sess.SenderName, sess.ReceiverName); err != nil { + return fmt.Errorf("%w: %v", ErrRelayUnavailable, err) + } + } + + args := db.UpdateTransferStateParams{ + State: target, + ID: sessionID, + } + if mode != "" { + v := mode + args.Mode = &v + } + if reason != "" { + v := reason + args.FailReason = &v + } + if IsTerminal(target) { + t := s.now().Unix() + args.FinishedAt = &t + } + if bytes > 0 { + v := bytes + args.BytesTransferred = &v + } + + if _, err := s.queries.UpdateTransferState(ctx, args); err != nil { + return fmt.Errorf("update state: %w", err) + } + + stateEv := hub.Event{ + Type: "transfer:state", + Data: stateEventPayload(sessionID, target, mode, reason), + } + s.hub.SendTo(userID, sess.SenderName, stateEv) + s.hub.SendTo(userID, sess.ReceiverName, stateEv) + + if target == StateRelayActive { + readyEv := hub.Event{ + Type: "transfer:relay_ready", + Data: map[string]any{ + "session_id": sessionID, + "sender_url": fmt.Sprintf("/api/relay/%s/chunk", sessionID), + "receiver_url": fmt.Sprintf("/api/relay/%s/stream", sessionID), + }, + } + s.hub.SendTo(userID, sess.SenderName, readyEv) + s.hub.SendTo(userID, sess.ReceiverName, readyEv) + } + + if IsTerminal(target) { + s.relay.Release(sessionID) + } + return nil +} + +func stateEventPayload(sessionID, state, mode, reason string) map[string]any { + p := map[string]any{ + "session_id": sessionID, + "state": state, + } + if mode != "" { + p["mode"] = mode + } + if reason != "" { + p["reason"] = reason + } + return p +} + +// ExpirePending cancels any PENDING sessions older than now-ttl and pushes +// transfer:state to both peers. Returns the number of expired sessions. +func (s *Service) ExpirePending(ctx context.Context, ttl time.Duration) (int, error) { + now := s.now() + cutoff := now.Add(-ttl).Unix() + finishedAt := now.Unix() + + rows, err := s.queries.ExpirePendingSessions(ctx, db.ExpirePendingSessionsParams{ + FinishedAt: &finishedAt, + Cutoff: cutoff, + }) + if err != nil { + return 0, fmt.Errorf("expire pending: %w", err) + } + for _, r := range rows { + ev := hub.Event{ + Type: "transfer:state", + Data: stateEventPayload(r.ID, StateCancelled, "", "pending_timeout"), + } + s.hub.SendTo(r.UserID, r.SenderName, ev) + s.hub.SendTo(r.UserID, r.ReceiverName, ev) + s.relay.Release(r.ID) + } + return len(rows), nil +} diff --git a/internal/transfer/service_test.go b/internal/transfer/service_test.go new file mode 100644 index 0000000..dcc2ea8 --- /dev/null +++ b/internal/transfer/service_test.go @@ -0,0 +1,351 @@ +package transfer + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "commilitia.net/cdrop/internal/db" + "commilitia.net/cdrop/internal/hub" +) + +// recHub records SendTo calls without spinning up real SSE clients. +// We can't easily mock *hub.Hub directly because it's a concrete type — instead +// we feed it a fake DeviceLister and ignore the broadcast side; the fact that +// SendTo to a non-connected client returns false is acceptable here. +type recordingLister struct { + mu sync.Mutex + calls int +} + +func (r *recordingLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls++ + return nil, nil +} + +func newTestHub() *hub.Hub { + return hub.New(&recordingLister{}) +} + +func openTestDB(t *testing.T) *db.Queries { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "x.db") + conn, err := db.Open(dbPath) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := db.Bootstrap(context.Background(), conn); err != nil { + t.Fatalf("bootstrap: %v", err) + } + return db.New(conn) +} + +func newTestService(t *testing.T) *Service { + t.Helper() + q := openTestDB(t) + h := newTestHub() + t.Cleanup(h.Close) + return NewService(q, h, nil) +} + +// fakeRelay records Register/Release calls and can be primed to fail Register, +// standing in for *relay.Manager so the transfer wiring can be exercised without +// importing relay. +type fakeRelay struct { + registers [][4]string // {id, userID, sender, receiver} + releases []string + registerErr error +} + +func (f *fakeRelay) Register(id, userID, sender, receiver string) error { + if f.registerErr != nil { + return f.registerErr + } + f.registers = append(f.registers, [4]string{id, userID, sender, receiver}) + return nil +} + +func (f *fakeRelay) Release(id string) { f.releases = append(f.releases, id) } + +func newTestServiceWithRelay(t *testing.T, r RelayController) *Service { + t.Helper() + q := openTestDB(t) + h := newTestHub() + t.Cleanup(h.Close) + return NewService(q, h, r) +} + +func TestInit_AllowsZeroByteFile(t *testing.T) { + svc := newTestService(t) + if _, err := svc.Init(context.Background(), InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "empty.txt", FileSize: 0, + }); err != nil { + t.Errorf("0-byte file must be accepted (brief §2 不限大小); got %v", err) + } +} + +func TestInit_PersistsPending(t *testing.T) { + svc := newTestService(t) + id, err := svc.Init(context.Background(), InitParams{ + UserID: "alice", + SenderName: "tab-1", + ReceiverName: "tab-2", + FileName: "doc.pdf", + FileSize: 1024, + }) + if err != nil { + t.Fatalf("init: %v", err) + } + if id == "" { + t.Fatal("expected non-empty id") + } + + sess, err := svc.queries.GetTransferSession(context.Background(), id) + if err != nil { + t.Fatalf("get: %v", err) + } + if sess.State != StatePending { + t.Errorf("state: got %q, want %q", sess.State, StatePending) + } + if sess.UserID != "alice" || sess.SenderName != "tab-1" || sess.ReceiverName != "tab-2" { + t.Errorf("unexpected fields: %+v", sess) + } +} + +func TestInit_RejectsBadInput(t *testing.T) { + svc := newTestService(t) + bad := []InitParams{ + {UserID: "u", SenderName: "", ReceiverName: "r", FileName: "f", FileSize: 1}, + {UserID: "u", SenderName: "s", ReceiverName: "", FileName: "f", FileSize: 1}, + {UserID: "u", SenderName: "s", ReceiverName: "s", FileName: "f", FileSize: 1}, // self + {UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "", FileSize: 1}, + {UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1}, + } + for i, p := range bad { + if _, err := svc.Init(context.Background(), p); err == nil { + t.Errorf("case %d should error: %+v", i, p) + } + } +} + +func TestTransition_HappyPath_PendingToDone(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + id, err := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 1024, + }) + if err != nil { + t.Fatalf("init: %v", err) + } + + steps := []struct { + state, mode string + }{ + {StateAccepted, ""}, + {StateP2PActive, ModeP2P}, + {StateDone, ""}, + } + for _, step := range steps { + if err := svc.Transition(ctx, "u", id, step.state, step.mode, "", 1024); err != nil { + t.Fatalf("transition to %s: %v", step.state, err) + } + } + sess, _ := svc.queries.GetTransferSession(ctx, id) + if sess.State != StateDone { + t.Errorf("final state: got %q, want %q", sess.State, StateDone) + } + if sess.Mode == nil || *sess.Mode != ModeP2P { + t.Errorf("mode: got %v, want %q", sess.Mode, ModeP2P) + } + if sess.FinishedAt == nil { + t.Error("FinishedAt should be set on terminal state") + } +} + +func TestTransition_FallbackToRelayThenDone(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + for _, st := range []string{StateAccepted, StateP2PActive, StateRelayActive, StateDone} { + mode := "" + switch st { + case StateP2PActive: + mode = ModeP2P + case StateRelayActive: + mode = ModeRelay + } + if err := svc.Transition(ctx, "u", id, st, mode, "", 0); err != nil { + t.Fatalf("transition to %s: %v", st, err) + } + } +} + +// Entering RELAY_ACTIVE must reserve a relay slot, recording the transfer's two +// participants so the relay endpoints can later reject anyone else (R1/G2). +func TestTransition_RegistersRelayOnFallback(t *testing.T) { + fr := &fakeRelay{} + svc := newTestServiceWithRelay(t, fr) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "laptop", ReceiverName: "phone", + FileName: "f", FileSize: 10, + }) + if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil { + t.Fatalf("accept: %v", err) + } + if err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0); err != nil { + t.Fatalf("fallback: %v", err) + } + if len(fr.registers) != 1 { + t.Fatalf("expected 1 Register call, got %d", len(fr.registers)) + } + if got := fr.registers[0]; got != [4]string{id, "u", "laptop", "phone"} { + t.Errorf("Register args: got %v", got) + } +} + +// When the relay is full, the fallback must NOT advance the transfer into relay +// mode: Transition surfaces ErrRelayUnavailable and the persisted state is +// unchanged so the client can retry. +func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) { + fr := &fakeRelay{registerErr: errors.New("too many")} + svc := newTestServiceWithRelay(t, fr) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil { + t.Fatalf("accept: %v", err) + } + err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0) + if !errors.Is(err, ErrRelayUnavailable) { + t.Fatalf("expected ErrRelayUnavailable, got %v", err) + } + sess, _ := svc.queries.GetTransferSession(ctx, id) + if sess.State != StateAccepted { + t.Errorf("state must be unchanged on relay-full; got %q, want ACCEPTED", sess.State) + } +} + +func TestTransition_RejectsInvalidJump(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + // PENDING → DONE 仍非法(必须先 ACCEPTED 或 P2P_ACTIVE / RELAY_ACTIVE 至少一步) + if err := svc.Transition(ctx, "u", id, StateDone, "", "", 0); err == nil { + t.Error("PENDING→DONE should be rejected") + } +} + +func TestTransition_PendingToActiveAllowed(t *testing.T) { + // Race tolerance: sender may call /p2p before receiver's /accept commits. + svc := newTestService(t) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + if err := svc.Transition(ctx, "u", id, StateP2PActive, ModeP2P, "", 0); err != nil { + t.Errorf("PENDING→P2P_ACTIVE should be allowed: %v", err) + } +} + +func TestTransition_AcceptedToDoneAllowed(t *testing.T) { + // Race tolerance: receiver may finish receiving (call /done) before /p2p ever fired. + svc := newTestService(t) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil { + t.Fatalf("PENDING→ACCEPTED: %v", err) + } + if err := svc.Transition(ctx, "u", id, StateDone, "", "", 10); err != nil { + t.Errorf("ACCEPTED→DONE should be allowed (race-tolerant): %v", err) + } +} + +func TestTransition_RejectsForeignUser(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "alice", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + if err := svc.Transition(ctx, "mallory", id, StateAccepted, "", "", 0); err == nil { + t.Error("foreign user must be forbidden") + } +} + +func TestTransition_NotFound(t *testing.T) { + svc := newTestService(t) + if err := svc.Transition(context.Background(), "u", "nope", StateAccepted, "", "", 0); err == nil { + t.Error("unknown id should error") + } +} + +func TestExpirePending_CancelsOldSessions(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return old } + + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + + // jump 200s into the future; PendingTTL is 120s so this should expire. + svc.now = func() time.Time { return old.Add(200 * time.Second) } + n, err := svc.ExpirePending(ctx, PendingTTL) + if err != nil { + t.Fatalf("expire: %v", err) + } + if n != 1 { + t.Errorf("expired count: got %d, want 1", n) + } + sess, _ := svc.queries.GetTransferSession(ctx, id) + if sess.State != StateCancelled { + t.Errorf("state: got %q, want %q", sess.State, StateCancelled) + } + if sess.FailReason == nil || *sess.FailReason != "pending_timeout" { + t.Errorf("fail_reason: got %v, want pending_timeout", sess.FailReason) + } +} + +func TestExpirePending_LeavesFreshAlone(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + id, _ := svc.Init(ctx, InitParams{ + UserID: "u", SenderName: "s", ReceiverName: "r", + FileName: "f", FileSize: 10, + }) + n, err := svc.ExpirePending(ctx, PendingTTL) + if err != nil { + t.Fatalf("expire: %v", err) + } + if n != 0 { + t.Errorf("expired count: got %d, want 0", n) + } + sess, _ := svc.queries.GetTransferSession(ctx, id) + if sess.State != StatePending { + t.Errorf("state: got %q, want PENDING", sess.State) + } +} diff --git a/internal/transfer/state.go b/internal/transfer/state.go new file mode 100644 index 0000000..1f1865b --- /dev/null +++ b/internal/transfer/state.go @@ -0,0 +1,54 @@ +package transfer + +const ( + StatePending = "PENDING" + StateAccepted = "ACCEPTED" + StateP2PActive = "P2P_ACTIVE" + StateRelayActive = "RELAY_ACTIVE" + StateDone = "DONE" + StateFailed = "FAILED" + StateCancelled = "CANCELLED" +) + +const ( + ModeP2P = "p2p" + ModeRelay = "relay" +) + +// AutoAcceptThreshold is the file size at/under which the receiver may +// silently auto-accept a transfer. brief §2: "> 100 MB 二次确认". +const AutoAcceptThreshold = 100 * 1024 * 1024 + +// IsValidTransition reports whether a transfer may move from→to. +// +// Forward-only: any state can advance to a "later" state, but never go back. +// We're permissive because client→server timing races are real: +// - sender's /p2p can land before receiver's /accept commits → PENDING → P2P_ACTIVE +// - either side may call /done before /p2p ever fired → ACCEPTED → DONE +// Strict ordering would surface as 409s the user can't recover from. +// +// Terminal states (DONE / FAILED / CANCELLED) accept no further transitions. +func IsValidTransition(from, to string) bool { + switch from { + case StatePending: + return to == StateAccepted || to == StateP2PActive || to == StateRelayActive || + to == StateCancelled || to == StateFailed + case StateAccepted: + return to == StateP2PActive || to == StateRelayActive || + to == StateDone || to == StateCancelled || to == StateFailed + case StateP2PActive: + // P2P 失败自动 fallback 到 Relay (brief §2). + return to == StateRelayActive || to == StateDone || + to == StateFailed || to == StateCancelled + case StateRelayActive: + return to == StateDone || to == StateFailed || to == StateCancelled + case StateDone, StateFailed, StateCancelled: + return false + } + return false +} + +// IsTerminal reports whether the state is final (no further transitions). +func IsTerminal(state string) bool { + return state == StateDone || state == StateFailed || state == StateCancelled +} diff --git a/internal/transfer/state_test.go b/internal/transfer/state_test.go new file mode 100644 index 0000000..bfe2650 --- /dev/null +++ b/internal/transfer/state_test.go @@ -0,0 +1,54 @@ +package transfer + +import "testing" + +func TestIsValidTransition(t *testing.T) { + allowed := map[string][]string{ + StatePending: {StateAccepted, StateP2PActive, StateRelayActive, StateCancelled, StateFailed}, + StateAccepted: {StateP2PActive, StateRelayActive, StateDone, StateCancelled, StateFailed}, + StateP2PActive: {StateRelayActive, StateDone, StateFailed, StateCancelled}, + StateRelayActive: {StateDone, StateFailed, StateCancelled}, + } + all := []string{StatePending, StateAccepted, StateP2PActive, StateRelayActive, + StateDone, StateFailed, StateCancelled} + + for from, tos := range allowed { + ok := map[string]bool{} + for _, to := range tos { + ok[to] = true + } + for _, to := range all { + got := IsValidTransition(from, to) + want := ok[to] + if got != want { + t.Errorf("transition %s→%s: got %v, want %v", from, to, got, want) + } + } + } + + // Terminals reject all outbound. + for _, term := range []string{StateDone, StateFailed, StateCancelled} { + for _, to := range all { + if IsValidTransition(term, to) { + t.Errorf("terminal %s should not allow transition to %s", term, to) + } + } + } +} + +func TestIsTerminal(t *testing.T) { + cases := map[string]bool{ + StatePending: false, + StateAccepted: false, + StateP2PActive: false, + StateRelayActive: false, + StateDone: true, + StateFailed: true, + StateCancelled: true, + } + for s, want := range cases { + if got := IsTerminal(s); got != want { + t.Errorf("IsTerminal(%s): got %v, want %v", s, got, want) + } + } +} diff --git a/internal/transfer/sweeper.go b/internal/transfer/sweeper.go new file mode 100644 index 0000000..cc06b84 --- /dev/null +++ b/internal/transfer/sweeper.go @@ -0,0 +1,36 @@ +package transfer + +import ( + "context" + "log/slog" + "time" +) + +// PendingTTL is the wall-clock window after which a PENDING session +// is auto-cancelled by the sweeper (plan §4.5 待决策清单 #5). +const PendingTTL = 120 * time.Second + +// SweepInterval is how often the sweeper goroutine wakes up. +const SweepInterval = 30 * time.Second + +// RunSweeper blocks until ctx is cancelled, periodically expiring +// stale PENDING sessions. +func RunSweeper(ctx context.Context, svc *Service) { + t := time.NewTicker(SweepInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + n, err := svc.ExpirePending(ctx, PendingTTL) + if err != nil { + slog.Error("sweeper: expire pending failed", "err", err) + continue + } + if n > 0 { + slog.Info("sweeper: expired pending sessions", "count", n) + } + } + } +} diff --git a/internal/webui/dist/.gitkeep b/internal/webui/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/webui/embed.go b/internal/webui/embed.go new file mode 100644 index 0000000..f7a0fa9 --- /dev/null +++ b/internal/webui/embed.go @@ -0,0 +1,58 @@ +package webui + +import ( + "embed" + "io/fs" + "net/http" + "strings" +) + +//go:embed all:dist +var distFS embed.FS + +// Handler returns an http.Handler that serves the embedded frontend bundle +// (web/dist copied into ./dist by the Dockerfile). Unmatched paths fall back +// to /index.html so TanStack Router's client-side routes (/login, /setup, +// /oauth/callback, …) work on hard-refresh. +// +// In local dev the embed FS is empty (only .gitkeep), so this returns a 404 +// for everything; the frontend is served by `vite dev` on :5173 instead. +func Handler() http.Handler { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + // Should be unreachable; the embed dir always exists. + panic(err) + } + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + // Probe the file. If absent, rewrite to "/" so FileServer serves the + // SPA index.html. + served := path + f, err := sub.Open(path) + if err != nil { + r.URL.Path = "/" + served = "index.html" + } else { + _ = f.Close() + } + // 缓存策略:embed.FS 无 modtime → 默认不带任何缓存头,浏览器每次都重取 + // (表现为登录跳转回来后 logo 等图片闪烁重载)。按资源类型显式分级: + // - /assets/* 是 Vite 带内容哈希的 bundle → 永久不可变缓存 + // - index.html(含 SPA 回退)必须每次校验,才能在新部署后拉到新哈希 + // - 其余静态资源(logo / favicon / manifest)极少变 → 一周缓存, + // 使 logo 仅首访下载一次、后续导航与跨页直接命中缓存、不再重载 + switch { + case strings.HasPrefix(served, "assets/"): + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + case served == "index.html": + w.Header().Set("Cache-Control", "no-cache") + default: + w.Header().Set("Cache-Control", "public, max-age=604800") + } + fileServer.ServeHTTP(w, r) + }) +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..e9dab26 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,11 @@ +version: "2" +sql: + - engine: "sqlite" + queries: "internal/db/queries" + schema: "internal/db/migrations" + gen: + go: + package: "db" + out: "internal/db" + emit_json_tags: true + emit_pointers_for_null_types: true diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..ff73b0b --- /dev/null +++ b/web/.env.example @@ -0,0 +1,7 @@ +# 复制为 web/.env.local,与后端 CDROP_DEV_TOKEN 保持一致 +VITE_CDROP_DEV_TOKEN=replace-with-32-byte-random-base64 + +# 可选:自定义 STUN(逗号分隔)。默认 stun:stun.cloudflare.com:3478 +# (Cloudflare 全球 anycast,国内连通性优于 Google STUN)。 +# 自建 coturn 时改为 stun:your-turn.example:3478。 +# VITE_CDROP_STUN_URLS=stun:stun.cloudflare.com:3478 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9ea2b01 --- /dev/null +++ b/web/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + Commilitia Drop + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..2166fde --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3671 @@ +{ + "name": "cdrop-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cdrop-web", + "version": "0.0.0", + "dependencies": { + "@mantine/core": "^7.13.5", + "@mantine/dropzone": "^7.13.5", + "@mantine/hooks": "^7.13.5", + "@mantine/notifications": "^7.13.5", + "@tanstack/react-router": "^1.82.0", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@tanstack/router-plugin": "^1.82.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mantine/core": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz", + "integrity": "sha512-42sfdLZSCpsCYmLCjSuntuPcDg3PLbakSmmYfz5Auea8gZYLr+8SS5k647doVu0BRAecqYOytkX2QC5/u/8VHw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.28", + "clsx": "^2.1.1", + "react-number-format": "^5.4.3", + "react-remove-scroll": "^2.6.2", + "react-textarea-autosize": "8.5.9", + "type-fest": "^4.27.0" + }, + "peerDependencies": { + "@mantine/hooks": "7.17.8", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/dropzone": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/dropzone/-/dropzone-7.17.8.tgz", + "integrity": "sha512-c9WEArpP23E9tbRWqoznEY3bGPVntMuBKr3F2LQijgdpdALIzt6DYmwXu7gUajGX9Qg9NrCHenhvWLTYqKnRlA==", + "license": "MIT", + "dependencies": { + "react-dropzone-esm": "15.2.0" + }, + "peerDependencies": { + "@mantine/core": "7.17.8", + "@mantine/hooks": "7.17.8", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/hooks": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.8.tgz", + "integrity": "sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/notifications": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-7.17.8.tgz", + "integrity": "sha512-/YK16IZ198W6ru/IVecCtHcVveL08u2c8TbQTu/2p26LSIM9AbJhUkrU6H+AO0dgVVvmdmNdvPxcJnfq3S9TMg==", + "license": "MIT", + "dependencies": { + "@mantine/store": "7.17.8", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "7.17.8", + "@mantine/hooks": "7.17.8", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/store": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-7.17.8.tgz", + "integrity": "sha512-/FrB6PAVH4NEjQ1dsc9qOB+VvVlSuyjf4oOOlM9gscPuapDP/79Ryq7JkhHYfS55VWQ/YUlY24hDI2VV+VptXg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/history": { + "version": "1.161.6", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", + "integrity": "sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.168.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.24.tgz", + "integrity": "sha512-CQWd9ywDZU6icG65SrjJMzWcNg/ehBKFxfxYssKSuELk0eM9SzJxJ3JBI760kdLXIsXewOX9PHpJDknxC/R5Ag==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.168.16", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.168.16", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.168.16.tgz", + "integrity": "sha512-2lkWNMzDWWxVqTf9Y54DH1ceZOrGrOGzx9CFVMq8gQSOxhr3x3+otjVei1Rlx4xmsCc4yCqccqjun5wDtE9MZA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.161.6", + "cookie-es": "^3.0.0", + "seroval": "^1.5.0", + "seroval-plugins": "^1.5.0" + }, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.166.35", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.166.35.tgz", + "integrity": "sha512-d3tdUTf6QRjMW4V2P91mE8OR0haHgGktkLOs3zQmirEokQhu2qgoVfcSxLF6DfmMR6smvDjwdeMdy2sudImuFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.168.16", + "@tanstack/router-utils": "1.161.7", + "@tanstack/virtual-file-routes": "1.161.7", + "jiti": "^2.6.1", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^3.24.2" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.167.27", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.167.27.tgz", + "integrity": "sha512-3jl+9I6bgSqAhwIDU2ps4pjhaEy2kugXvZ/dCAE6zF+pYEuRkNr8GA7bpoboZqdAtbceyZ2sLgX6aB7VQvHGUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.168.16", + "@tanstack/router-generator": "1.166.35", + "@tanstack/router-utils": "1.161.7", + "@tanstack/virtual-file-routes": "1.161.7", + "chokidar": "^3.6.0", + "unplugin": "^3.0.0", + "zod": "^3.24.2" + }, + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.168.24", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.161.7", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.161.7.tgz", + "integrity": "sha512-VkY0u7ax/GD0qU6ZLLnfPC+UMxVzxRbvZp4yV4iUSXjgJZ/siAT5/QlLm9FEDJ9QDoC0VD9W7f00tKKreUI7Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.161.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.161.7.tgz", + "integrity": "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==", + "dev": true, + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isbot": { + "version": "5.1.39", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.39.tgz", + "integrity": "sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-mixins": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/postcss-mixins/-/postcss-mixins-12.1.2.tgz", + "integrity": "sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-js": "^4.0.1", + "postcss-simple-vars": "^7.0.1", + "sugarss": "^5.0.0", + "tinyglobby": "^0.2.14" + }, + "engines": { + "node": "^20.0 || ^22.0 || >=24.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-preset-mantine": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.18.0.tgz", + "integrity": "sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-mixins": "^12.0.0", + "postcss-nested": "^7.0.2" + }, + "peerDependencies": { + "postcss": ">=8.0.0" + } + }, + "node_modules/postcss-preset-mantine/node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-preset-mantine/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-simple-vars": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz", + "integrity": "sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.1" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dropzone-esm": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/react-dropzone-esm/-/react-dropzone-esm-15.2.0.tgz", + "integrity": "sha512-pPwR8xWVL+tFLnbAb8KVH5f6Vtl397tck8dINkZ1cPMxHWH+l9dFmIgRWgbh7V7jbjIcuKXCsVrXbhQz68+dVA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-number-format": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz", + "integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz", + "integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sugarss": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-5.0.1.tgz", + "integrity": "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..23b11b7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "cdrop-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@mantine/core": "^7.13.5", + "@mantine/dropzone": "^7.13.5", + "@mantine/hooks": "^7.13.5", + "@mantine/notifications": "^7.13.5", + "@tanstack/react-router": "^1.82.0", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zustand": "^4.5.5" + }, + "devDependencies": { + "@tanstack/router-plugin": "^1.82.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..92f56e4 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,16 @@ +export default { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/public/apple-touch-icon-180.png b/web/public/apple-touch-icon-180.png new file mode 100644 index 0000000..b2e40e6 Binary files /dev/null and b/web/public/apple-touch-icon-180.png differ diff --git a/web/public/favicon-16.png b/web/public/favicon-16.png new file mode 100644 index 0000000..bc11fd1 Binary files /dev/null and b/web/public/favicon-16.png differ diff --git a/web/public/favicon-32.png b/web/public/favicon-32.png new file mode 100644 index 0000000..f4bb3ce Binary files /dev/null and b/web/public/favicon-32.png differ diff --git a/web/public/favicon-48.png b/web/public/favicon-48.png new file mode 100644 index 0000000..a299446 Binary files /dev/null and b/web/public/favicon-48.png differ diff --git a/web/public/favicon-96.png b/web/public/favicon-96.png new file mode 100644 index 0000000..69cc7b7 Binary files /dev/null and b/web/public/favicon-96.png differ diff --git a/web/public/icon-192.png b/web/public/icon-192.png new file mode 100644 index 0000000..65a2f23 Binary files /dev/null and b/web/public/icon-192.png differ diff --git a/web/public/icon-512.png b/web/public/icon-512.png new file mode 100644 index 0000000..75d8347 Binary files /dev/null and b/web/public/icon-512.png differ diff --git a/web/public/logo-mark.png b/web/public/logo-mark.png new file mode 100644 index 0000000..5928044 Binary files /dev/null and b/web/public/logo-mark.png differ diff --git a/web/public/site.webmanifest b/web/public/site.webmanifest new file mode 100644 index 0000000..2e6ccfe --- /dev/null +++ b/web/public/site.webmanifest @@ -0,0 +1,11 @@ +{ + "name": "Commilitia Drop", + "short_name": "CDrop", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ], + "theme_color": "#E8B923", + "background_color": "#E8B923", + "display": "standalone" +} diff --git a/web/src/features/auth/AuthShell.module.css b/web/src/features/auth/AuthShell.module.css new file mode 100644 index 0000000..a84c70e --- /dev/null +++ b/web/src/features/auth/AuthShell.module.css @@ -0,0 +1,162 @@ +.shell +{ + min-height: 100vh; + background: var(--surface-sunken); + display: flex; + flex-direction: column; + align-items: center; + padding: var(--space-5) var(--space-4); + position: relative; +} + +.topbar +{ + position: absolute; + top: var(--space-4); + left: var(--space-5); + right: var(--space-5); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); +} + +/* 页面级 logo 容器:仅定字号(驱动内部 Wordmark 的图文整体大小), + 字体 / 字重 / 双色由内部 Wordmark 负责。 */ +.brand +{ + font-size: var(--fs-22); +} + +.center +{ + flex: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.card +{ + width: 100%; + max-width: 440px; + background: var(--surface-raised); + border-radius: var(--radius-card); + box-shadow: var(--shadow-raised); + border: 1px solid var(--divider); + padding: var(--space-6) var(--space-6) var(--space-5); + display: flex; + flex-direction: column; + gap: var(--space-4); + position: relative; +} + +/* 顶部 3px accent 条:整张卡的语义锚,在浅 / 深主题里都能突出。 */ +.card::before +{ + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--accent); + border-radius: var(--radius-card) var(--radius-card) 0 0; +} + +/* 卡内品牌字标:表单 hero 上方的小号 logo,内部 Wordmark 负责字体 / 双色。 + 字号小于顶栏 .brand —— 顶栏是页面级 logo,这里是表单 hero 的一部分。 */ +.cardBrand +{ + display: inline-flex; + align-items: center; + font-size: var(--fs-18); + margin-bottom: calc(var(--space-1) * -1); +} + +.hero +{ + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.title +{ + margin: 0; + font-size: var(--fs-28); + font-weight: 600; + line-height: var(--lh-title); + letter-spacing: -0.01em; + color: var(--text); +} + +.subtitle +{ + margin: 0; + font-size: var(--fs-13); + line-height: var(--lh-body); + color: var(--text-muted); +} + +.body +{ + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* 卡内分隔线:用于"或"切换不同登录方式时。 */ +.divider +{ + display: flex; + align-items: center; + gap: var(--space-3); + color: var(--text-muted); + font-size: var(--fs-11); + letter-spacing: var(--ls-label); + text-transform: uppercase; + margin: var(--space-1) 0; +} +.divider::before, +.divider::after +{ + content: ""; + flex: 1; + height: 1px; + background: var(--divider); +} + +/* footer:卡片底部留给版权 / 链接行,与卡内主行为隔开。 */ +.footer +{ + text-align: center; + color: var(--text-muted); + font-size: var(--fs-11); + padding: var(--space-4) 0 0; +} + +.footerLinks +{ + display: inline-flex; + align-items: center; + gap: var(--space-3); + justify-content: center; +} +.footerLinks a +{ + color: var(--text-muted); + text-decoration: none; + transition: color var(--dur-fast) var(--ease-out); +} +.footerLinks a:hover { color: var(--accent); text-decoration: underline; } +.footerLinks .sep { color: var(--divider); } + +@media (max-width: 480px) +{ + .card { padding: var(--space-5) var(--space-4); } + .title { font-size: var(--fs-22); } + .cardBrand { font-size: var(--fs-11); } +} diff --git a/web/src/features/auth/AuthShell.tsx b/web/src/features/auth/AuthShell.tsx new file mode 100644 index 0000000..e507ea9 --- /dev/null +++ b/web/src/features/auth/AuthShell.tsx @@ -0,0 +1,83 @@ +import type { ReactNode } from "react"; +import { IconButton, Tooltip } from "../../ui/primitives"; +import { Wordmark } from "../../ui/brand"; +import { Monitor, Moon, Sun } from "lucide-react"; +import { useAppStore, type ThemeMode } from "../../store"; +import { t } from "../../i18n"; +import s from "./AuthShell.module.css"; + +export interface AuthShellProps +{ + title: ReactNode; + subtitle?: ReactNode; + children: ReactNode; + footer?: ReactNode; + /** 卡内品牌字标。默认不渲染——顶栏已有 logo,卡内再放会一屏两 logo; + * 需要时显式传入一个节点即可。 */ + cardBrand?: ReactNode | null; + /** 隐藏顶栏 logo。过渡型认证屏(OAuth 回调 / 首次设置)用:它们会 SPA + * 跳转到主界面,跳转帧里顶栏 logo 会与主界面 header 的 logo 重叠成“两个 + * logo”,隐藏即可消除。登录页是整页跳 IdP、无此重叠,保留 logo。 */ + hideBrand?: boolean; +} + +// 全屏外壳:登录、首次设置、OAuth 回调共用。带轻量品牌 + 主题切换, +// 让用户在登录前也能调主题。 +export function AuthShell(props: AuthShellProps) +{ + const { title, subtitle, children, footer, cardBrand, hideBrand } = props; + const theme = useAppStore((s) => s.theme); + const setTheme = useAppStore((s) => s.setTheme); + const next: ThemeMode = theme === "system" ? "light" : theme === "light" ? "dark" : "system"; + const icon = theme === "light" ? + : theme === "dark" ? + : ; + + // 默认不在卡内重复 logo(顶栏已有),避免一屏两 logo;需要时显式传入。 + const brandNode = cardBrand === undefined ? null : cardBrand; + + return ( +
+
+ {/* 空 span 占位保持 space-between,主题切换仍靠右。 */} + {hideBrand ? : } + + setTheme(next)}> + {icon} + + +
+ +
+
+ {brandNode &&
{brandNode}
} +
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {children} +
+
+
+ + {footer &&
{footer}
} +
+ ); +} + +// 公共 footer 链接组件:登录页用,setup / oauth 不一定要。 +export function AuthFooterLinks(props: { items: { label: string; href: string }[] }) +{ + const { items } = props; + return ( +
+ {items.map((it, i) => ( + + {i > 0 && ·} + {it.label} + + ))} +
+ ); +} diff --git a/web/src/features/auth/auth.ts b/web/src/features/auth/auth.ts new file mode 100644 index 0000000..bc40d55 --- /dev/null +++ b/web/src/features/auth/auth.ts @@ -0,0 +1,220 @@ +import { useAppStore, type User } from "../../store"; +import { t } from "../../i18n"; +import { apiFetch } from "../../net/api"; +import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop"; + +// ---- dev mode ------------------------------------------------------------- + +// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage), +// stamp the store with a synthetic User and the build-time dev token. +// This bypasses Casdoor entirely. The middleware on the backend accepts +// any X-Dev-User as long as the bearer matches CDROP_DEV_TOKEN. +export function loginDev(searchParams: URLSearchParams): User +{ + const devUser = searchParams.get("dev_user") + ?? window.localStorage.getItem("cdrop.dev_user") + ?? "dev-user"; + window.localStorage.setItem("cdrop.dev_user", devUser); + + const token = import.meta.env.VITE_CDROP_DEV_TOKEN; + if (!token) + { + throw new Error(t("errors.devTokenMissing")); + } + + const user: User = { id: devUser, name: devUser }; + useAppStore.getState().setAuth({ accessToken: token, user }); + return user; +} + +export function logout() +{ + // Fire-and-forget 通知后端立刻把本设备从 Hub 踢掉 + 广播 presence,让对端 + // 不必等 30s 宽限期。apiFetch 同步抓取 access_token 后才让出(fetch 已发出), + // 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略 + // ——只是体验降级回宽限期路径,不影响登出本身。 + void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ }); + useAppStore.getState().clearAuth(); + // 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。 + if (isDesktop()) { clearDesktopSession(); } +} + +// ---- prod (OIDC PKCE) ----------------------------------------------------- + +interface AuthConfig +{ + auth_mode: "dev" | "prod"; + authorize_url: string; + client_id: string; + redirect_uri: string; + scopes: string; +} + +let cachedConfig: AuthConfig | null = null; + +export async function fetchAuthConfig(): Promise +{ + if (cachedConfig) { return cachedConfig; } + const r = await fetch("/api/auth/config"); + if (!r.ok) { throw new Error(`auth config ${r.status}`); } + cachedConfig = await r.json() as AuthConfig; + return cachedConfig; +} + +const PKCE_VERIFIER_KEY = "cdrop.pkce_verifier"; +const OAUTH_STATE_KEY = "cdrop.oauth_state"; + +// loginProd kicks off OIDC PKCE: generate verifier+challenge+state, stash the +// secrets in sessionStorage, navigate the browser to the provider's +// /authorize endpoint. The callback page completes the flow. +export async function loginProd(): Promise +{ + const cfg = await fetchAuthConfig(); + if (cfg.auth_mode !== "prod") + { + throw new Error(`server reports auth_mode=${cfg.auth_mode}, expected prod`); + } + if (!cfg.authorize_url || !cfg.client_id || !cfg.redirect_uri) + { + throw new Error("OIDC config incomplete (authorize_url / client_id / redirect_uri)"); + } + + const verifier = generateRandomBase64url(32); + const challenge = await sha256Base64url(verifier); + const state = generateRandomBase64url(16); + + sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier); + sessionStorage.setItem(OAUTH_STATE_KEY, state); + + const params = new URLSearchParams({ + client_id: cfg.client_id, + redirect_uri: cfg.redirect_uri, + response_type: "code", + scope: cfg.scopes || "openid profile email", + state, + code_challenge: challenge, + code_challenge_method: "S256", + }); + window.location.href = `${cfg.authorize_url}?${params}`; +} + +interface ExchangeResp +{ + access_token: string; + refresh_token: string; + expires_in: number; + user: { id: string; name: string; avatar?: string }; +} + +// completeOAuthLogin runs on /oauth/callback after the provider redirects back. +// Verifies state (CSRF), trades code+verifier for tokens via the backend +// proxy, and stamps the store with the user identity. +// +// Idempotent:若 store 中已有 user + accessToken(典型场景:useEffect 在 React +// 渲染管线中重入,首次已成功 setAuth + 消耗 sessionStorage,二次入场不能再被 +// "PKCE verifier missing" / "state mismatch" 当作失败处理)→ 当作 noop 直接 +// resolve,由上层 navigate 接管。 +export async function completeOAuthLogin(code: string, state: string): Promise +{ + const cur = useAppStore.getState(); + if (cur.user && cur.accessToken) + { + return; + } + + const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY); + if (!expectedState || state !== expectedState) + { + throw new Error("OAuth state mismatch (possible CSRF or stale tab)"); + } + const verifier = sessionStorage.getItem(PKCE_VERIFIER_KEY); + if (!verifier) + { + throw new Error("PKCE verifier missing — restart the login flow"); + } + + const r = await fetch("/api/auth/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code, code_verifier: verifier }), + }); + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`token exchange ${r.status}: ${text}`); + } + const data = await r.json() as ExchangeResp; + + sessionStorage.removeItem(PKCE_VERIFIER_KEY); + sessionStorage.removeItem(OAUTH_STATE_KEY); + + useAppStore.getState().setAuth({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar }, + }); +} + +// refreshTokens proxies to /api/auth/refresh. Returns true on success. +// api.ts calls this lazily when a request comes back 401. +export async function refreshTokens(): Promise +{ + const store = useAppStore.getState(); + if (store.authMode !== "prod") { return false; } + + // 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),故 + // 无需 store.refreshToken,直接无参调用。 + if (isDesktop()) + { + const res = await desktopRefresh(); + if (!res?.access_token) { return false; } + const cur = useAppStore.getState(); + if (!cur.user) { return false; } + cur.setAuth({ accessToken: res.access_token, user: cur.user }); + return true; + } + + const refreshToken = store.refreshToken; + if (!refreshToken) { return false; } + + const r = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + if (!r.ok) { return false; } + const data = await r.json() as { access_token: string; refresh_token?: string }; + if (!data.access_token) { return false; } + + const cur = useAppStore.getState(); + if (!cur.user) { return false; } + cur.setAuth({ + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + user: cur.user, + }); + return true; +} + +// ---- PKCE helpers --------------------------------------------------------- + +function generateRandomBase64url(numBytes: number): string +{ + const arr = new Uint8Array(numBytes); + crypto.getRandomValues(arr); + return base64urlEncode(arr); +} + +async function sha256Base64url(input: string): Promise +{ + const buf = new TextEncoder().encode(input); + const hash = await crypto.subtle.digest("SHA-256", buf); + return base64urlEncode(new Uint8Array(hash)); +} + +function base64urlEncode(bytes: Uint8Array): string +{ + let s = ""; + for (let i = 0; i < bytes.length; i += 1) { s += String.fromCharCode(bytes[i]); } + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} diff --git a/web/src/features/clipboard/ClipboardPanel.module.css b/web/src/features/clipboard/ClipboardPanel.module.css new file mode 100644 index 0000000..82f43b2 --- /dev/null +++ b/web/src/features/clipboard/ClipboardPanel.module.css @@ -0,0 +1,104 @@ +.root +{ + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; /* grid 子项默认 min-width auto,长内容会撑破列宽 */ +} + +.cloud +{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.cloudEmpty +{ + color: var(--text-muted); + font-size: var(--fs-13); + padding: var(--space-3) 0; +} + +/* meta 行(大小 · 来源 · 时间)总是可见,是用户决定要不要 "显示" 内容的依据。 */ +.metaRow +{ + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + font-size: var(--fs-11); + color: var(--text-muted); + padding: var(--space-2) var(--space-3); + background: var(--surface-sunken); + border: 1px solid var(--divider); + border-radius: var(--radius-control); +} +.metaSize { font-family: var(--font-mono); } +.metaFrom { color: var(--success); font-weight: 600; } + +/* shade:默认隐藏态的占位。dashed 边 + muted 文字。 */ +.shade +{ + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-3); + background: var(--surface-sunken); + border: 1px dashed var(--divider); + border-left: 3px solid var(--text-muted); + border-radius: var(--radius-control); + color: var(--text-muted); + font-size: var(--fs-12); + text-align: center; +} +.shade svg { flex-shrink: 0; } + +/* 显示态内容:mono 字体,让 URL / token / 代码片段对齐; + 长文本可滚动,防止撑高 panel。 */ +.content +{ + background: var(--surface-sunken); + border: 1px solid var(--divider); + border-left: 3px solid var(--success); + border-radius: var(--radius-control); + padding: var(--space-3); + font-family: var(--font-mono); + font-size: var(--fs-12); + line-height: 1.55; + color: var(--text); + white-space: pre-wrap; + word-break: break-all; + max-height: 220px; + overflow: auto; +} + +.divider +{ + height: 1px; + background: var(--divider); + margin: var(--space-2) 0; +} + +.uploadHint { color: var(--text-muted); font-size: var(--fs-11); } + +/* 按钮组:占满 panel 宽,多按钮均分;单按钮即 block。 */ +.actions +{ + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + width: 100%; +} +.actions > * +{ + flex: 1 1 auto; + min-width: 0; +} +/* 按钮内文字允许换行(覆盖 Button 默认 nowrap),窄列宽下不溢出。 */ +.actions button +{ + white-space: normal; + line-height: 1.25; +} diff --git a/web/src/features/clipboard/ClipboardPanel.tsx b/web/src/features/clipboard/ClipboardPanel.tsx new file mode 100644 index 0000000..4b44ff2 --- /dev/null +++ b/web/src/features/clipboard/ClipboardPanel.tsx @@ -0,0 +1,216 @@ +import { useEffect, useRef, useState } from "react"; +import { + ClipboardCopy, + ClipboardPaste, + Eye, + EyeOff, + RefreshCw, + Trash2, +} from "lucide-react"; +import { Button, IconButton, Panel, Tooltip } from "../../ui/primitives"; +import { toast } from "../../ui/feedback"; +import type { ClipboardState } from "../../store"; +import { t } from "../../i18n"; +import { formatBytes, formatRelative } from "../../utils/format"; +import { + fetchClipboard, + readClipboardText, + uploadClipboard, + utf8ByteLength, +} from "./clipboard"; +import s from "./ClipboardPanel.module.css"; + +export interface ClipboardPanelProps +{ + state: ClipboardState | null; + selfDevice: string | null; +} + +// 默认隐藏内容(隐私默认):仅展示 meta(大小 · 类型 · 来自 X · 时间), +// 用户必须点 "显示" 才能看到原文。新内容到达(SSE 推送 / 上传回声 / 切换设备) +// 自动收回隐藏态,防止上一次显示的暴露面延续到不同内容。 +// +// 操作结果走 toast。 +export function ClipboardPanel(props: ClipboardPanelProps) +{ + const { state, selfDevice } = props; + const [ busy, setBusy ] = useState(false); + const [ revealed, setRevealed ] = useState(false); + + const hasContent = !!state && state.content.length > 0; + const isSelf = hasContent && !!selfDevice && state.sourceDevice === selfDevice; + + // updatedAt 变化即视为"新内容"——任何来源(远端 SSE、自己上传后的回声、 + // 切换 selfDevice 后从 ETag 拉回的不同内容)都会触发收回。 + const lastUpdatedRef = useRef(state?.updatedAt ?? 0); + useEffect(() => + { + const cur = state?.updatedAt ?? 0; + if (cur !== lastUpdatedRef.current) + { + lastUpdatedRef.current = cur; + setRevealed(false); + } + }, [ state?.updatedAt ]); + + const sizeText = hasContent + ? formatBytes(utf8ByteLength(state.content)) + : ""; + const fromText = hasContent + ? ( isSelf + ? t("home.clipboard.cloudFromSelf", { time: formatRelative(state.updatedAt) }) + : t("home.clipboard.cloudFrom", { + name: state.sourceDevice || "?", + time: formatRelative(state.updatedAt), + }) ) + : ""; + + const handleUpload = async () => + { + setBusy(true); + try + { + const text = await readClipboardText(); + if (!text) { toast.warn(t("home.clipboard.uploadEmpty")); return; } + await uploadClipboard(text); + toast.ok("已上传到云端"); + } + catch (e) + { + toast.error("剪贴板上传失败", e instanceof Error ? e.message : String(e)); + } + finally { setBusy(false); } + }; + + const handleCopy = async () => + { + if (!state) { return; } + try + { + await navigator.clipboard.writeText(state.content); + toast.ok(t("home.clipboard.copyToLocalSuccess")); + } + catch (e) + { + toast.error(t("errors.clipboardWriteFailed", { + message: e instanceof Error ? e.message : String(e), + })); + } + }; + + const handleRefresh = async () => + { + try { await fetchClipboard(); } + catch (e) + { + toast.error("刷新失败", e instanceof Error ? e.message : String(e)); + } + }; + + const handleClear = async () => + { + if (!hasContent) { return; } + if (!window.confirm(t("home.clipboard.clearConfirm"))) { return; } + setBusy(true); + try + { + await uploadClipboard(""); + toast.ok(t("home.clipboard.clearSuccess")); + } + catch (e) + { + toast.error("清空失败", e instanceof Error ? e.message : String(e)); + } + finally { setBusy(false); } + }; + + const toggleReveal = () => { setRevealed((v) => !v); }; + + return ( + + + + + + + + + + + + + } + > +
+ {hasContent ? ( +
+
+ {sizeText} + {fromText} +
+ + {revealed ? ( +
{state.content}
+ ) : ( +
+ + {t("home.clipboard.hidden")} +
+ )} + +
+ + +
+
+ ) : ( +
{t("home.clipboard.cloudEmpty")}
+ )} + +
+ +
{t("home.clipboard.uploadHint")}
+
+ +
+
+ + ); +} diff --git a/web/src/features/clipboard/clipboard.ts b/web/src/features/clipboard/clipboard.ts new file mode 100644 index 0000000..c839a9f --- /dev/null +++ b/web/src/features/clipboard/clipboard.ts @@ -0,0 +1,174 @@ +import { apiFetch } from "../../net/api"; +import { writeNativeClipboard } from "../../net/desktop"; +import { useAppStore, type ClipboardState } from "../../store"; +import { t } from "../../i18n"; + +// 字节上限要与后端 CDROP_CLIPBOARD_MAX_BYTES 同步(默认 64 KB)。 +// 前端按 UTF-8 字节算长度(不是 string.length,后者是 UTF-16 code unit)。 +export const CLIPBOARD_MAX_BYTES = 64 * 1024; + +interface PutBody +{ + content: string; + content_type: "text/plain"; + // origin_ts 是本机复制时刻(ms),服务端据它做 LWW。浏览器拿不到精确复制时间, + // 用读取/上传瞬间的 Date.now() 近似——比服务端「收到时刻」更贴近真实复制时刻。 + origin_ts: number; +} + +// readClipboardText 主路径:readText() 拿浏览器为常规复制场景自动合成的 +// text/plain;只有当 readText 返回空且 read() 可用时,才 fallback 到读取 +// ClipboardItem,按 text/plain → text/html (DOMParser 提 textContent) 顺序 +// 取文本,避免"源应用只塞 text/html 时丢失文本"。 +export async function readClipboardText(): Promise +{ + if (!navigator.clipboard) + { + throw new Error(t("errors.clipboardUnavailable")); + } + + try + { + const text = await navigator.clipboard.readText(); + if (text) { return text; } + } + catch + { + // 主路径不可用(权限 / 不支持)→ 降级到 read() + } + + if (typeof navigator.clipboard.read !== "function") + { + return ""; + } + + const items = await navigator.clipboard.read(); + for (const item of items) + { + if (item.types.includes("text/plain")) + { + const blob = await item.getType("text/plain"); + const text = await blob.text(); + if (text) { return text; } + } + if (item.types.includes("text/html")) + { + const blob = await item.getType("text/html"); + const html = await blob.text(); + const text = htmlToPlainText(html); + if (text) { return text; } + } + } + return ""; +} + +// htmlToPlainText 用 DOMParser 解析 HTML(不执行脚本),按块级元素补换行后 +// 取 textContent。XSS 风险为零——我们丢弃所有标签,只保留文本节点内容。 +export function htmlToPlainText(html: string): string +{ + const doc = new DOMParser().parseFromString(html, "text/html"); + doc.querySelectorAll("br").forEach((br) => br.replaceWith("\n")); + doc + .querySelectorAll("p, div, li, h1, h2, h3, h4, h5, h6, tr, blockquote, pre") + .forEach((b) => b.append("\n")); + return ( doc.body.textContent ?? "" ).replace(/\n{3,}/g, "\n\n").trim(); +} + +export function utf8ByteLength(text: string): number +{ + return new TextEncoder().encode(text).length; +} + +// uploadClipboard 把一段纯文本作为剪贴板写入推到云端(PUT /api/clipboard)。 +// 字节超限 / 空字符串 / 网络错误统一抛 Error,调用点显示给用户。 +export async function uploadClipboard(content: string): Promise +{ + if (utf8ByteLength(content) > CLIPBOARD_MAX_BYTES) + { + throw new Error(t("errors.clipboardOverflow", { max: CLIPBOARD_MAX_BYTES })); + } + const body: PutBody = { content, content_type: "text/plain", origin_ts: Date.now() }; + const r = await apiFetch("/api/clipboard", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + // 409 = LWW 落败:别的设备有更晚的复制赢了。不是错误——胜出内容会经 SSE 到达, + // 这里静默返回即可,不打扰用户。 + if (r.status === 409) { return; } + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`PUT /api/clipboard ${r.status}: ${text}`); + } + + // 立即把自己的写入合入本地 store(用服务端回的权威 version / updated_at), + // 上传后 UI 立刻反映"云端最新";同步窗口结束后 SSE 回声内容相同、被自回声挡掉。 + const accepted = await r.json().catch(() => null) as { version?: number; updated_at?: number } | null; + const state = useAppStore.getState(); + state.setClipboard({ + content, + contentType: "text/plain", + sourceDevice: state.selfDeviceName ?? "", + updatedAt: accepted?.updated_at ?? Math.floor(Date.now() / 1000), + version: accepted?.version ?? 0, + }); +} + +// fetchClipboard 用 If-None-Match 拉一次最新;304 表示与本地内存一致,不动 store。 +// ETag 直接从 store.clipboard.updatedAt 派生(与"本地内存"等价),刷新后内存清零 +// → 不发 If-None-Match → 总是拉到最新内容。后端在尚未写入时返回 200 + 空 State +// + ETag "0",所以这里不需要再处理 404。 +export async function fetchClipboard(): Promise +{ + const headers = new Headers(); + const cur = useAppStore.getState().clipboard; + if (cur && cur.version > 0) + { + // ETag 是版本号(与后端 etagFor(version) 对齐);版本未变即 304、省下整份内容。 + headers.set("If-None-Match", `"${cur.version}"`); + } + + const r = await apiFetch("/api/clipboard", { headers }); + if (r.status === 304) { return; } + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`GET /api/clipboard ${r.status}: ${text}`); + } + const data = await r.json() as { + content: string; + content_type: string; + source_device: string; + updated_at: number; + version: number; + }; + applyRemoteClipboard({ + content: data.content, + contentType: data.content_type, + sourceDevice: data.source_device, + updatedAt: data.updated_at, + version: data.version, + }); +} + +// applyRemoteClipboard 由 SSE clipboard:update 事件 / fetchClipboard 调用。 +// 自回声(source_device == 本机)时不覆盖——本机 PUT 后已即时写过 store。 +export function applyRemoteClipboard(state: ClipboardState): void +{ + const selfDevice = useAppStore.getState().selfDeviceName; + const cur = useAppStore.getState().clipboard; + if (selfDevice && state.sourceDevice === selfDevice && cur?.updatedAt === state.updatedAt) + { + return; + } + useAppStore.getState().setClipboard(state); + + // 桌面端:把来自其他设备的更新写回本机原生剪贴板(下行同步)。自身来源的回声 + // 不写(Go 侧 Monitor 亦会再挡一层);浏览器里 writeNativeClipboard 为 no-op。 + if (!selfDevice || state.sourceDevice !== selfDevice) + { + writeNativeClipboard(state.content, state.sourceDevice); + } +} + diff --git a/web/src/features/desktop/DesktopSettings.tsx b/web/src/features/desktop/DesktopSettings.tsx new file mode 100644 index 0000000..9f1fd00 --- /dev/null +++ b/web/src/features/desktop/DesktopSettings.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { Group, Stack, Switch, Text } from "@mantine/core"; +import { Button, Panel } from "../../ui/primitives"; +import { toast } from "../../ui/feedback"; +import { t } from "../../i18n"; +import { + chooseDownloadDir, + effectiveDownloadDir, + loadDesktopSettings, + saveDesktopSettings, + type DesktopConfig, +} from "../../net/desktop"; + +// DesktopSettings 是桌面专属的设置区块,仅在 Wails 壳内渲染(调用点用 isDesktop() +// 门控)。复用 web 设计系统(Panel + Mantine Switch)。 +export function DesktopSettings() +{ + const [ cfg, setCfg ] = useState(null); + const [ saving, setSaving ] = useState(false); + // 当前实际落盘目录(配置覆盖或系统默认),仅用于显示;config 里 download_dir + // 为空时这里仍展示解析后的默认路径。 + const [ effectiveDir, setEffectiveDir ] = useState(""); + + useEffect(() => + { + let alive = true; + void loadDesktopSettings().then((c) => { if (alive && c) { setCfg(c); } }); + void effectiveDownloadDir().then((d) => { if (alive) { setEffectiveDir(d); } }); + return () => { alive = false; }; + }, []); + + const update = async (patch: Partial) => + { + if (!cfg) { return; } + const prev = cfg; + const next = { ...cfg, ...patch }; + setCfg(next); + setSaving(true); + try + { + await saveDesktopSettings(next); + } + catch (e) + { + setCfg(prev); // 回滚 UI 到保存前状态 + toast.error(e instanceof Error ? e.message : String(e)); + } + finally + { + setSaving(false); + } + }; + + const chooseDir = async () => + { + const picked = await chooseDownloadDir(t("settings.desktop.downloadDirPicker")); + if (!picked) { return; } // 用户取消 + await update({ download_dir: picked }); + setEffectiveDir(picked); + }; + + const resetDir = async () => + { + await update({ download_dir: "" }); // 空=回到系统默认目录 + setEffectiveDir(await effectiveDownloadDir()); + }; + + if (!cfg) { return null; } + + return ( + + + void update({ clipboard_sync_enabled: v })} + /> + void update({ launch_at_login: v })} + /> + + + {t("settings.desktop.downloadDir")} + {t("settings.desktop.downloadDirHint")} + + {effectiveDir} + + + + + {cfg.download_dir !== "" && ( + + )} + + +
+ + {t("settings.desktop.ttlNote")} + + + + ); +} + +function ToggleRow(props: { + title: string; + hint: string; + checked: boolean; + disabled: boolean; + onChange: (value: boolean) => void; +}) +{ + const { title, hint, checked, disabled, onChange } = props; + return ( + + + {title} + {hint} + + onChange(e.currentTarget.checked)} + /> + + ); +} diff --git a/web/src/features/devices/DeviceStrip.module.css b/web/src/features/devices/DeviceStrip.module.css new file mode 100644 index 0000000..6a8556b --- /dev/null +++ b/web/src/features/devices/DeviceStrip.module.css @@ -0,0 +1,175 @@ +.strip +{ + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: var(--space-3); + width: 100%; +} + +.port +{ + position: relative; + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface); + border: 1px solid var(--divider); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + cursor: pointer; + text-align: left; + font: inherit; + color: var(--text); + /* grid 单元兜底:长设备名 / 长 meta 不能撑破单元宽度;内层 body + 会用 min-width:0 + ellipsis 收敛。 */ + overflow: hidden; + min-width: 0; + transition: + background-color var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + box-shadow var(--dur-base) var(--ease-out), + transform var(--dur-fast) var(--ease-out); +} + +/* selected 标记图标()在 body 之后,锁宽防被挤压。 */ +.port > svg:last-child { flex-shrink: 0; } + +.port:hover:not(:disabled) +{ + background: var(--surface-raised); + box-shadow: var(--shadow-raised); + transform: translateY(-2px); +} + +.port:focus-visible +{ + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.portSelected +{ + background: var(--accent-softer); + border-color: var(--accent); + box-shadow: + 0 0 0 1px var(--accent), + var(--shadow-raised); +} + +.portOffline +{ + opacity: 0.55; + cursor: not-allowed; + box-shadow: none; + background: var(--surface-sunken); +} + +.portDragOver +{ + border-color: var(--accent); + background: var(--accent-soft); + transform: scale(1.015); +} + +.icon +{ + flex-shrink: 0; + color: var(--text-muted); + transition: color var(--dur-fast) var(--ease-out); +} + +.portSelected .icon { color: var(--accent); } + +.body +{ + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.name +{ + font-size: var(--fs-13); + font-weight: 600; + color: var(--text); + line-height: var(--lh-ui); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.meta +{ + display: flex; + align-items: center; + gap: 5px; + font-size: var(--fs-11); + color: var(--text-muted); + line-height: 1; + min-width: 0; +} +/* StateDot 锁宽;文本 span 走 ellipsis,避免"上次活跃 X 分钟前"这类长串溢出。 */ +.meta > :first-child { flex-shrink: 0; } +.meta > span +{ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.selfBadge +{ + margin-left: auto; + font-size: var(--fs-11); + color: var(--accent); + text-transform: uppercase; + letter-spacing: var(--ls-label); +} + +.empty +{ + padding: var(--space-5); + background: var(--surface-sunken); + border-radius: var(--radius-card); + color: var(--text-muted); + font-size: var(--fs-13); + text-align: center; +} + +.header +{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-2); +} + +.headerTitle +{ + font-size: var(--fs-13); + font-weight: 600; + letter-spacing: var(--ls-label); + text-transform: uppercase; + color: var(--text-muted); +} + +.headerCount { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; } + +.headerSwitch +{ + background: transparent; + border: 0; + color: var(--text-muted); + font: inherit; + font-size: var(--fs-12); + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-control); + transition: background-color var(--dur-fast) var(--ease-out); +} +.headerSwitch:hover { background: var(--accent-soft); color: var(--accent); } diff --git a/web/src/features/devices/DeviceStrip.tsx b/web/src/features/devices/DeviceStrip.tsx new file mode 100644 index 0000000..91e8dea --- /dev/null +++ b/web/src/features/devices/DeviceStrip.tsx @@ -0,0 +1,159 @@ +import { useMemo, useState, type DragEvent } from "react"; +import { Check } from "lucide-react"; +import { DeviceTypeIcon, StateDot } from "../../ui/glyphs"; +import type { DeviceKind } from "../../ui/glyphs"; +import type { DeviceInfo } from "../../store"; +import { formatRelative } from "../../utils/format"; +import { t } from "../../i18n"; +import s from "./DeviceStrip.module.css"; + +export interface DeviceStripProps +{ + devices: DeviceInfo[]; + selfDeviceName: string | null; + selectedDevice: string | null; + onSelect: (name: string | null) => void; + /** 拖拽文件落到设备卡上,跳过 composer 直接发送。 */ + onDropFile?: (deviceName: string, file: File) => void; +} + +export function DeviceStrip(props: DeviceStripProps) +{ + const { devices, selfDeviceName, selectedDevice, onSelect, onDropFile } = props; + const [ showOffline, setShowOffline ] = useState(false); + + const peers = useMemo( + () => devices.filter((d) => d.name !== selfDeviceName), + [ devices, selfDeviceName ], + ); + const visible = useMemo( + () => (showOffline ? peers : peers.filter((d) => d.online)), + [ peers, showOffline ], + ); + const onlineCount = peers.filter((d) => d.online).length; + const offlineCount = peers.length - onlineCount; + + return ( +
+
+
+ {t("home.deviceList.title")} + + {t("home.deviceList.onlineSuffix", { count: onlineCount })} + +
+ {offlineCount > 0 && ( + + )} +
+ + {visible.length === 0 ? ( +
{t("home.deviceList.empty")}
+ ) : ( +
+ {visible.map((d) => ( + onSelect(selectedDevice === d.name ? null : d.name)} + onDropFile={onDropFile} + /> + ))} +
+ )} +
+ ); +} + +interface DevicePortProps +{ + device: DeviceInfo; + selected: boolean; + onClick: () => void; + onDropFile?: (deviceName: string, file: File) => void; +} + +function DevicePort(props: DevicePortProps) +{ + const { device, selected, onClick, onDropFile } = props; + const [ dragOver, setDragOver ] = useState(false); + const disabled = !device.online; + + const handleDragOver = (e: DragEvent) => + { + if (disabled || !onDropFile) { return; } + if (!Array.from(e.dataTransfer.types).includes("Files")) { return; } + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDragOver(true); + }; + + const handleDragLeave = () => { setDragOver(false); }; + + const handleDrop = (e: DragEvent) => + { + if (disabled || !onDropFile) { return; } + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files[0]; + if (f) { onDropFile(device.name, f); } + }; + + const cls = [ + s.port, + selected ? s.portSelected : "", + disabled ? s.portOffline : "", + dragOver ? s.portDragOver : "", + ].filter(Boolean).join(" "); + + return ( + + ); +} + +// 后端目前所有 web 接入都写 type="browser";客户端时代会扩展为 macos/windows/... +function mapKind(type: string): DeviceKind +{ + const t = type.toLowerCase(); + if (t === "shortcut") { return "shortcut"; } + if (t.includes("mac") || t === "darwin") { return "macos"; } + if (t.includes("win")) { return "windows"; } + if (t.includes("linux")) { return "linux"; } + if (t.includes("ios") || t.includes("iphone") || t.includes("ipad")) { return "ios"; } + if (t.includes("android")) { return "android"; } + return "unknown"; +} diff --git a/web/src/features/messaging/MessageList.module.css b/web/src/features/messaging/MessageList.module.css new file mode 100644 index 0000000..4156106 --- /dev/null +++ b/web/src/features/messaging/MessageList.module.css @@ -0,0 +1,88 @@ +.section +{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.head +{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); +} + +.title +{ + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--ls-label); + text-transform: uppercase; + color: var(--text-muted); +} + +.count { color: var(--text-muted); font-size: var(--fs-12); margin-left: 4px; } + +.list +{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.row +{ + display: flex; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--surface); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + /* 描边:1px divider 全包,左侧 3px 角色色覆盖。 + 消息走 emphasis(pink)区分于 device / activity / clipboard; + 默认(outgoing,自发) = muted 左条,incoming = emphasis 左条 + 让"刚收到的消息"在视觉上更跳。 */ + border: 1px solid var(--divider); + border-left: 3px solid var(--text-muted); +} + +.rowIncoming { border-left-color: var(--emphasis); } + +.body +{ + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.meta +{ + font-family: var(--font-mono); + font-size: var(--fs-11); + color: var(--text-muted); +} + +.text +{ + font-size: var(--fs-13); + line-height: var(--lh-body); + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} + +.dismiss +{ + flex-shrink: 0; + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-control); + transition: color var(--dur-fast) var(--ease-out), background-color var(--dur-fast) var(--ease-out); +} +.dismiss:hover { background: var(--accent-soft); color: var(--accent); } diff --git a/web/src/features/messaging/MessageList.tsx b/web/src/features/messaging/MessageList.tsx new file mode 100644 index 0000000..c096b91 --- /dev/null +++ b/web/src/features/messaging/MessageList.tsx @@ -0,0 +1,65 @@ +import { X } from "lucide-react"; +import { Button } from "../../ui/primitives"; +import type { MessageRecord } from "../../store"; +import { t } from "../../i18n"; +import { formatRelative } from "../../utils/format"; +import s from "./MessageList.module.css"; + +export interface MessageListProps +{ + messages: MessageRecord[]; + onDismiss: (id: string) => void; + onClearAll: () => void; +} + +export function MessageList(props: MessageListProps) +{ + const { messages, onDismiss, onClearAll } = props; + if (messages.length === 0) { return null; } + + return ( +
+
+
+ {t("home.message.title")} + + {t("home.message.countSuffix", { count: messages.length })} + +
+ +
+ +
+ {messages.map((m) => ( + onDismiss(m.id)} /> + ))} +
+
+ ); +} + +function MessageRow(props: { m: MessageRecord; onDismiss: () => void }) +{ + const { m, onDismiss } = props; + const arrow = m.direction === "outgoing" ? "→" : "←"; + const cls = [ s.row, m.direction === "incoming" ? s.rowIncoming : "" ].join(" "); + + return ( +
+
+
{arrow} {m.peerName} · {formatRelative(m.sentAt)}
+
{m.text}
+
+ +
+ ); +} diff --git a/web/src/features/messaging/messaging.ts b/web/src/features/messaging/messaging.ts new file mode 100644 index 0000000..8d9f2f0 --- /dev/null +++ b/web/src/features/messaging/messaging.ts @@ -0,0 +1,36 @@ +import { apiFetch } from "../../net/api"; +import { useAppStore } from "../../store"; +import { t } from "../../i18n"; + +// One-shot text-only message between same-user devices. Reuses the SSE Hub +// the same way the file-transfer signaling channel does (POST /api/message). +// No DB persistence; the receiver-side store keeps it in memory only. +export async function sendMessage(receiverName: string, text: string): Promise +{ + const trimmed = text.trim(); + if (!trimmed) { throw new Error(t("errors.messageEmpty")); } + if (trimmed.length > 4096) + { + throw new Error(t("errors.messageOverflow")); + } + if (!receiverName) { throw new Error(t("errors.noReceiver")); } + + const r = await apiFetch("/api/message", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ to: receiverName, text: trimmed }), + }); + if (!r.ok) + { + const text = await r.text().catch(() => r.statusText); + throw new Error(`message ${r.status}: ${text}`); + } + + useAppStore.getState().addMessage({ + id: crypto.randomUUID(), + direction: "outgoing", + peerName: receiverName, + text: trimmed, + sentAt: Math.floor(Date.now() / 1000), + }); +} diff --git a/web/src/features/shortcut/ShortcutTokens.tsx b/web/src/features/shortcut/ShortcutTokens.tsx new file mode 100644 index 0000000..d731a97 --- /dev/null +++ b/web/src/features/shortcut/ShortcutTokens.tsx @@ -0,0 +1,336 @@ +import { useEffect, useState } from "react"; +import { Group, Stack, Text } from "@mantine/core"; +import { Copy, KeyRound, Plus } from "lucide-react"; +import { apiJSON } from "../../net/api"; +import { t } from "../../i18n"; +import { formatRelative } from "../../utils/format"; +import { Badge, Button, Callout, Panel, TextField } from "../../ui/primitives"; +import { toast } from "../../ui/feedback"; + +type ShortcutTokenView = { + jti: string; + label: string; + scopes: string[]; + created_at: number; + expires_at: number; + last_used_at: number | null; + revoked: boolean; +}; + +type IssueResp = { + token: string; + jti: string; + label: string; + scopes: string[]; + expires_at: number; +}; + +const listTokens = () => apiJSON<{ tokens: ShortcutTokenView[] }>("/api/shortcut"); + +const issueToken = (label: string) => + apiJSON("/api/shortcut/issue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }); + +const revokeToken = (jti: string) => + apiJSON(`/api/shortcut/${encodeURIComponent(jti)}`, { method: "DELETE" }); + +// baseURL 是本服务器的对外地址,快捷指令请求剪贴板端点时用它拼 URL。桌面注入了 +// loopback 代理地址,但快捷指令跑在 iPhone 上,应当用真正的对外 origin。 +function baseURL(): string +{ + return window.location.origin; +} + +async function copyText(value: string): Promise +{ + try + { + await navigator.clipboard.writeText(value); + toast.ok(t("settings.shortcut.copied")); + } + catch + { + toast.error(t("settings.shortcut.copyFailed")); + } +} + +// ShortcutTokens 管理 iOS 快捷指令的长效、仅剪贴板、可吊销令牌:列表 / 签发 +// (仅展示一次)/ 吊销 + 安装指引。挂在设置页,需登录会话(用令牌本身不能管理令牌)。 +export function ShortcutTokens() +{ + const [ tokens, setTokens ] = useState(null); + const [ loadError, setLoadError ] = useState(false); + const [ label, setLabel ] = useState(""); + const [ issuing, setIssuing ] = useState(false); + const [ revoking, setRevoking ] = useState>(new Set()); + const [ issued, setIssued ] = useState(null); + + const reload = async () => + { + try + { + const r = await listTokens(); + setTokens(r.tokens ?? []); + setLoadError(false); + } + catch + { + // 网络波动不该让整页报错;显示重试入口即可(容错要求)。 + setLoadError(true); + } + }; + + useEffect(() => + { + void reload(); + }, []); + + const handleIssue = async () => + { + setIssuing(true); + try + { + const res = await issueToken(label.trim()); + setIssued(res); // 内联展示一次性令牌 + 指引 + setLabel(""); + await reload(); + } + catch (e) + { + const msg = e instanceof Error ? e.message : String(e); + if (msg.startsWith("503")) { toast.error(t("settings.shortcut.unavailable")); } + else if (msg.startsWith("409")) { toast.error(t("settings.shortcut.tooMany")); } + else { toast.error(t("settings.shortcut.issueError", { message: msg })); } + } + finally + { + setIssuing(false); + } + }; + + const handleRevoke = async (token: ShortcutTokenView) => + { + if (!window.confirm(t("settings.shortcut.revokeConfirm"))) { return; } + + setRevoking((prev) => new Set(prev).add(token.jti)); + try + { + await revokeToken(token.jti); + toast.ok(t("settings.shortcut.revokedToast")); + await reload(); + } + catch (e) + { + toast.error(t("settings.shortcut.issueError", { + message: e instanceof Error ? e.message : String(e), + })); + } + finally + { + setRevoking((prev) => + { + const next = new Set(prev); + next.delete(token.jti); + return next; + }); + } + }; + + return ( + + + {t("settings.shortcut.intro")} + + +
+ setLabel(e.currentTarget.value)} + onKeyDown={(e) => + { + if (e.key === "Enter" && !issuing) + { + e.preventDefault(); + void handleIssue(); + } + }} + /> +
+ +
+ + {issued && ( + setIssued(null)} + /> + )} + + {loadError && ( + + {t("settings.shortcut.loadError")} + + + )} + + {tokens && tokens.length === 0 && !loadError && ( + {t("settings.shortcut.empty")} + )} + + {tokens && tokens.length > 0 && ( + + {tokens.map((tok) => ( + handleRevoke(tok)} + /> + ))} + + )} +
+
+ ); +} + +function TokenRow(props: { token: ShortcutTokenView; revoking: boolean; onRevoke: () => void }) +{ + const { token, revoking, onRevoke } = props; + const expired = token.expires_at * 1000 < Date.now(); + const lastUsed = token.last_used_at + ? t("settings.shortcut.lastUsed", { time: formatRelative(token.last_used_at) }) + : t("settings.shortcut.neverUsed"); + + return ( + + + + + {token.label} + {token.revoked && {t("settings.shortcut.revoked")}} + {!token.revoked && expired && ( + {t("settings.shortcut.expired")} + )} + + + {t("settings.shortcut.expiresAt", { + date: new Date(token.expires_at * 1000).toLocaleDateString(), + })} + {" · "} + {lastUsed} + + + {!token.revoked && ( + + )} + + + ); +} + +// IssuedReveal 内联展示刚签发的令牌(仅此一次)+ 服务器地址 + 手动搭建指引。 +// 沿用设置页一贯的「下沉面板 + Callout + Button」语汇,不引入弹窗。 +function IssuedReveal(props: { issued: IssueResp; base: string; onDismiss: () => void }) +{ + const { issued, base, onDismiss } = props; + return ( + + + } + title={t("settings.shortcut.issuedTitle")} + > + {t("settings.shortcut.onceWarning")} + + + + + + + {t("settings.shortcut.guideTitle")} + {t("settings.shortcut.guideIntro")} + {t("settings.shortcut.guideDeviceName")} + + {t("settings.shortcut.guidePullTitle")} + + 1. {t("settings.shortcut.guidePull1")} + 2. {t("settings.shortcut.guidePull2")} + 3. {t("settings.shortcut.guidePull3")} + + + {t("settings.shortcut.guidePushTitle")} + + 1. {t("settings.shortcut.guidePush1")} + 2. {t("settings.shortcut.guidePush2")} + 3. {t("settings.shortcut.guidePush3")} + + + {t("settings.shortcut.guideToleranceNote")} + + + + + + + + ); +} + +// Field 是一行「标签 + 单行只读值 + 复制按钮」,值用等宽字体、可断行。 +function Field(props: { label: string; value: string; mono?: boolean }) +{ + const { label, value, mono } = props; + return ( + + {label} + + + {value} + + + + + ); +} diff --git a/web/src/features/transfer/ActivityFeed.module.css b/web/src/features/transfer/ActivityFeed.module.css new file mode 100644 index 0000000..9a48f9b --- /dev/null +++ b/web/src/features/transfer/ActivityFeed.module.css @@ -0,0 +1,30 @@ +.section +{ + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.subhead +{ + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--ls-label); + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: var(--space-2); +} + +.list +{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.empty +{ + color: var(--text-muted); + font-size: var(--fs-12); + padding: var(--space-3) 0; +} diff --git a/web/src/features/transfer/ActivityFeed.tsx b/web/src/features/transfer/ActivityFeed.tsx new file mode 100644 index 0000000..6b79f16 --- /dev/null +++ b/web/src/features/transfer/ActivityFeed.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import type { TransferRecord } from "../../store"; +import { TransferRow } from "./TransferRow"; +import { t } from "../../i18n"; +import s from "./ActivityFeed.module.css"; + +export interface ActivityFeedProps +{ + active: TransferRecord[]; + history: TransferRecord[]; +} + +// 1Hz now tick:仅在有 active 时启动,避免空转。 +function useNowTick(activeKey: string) +{ + const [ now, setNow ] = useState(() => Date.now()); + useEffect(() => + { + if (!activeKey) { return; } + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [ activeKey ]); + return now; +} + +export function ActivityFeed(props: ActivityFeedProps) +{ + const { active, history } = props; + const activeKey = active.map((t) => t.sessionId).sort().join("|"); + const now = useNowTick(activeKey); + + if (active.length === 0 && history.length === 0) { return null; } + + return ( +
+ {active.length > 0 && ( +
+
{t("home.transfer.active")}
+
+ {active.map((tr) => ( + + ))} +
+
+ )} + {history.length > 0 && ( +
+
{t("home.transfer.history")}
+
+ {history.map((tr) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/web/src/features/transfer/Composer.module.css b/web/src/features/transfer/Composer.module.css new file mode 100644 index 0000000..570bc6b --- /dev/null +++ b/web/src/features/transfer/Composer.module.css @@ -0,0 +1,89 @@ +.root +{ + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.targetLine +{ + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--fs-12); + color: var(--text-muted); + line-height: var(--lh-ui); +} + +.targetName { color: var(--accent); font-weight: 500; } +.targetMuted { color: var(--text-muted); } + +.dropzone +{ + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 8px; + min-height: 140px; + padding: var(--space-4); + background: var(--surface-sunken); + border: 1.5px dashed var(--divider); + border-radius: var(--radius-card); + color: var(--text-muted); + text-align: center; + font-size: var(--fs-13); + cursor: pointer; + transition: + background-color var(--dur-fast) var(--ease-out), + border-color var(--dur-fast) var(--ease-out), + color var(--dur-fast) var(--ease-out); +} + +.dropzone:hover +{ + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +.dropzoneActive +{ + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} + +.dropzoneHasFile +{ + border-style: solid; + border-color: var(--divider); + background: var(--surface); + color: var(--text); +} + +.fileLine +{ + display: flex; + align-items: center; + gap: 8px; + word-break: break-all; +} + +.fileMeta { color: var(--text-muted); font-size: var(--fs-12); } + +.removeBtn +{ + margin-top: 4px; + background: transparent; + border: 0; + color: var(--text-muted); + font: inherit; + font-size: var(--fs-11); + text-decoration: underline; + cursor: pointer; +} +.removeBtn:hover { color: var(--state-error); } + +.hint { color: var(--text-muted); font-size: var(--fs-11); } diff --git a/web/src/features/transfer/Composer.tsx b/web/src/features/transfer/Composer.tsx new file mode 100644 index 0000000..dbc5f71 --- /dev/null +++ b/web/src/features/transfer/Composer.tsx @@ -0,0 +1,232 @@ +import { useRef, useState, type DragEvent } from "react"; +import { FileText, MessageSquare, Send, Upload } from "lucide-react"; +import { Button, Panel, Tabs, TextArea } from "../../ui/primitives"; +import { toast } from "../../ui/feedback"; +import { t } from "../../i18n"; +import { formatBytes } from "../../utils/format"; +import s from "./Composer.module.css"; + +export type ComposerMode = "file" | "message"; + +export interface ComposerProps +{ + targetDevice: string | null; + selectedFile: File | null; + onFileChange: (f: File | null) => void; + onSendFile: () => Promise | void; + onSendMessage: (text: string) => Promise | void; + /** 默认 'file'。 */ + initialMode?: ComposerMode; +} + +export function Composer(props: ComposerProps) +{ + const { + targetDevice, selectedFile, + onFileChange, onSendFile, onSendMessage, + initialMode = "file", + } = props; + + const [ mode, setMode ] = useState(initialMode); + const [ msgText, setMsgText ] = useState(""); + const fileInput = useRef(null); + + // 目标缺失时用 muted 提示而非 error 红 —— 提示职责与发送按钮的 disabled + // 状态重合时不应双倍渲染告警。允许预输入文件/消息,等用户选完设备再点发送。 + const targetLabel = targetDevice + ? <>发送到 {targetDevice} + : {t("home.cta.selectDevice")}; + + const canSendFile = !!targetDevice && !!selectedFile; + const canSendMsg = !!targetDevice && msgText.trim().length > 0; + + const handleSubmitFile = async () => + { + if (!canSendFile) { return; } + await onSendFile(); + }; + + const handleSubmitMsg = async () => + { + if (!targetDevice) { toast.warn(t("home.cta.selectDevice")); return; } + if (!msgText.trim()) { return; } + try + { + await onSendMessage(msgText); + setMsgText(""); + } + catch (e) + { + toast.error("消息发送失败", e instanceof Error ? e.message : String(e)); + } + }; + + return ( + + items={[ + { value: "file", label: {t("home.tabs.file")} }, + { value: "message", label: {t("home.tabs.message")} }, + ]} + value={mode} + onChange={setMode} + aria-label="compose mode" + /> + } + > +
+ {/* targetLabel 必须包在单个 span 里,防聚珍(Juzhen)shim 拆段后 + flex gap 在 CJK ↔ Latin 文本中间叠加 —— + 同 Button.tsx 的 label span。 */} +
{targetLabel}
+ + {mode === "file" ? ( + + ) : ( +