package httpapi import ( "encoding/json" "log/slog" "net/http" "github.com/go-chi/chi/v5" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/jwtauth" ) type deviceItem struct { DeviceID string `json:"device_id"` Name string `json:"name"` Type string `json:"type"` Tier string `json:"tier"` 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{ DeviceID: d.DeviceID, Name: d.Name, Type: d.Type, Tier: d.Tier, Online: s.hub.Online(claims.UserID, d.Name), LastSeen: d.LastSeen, }) } writeJSON(w, http.StatusOK, map[string]any{"devices": out}) } // handleDeleteDevice removes a device: it revokes the device's broker session (so it // can no longer refresh and dies within its short access TTL), then drops the local // row, kicks its live SSE, and re-broadcasts presence. Identified by the stable // device_id; the caller must own it. A full session is required (route-gated) — the // old step-up requirement is gone, since a full-tier session is itself trusted. func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) deviceID := chi.URLParam(r, "device_id") if deviceID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"}) return } status, ok := s.revokeDevice(r, claims.UserID, deviceID) if !ok { writeJSON(w, status, map[string]string{"error": revokeErrorMsg(status)}) return } w.WriteHeader(http.StatusNoContent) } type renameDeviceReq struct { Name string `json:"name"` } // handleRenameDevice changes a device's human-readable name. The device's identity is // device_id, so a rename never changes which session / row it is — fixing the old // model where the name was the key and renaming meant losing the device. Full session. func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request) { claims, _ := jwtauth.ClaimsFromContext(r.Context()) deviceID := chi.URLParam(r, "device_id") if deviceID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing device id"}) return } var body renameDeviceReq if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } name := jwtauth.SanitizeDeviceName(body.Name) if name == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"}) return } n, err := s.queries.RenameDevice(r.Context(), db.RenameDeviceParams{ Name: name, DeviceID: deviceID, UserID: claims.UserID, }) if err != nil { slog.Error("rename device failed", "err", err, "user", claims.UserID, "device", deviceID) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"}) return } if n == 0 { writeJSON(w, http.StatusNotFound, map[string]string{"error": "device not found"}) return } w.WriteHeader(http.StatusNoContent) }