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/brokerclient" "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 // broker delegates session lifecycle to the Auth Broker (mint / revoke / refresh). broker *brokerclient.Client // siteOrigin is the deployment's scheme://host, used for the CSRF Origin check // and the scan-login QR link. siteOrigin string } 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(), broker: brokerclient.New(cfg.BrokerBaseURL, cfg.BrokerInternalKey, cfg.BrokerAppOrDefault()), siteOrigin: deriveSiteOrigin(cfg.PublicURL), } 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 navigation: 302 to the broker's global-SSO login (bootstraps login). r.Get("/auth/login", s.handleAuthLogin) // Public: broker coordinates for native clients' device-authorization flow. r.Get("/auth/config", s.handleAuthConfig) // Public, rate-limited (per-IP; RealIP is mounted above). No bearer: the new // device isn't logged in yet (its poll_secret is the only credential), and // refresh runs precisely when the access token is expired. 60/min is ample // for real use behind a shared NAT while choking a scripted flood. r.Group(func(r chi.Router) { r.Use(httprate.LimitByIP(60, time.Minute)) // Token refresh — proxied to the Auth Broker, so the SPA stays same-origin // and cdrop stores no credential (it just relays the rotated pair). r.Post("/auth/refresh", s.handleAuthRefresh) // Scan-login: the new device opens a request and long-polls status. 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) }) // Authenticated routes. Every request past here carries the broker's // edge-injected X-Auth-* identity (prod) or a dev token. Guests and full // sessions both reach this tier. gzip / compress is intentionally NOT mounted — // it would buffer the SSE stream. r.Group(func(r chi.Router) { r.Use(s.auth.Middleware) // Clipboard, transfer, messaging, presence, push — guests included. r.Get("/clipboard", s.handleClipboardGet) r.Put("/clipboard", s.handleClipboardPut) // Lightweight version probe — no content; lets pollers detect change // cheaply before fetching the full body. r.Get("/clipboard/version", s.handleClipboardVersion) r.Get("/me", s.handleMe) r.Post("/me/disconnect", s.handleDisconnect) // 代铸: turn an edge-verified login (SSO cookie or device-authorize bootstrap) // into a cdrop-managed device session bound to a stable device_id, so browser // and native clients share one device list. Authenticated but not full-only — // it mints at the caller's own tier. Rate-limited per IP: with R2 idempotency a // normal client mints about once per login, so a generous cap simply bounds an // authenticated identity that loops fresh device_ids to mint unbounded sessions. r.Group(func(r chi.Router) { r.Use(httprate.LimitByIP(20, time.Minute)) r.Post("/auth/device-session", s.handleDeviceSession) }) // Logout revokes the calling device's own broker session (self-service). r.Post("/auth/logout", s.handleLogout) 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) // Full sessions only — restricted guests (scan-login borrows) are // rejected: they transfer files but can't manage devices, approve other // devices, or revoke sessions. r.Group(func(r chi.Router) { r.Use(requireFullSession) r.Delete("/devices/{device_id}", s.handleDeleteDevice) r.Patch("/devices/{device_id}", s.handleRenameDevice) // Scan-login approval side: the logged-in approver views and authorises // the new device. Guests 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 logged-in devices with their permission // level and revoke one (logout that device). 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"` // Name is the display name from the broker (X-Auth-Name); falls back to user_id. Name string `json:"name"` // Avatar is the broker account's picture URL (X-Auth-Avatar); empty when none. Avatar string `json:"avatar,omitempty"` 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. 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" } name := claims.Name if name == "" { name = claims.UserID } writeJSON(w, http.StatusOK, meResp{ UserID: claims.UserID, Name: name, Avatar: claims.Avatar, 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) }