889f0f3f38
Spec #134, all ten tickets. Closes #134. ## What ships The admin surface becomes four bookmarkable addresses behind one nav row, and Lane observability stops dying with the process. - **#138** `/admin` splits into Overview, Lanes, Readers, Series, each a real route with the active tab underlined. - **#139** `poll_passes` and `poll_lanes` land as durable tables with their store surface. - **#140** cross-Series admin read model, with the privacy boundary in the projection: the Reader id that raised a Latest Chapter never leaves the store package. - **#141** the poller records exactly one pass row per exit, with a skip reason and outcome counts. - **#142** Series list: eight hygiene filters, Site and Library narrowing, paging — all of it in the query string, so a filtered list is a bookmark. - **#143** Overview: a three-state verdict line and a stats block where every non-zero figure links to the list that counts it. - **#144** per-Series detail page, keyed by the `site:series_id` composite the rest of the system already uses. - **#145** the Lanes page reads the database; the in-memory Lane state, `web.LaneReporter` and `latest.Status` are deleted. - **#146** Forced Poll: *Check now* stamps `series.force_poll_at` and never commands the poller. - **#147** pause and resume one Site's Lane, with a mandatory 1h/6h/24h expiry. ## Shape of the design Two decisions carry the rest. **Commands go through the database, never at the poller**: both *Check now* and a Lane pause write a row the next pass reads, so they survive a restart and the whole surface stays testable with no poller running. And **pending is derived, never stored** — the request stamp being newer than the check stamp — which self-clears on the check stamp with no second write and no sweeper, because the check stamp is written before the fetch. ADRs: `docs/adr/0012-persisted-lane-state.md`, `docs/adr/0013-commands-through-the-database.md`. ## Verification `go test ./...` green on the merged base (`264839e`), all packages, Docker-backed. `gofmt -l` and `go vet` clean. Every ticket was reviewed on both axes (`cr-spec` + `cr-standards`) before merge. ## Known, non-blocking - **#143** the verdict ignores never-reported Lanes when other Lanes have reported, and the per-Site table lists Sites that have Series rather than the whole registry. The ticket prose asks for eight hygiene figures per Site; the design mock and the landed `.tbl.sites` grid both say six columns, and the mock won. - **#146** two `SeriesPage` scans per press instead of a keyed read — `ponytail:`-commented in-tree with the upgrade path. - **#147** a paused Site with no pass row yet renders no row and so no control, since the Lanes page lists Sites that have passed. - **#141** a sibling browser Lane declining at the top of a pass records as `sidecar-down`. Specified deliberately; the later spec in this series settles it. Reviewed-on: #148 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
176 lines
6.1 KiB
Go
176 lines
6.1 KiB
Go
package web
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// 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
|
|
// Detail is the per-Series page data; zero on every other page.
|
|
Detail seriesDetailView
|
|
// Overview is the landing page data; zero on every other page.
|
|
Overview overviewView
|
|
}
|
|
|
|
// 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 /admin/series/{key}", h.adminSeriesDetail},
|
|
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
|
|
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
|
|
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
|
|
{"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 landing page: a verdict line, a stats block
|
|
// where every figure is a door into the list it counts, and the per-Site
|
|
// library shape table — all read from the database, never from a poller.
|
|
func (h *Handler) admin(w http.ResponseWriter, r *http.Request) {
|
|
view, err := h.overviewView()
|
|
if err != nil {
|
|
log.Printf("admin overview: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.renderAdmin(w, adminView{Page: "overview", Overview: view})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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()})
|
|
}
|