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.
This commit is contained in:
2026-08-16 14:17:30 +07:00
parent d555f54529
commit e69da2a99b
8 changed files with 430 additions and 87 deletions
+19 -8
View File
@@ -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
}