package httpapi import ( "log/slog" "net/http" "github.com/go-chi/chi/v5" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) type deviceItem struct { Name string `json:"name"` Type string `json:"type"` Online bool `json:"online"` LastSeen int64 `json:"last_seen"` } // handleDevices returns the calling user's known devices with live online flags. // Online = currently connected to /api/events; LastSeen = epoch from devices.last_seen. func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) devs, err := s.queries.ListDevicesByUser(r.Context(), claims.UserID) if err != nil { slog.Error("list devices failed", "err", err, "user", claims.UserID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } out := make([]deviceItem, 0, len(devs)) for _, d := range devs { out = append(out, deviceItem{ Name: d.Name, Type: d.Type, Online: s.hub.Online(claims.UserID, d.Name), LastSeen: d.LastSeen, }) } writeJSON(w, http.StatusOK, map[string]any{"devices": out}) } // handleDeleteDevice 注销设备:删除 (user, name) 行 + Kick 在线 SSE + 立刻广播 // 新的 presence。请求方若注销的是自己当前设备,前端需同步调用 logout,否则 // 任何后续带 X-Device-Name 的认证请求都会被中间件再次 UPSERT 回来。 // // 这是登录会话列表里「原生客户端(桌面 / iOS)」一栏的登出路径——它们用 IdP 令牌、 // 不入 web_sessions,故按设备名吊销(AUTH.md §4)。注销设备属敏感动作,与吊销网页 // 会话同口径要求最近一次 step-up(403 step_up_required,前端据此发起再认证)。 func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { if s.cfg.StepUpEnabled && !s.sessionRecentlySteppedUp(r) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "step_up_required"}) return } claims, _ := jwtauth.ClaimsFromContext(r.Context()) name := chi.URLParam(r, "name") if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device name"}) return } rows, err := s.queries.DeleteDevice(r.Context(), db.DeleteDeviceParams{ UserID: claims.UserID, Name: name, }) if err != nil { slog.Error("delete device failed", "err", err, "user", claims.UserID, "name", name) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if rows == 0 { writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) return } s.hub.Kick(claims.UserID, name) s.hub.PublishPresence(r.Context(), claims.UserID) w.WriteHeader(http.StatusNoContent) }