package httpapi import ( "log/slog" "net/http" "github.com/go-chi/chi/v5" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) // Session management. After the unified-session-model rework, every logged-in client — // browser, desktop, QR-paired device — is one delegated device session in the broker. The // broker (R1: GET /internal/sessions) is the single authoritative device list; cdrop no // longer keeps a parallel authoritative session table. The local `devices` rows survive only // as a type/presence cache: they supply each device's type for the list overlay and back the // real-time presence view (which is cdrop's domain, keyed by device name). Listing reads R1 // and overlays type/online/current; revoking calls the broker (the session's source of truth) // then drops the local cache row. // requireFullSession rejects restricted guest sessions (scan-login borrow): they can // transfer files but not manage devices, approve other devices, or revoke sessions. // A full / dev session passes (AUTH Broker scope tier — Claims.Guest reads X-Auth-Scope). func requireFullSession(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.StatusUnauthorized, map[string]string{"error": "no session"}) return } if claims.Guest() { writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"}) return } next.ServeHTTP(w, r) }) } type sessionView struct { ID string `json:"id"` // device_id (the revoke handle exposed to the client) DeviceID string `json:"device_id"` DeviceName string `json:"device_name"` Kind string `json:"kind"` // device type: browser | macos | windows | linux | ios Scope string `json:"scope"` // full | guest Current bool `json:"current"` Online bool `json:"online"` CreatedAt int64 `json:"created_at"` LastUsedAt int64 `json:"last_used_at"` } // handleSessionsList returns the caller's logged-in devices with their permission level // (完整 / 受限访客) and live online flag, so the UI can show each and offer logout. The // authoritative list is the broker's delegated device sessions (R1); cdrop overlays the // device type (local cache), the online dot (hub presence, keyed by device name), and the // "current" flag (the session whose meta is this request's X-Auth-Meta). func (s *Server) handleSessionsList(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) sessions, err := s.broker.ListSessions(r.Context(), claims.UserID) if err != nil { slog.Error("list sessions failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusBadGateway, map[string]string{"error": "broker"}) return } // device_id -> cached type, for the type overlay (a missing cache falls back to // "browser"). The cache is bounded by the background device sweeper (last_seen TTL); a // logged-out device's row is reaped there, not on this read path — this GET stays // side-effect-free, and the session list itself is always R1-authoritative regardless of // any stale cache row (the row only ever supplies a type for a device that is in R1). typeByID := map[string]string{} if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil { for _, d := range devs { typeByID[d.DeviceID] = d.Type } } out := make([]sessionView, 0, len(sessions)) for _, sess := range sessions { // A meta-less session is a non-cdrop-managed machine session — a desktop // device-authorize bootstrap that the desktop replaces via 代铸 right away. It has // no device_id, so it isn't a managed device and must not show as a phantom row. if sess.Meta == "" { continue } typ := typeByID[sess.Meta] if typ == "" { typ = "browser" } scope := "full" if jwtauth.ScopeTier(sess.Scope) == "guest" { scope = "guest" } out = append(out, sessionView{ ID: sess.Meta, DeviceID: sess.Meta, DeviceName: sess.Label, Kind: typ, Scope: scope, Current: sess.Meta == claims.DeviceID, Online: s.hub.Online(claims.UserID, sess.Label), CreatedAt: sess.CreatedAt, LastUsedAt: sess.LastUsedAt, }) } writeJSON(w, http.StatusOK, map[string]any{"sessions": out}) } // handleSessionRevoke logs out a device by id (= device_id): it resolves the device's broker // session, revokes it, and drops the local cache row. Full session required (route-gated). func (s *Server) handleSessionRevoke(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 } status, ok := s.revokeDevice(r, claims.UserID, id) if !ok { writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)}) return } w.WriteHeader(http.StatusNoContent) } // revokeDevice tears down a device's broker session and local cache row, then kicks its live // SSE and re-broadcasts presence. Returns the HTTP status to report and whether it succeeded; // the caller writes the response. Shared by device deletion, session revocation, and logout // (they are the same operation). The broker session id is resolved cache-first (the local row // holds the stable broker_sid) and falls back to R1 — the authoritative list — so a missing or // pruned cache row still revokes correctly and stays authorized to this user. func (s *Server) revokeDevice(r *http.Request, userID, deviceID string) (int, bool) { sid, name := "", "" if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID { sid = dev.BrokerSid name = dev.Name } if sid == "" { sessions, err := s.broker.ListSessions(r.Context(), userID) if err != nil { slog.Error("revoke: list sessions failed", "err", err, "user", userID) return http.StatusBadGateway, false } for _, sess := range sessions { if sess.Meta == deviceID { sid = sess.SID name = sess.Label break } } } if sid == "" { // Not this user's device, or already gone from both the cache and the broker. return http.StatusNotFound, false } // Revoke the broker session first so the device can't refresh; a 404 (already gone) is // idempotent success inside RevokeSession. if err := s.broker.RevokeSession(r.Context(), sid); err != nil { slog.Error("broker revoke failed", "err", err, "user", userID, "sid", sid) return http.StatusBadGateway, false } if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ DeviceID: deviceID, UserID: userID, }); err != nil { // The session is already revoked; a failed cache delete is non-fatal (the sweeper // and the next list-prune clean it). Report success so the client sees the logout. slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID) } if name != "" { s.hub.Kick(userID, name) } s.hub.PublishPresence(r.Context(), userID) return http.StatusNoContent, true } // handleLogout logs out the calling device by revoking its own broker session (so it can no // longer refresh) and dropping its cache row. The client also discards its tokens. A caller // with no managed device (no X-Auth-Meta) just succeeds — there is nothing server-side to // revoke. Origin-checked for CSRF. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) if claims.DeviceID != "" { if status, ok := s.revokeDevice(r, claims.UserID, claims.DeviceID); !ok && status != http.StatusNotFound { slog.Warn("logout revoke failed", "status", status, "user", claims.UserID) } } w.WriteHeader(http.StatusNoContent) } func revokeErrorMsg(status int) string { switch status { case http.StatusNotFound: return "device not found" case http.StatusBadGateway: return "revoke failed" default: return "db" } }