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 }