Closes #102.
The only operational surface was /healthz and a fold-out roster inside the owner's own reading page. This adds /admin: an owner-only page carrying the Reader roster and one row per Poll Lane.
- **Poller seam.** `latest.Poller` records each Lane's last pass (`Site`, `Due`, `Checked`, `LastRun`, `Gap`, `Clamped`, `Browser`) and answers `LaneStatus()`; the page reads that snapshot, never a table. A pass that returns before computing its figures (refusal backoff, sidecar down) carries the previous pass's figures forward rather than recording zeroes, and a Lane that has never reached a pace renders no gap at all. Refusal and sidecar reachability are derived at snapshot time.
- **Owner gate at registration.** Every route reaching past the acting Reader lives in `adminRoutes()` and is wrapped in `requireOwner` when it is registered, so a missing gate is visible in the route list rather than hidden in a handler. `web.AdminPatterns()` is what the gate test walks, so a new route cannot be added without being tested. A non-owner gets 404, never 403.
- **Nil poller is a first-class state.** `main.newRouter` takes the reporter as an interface and converts a nil `*Poller` to a nil interface; no poller and no completed pass both render "No data yet" with the reason spelled out, rather than confident zeroes.
- **Roster moved** off the reading page onto /admin, with the Sighting counters and a confirm-gated `Clear marks` control. #103 fills those counters, so on delivery they read zero for everyone - deliberate ordering.
- **One accent, `--patina`** (verdigris, both colour branches): the far side of the wheel from ember's crimson and clear of the archive blue. Ember still means new chapter only; revocation still wears --danger.
Verification: `go vet ./...` and `go test ./...` green (Docker-backed); admin page screenshotted at 1100px and 390px in both colour schemes. Reviewed on both axes (spec, standards); findings on the accent hue, zero-figure honesty and three tests that could not fail are fixed in 58014eb.
Reviewed-on: #107
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #107.
This commit is contained in:
@@ -57,6 +57,10 @@ type Poller struct {
|
||||
mu sync.Mutex
|
||||
refuseUntil map[string]time.Time
|
||||
browserDownAt time.Time
|
||||
// laneStates is the owner's page snapshot of each Lane's last pass
|
||||
// (issue #102), keyed by Site. Guarded by mu; a Site appears only after
|
||||
// its first pass, so a restart renders "no data yet" rather than zeroes.
|
||||
laneStates map[string]LaneState
|
||||
// coverWG tracks in-flight cover work. Covers heal in the background so a
|
||||
// slow cover host cannot delay the next Series-page Poll; tests join it
|
||||
// before asserting on cover fetches.
|
||||
@@ -218,6 +222,10 @@ func (p *Poller) runOnce(ctx context.Context) {
|
||||
// production Lane's rate limit; the deterministic test entry runs back to back.
|
||||
func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.Duration {
|
||||
now := p.Now()
|
||||
// Snapshot this pass for the owner's page (issue #102). Recorded on every
|
||||
// return path, with the figures filled in where the pass computes them.
|
||||
st := LaneState{Site: name, LastRun: now, Browser: isBrowserSite(name)}
|
||||
defer func() { p.recordLaneState(st) }()
|
||||
if until := p.refusalBackoff(name); now.Before(until) {
|
||||
// Cooling down after a refusal: do not attempt this Site at all.
|
||||
return until.Sub(now)
|
||||
@@ -238,18 +246,23 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
// No fetcher at all right now (browser absent, no fallback): every
|
||||
// Series stays unstamped and due, so a browser that appears after a
|
||||
// restart finds its full queue waiting (issue #100).
|
||||
st.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
|
||||
due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli())
|
||||
if err != nil {
|
||||
log.Printf("latest poll %s: due query: %v", name, err)
|
||||
st.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
st.Due = len(due)
|
||||
if s.Browser != nil && f == p.BrowserFetch && !browserWakeDue(due, now, s.Rest) {
|
||||
// Below both thresholds Chrome stays asleep (ADR-0005 on-demand
|
||||
// browser): waking it for a single Poll would cost a challenge solve
|
||||
// per request.
|
||||
// per request. The Lane still paces at the default gap, which is what
|
||||
// the owner's page must show rather than a zero.
|
||||
st.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
if s.Browser != nil {
|
||||
@@ -265,9 +278,11 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
eligible, err := p.Store.EligibleSeriesCount(name)
|
||||
if err != nil {
|
||||
log.Printf("latest poll %s: eligible count: %v", name, err)
|
||||
st.Gap = defaultGap
|
||||
return defaultGap
|
||||
}
|
||||
gap, clamped := effectiveGap(s, eligible)
|
||||
st.Gap, st.Clamped = gap, clamped
|
||||
if clamped {
|
||||
log.Printf("latest poll %s: gap clamped to %s floor (eligible series=%d)", name, minGap, eligible)
|
||||
}
|
||||
@@ -278,7 +293,6 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
}
|
||||
|
||||
refusals := 0
|
||||
checked := 0
|
||||
for i, sr := range due {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
@@ -313,10 +327,10 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
|
||||
} else {
|
||||
refusals = 0
|
||||
}
|
||||
checked++
|
||||
st.Checked++
|
||||
}
|
||||
if checked > 0 {
|
||||
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), checked)
|
||||
if st.Checked > 0 {
|
||||
log.Printf("latest poll %s: due=%d checked=%d", name, len(due), st.Checked)
|
||||
}
|
||||
if refusals >= 2 {
|
||||
p.setRefusalBackoff(name, now.Add(refuseBackoff))
|
||||
|
||||
@@ -1580,3 +1580,91 @@ func TestRunOnceClampWarningNamesTheSite(t *testing.T) {
|
||||
t.Fatalf("clamp warning = %q, want it to name asura and 3601", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's admin page (issue #102) reads Lane state out of the poller.
|
||||
// Before any pass the snapshot is empty — a restart must render "no data
|
||||
// yet", not zeroes — and each pass records what it saw: the frozen clock,
|
||||
// the due count and the pace, with refusal backoff derived at snapshot time.
|
||||
func TestLaneStatus(t *testing.T) {
|
||||
s, _ := newTestStore(t)
|
||||
now := time.UnixMilli(5_000_000)
|
||||
p := newTestPoller(t, s, &fakeFetcher{body: asuraSeriesFixture, status: 200}, now)
|
||||
|
||||
if st := p.LaneStatus(); len(st.Lanes) != 0 {
|
||||
t.Fatalf("lanes before any pass = %d, want 0 (nothing has run)", len(st.Lanes))
|
||||
} else if st.BrowserConfigured || st.BrowserReachable {
|
||||
t.Fatalf("browser before any pass = configured=%v reachable=%v, want false without a browser fetcher", st.BrowserConfigured, st.BrowserReachable)
|
||||
}
|
||||
|
||||
seedForCheck(t, s, "asura:chronicles", "https://asurascans.com/series/chronicles", 0)
|
||||
// Two refusals put kagane's Lane into backoff; asura sits on its own Lane.
|
||||
browser := &fakeFetcher{status: 403}
|
||||
p.BrowserFetch = browser
|
||||
for i := range 2 {
|
||||
key := fmt.Sprintf("kagane:s%d", i)
|
||||
seedForCheck(t, s, key, "https://kagane.to/series/"+key[7:], 0)
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
|
||||
st := p.LaneStatus()
|
||||
var asura, kagane LaneState
|
||||
for _, lane := range st.Lanes {
|
||||
switch lane.Site {
|
||||
case "asura":
|
||||
asura = lane
|
||||
case "kagane":
|
||||
kagane = lane
|
||||
}
|
||||
}
|
||||
if st.Lanes[0].Site != "asura" {
|
||||
t.Fatalf("first lane = %q, want asura (snapshot sorted by Site)", st.Lanes[0].Site)
|
||||
}
|
||||
if asura.Site == "" {
|
||||
t.Fatalf("asura missing from snapshot: %+v", st.Lanes)
|
||||
}
|
||||
if !asura.LastRun.Equal(now) {
|
||||
t.Fatalf("asura LastRun = %s, want the frozen clock %s", asura.LastRun, now)
|
||||
}
|
||||
if asura.Due != 1 {
|
||||
t.Fatalf("asura Due = %d, want 1", asura.Due)
|
||||
}
|
||||
if asura.Gap == 0 {
|
||||
t.Fatal("asura Gap = 0, want the Lane's pace")
|
||||
}
|
||||
if asura.Browser || asura.Refusing {
|
||||
t.Fatalf("asura = %+v, want a TLS Lane that is not refusing", asura)
|
||||
}
|
||||
if !kagane.Browser || !kagane.Refusing {
|
||||
t.Fatalf("kagane = %+v, want a browser Lane in refusal backoff", kagane)
|
||||
}
|
||||
if !st.BrowserConfigured || !st.BrowserReachable {
|
||||
t.Fatalf("browser after round = configured=%v reachable=%v, want true/true (sidecar never lost)", st.BrowserConfigured, st.BrowserReachable)
|
||||
}
|
||||
if asura.Checked != 1 {
|
||||
t.Fatalf("asura Checked = %d, want the one Series it read", asura.Checked)
|
||||
}
|
||||
|
||||
// A pass that declines to look (kagane is now in backoff) must not restate
|
||||
// the figures it never gathered as zeroes: the last real pass's due count
|
||||
// and pace stand until a pass replaces them.
|
||||
before := kagane
|
||||
if before.Due == 0 || before.Gap == 0 {
|
||||
t.Fatalf("kagane after its refusing pass = %+v, want the figures that pass gathered", before)
|
||||
}
|
||||
p.runOnce(context.Background())
|
||||
for _, lane := range p.LaneStatus().Lanes {
|
||||
if lane.Site != "kagane" {
|
||||
continue
|
||||
}
|
||||
if lane.Due != before.Due || lane.Gap != before.Gap {
|
||||
t.Fatalf("kagane after a skipped pass = due %d gap %s, want the previous pass's %d / %s",
|
||||
lane.Due, lane.Gap, before.Due, before.Gap)
|
||||
}
|
||||
}
|
||||
|
||||
// A lost sidecar reads as unreachable for the same window the Lanes skip.
|
||||
p.setBrowserDown(now)
|
||||
if st := p.LaneStatus(); !st.BrowserConfigured || st.BrowserReachable {
|
||||
t.Fatalf("browser after loss = configured=%v reachable=%v, want true/false", st.BrowserConfigured, st.BrowserReachable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package latest
|
||||
|
||||
import "time"
|
||||
|
||||
// LaneState is the administrative page's view of one Poll Lane (issue #102):
|
||||
// what the Lane's last pass saw. Due, Gap and Checked are filled in as the
|
||||
// pass computes them; a pass that returned before reaching a figure (refusal
|
||||
// backoff, sidecar down) carries the previous pass's figures forward rather
|
||||
// than overwriting them with zeroes the page would state as fact.
|
||||
type LaneState struct {
|
||||
Site string
|
||||
Due int
|
||||
LastRun time.Time
|
||||
Gap time.Duration
|
||||
// Checked is how many Series this pass actually read. A Lane with Series
|
||||
// due and nothing checked has stopped working; one with nothing due is
|
||||
// merely quiet, and the page must not draw the two the same (story 13).
|
||||
Checked int
|
||||
Clamped bool
|
||||
Refusing bool
|
||||
Browser bool
|
||||
}
|
||||
|
||||
// Status is the owner's page snapshot of the whole poller (issue #102).
|
||||
type Status struct {
|
||||
Lanes []LaneState
|
||||
BrowserConfigured bool
|
||||
BrowserReachable bool
|
||||
}
|
||||
|
||||
// LaneStatus returns a copy of the poller's Lane state for the owner's page.
|
||||
// Only Sites that have completed a pass appear — a restart therefore renders
|
||||
// "no data yet" instead of confident zeroes — in the same order Run iterates.
|
||||
// Refusing is derived at snapshot time from the refusal backoff, not stored,
|
||||
// so a Lane that cooled down between passes reports false without a new pass.
|
||||
// BrowserReachable mirrors the Lanes' own gate: the sidecar is down only
|
||||
// within the refuseBackoff window since its last loss.
|
||||
func (p *Poller) LaneStatus() Status {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
lanes := make([]LaneState, 0, len(p.laneStates))
|
||||
now := p.Now()
|
||||
for _, name := range laneNames() {
|
||||
st, ok := p.laneStates[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
st.Refusing = now.Before(p.refuseUntil[name])
|
||||
lanes = append(lanes, st)
|
||||
}
|
||||
configured := p.BrowserFetch != nil
|
||||
reachable := configured
|
||||
if reachable && !p.browserDownAt.IsZero() && now.Sub(p.browserDownAt) < refuseBackoff {
|
||||
reachable = false
|
||||
}
|
||||
return Status{Lanes: lanes, BrowserConfigured: configured, BrowserReachable: reachable}
|
||||
}
|
||||
|
||||
// recordLaneState stores one Lane's last pass for LaneStatus. Called deferred
|
||||
// from runLanePass so every return path records, even a pass that refused.
|
||||
// A pass that never reached the pace (Gap zero) keeps the last pass's figures:
|
||||
// the Lane's due count and gap did not become zero because this pass declined
|
||||
// to look, and the row's own marks say why it declined.
|
||||
func (p *Poller) recordLaneState(st LaneState) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.laneStates == nil {
|
||||
p.laneStates = make(map[string]LaneState)
|
||||
}
|
||||
if prev, ok := p.laneStates[st.Site]; ok && st.Gap == 0 {
|
||||
st.Due, st.Gap, st.Clamped, st.Checked = prev.Due, prev.Gap, prev.Clamped, prev.Checked
|
||||
}
|
||||
p.laneStates[st.Site] = st
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- The owner's administrative page (issue #102) renders these counters and
|
||||
-- offers a control to clear them, deliberately shipped before the Sighting
|
||||
-- feature (issue #103) that fills them, so a false mark never needs SQL
|
||||
-- against production. Zero counters mean a trusted Reader.
|
||||
ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0;
|
||||
@@ -315,25 +315,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// SightingDisagreementLimit is the number of contradictions that stop that
|
||||
// Reader's Sightings from deferring a Poll (issue #103). The counters exist
|
||||
// before the mechanism that moves them, so the admin page (issue #102) can
|
||||
// clear a false mark without waiting for the Sighting feature.
|
||||
const SightingDisagreementLimit = 3
|
||||
|
||||
// Blocked reports whether this Reader's Sighting marks have reached the
|
||||
// disagreement limit, which stops their Sightings from deferring a Poll.
|
||||
func (r ReaderSummary) Blocked() bool {
|
||||
return r.Disagreements >= SightingDisagreementLimit
|
||||
}
|
||||
|
||||
// ReaderSummary is one Reader as the owner's administration panel sees them:
|
||||
// who they are and how many live sessions they hold. No credential material,
|
||||
// hashed or otherwise, is exposed.
|
||||
// who they are, how many live sessions they hold, and their Sighting marks.
|
||||
// No credential material, hashed or otherwise, is exposed.
|
||||
type ReaderSummary struct {
|
||||
ID int64
|
||||
DiscordID string
|
||||
// Sessions counts unexpired session rows — what the owner revokes.
|
||||
Sessions int
|
||||
// Agreements and Disagreements are the Sighting counters (issue #102);
|
||||
// zero means a trusted Reader.
|
||||
Agreements int
|
||||
Disagreements int
|
||||
}
|
||||
|
||||
// Readers lists every Reader with their live session count, oldest first, so
|
||||
// the owner row (always the oldest) heads the list.
|
||||
// Readers lists every Reader with their live session count and Sighting
|
||||
// marks, oldest first, so the owner row (always the oldest) heads the list.
|
||||
func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.id, r.discord_id,
|
||||
r.sighting_agreements, r.sighting_disagreements,
|
||||
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
|
||||
FROM readers r
|
||||
LEFT JOIN sessions sess ON sess.reader_id = r.id
|
||||
GROUP BY r.id, r.discord_id
|
||||
GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements
|
||||
ORDER BY r.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query readers: %w", err)
|
||||
@@ -343,7 +360,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
out := []ReaderSummary{}
|
||||
for rows.Next() {
|
||||
var r ReaderSummary
|
||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil {
|
||||
return nil, fmt.Errorf("scan reader: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
@@ -351,6 +368,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's
|
||||
// remedy for a mark produced by a broken Site adapter rather than a dishonest
|
||||
// Reader: it restores a privilege, it is not destruction.
|
||||
func (s *Store) ClearReaderMarks(readerID int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0
|
||||
WHERE id = $1`, readerID); err != nil {
|
||||
return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readersMigration is the version that creates the readers table. The owner
|
||||
// seed runs between two migrate passes, so that the run-once migration which
|
||||
// attaches existing bookmarks (0004) finds the owner row.
|
||||
|
||||
@@ -1334,6 +1334,86 @@ func TestReadersAndSessionRevocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Sighting counters ship before the mechanism that fills them (issue
|
||||
// #102 before #103), so the admin page depends on their default: a fresh
|
||||
// Reader reads back trusted. Marking one directly proves Readers() reports
|
||||
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
|
||||
// the owner's remedy for a false mark — zeroes them again.
|
||||
func TestReaderSightingMarks(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
if len(readers) != 2 {
|
||||
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
||||
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
|
||||
}
|
||||
}
|
||||
|
||||
// The counters' default is the trusted state; writing them directly is
|
||||
// the only way to exercise the read path until issue #103 moves them.
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
||||
WHERE id = $1`, other); err != nil {
|
||||
t.Fatalf("mark reader: %v", err)
|
||||
}
|
||||
readers, err = s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
var marked *ReaderSummary
|
||||
for i := range readers {
|
||||
if readers[i].ID == other {
|
||||
marked = &readers[i]
|
||||
}
|
||||
}
|
||||
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
|
||||
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
|
||||
}
|
||||
if marked.Blocked() {
|
||||
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
|
||||
t.Fatalf("block reader: %v", err)
|
||||
}
|
||||
readers, err = s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.ID == other && !r.Blocked() {
|
||||
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
|
||||
}
|
||||
if r.ID == s.OwnerID() && r.Blocked() {
|
||||
t.Fatalf("untouched owner became blocked: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ClearReaderMarks(other); err != nil {
|
||||
t.Fatalf("ClearReaderMarks: %v", err)
|
||||
}
|
||||
if err := s.ClearReaderMarks(other + 9999); err != nil {
|
||||
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
|
||||
}
|
||||
readers, err = s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
||||
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two Readers on one Series: one series row, two independent progresses. The
|
||||
// second Reader starts at zero however far the first has read, and the shared
|
||||
// row is still due exactly once.
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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
|
||||
// PollerOff means no poller is running at all (disabled by config, or its
|
||||
// client could not be built). The browser line must not answer "not
|
||||
// configured" then: the sidecar is not the reason nothing is polled.
|
||||
PollerOff bool
|
||||
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
|
||||
// Checked is how many Series the last pass read. Due without Checked is a
|
||||
// Lane that has stopped working; the two figures side by side are what
|
||||
// separate that from a Lane with nothing to do.
|
||||
Checked int
|
||||
// Gap is empty when no pass has reached the pace yet, so the row omits the
|
||||
// figure instead of stating a zero.
|
||||
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
|
||||
// Stalled marks a Lane with Series waiting that its last pass did not read
|
||||
// — the difference between a stopped Lane and a quiet one (story 13).
|
||||
Stalled 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{PollerOff: true}
|
||||
}
|
||||
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
|
||||
// Series waiting and none read is the shape of a Lane that has stopped
|
||||
// working, as distinct from one that is quiet for want of work.
|
||||
stalled := l.Due > 0 && l.Checked == 0
|
||||
gap := ""
|
||||
if l.Gap > 0 {
|
||||
gap = l.Gap.Truncate(time.Second).String()
|
||||
}
|
||||
v.Rows = append(v.Rows, laneRow{
|
||||
Site: l.Site,
|
||||
Due: l.Due,
|
||||
Ran: since(now, l.LastRun),
|
||||
Checked: l.Checked,
|
||||
Gap: gap,
|
||||
Clamped: l.Clamped,
|
||||
Refusing: l.Refusing,
|
||||
BrowserLost: lost,
|
||||
Stalled: stalled,
|
||||
Attention: l.Clamped || l.Refusing || lost || stalled,
|
||||
})
|
||||
}
|
||||
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()})
|
||||
}
|
||||
@@ -87,6 +87,11 @@
|
||||
--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. Verdigris — cool,
|
||||
the far side of the wheel from ember's crimson, and clear of the archive
|
||||
blue. Neither ember (new chapter) nor danger (destruction) may say
|
||||
"system unhealthy". */
|
||||
--patina: #5fb3a6;
|
||||
|
||||
/* Desktop cell borders for the two coloured action states. */
|
||||
--play-hot-line: #3a1d18;
|
||||
@@ -146,6 +151,7 @@
|
||||
--moss: #3d6c46;
|
||||
--clay: #7c5533;
|
||||
--trash: #8c6558;
|
||||
--patina: #1f6f66;
|
||||
|
||||
--play-hot-line: #f0cfc6;
|
||||
--fav-line: #e3d3a4;
|
||||
@@ -290,9 +296,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 +318,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 +331,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 +643,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 +655,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 ---- */
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{{/* 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"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>BookmarkManager — Admin</title>
|
||||
<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
<header class="topbar">
|
||||
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
|
||||
{{/* Back to the library, no switch: this page belongs to neither library,
|
||||
and the ember-lit switch says which library you are reading. */}}
|
||||
<a class="ghost back" href="/">Library</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
{{/* The live region wraps the swapped block rather than being it: the
|
||||
refresh replaces the section wholesale, and a region recreated on every
|
||||
update is never announced. */}}
|
||||
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
|
||||
|
||||
{{template "readers" .}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -28,6 +28,10 @@
|
||||
<a href="/?lib=novel&tab=all" class="{{if eq .Lib "novel"}}active{{end}}"
|
||||
{{if eq .Lib "novel"}}aria-current="page"{{end}}>Novels</a>
|
||||
</nav>
|
||||
{{/* 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}}<a class="ghost" href="/admin">Admin</a>{{end}}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="ghost">Log out</button>
|
||||
</form>
|
||||
@@ -76,8 +80,6 @@
|
||||
|
||||
{{template "setup" .}}
|
||||
|
||||
{{if .Owner}}{{template "readers" .}}{{end}}
|
||||
|
||||
{{template "keyrow" .}}
|
||||
|
||||
{{template "recent" .}}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{{/* 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"}}
|
||||
<section class="lanes" id="lanes"
|
||||
hx-get="/ui/admin/lanes" hx-trigger="every 30s" hx-swap="outerHTML">
|
||||
<h2>Poll Lanes</h2>
|
||||
{{if .Rows}}
|
||||
<ul class="lanelist">
|
||||
{{range .Rows}}
|
||||
<li{{if .Attention}} class="attention"{{end}}>
|
||||
<span class="lane-site">{{.Site}}</span>
|
||||
<span class="lane-fact">{{.Due}} due</span>
|
||||
<span class="lane-fact">{{.Checked}} checked</span>
|
||||
<span class="lane-fact">ran {{.Ran}}</span>
|
||||
{{if .Gap}}<span class="lane-fact">gap {{.Gap}}</span>{{end}}
|
||||
{{if .Clamped}}<span class="lane-mark">gap at floor</span>{{end}}
|
||||
{{if .Refusing}}<span class="lane-mark">refusing</span>{{end}}
|
||||
{{if .BrowserLost}}<span class="lane-mark">no browser</span>{{end}}
|
||||
{{if .Stalled}}<span class="lane-mark">not checking</span>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="setup-copy">No data yet — no Lane has completed a pass since the
|
||||
backend started.</p>
|
||||
{{end}}
|
||||
<p class="setup-copy lane-browser">
|
||||
{{if .PollerOff}}Polling is switched off in this deployment: no Lane runs,
|
||||
and Latest Chapter comes from the userscripts alone.
|
||||
{{else}}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}}</p>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -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"}}
|
||||
<details class="setup" id="readers">
|
||||
<summary>Readers</summary>
|
||||
<section class="readers" id="readers">
|
||||
<h2>Readers</h2>
|
||||
<p class="setup-copy">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.</p>
|
||||
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.</p>
|
||||
<ul class="readerlist">
|
||||
{{range .Readers}}
|
||||
<li>
|
||||
<span class="reader-id">{{.DiscordID}}</span>
|
||||
<span class="reader-sessions">{{.Sessions}} session{{if ne .Sessions 1}}s{{end}}</span>
|
||||
{{/* 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)}}
|
||||
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
|
||||
<button type="submit" class="ghost danger">Revoke sessions</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="reader-sightings">{{.Agreements}} confirmed / {{.Disagreements}} contradicted</span>
|
||||
{{/* Blocked is spelled out rather than left to be worked out from two
|
||||
numbers and a threshold. */}}
|
||||
{{if .Blocked}}<span class="reader-blocked">deferral blocked</span>{{end}}
|
||||
<span class="reader-actions">
|
||||
{{/* 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. */}}
|
||||
<form hx-post="/readers/{{.ID}}/clear-marks" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Clearing wipes this Reader's whole Sighting record, confirmations included. Clear?">
|
||||
<button type="submit" class="ghost">Clear marks</button>
|
||||
</form>
|
||||
{{/* 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)}}
|
||||
<form hx-post="/readers/{{.ID}}/revoke" hx-target="#readers" hx-swap="outerHTML"
|
||||
hx-confirm="Revoking signs this Reader out on every device immediately. Revoke?">
|
||||
<button type="submit" class="ghost danger">Revoke sessions</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
+18
-56
@@ -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()})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user