From e69da2a99bb8c4036dc3d9faebd9a55bcf61135e Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sun, 16 Aug 2026 14:17:30 +0700
Subject: [PATCH] web: owner-only /admin with the Reader roster and Poll Lane
status (#102)
The only operational surface was /healthz and a fold-out roster inside the
owner's own reading page. This gives the owner a page: two sections of facts on
the same measured sheet, no cards.
- admin.go holds every route that reaches past the acting Reader, listed once
in adminRoutes() and wrapped in requireOwner at registration - a missing gate
is visible in the route list rather than hidden inside a handler. A non-owner
gets 404, the same answer revoke already gave.
- Lane figures arrive through the LaneReporter seam, so the page reads the
running poller rather than a table. newRouter converts a nil *Poller to a nil
interface: a typed nil would make the page claim a poller exists.
- The roster moves out of the reading page and gains the Sighting counters, the
blocked verdict and Clear marks. Clearing restores a privilege, so it is a
plain ghost button; --danger stays with revocation.
- --patina is the page's one accent, held at the weight of the other action
accents. Neither --ember (new chapter) nor --danger (destruction) is borrowed
for system health.
---
backend/internal/web/admin.go | 229 ++++++++++++++++++++
backend/internal/web/static/style.css | 56 ++++-
backend/internal/web/templates/admin.html | 36 +++
backend/internal/web/templates/app.html | 6 +-
backend/internal/web/templates/lanes.html | 36 +++
backend/internal/web/templates/readers.html | 53 +++--
backend/internal/web/web.go | 74 ++-----
backend/main.go | 27 ++-
8 files changed, 430 insertions(+), 87 deletions(-)
create mode 100644 backend/internal/web/admin.go
create mode 100644 backend/internal/web/templates/admin.html
create mode 100644 backend/internal/web/templates/lanes.html
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"}}
+
+
+
+
+
+
+ BookmarkManager — Admin
+
+
+
+
+
+
+
+
+
{{template "mark" .}}BookmarkManager
+ {{/* Back to the library, no switch: this page belongs to neither library,
+ and the ember-lit switch says which library you are reading. */}}
+ Library
+
+
+
+ {{template "lanes" .Lanes}}
+
+ {{template "readers" .}}
+
+
+
+{{end}}
diff --git a/backend/internal/web/templates/app.html b/backend/internal/web/templates/app.html
index ae6586a..d761b95 100644
--- a/backend/internal/web/templates/app.html
+++ b/backend/internal/web/templates/app.html
@@ -28,6 +28,10 @@
Novels
+ {{/* The owner's only difference on this page: a link out to the
+ administrative one. It sits beside Log out rather than in the library
+ switch — that switch says which library, not which page. */}}
+ {{if .Owner}}Admin{{end}}
@@ -76,8 +80,6 @@
{{template "setup" .}}
- {{if .Owner}}{{template "readers" .}}{{end}}
-
{{template "keyrow" .}}
{{template "recent" .}}
diff --git a/backend/internal/web/templates/lanes.html b/backend/internal/web/templates/lanes.html
new file mode 100644
index 0000000..79a70fb
--- /dev/null
+++ b/backend/internal/web/templates/lanes.html
@@ -0,0 +1,36 @@
+{{/* Poll Lane status: one row per Site, refreshing itself so a run can be
+ watched rather than sampled by reloading. The refresh is one attribute on
+ the fragment root and the endpoint answers with this same fragment, so the
+ swap replaces the element that asked for it.
+
+ Every figure here is read out of the running poller, never out of a table:
+ a Site absent from Rows has not completed a pass since the last restart,
+ which the empty state must say — zeroes would read as a stopped Lane. */}}
+{{define "lanes"}}
+
+
Poll Lanes
+ {{if .Rows}}
+
+ {{range .Rows}}
+
+ {{.Site}}
+ {{.Due}} due
+ ran {{.Ran}}
+ gap {{.Gap}}
+ {{if .Clamped}}gap at floor{{end}}
+ {{if .Refusing}}refusing{{end}}
+ {{if .BrowserLost}}no browser{{end}}
+
+ {{end}}
+
+ {{else}}
+
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}}.
+
+{{end}}
diff --git a/backend/internal/web/templates/readers.html b/backend/internal/web/templates/readers.html
index 85b844c..0cef186 100644
--- a/backend/internal/web/templates/readers.html
+++ b/backend/internal/web/templates/readers.html
@@ -1,29 +1,48 @@
-{{/* The owner's Reader roster. Rendered only for the owner (listView.Owner),
- and re-rendered whole as the response to a revocation so the session
- counts cannot describe the state before the tap. Revocation is
- confirm-gated: it signs someone out of every device at once. */}}
+{{/* The Reader roster, on the owner's administrative page. Re-rendered whole
+ as the response to an action so the counts and marks it shows cannot
+ describe the state before the tap. Both actions are confirm-gated: one
+ signs a Reader out of every device at once, the other wipes a record.
+
+ Owner-only at route registration, so nothing here re-tests who is asking. */}}
{{define "readers"}}
-
- Readers
+
+
Readers
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.
{{range .Readers}}
{{.DiscordID}}{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}
- {{/* The owner's own row never offers Revoke: it is the one row where the
- button would sign the tapping browser out, and the endpoint refuses
- it anyway. Logout is the deliberate way to do that. */}}
- {{if and .Sessions (ne .ID $.OwnerID)}}
-
- {{end}}
+ {{.Agreements}} confirmed / {{.Disagreements}} contradicted
+ {{/* Blocked is spelled out rather than left to be worked out from two
+ numbers and a threshold. */}}
+ {{if .Blocked}}deferral blocked{{end}}
+
+ {{/* Clearing restores a privilege, so it is a plain ghost button —
+ the destruction accent belongs to revocation alone. It is offered
+ on every row, including one reading zero: the remedy must be
+ findable before the counters climb, not after. */}}
+
+ {{/* The owner's own row never offers Revoke: it is the one row where the
+ button would sign the tapping browser out, and the endpoint refuses
+ it anyway. Logout is the deliberate way to do that. */}}
+ {{if and .Sessions (ne .ID $.OwnerID)}}
+
+ {{end}}
+
{{end}}
-
+
{{end}}
diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go
index 4c3c753..e1f3a69 100644
--- a/backend/internal/web/web.go
+++ b/backend/internal/web/web.go
@@ -50,6 +50,9 @@ type Handler struct {
// httpClient is the plain stdlib client that talks to Discord. It is not
// an injected interface: tests point APIBase at a stub server instead.
httpClient *http.Client
+ // lanes is the Poll Lane snapshot source the administrative page reads.
+ // Nil is a running deployment with no poller, not a bug.
+ lanes LaneReporter
}
// listView is what every list-rendering template receives.
@@ -77,14 +80,9 @@ type listView struct {
// It is not "newly registered": a Reader who deletes their last bookmark is
// in the same position and needs the same links.
EmptyLibrary bool
- // Owner marks the acting Reader as the deployment's owner, which unlocks
- // the Readers panel. Nothing else in the UI differs.
+ // Owner marks the acting Reader as the deployment's owner, which offers
+ // the link to the administrative page. Nothing else in the UI differs.
Owner bool
- // Readers is the owner's roster, populated only for the owner's own page
- // render and the revocation fragment. OwnerID travels with it so the roster
- // can tell the owner's own row apart from the Readers they may revoke.
- Readers []store.ReaderSummary
- OwnerID int64
}
// PageURL and ListURL are the two link shapes every tab needs. Building them
@@ -111,7 +109,10 @@ type loginView struct {
// New parses every template up front so a broken one kills the process at
// startup rather than the first request that touches it.
-func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
+//
+// lanes is the administrative page's window onto the running Poller; nil means
+// nothing is polling, which the page reports rather than hides.
+func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, lanes LaneReporter) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil {
return nil, err
@@ -126,6 +127,7 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
states: newOAuthStates(),
limiter: session.NewLoginLimiter(),
httpClient: &http.Client{Timeout: discordTimeout},
+ lanes: lanes,
}, nil
}
@@ -149,9 +151,11 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /install/manga-bookmark.user.js", h.requireSession(h.installUserscript("manga-bookmark.user.js")))
mux.HandleFunc("GET /install/novel-bookmark.user.js", h.requireSession(h.installUserscript("novel-bookmark.user.js")))
mux.HandleFunc("POST /rotate-token", h.requireSession(h.rotateToken))
-
- // Owner-only: the one place the UI crosses the Reader boundary.
- mux.HandleFunc("POST /readers/{id}/revoke", h.requireSession(h.revokeReaderSessions))
+ // Owner-only: every route that reaches past the acting Reader is gated in
+ // one place, so a missing gate is visible in the route list.
+ for _, rt := range h.adminRoutes() {
+ mux.HandleFunc(rt.pattern, h.requireOwner(rt.handler))
+ }
}
// staticHandler serves the embedded assets. An hour, not longer: assets are
@@ -240,14 +244,9 @@ func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- if readerID == h.store.OwnerID() {
- view.Owner, view.OwnerID = true, readerID
- if view.Readers, err = h.store.Readers(); err != nil {
- log.Printf("index readers: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- }
+ // The owner's page differs only by the link to the administrative page:
+ // the roster lives there now, so the page read every day is only reading.
+ view.Owner = readerID == h.store.OwnerID()
h.render(w, http.StatusOK, "app", view)
}
@@ -618,40 +617,3 @@ func (h *Handler) rotateToken(w http.ResponseWriter, r *http.Request) {
view := listView{Lib: store.KindManga, Rotated: true}
h.render(w, http.StatusOK, "setup", view)
}
-
-// revokeReaderSessions logs one Reader out of every browser they are signed
-// in on. Owner-only: it reaches across the Reader boundary every other handler
-// respects, so the guard is a comparison against the seeded owner rather than
-// a role a Reader could acquire. A non-owner gets 404 — the panel does not
-// exist for them, so neither should the endpoint.
-func (h *Handler) revokeReaderSessions(w http.ResponseWriter, r *http.Request) {
- if readerOf(r) != h.store.OwnerID() {
- http.NotFound(w, r)
- return
- }
- target, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
- if err != nil {
- http.Error(w, "bad reader id", http.StatusBadRequest)
- 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
- }
- readers, err := h.store.Readers()
- if err != nil {
- log.Printf("revoke sessions: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- h.render(w, http.StatusOK, "readers", listView{Owner: true, Readers: readers, OwnerID: h.store.OwnerID()})
-}
diff --git a/backend/main.go b/backend/main.go
index 74f16cd..bebff8d 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -129,7 +129,10 @@ func loadConfig() Config {
// newRouter wires routes and middleware. CORS is the outermost layer so
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public.
-func newRouter(s *store.Store, cfg Config) http.Handler {
+//
+// lanes may be nil — polling disabled, or its client could not be built. The
+// admin page reports that rather than pretending Lanes exist.
+func newRouter(s *store.Store, cfg Config, lanes web.LaneReporter) http.Handler {
mux := http.NewServeMux()
h := &api.Handler{Store: s}
mux.HandleFunc("GET /healthz", api.Healthz)
@@ -160,7 +163,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// The browser UI is always registered; signing in is Discord OAuth, so
// there is no password to forget and no gate to leave unset.
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
- cfg.UserscriptPath, cfg.NovelUserscriptPath)
+ cfg.UserscriptPath, cfg.NovelUserscriptPath, lanes)
if err != nil {
log.Fatalf("web handler: %v", err)
}
@@ -275,11 +278,17 @@ func main() {
}
s.OnSeriesCreated = acq.Acquire
}
- startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
+ // A nil *Poller must not become a non-nil interface holding a nil pointer:
+ // the admin page tests the reporter for nil to decide whether anything is
+ // polling at all.
+ var lanes web.LaneReporter
+ if poller := startLatestPoller(pollCtx, s, cfg.LatestPoll, browser); poller != nil {
+ lanes = poller
+ }
srv := &http.Server{
Addr: ":" + cfg.Port,
- Handler: newRouter(s, cfg),
+ Handler: newRouter(s, cfg, lanes),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -326,16 +335,17 @@ func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetch
// startLatestPoller launches the background poller unless it is disabled or its
// HTTP client cannot be built. Any problem here is logged and skipped: this
// feature going missing degrades the service to userscript-only latest-chapter
-// tracking, which is exactly how it behaved before.
-func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) {
+// tracking, which is exactly how it behaved before. It returns the running
+// Poller, or nil when there is none — the admin page's Lane status reads it.
+func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) *latest.Poller {
if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config")
- return
+ return nil
}
f, err := latest.NewTLSFetcher()
if err != nil {
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
- return
+ return nil
}
// Nil browser: sites behind a JavaScript challenge are simply not polled,
// and their latest_chapter comes from the userscript alone — which is how
@@ -343,4 +353,5 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, brow
p := newLatestPoller(s, cfg, f, browser)
go p.Run(ctx)
+ return p
}