diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go new file mode 100644 index 0000000..df5bd4c --- /dev/null +++ b/backend/internal/web/admin.go @@ -0,0 +1,229 @@ +package web + +import ( + "log" + "net/http" + "strconv" + "time" + + "bookmarkmanager/backend/internal/latest" + "bookmarkmanager/backend/internal/store" +) + +// LaneReporter is the administrative page's whole window onto the running +// poller: one snapshot of Poll Lane state, copied out of memory on request. +// The Poller satisfies it in production and a fake with fixed values satisfies +// it in tests, so the page's tests need neither a poller nor a Site. +type LaneReporter interface { + LaneStatus() latest.Status +} + +// adminView is what the administrative page and the roster fragment receive. +type adminView struct { + Readers []store.ReaderSummary + // OwnerID travels with the roster so it can tell the owner's own row from + // the Readers they may act on. + OwnerID int64 + Lanes lanesView +} + +// lanesView is the Lane status block: one row per Site that has run, plus the +// browser fact, which is shared by the three browser Sites rather than held +// once per Site. +type lanesView struct { + Rows []laneRow + BrowserConfigured bool + BrowserReachable bool +} + +// laneRow is one Lane formatted for reading rather than for arithmetic: the +// template renders strings and flags, and every judgement about what they mean +// is made here. +type laneRow struct { + Site string + Due int + Ran string + Gap string + Clamped bool + Refusing bool + // BrowserLost marks a Lane whose pages can only be read through the + // sidecar while the sidecar is unreachable — including the case where none + // is configured, which stops those Series just as completely. + BrowserLost bool + // Attention is the one flag the template colours on, so an unhealthy Lane + // is found at a glance rather than read for. + Attention bool +} + +// adminRoute pairs a route pattern with its handler so the route list and the +// gate cannot drift apart. +type adminRoute struct { + pattern string + handler http.HandlerFunc +} + +// adminRoutes is every route that reaches past the acting Reader. Register +// wraps each one in requireOwner, so a new administrative route is gated by +// being listed here rather than by remembering to write a check inside it. +func (h *Handler) adminRoutes() []adminRoute { + return []adminRoute{ + {"GET /admin", h.admin}, + {"GET /ui/admin/lanes", h.uiLanes}, + {"POST /readers/{id}/revoke", h.revokeReaderSessions}, + {"POST /readers/{id}/clear-marks", h.clearReaderMarks}, + } +} + +// AdminPatterns names every administrative route, so one test can prove the +// owner gate covers all of them rather than one test per route. The receiver is +// nil because only the patterns are read; the bound handlers are never called. +func AdminPatterns() []string { + routes := (*Handler)(nil).adminRoutes() + out := make([]string, 0, len(routes)) + for _, rt := range routes { + out = append(out, rt.pattern) + } + return out +} + +// requireOwner is the owner test, in one place, layered on the session gate: no +// session is still 401, and a signed-in Reader who is not the owner gets 404 +// rather than 403 — a refusal that confirms the address exists is a refusal +// that helps whoever is probing for it. +func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc { + return h.requireSession(func(w http.ResponseWriter, r *http.Request) { + if readerOf(r) != h.store.OwnerID() { + http.NotFound(w, r) + return + } + next(w, r) + }) +} + +// admin renders the owner's page: the Reader roster and Poll Lane status. +func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { + readers, err := h.store.Readers() + if err != nil { + log.Printf("admin: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.render(w, http.StatusOK, "admin", adminView{ + Readers: readers, + OwnerID: h.store.OwnerID(), + Lanes: h.lanesView(), + }) +} + +// uiLanes answers the status block's own refresh. Only the block refreshes on a +// timer; the roster re-renders after an action, as it always has. +func (h *Handler) uiLanes(w http.ResponseWriter, r *http.Request) { + h.render(w, http.StatusOK, "lanes", h.lanesView()) +} + +// lanesView copies the poller's snapshot into display form. A nil reporter (no +// poller running) and a poller no Lane has reported to yet are the same thing +// to the page: no data, which it must say rather than draw as confident zeroes +// — an empty page a few seconds after a restart must not read as a stopped one. +func (h *Handler) lanesView() lanesView { + if h.lanes == nil { + return lanesView{} + } + snap := h.lanes.LaneStatus() + v := lanesView{ + Rows: make([]laneRow, 0, len(snap.Lanes)), + BrowserConfigured: snap.BrowserConfigured, + BrowserReachable: snap.BrowserReachable, + } + now := time.Now() + for _, l := range snap.Lanes { + lost := l.Browser && !snap.BrowserReachable + v.Rows = append(v.Rows, laneRow{ + Site: l.Site, + Due: l.Due, + Ran: since(now, l.LastRun), + Gap: l.Gap.Truncate(time.Second).String(), + Clamped: l.Clamped, + Refusing: l.Refusing, + BrowserLost: lost, + Attention: l.Clamped || l.Refusing || lost, + }) + } + return v +} + +// since formats how long ago a Lane last ran, at second resolution: the block +// refreshes every thirty seconds, so anything finer is noise the owner would +// have to ignore. +func since(now, then time.Time) string { + d := now.Sub(then).Truncate(time.Second) + if d < time.Second { + return "just now" + } + return d.String() + " ago" +} + +// revokeReaderSessions logs one Reader out of every browser they are signed in +// on. The owner gate is the route's, not this handler's. +func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) { + target, ok := readerPathID(w, r) + if !ok { + return + } + // The owner is not one of the Readers this endpoint reaches: revoking + // themselves would sign out the browser making the request, which is what + // logout is for. The roster hides the button; this refuses the hand-rolled + // POST behind it. + if target == h.store.OwnerID() { + http.NotFound(w, r) + return + } + if err := h.store.DeleteReaderSessions(target); err != nil { + log.Printf("revoke sessions: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.renderRoster(w, "revoke sessions") +} + +// clearReaderMarks zeroes one Reader's Sighting counters. The guard those +// counters feed has one known false positive — a Site changing its page shape +// makes a correct adapter read a wrong high number and marks every honest +// Reader of that Site at once (issue #103) — and this is its remedy. It +// restores a privilege rather than destroying anything, so the control is +// confirmed but never wears the destruction accent. +func (h *Handler) clearReaderMarks(w http.ResponseWriter, r *http.Request) { + target, ok := readerPathID(w, r) + if !ok { + return + } + if err := h.store.ClearReaderMarks(target); err != nil { + log.Printf("clear marks: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.renderRoster(w, "clear marks") +} + +// readerPathID reads the Reader a route names, answering the request itself +// when there is nobody to act on. +func readerPathID(w http.ResponseWriter, r *http.Request) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.Error(w, "bad reader id", http.StatusBadRequest) + return 0, false + } + return id, true +} + +// renderRoster answers an action with the whole roster, so the counts and marks +// it shows cannot describe the state before the tap. +func (h *Handler) renderRoster(w http.ResponseWriter, what string) { + readers, err := h.store.Readers() + if err != nil { + log.Printf("%s: %v", what, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.render(w, http.StatusOK, "readers", adminView{Readers: readers, OwnerID: h.store.OwnerID()}) +} diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index fbf07ec..9a091b0 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -87,6 +87,10 @@ --moss: #7fae86; /* finished */ --clay: #b5906f; /* set chapter */ --trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */ + /* A Lane needing attention: the admin page's only accent, held at the same + muted weight as --brass so it never competes with ember. Neither ember + (new chapter) nor danger (destruction) may say "system unhealthy". */ + --patina: #b08a4a; /* Desktop cell borders for the two coloured action states. */ --play-hot-line: #3a1d18; @@ -146,6 +150,7 @@ --moss: #3d6c46; --clay: #7c5533; --trash: #8c6558; + --patina: #7a5a1e; --play-hot-line: #f0cfc6; --fav-line: #e3d3a4; @@ -290,9 +295,21 @@ button { cursor: pointer; } letter-spacing: .04em; } -/* ---- reader roster (owner only): same hairline panel, one row per Reader ---- */ -.readerlist { margin: 0; padding: 0; list-style: none; } -.readerlist li { +/* ---- admin page: two sections on the same measured sheet, no cards ---- + The reading page is a list of series; this is a list of facts. Both are + sheets of hairline-separated rows, so the roster keeps the shape it had as + a fold-out and the Lane block copies it. */ +.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); } +.readers h2, .lanes h2 { + margin: 0; + padding: 8px 0; + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} +.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; } +.readerlist li, .lanelist li { display: flex; align-items: center; flex-wrap: wrap; @@ -300,7 +317,8 @@ button { cursor: pointer; } min-height: 44px; border-top: 1px solid var(--rule); } -.readerlist form { margin: 0 0 0 auto; } +.reader-actions { display: flex; gap: 18px; margin-left: auto; } +.readerlist form { margin: 0; } .reader-id { font: 500 13px/1.4 var(--font-mono); letter-spacing: .04em; @@ -312,6 +330,30 @@ button { cursor: pointer; } text-transform: uppercase; color: var(--mute); } +.reader-sightings, .lane-fact { + font: 500 10px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; + color: var(--mute-2); +} +/* Two states the owner is meant to find rather than read for: a Reader whose + reports no longer defer a Poll, and a Lane that is not keeping its promise. + Both wear --patina — never ember, which means one thing, and never danger, + which is destruction. */ +.reader-blocked, .lane-mark { + font: 500 10px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; + color: var(--patina); +} +.lane-site { + font: 400 19px/1.2 var(--font-display); + color: var(--paper-dim); +} +/* The whole row leans patina when the Lane needs attention, so the scan is one + pass down the left edge rather than a read of every mark. */ +.lanelist li.attention .lane-site { color: var(--patina); } +.lane-browser { padding: 12px 0 0; } /* Revocation cuts someone off, so it wears --danger. Ember stays reserved for the new-chapter signal. */ .ghost.danger { color: var(--danger); } @@ -600,6 +642,9 @@ button { cursor: pointer; } box-shadow: inset 0 -2px 0 var(--ember); } .topbar form { margin-left: 18px; } +/* The admin page's topbar has no switch to fill the middle, so its back link + keeps company with Log out at the right edge instead of floating centre. */ +.topbar .back { margin-left: auto; } /* At phone width brand + switch + Log out do not fit on one line, so the switch takes its own row under the wordmark rather than pushing Log out off-screen. */ @@ -609,6 +654,9 @@ button { cursor: pointer; } .libswitch { order: 3; margin-left: 0; } .libswitch a { flex: 1; text-align: center; padding: 8px 14px; } .topbar form { margin-left: 12px; } + /* The admin page has no switch to take the second row, so its brand claims + the first outright and the back link keeps Log out company below. */ + .topbar:has(.back) .brand { flex: 1 1 100%; } } /* ---- action strip: full-width on a phone, hairline-divided cells ---- */ diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html new file mode 100644 index 0000000..c1fdaae --- /dev/null +++ b/backend/internal/web/templates/admin.html @@ -0,0 +1,36 @@ +{{/* The owner's administrative page: everything that reaches past one Reader, + at its own address so it can be bookmarked rather than hunted for inside + the reading page. Owner-only at route registration (requireOwner), which + is why nothing in here re-tests who is asking. */}} +{{define "admin"}} + + +
+ + + +No data yet — no Lane has completed a pass since the + backend started.
+ {{end}} +Browser sidecar: + {{if not .BrowserConfigured}}not configured — comix, kagane and novelfull + pages are not fetched through it{{else if .BrowserReachable}}reachable + {{else}}unreachable{{end}}.
+Everyone who has signed in through Discord. Revoking signs a Reader out of every device; their library and bookmarks are - untouched, and they can sign in again.
+ untouched, and they can sign in again. The Sighting counters record how + often a later Poll confirmed or contradicted what that Reader's browser + reported; enough contradictions stop their reports deferring a Poll, and + clearing the marks gives that back.