Files
Commilitia-Drop/internal/config/config.go
T
admin 92a03aeafd 推送通知:Web Push(网页 / PWA)+ 桌面原生通知
页面 / PWA 关闭、或桌面窗口非前台时,收到文件、消息、传输完成 / 失败以系统通知提醒。
判定统一为「页面打开走应用内提示,否则系统通知」,三端各自落地;网页端的判定直接复用
Hub.SendTo 的投递结果,零额外状态。

- Web Push(VAPID,账号无关):服务端 internal/push.Sender 在事件未经 SSE 投递到目标
  设备时(即该设备页面已关)发推送,文案按订阅 locale 在服务端渲染(中简 / 中繁 / 英)、
  404 / 410 自动清理死订阅;新增 push_subscriptions 表(含 locale,主键为 endpoint 的
  SHA-256 以幂等 upsert)+ /api/push/{vapid-key, subscribe POST/DELETE} 端点
- 事件挂接:transfer:incoming / message(离线返 202,文本随推送送达)/ transfer:state
  DONE|FAILED,均未投递才推
- 配置 CDROP_VAPID_PUBLIC_KEY / CDROP_VAPID_PRIVATE_KEY(半对即拒启动,都空则推送惰性
  关闭)+ just vapid-keygen 生成工具(cmd/vapidgen)
- 前端:service worker 加 push / notificationclick(仍不拦截 /api 与导航);net/push.ts
  订阅管理 + push store slice + 设置页「推送通知」面板(仅浏览器 / PWA);登录后 resyncPush
  把既有订阅重新登记到当前用户,换用户 / endpoint 轮换 / 切换语言皆自愈
- 桌面原生通知:platform/notify_{darwin,windows,other}.go + notify_darwin.m,仅窗口非
  前台时弹原生通知(macOS UNUserNotificationCenter,未签名时安全空操作、待签名后显示;
  Windows go-toast 即用);app.go ShowNotification 绑定,前端 notifyNativeIfBackground
  复用 hub 事件分发 + 客户端本地化
- 文档:README / .env.example / compose 模板补 CDROP_VAPID_* 配置

注:sqlc v1.31.1 对 query 文件里的多字节字符会错位偏移、生成损坏 SQL,故 query 注释保持纯
ASCII(文件内留有警示)。
2026-06-18 16:56:47 +08:00

187 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"errors"
"fmt"
"net/url"
"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"`
// SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at
// rest in web_sessions (the "passwordless re-login" feature). Any-length
// high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only
// here (container env), never in the DB file, so an exfiltrated SQLite file
// can't be decrypted. Required in prod — refuse to boot without it.
SessionSecret string `koanf:"session_secret"`
// Shortcut tokensiOS 快捷指令用的长效 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"`
// Web Push (RFC 8291/8292):浏览器 / PWA 在页面关闭时仍能收到系统通知。
// 公私钥是自签的 VAPID 密钥对(与 Apple/Google 账号无关),用 `just vapid-keygen`
// 生成一次后固定——更换会使所有既有订阅失效。两者皆设才启用推送,缺一即整体
// 关闭(订阅端点返回 503,事件回退为「仅在线设备收 SSE」)。Subject 是 VAPID
// 要求的联系标识(mailto: 或站点 URL);留空时回退为部署站点 URL。
VAPIDPublicKey string `koanf:"vapid_public_key"`
VAPIDPrivateKey string `koanf:"vapid_private_key"`
VAPIDSubject string `koanf:"vapid_subject"`
}
// 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")
}
// SessionSecret keys at-rest encryption of browser refresh_tokens. Empty
// would either disable passwordless re-login or, worse, tempt a plaintext
// fallback; require it so the encryption path is always live in prod.
if c.SessionSecret == "" {
return errors.New(
"CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " +
"(keys web-session refresh_token encryption); refusing to start")
}
default:
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
}
// Web Push is optional, but a half-configured pair is always an operator
// mistake: with only one key set, every push send would fail at runtime
// while the feature looks "on". Fail fast instead of silently degrading.
if (c.VAPIDPublicKey == "") != (c.VAPIDPrivateKey == "") {
return errors.New(
"CDROP_VAPID_PUBLIC_KEY and CDROP_VAPID_PRIVATE_KEY must be set together " +
"(run `just vapid-keygen`); set neither to disable Web Push")
}
return nil
}
// PushEnabled reports whether Web Push is configured (both VAPID keys present).
func (c *Config) PushEnabled() bool {
return c.VAPIDPublicKey != "" && c.VAPIDPrivateKey != ""
}
// VAPIDSubjectOrDefault returns the VAPID contact identity. Push services want a
// way to reach the operator; the configured value wins, else the deployment's
// own origin (derived from the OIDC redirect URI), else the product URL.
func (c *Config) VAPIDSubjectOrDefault() string {
if c.VAPIDSubject != "" {
return c.VAPIDSubject
}
if u, err := url.Parse(c.OIDCRedirectURI); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return "https://drop.commilitia.net"
}