package httpapi import ( "context" "database/sql" "encoding/json" "log/slog" "net/http" "sync" "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/push" "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 push *push.Sender // inert when VAPID keys unset → push endpoints 503 mux *chi.Mux // sessionKey encrypts browser refresh_tokens at rest (web_sessions); nil when // CDROP_SESSION_SECRET is unset (dev), which disables passwordless re-login. // siteOrigin is the deployment's scheme://host, used for the CSRF Origin check. sessionKey []byte siteOrigin string // sessionTokenKey signs cdrop's self-signed session access tokens (scan-login; // AUTH.md §3.1), derived from SessionSecret in a separate domain from sessionKey. // nil when SessionSecret is unset → minting is unavailable (QR stays off). sessionTokenKey []byte // refreshLocks serialise concurrent refreshes of the same web session (all of // a user's browser tabs share one cookie → one refresh_token). Striped so the // lock set stays bounded; collisions just serialise unrelated sessions, which // is harmless. Prevents a multi-tab race from spending a one-time-use // refresh_token twice and logging the user out everywhere. refreshLocks [refreshLockStripes]sync.Mutex } 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, pushSender *push.Sender, ) *Server { s := &Server{ cfg: cfg, db: dbConn, queries: queries, auth: auth, hub: h, transfers: transfers, relay: rm, calls: cp, clipboard: clip, push: pushSender, mux: chi.NewRouter(), sessionKey: deriveSessionKey(cfg.SessionSecret), siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI), sessionTokenKey: jwtauth.DeriveSessionTokenKey(cfg.SessionSecret), } 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) // Cookie-authenticated (no bearer): the HttpOnly session cookie is the // only credential. logout drops the server session; device persists // this browser's name into the session for PWA-eviction recovery. r.Post("/auth/logout", s.handleAuthLogout) r.Post("/auth/device", s.handleAuthDevice) // Scan-login: the new device opens a request and long-polls status. // Public (no bearer — the device isn't logged in yet); the private // poll_secret in the X-Poll-Secret header is the only credential. r.Post("/auth/qr/start", s.handleQRStart) r.Get("/auth/qr/status", s.handleQRStatus) }) // 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.Get("/push/vapid-key", s.handlePushVAPIDKey) r.Post("/push/subscribe", s.handlePushSubscribe) r.Delete("/push/subscribe", s.handlePushUnsubscribe) r.Get("/calls/credentials", s.handleCallsCredentials) // Account-management surface: restricted guest (scan-login borrow) // sessions are rejected here too — they can transfer files but not // remove devices, nor mint / list / revoke long-lived shortcut // tokens. Full / OIDC / dev sessions pass (AUTH.md §3.2). r.Group(func(r chi.Router) { r.Use(requireFullSession) r.Delete("/devices/{name}", s.handleDeleteDevice) r.Post("/shortcut/issue", s.handleShortcutIssue) r.Get("/shortcut", s.handleShortcutList) r.Delete("/shortcut/{jti}", s.handleShortcutRevoke) // Scan-login approval side: the logged-in approver views and // authorises the new device. Guest sessions can't reach here, so a // borrowed device can't approve further devices. r.Get("/auth/qr/request", s.handleQRRequest) r.Post("/auth/qr/approve", s.handleQRApprove) r.Post("/auth/qr/deny", s.handleQRDeny) // Session management: list logins with their permission level, // really revoke one (logout), and the standalone step-up the // revoke flow re-uses. All under /api/auth so the session cookie // (Path=/api/auth) is available for step-up state. r.Post("/auth/stepup", s.handleStepUp) r.Get("/auth/sessions", s.handleSessionsList) r.Delete("/auth/sessions/{id}", s.handleSessionRevoke) }) 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"` // Scope is the session's capability level: "guest" (scan-login borrow, // capability-limited) or "full" (normal login). The UI hides account-management // affordances on guest sessions (AUTH.md §3.2). Scope string `json:"scope"` } 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()) scope := "full" if claims.Guest() { scope = "guest" } writeJSON(w, http.StatusOK, meResp{ UserID: claims.UserID, Groups: claims.Groups, DeviceName: device, Scope: scope, }) } // 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) }