Enrolling a device that cannot reach the admin UI meant transcribing a 200-character link with a base64 pin in it — the step the link format exists to avoid, and the one where a pin wrong by one character fails later as an inscrutable TLS error. Rendered as inline SVG rather than a PNG data: URI, because the page's CSP is default-src 'none' and means it: a data: image would need img-src opened, markup needs nothing. One path rather than a rect per module, since a link this long encodes to about 60x60 and two thousand elements is a lot of DOM for a picture of a square. It is generated from the same validated value as the href, so a rejected link produces neither. This relaxes the stdlib-only rule, deliberately and recorded in CLAUDE.md. The rule bought one self-contained binary with no supply chain to audit, which one small pure-Go package barely dents; F-Droid never applied to the server, only the app ships there. A correct QR encoder is ~500 lines of Reed-Solomon that nobody should be hand-writing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
7.5 KiB
Go
239 lines
7.5 KiB
Go
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|