package httpapi import ( "context" "log/slog" "net/http" "github.com/go-chi/chi/v5" "commilitia.net/cdrop/internal/brokerclient" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" "commilitia.net/cdrop/internal/push" ) // 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). // device_id -> cached type + live name. The name overlay is what keeps the session list // consistent after a decoupled rename: the broker Label is only set at mint time and does // not follow a PATCH /api/devices rename, so we prefer the devices-table name (the same // source presence uses) and fall back to the broker Label only for a row-less session. typeByID := map[string]string{} nameByID := map[string]string{} if devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID); err == nil { for _, d := range devs { typeByID[d.DeviceID] = d.Type nameByID[d.DeviceID] = d.Name } } // 按 meta 归并:一台设备(meta)只呈现一条。子会话(sub!="",如控件剪贴板会话)与主会话共享 meta // ——优先用主会话作代表;但若某设备只剩子会话存活(主会话已过期、控件会话仍独立刷新着),仍按该 // meta 呈现一台,故不能简单丢弃 sub!="" 行(否则“仅控件存活”的设备会从列表消失,见 auth/docs/ // 子会话方案.md §四)。meta-less 会话是非 cdrop 托管的机器会话(桌面 device-authorize bootstrap, // 随即被代铸替换),无 device_id、不是托管设备,跳过。 rep := make(map[string]brokerclient.SessionInfo, len(sessions)) order := make([]string, 0, len(sessions)) for _, sess := range sessions { if sess.Meta == "" { continue } if cur, ok := rep[sess.Meta]; !ok { rep[sess.Meta] = sess order = append(order, sess.Meta) } else if cur.Sub != "" && sess.Sub == "" { rep[sess.Meta] = sess // 主会话优先覆盖先到的子会话,作该设备的代表 } } out := make([]sessionView, 0, len(order)) for _, meta := range order { sess := rep[meta] typ := typeByID[meta] if typ == "" { typ = "browser" } name := nameByID[meta] if name == "" { name = sess.Label } scope := "full" if jwtauth.ScopeTier(sess.Scope) == "guest" { scope = "guest" } out = append(out, sessionView{ ID: meta, DeviceID: meta, DeviceName: name, Kind: typ, Scope: scope, Current: meta == claims.DeviceID, Online: s.hub.Online(claims.UserID, meta), 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, true) 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, notifyRevoked bool) (int, bool) { // Resolve the device name (for the live SSE kick + the cross-device revoke push). Cache-first // (the local row holds the live name, which a decoupled rename keeps current); fall back to the // broker R1 label for a row-less session. name := "" localRowOwned := false if dev, err := s.queries.GetDevice(r.Context(), deviceID); err == nil && dev.UserID == userID { name = dev.Name localRowOwned = true } if name == "" { if sessions, err := s.broker.ListSessions(r.Context(), userID); err == nil { for _, sess := range sessions { if sess.Meta == deviceID { name = sess.Label break } } } } // Cascade-revoke the whole device by meta: the main session AND any subordinate (clipboard // widget) sessions sharing this device_id, so no orphan sub-session survives to keep reading // the clipboard. Idempotent — revoked:0 when the device's sessions are already gone. revoked, err := s.broker.RevokeDeviceSessions(r.Context(), userID, deviceID) if err != nil { slog.Error("broker cascade revoke failed", "err", err, "user", userID, "meta", deviceID) return http.StatusBadGateway, false } // Nothing revoked and we own no local row → a device_id we have nothing for is a genuine 404. // (A phantom row with no broker session still owns a local row, so it falls through to cleanup // below — the user can always clear such a stale entry from their list.) if revoked == 0 && !localRowOwned { return http.StatusNotFound, false } // 跨设备登出(非自登出):推送告知被踢设备,使其立即知晓并清本地登录态——即便其页面 / app 已关闭, // 也不必等下次请求 401 才发现。用一次性 context(请求 ctx 会随响应取消),best-effort 异步发。 if notifyRevoked && name != "" { n := push.Notification{Type: push.KindSessionRevoked, Tag: "session:revoked"} if s.push.Enabled() { go s.push.Notify(context.Background(), userID, name, n) } if s.apns.Enabled() { go s.apns.Notify(context.Background(), userID, name, n) } } // Drop the local cache row (idempotent; non-fatal — the sweeper / next list-prune also clean it). if _, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ DeviceID: deviceID, UserID: userID, }); err != nil { slog.Warn("delete device cache failed", "err", err, "user", userID, "device", deviceID) } // Kick by the stable device_id (the hub key); name rides along for the code-less fallback path // inside Kick, so a renamed device is still kicked reliably (the prior "移除失败" symptom). s.hub.Kick(userID, deviceID, 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, false); !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" } }