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}} + + {{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 }