html/template rewrites an href whose scheme it does not recognise to "#ZgotmplZ". echolot:// is not on its list, so "Open in the Echolot app" was not a link at all — tapping it did nothing, and nothing showed it: the markup reads correctly, the app resolves the scheme, and only the sanitised attribute in the served HTML gives it away. Marking the value template.URL opts out of that sanitising, which is only safe because the shape is now checked first. The link arrives in a query parameter, so without the check a crafted /devices?link=javascript:… would put a script URL into the page for an admin to click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
7.4 KiB
Go
238 lines
7.4 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,
|
|
"Link": link, "LinkHref": 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)
|
|
}
|