Series URL repair: owner-typed, gated by the poller's own fetch gate (#151)

This commit is contained in:
2026-08-22 09:21:00 +07:00
parent 448631c78e
commit 424d2c6600
15 changed files with 265 additions and 22 deletions
+1
View File
@@ -48,6 +48,7 @@ func (h *Handler) adminRoutes() []adminRoute {
{"GET /admin/series/{key}", h.adminSeriesDetail},
{"POST /admin/series/{key}/poll", h.adminSeriesPoll},
{"POST /admin/series/{key}/latest", h.adminSeriesCorrectLatest},
{"POST /admin/series/{key}/series-url", h.adminSeriesSetURL},
{"POST /admin/lanes/{site}/pause", h.adminLanePause},
{"POST /admin/lanes/{site}/resume", h.adminLaneResume},
{"GET /ui/admin/lanes", h.uiLanes},
+54
View File
@@ -225,6 +225,60 @@ func (h *Handler) adminSeriesCorrectLatest(w http.ResponseWriter, r *http.Reques
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
}
// adminSeriesSetURL is the series URL repair: the owner types one address
// and the Series' Poll fetches it from then on, verified by the same gate
// the poller uses before it fetches anything — a URL failing
// latest.FetchableSeriesURL answers 400 and never reaches the store. The
// repair is a store, not a verification: it performs no outbound fetch, and
// the owner presses Check now afterwards. This lifts the write-once rule of
// Series.SeriesURL for the owner only — a Reader's PUT is still ignored. The
// owner gate is the route's, not this handler's; the body is capped like the
// API path caps its bodies; the key is validated here — a malformed key is a
// 400 and an unknown one a 404.
func (h *Handler) adminSeriesSetURL(w http.ResponseWriter, r *http.Request) {
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
if !ok || site == "" || seriesID == "" {
http.Error(w, "bad series key", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<16)
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
seriesURL := r.PostFormValue("series_url")
if !latest.FetchableSeriesURL(site, seriesURL) {
http.Error(w, "series URL must be an https address on this site's host", http.StatusBadRequest)
return
}
if _, found, err := h.adminSeriesByKey(site, seriesID); err != nil {
log.Printf("series url %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
} else if !found {
http.NotFound(w, r)
return
}
if err := h.store.SetSeriesURL(site, seriesID, seriesURL); err != nil {
log.Printf("series url %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Re-read after the write: the answer must describe the state after the
// press, like the correction's answer does.
a, found, err := h.adminSeriesByKey(site, seriesID)
if err != nil {
log.Printf("series url %s: %v", site+":"+seriesID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
h.render(w, http.StatusOK, "series-detail-meta", h.seriesDetailView(a))
}
// seriesListView assembles one Series list view from the request's query
// string. An unknown filter value is the absent All case, never an error: the
// select's options are not the only way this URL can be reached.
+5 -1
View File
@@ -24,11 +24,14 @@ type seriesDetailView struct {
Cover string
Chapter string // Latest Chapter number, or "—" before the first capture
Checked string // how long ago the poller last checked, or "never"
// URL is the stored source address, prefilled into the repair input —
// the one stored string this page renders back into a form (issue #151).
URL string
Readers int
// Corrected is the correction marker's text, "" while no Correction
// stands: "corrected <age> ago" — the copy that says the value is the
// owner's, and it dies with the stamp (a machine write of the number).
Corrected string
Readers int
// Marks, one per hygiene fact, rendered only while it holds.
Unpollable bool // no SeriesURL to fetch
@@ -106,6 +109,7 @@ func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
Kind: a.Kind,
Title: a.Title,
Cover: h.store.CoverWireURL(a.CoverAddress),
URL: a.SeriesURL,
Readers: a.ReaderCount,
Unpollable: a.SeriesURL == "",
NoCover: a.CoverAddress == "",
@@ -1,9 +1,9 @@
{{/* Per-Series page: one address per Series, keyed "<site>:<series_id>" so the
list row is one hop from it. Everything here is a Series-level fact plus
the anonymous Reader count. The Check now control lands in its own .dform
below the .detail-grid; the correction form is the grid's first column,
and the grid's second waits for the series-URL repair (#151). The pending
and corrected markers ride the meta line with the other marks. */}}
the anonymous Reader count. Check now lands in its own .dform below the
.detail-grid; the correction form is the grid's first column and the URL
repair the second (issue #151). The pending and corrected markers ride the
meta line with the other marks. */}}
{{define "series-detail"}}
<a class="ghost detail-back" href="/admin/series">← Series</a>
<h1 class="detail-title">{{.Title}}</h1>
@@ -20,6 +20,14 @@
<button type="submit" class="ghost">Set</button>
</div>
</form>
<form class="dform" hx-post="/admin/series/{{.Key}}/series-url" hx-target="#detail-meta" hx-swap="outerHTML">
<h3>Repair series URL</h3>
<p class="hint">The Poll fetches this address — storing is not verifying it. A Site-wide host change is a SQL migration, not two hundred forms.</p>
<div class="field">
<input type="url" name="series_url" value="{{.URL}}" required>
<button type="submit" class="ghost">Set</button>
</div>
</form>
</div>
{{if .CanPoll}}
<div class="dform">