// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package adminui import ( "encoding/json" "html/template" "log/slog" "net/http" "net/url" "sort" "strings" "time" "echo-lot.app/server/internal/adminauth" "echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/store" ) // visibleDevices returns the devices a session may see: everything for an administrator, and for // anyone else the devices linked to their own account. // // Every page goes through this rather than filtering for itself. Scoping applied per-page is // scoping that will be missing from the next page someone adds, and the failure is silent — a // listing that quietly shows other people's uploads looks exactly like one that does not. func (s *Server) visibleDevices(sess *adminauth.Session) []store.Device { all := s.Store.Devices() if sess.Admin { return all } owned := make(map[string]bool) for _, id := range s.Store.DeviceIDsForAccount(sess.Subject) { owned[id] = true } out := make([]store.Device, 0, len(owned)) for _, d := range all { if owned[d.ID] { out = append(out, d) } } return out } // mayTouchRun reports whether this session may read or delete a given run. // // Checked against the device list rather than against the run's own metadata, so an unlinked or // revoked device stops granting access the moment the link is gone. func (s *Server) mayTouchRun(sess *adminauth.Session, device string) bool { if sess.Admin { return true } for _, d := range s.visibleDevices(sess) { if d.ID == device { return true } } return false } func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) { if s.session(r) != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, r, "login", map[string]any{ "OIDC": s.oidcAvailable(), "LocalSet": s.Store.LocalAdmin() != nil, "AdminUser": s.AdminUser, }) } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { devices := s.visibleDevices(sess) linked := 0 for _, d := range devices { if d.LinkedToAccount() { linked++ } } var selftest any // The self-test describes the server's own health, which is an operator's concern; a user // looking at their uploads has no use for it and no ability to act on it. if s.SelfTest != nil && sess.Admin { selftest = s.SelfTest() } s.render(w, r, "dashboard", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Devices": len(devices), "Linked": linked, "Runs": s.totalRuns(devices), "SelfTest": selftest, "Version": s.Version, "Admin": sess.Admin, }) } func (s *Server) totalRuns(devices []store.Device) int { if s.Runs == nil { return 0 } n := 0 for _, d := range devices { n += len(s.Runs.List(d.ID)) } return n } func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { devices := s.visibleDevices(sess) // Newest first: the device someone is looking for is almost always the one just enrolled. sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) }) type row struct { store.Device Runs int } rows := make([]row, 0, len(devices)) for _, d := range devices { n := 0 if s.Runs != nil { n = len(s.Runs.List(d.ID)) } rows = append(rows, row{Device: d, Runs: n}) } // html/template rewrites an href whose scheme it does not recognise to "#ZgotmplZ", so the // enrollment link rendered as a dead anchor that did nothing when tapped — silently, since the // markup looks fine and only the sanitised attribute gives it away. // // Marking it template.URL opts out of that sanitising, which is only safe because the shape is // checked first: this value arrives in a query parameter, so without the check a crafted // /devices?link=javascript:… would put a script URL straight into the page. link := r.URL.Query().Get("link") var href template.URL if strings.HasPrefix(link, "echolot://enroll?") { href = template.URL(link) } s.render(w, r, "devices", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows, // Rendered from the same validated value as the href, so a rejected link produces neither. "Link": link, "LinkHref": href, "LinkQR": qrSVG(string(href)), "Admin": sess.Admin, }) } func (s *Server) revokeDevice(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { id := r.PathValue("id") if err := s.Store.DeleteDevice(id); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Worth a log line: revoking a device is destructive, immediate, and someone will eventually // want to know who did it and when. slog.Info("device revoked", "device", id, "by", sess.Subject) http.Redirect(w, r, "/devices", http.StatusSeeOther) } func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { tok, err := s.Store.NewEnrollToken(24*time.Hour, "admin-ui") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } slog.Info("enrolment token minted", "by", sess.Subject) // The whole link, not the bare token: it carries the URL and the pin as well, and assembling // those by hand is where an operator gets a pin wrong by one character. http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther) } // enrollTokensAPI is POST /admin/enroll-tokens, the endpoint the spec's §2.1 example names. // Content-negotiated: Accept: application/json gets the spec shape {token, expires_in_s, // enroll_uri}; anything else (a browser) gets the same redirect-to-QR flow as the form above, // so the one path serves both audiences. func (s *Server) enrollTokensAPI(w http.ResponseWriter, r *http.Request) { subject, ok := s.apiAdmin(w, r) if !ok { return } note := r.URL.Query().Get("note") if note == "" { note = "admin-api" } const ttl = 24 * time.Hour tok, err := s.Store.NewEnrollToken(ttl, note) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } slog.Info("enrolment token minted", "by", subject, "note", note) if !strings.Contains(r.Header.Get("Accept"), "application/json") { http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther) return } w.Header().Set("Content-Type", "application/json") // The whole link, not the bare token (§2.1): the server is the only party holding URL, pin // and token at once, and a hand-assembled pin wrong by one character fails as an inscrutable // TLS error later rather than loudly here. _ = json.NewEncoder(w).Encode(map[string]any{ "token": tok, "expires_in_s": int(ttl.Seconds()), "enroll_uri": s.EnrollLink(tok), }) } // EnrollLink is supplied by the caller so this package does not need the control server's pin. var _ = 0 func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { type row struct { runs.Meta DeviceName string } var rows []row for _, d := range s.visibleDevices(sess) { if s.Runs == nil { break } name := d.Name if name == "" { name = d.ID } for _, m := range s.Runs.List(d.ID) { rows = append(rows, row{Meta: m, DeviceName: name}) } } sort.Slice(rows, func(i, j int) bool { return rows[i].UploadedAt.After(rows[j].UploadedAt) }) if len(rows) > 200 { rows = rows[:200] // a page, not the archive; the count is on the dashboard } s.render(w, r, "runs", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows, "Admin": sess.Admin, }) } func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { // 404 rather than 403 for someone else's run: a distinguishable "you may not see this" tells // an unauthorised caller that the run exists, which is itself something they should not learn. if !s.mayTouchRun(sess, r.PathValue("device")) { http.NotFound(w, r) return } body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id")) if err != nil { http.NotFound(w, r) return } // Re-indented for reading, but otherwise exactly what was stored. An admin sees the document // at the privacy level its uploader chose — there is nothing here that can un-redact it. var pretty json.RawMessage = body out, err := json.MarshalIndent(json.RawMessage(pretty), "", " ") if err != nil { out = body } s.render(w, r, "run", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Admin": sess.Admin, "Device": r.PathValue("device"), "ID": r.PathValue("id"), "JSON": string(out), }) } func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { device, id := r.PathValue("device"), r.PathValue("id") if !s.mayTouchRun(sess, device) { http.NotFound(w, r) return } if err := s.Runs.Delete(device, id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } slog.Info("run deleted", "device", device, "run", id, "by", sess.Subject) http.Redirect(w, r, "/runs", http.StatusSeeOther) }