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 } // ownerWindow is the staleness boundary the Series list's "not checked in // 12h" filter compares against. Declared once; later admin tickets read it. const ownerWindow = 12 * time.Hour // adminView is the shared shell data for an administrative page and the roster // fragment returned after a Reader action. type adminView struct { Page string 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 SeriesList seriesListView } // 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). A // browser Lane holding Chrome asleep under the wake thresholds is neither, // so it carries Asleep instead and never Stalled. Stalled bool Asleep 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 /admin/lanes", h.adminLanes}, {"GET /admin/readers", h.adminReaders}, {"GET /admin/series", h.adminSeries}, {"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 Overview shell. Its data arrives in later admin tickets. func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { h.renderAdmin(w, adminView{Page: "overview"}) } // adminLanes renders the page that hosts the live Lane fragment. func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) { h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()}) } // adminReaders renders the Reader roster on its own bookmarkable page. func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) { readers, err := h.store.Readers() if err != nil { log.Printf("admin readers: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()}) } func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) { h.render(w, http.StatusOK, "admin", view) } // 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 — or one // deliberately leaving Chrome asleep until its group gathers. stalled := l.Due > 0 && l.Checked == 0 && !l.Asleep 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, Asleep: l.Asleep, 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()}) }