feat(web): per-Series detail page keyed by site:series_id (#144)
This commit is contained in:
@@ -27,6 +27,8 @@ type adminView struct {
|
||||
// the Readers they may act on.
|
||||
OwnerID int64
|
||||
Lanes lanesView
|
||||
// Detail is the per-Series page data; zero on every other page.
|
||||
Detail seriesDetailView
|
||||
}
|
||||
|
||||
// lanesView is the Lane status block: one row per Site that has run, plus the
|
||||
@@ -89,6 +91,7 @@ func (h *Handler) adminRoutes() []adminRoute {
|
||||
{"GET /admin/lanes", h.adminLanes},
|
||||
{"GET /admin/readers", h.adminReaders},
|
||||
{"GET /admin/series", h.adminSeries},
|
||||
{"GET /admin/series/{key}", h.adminSeriesDetail},
|
||||
{"GET /ui/admin/lanes", h.uiLanes},
|
||||
{"POST /readers/{id}/revoke", h.revokeReaderSessions},
|
||||
{"POST /readers/{id}/clear-marks", h.clearReaderMarks},
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
)
|
||||
|
||||
// seriesDetailView is one Series' page as the owner sees it: strings and
|
||||
// flags, every judgement made here, the template left to print. ReaderCount
|
||||
// is the only figure that crosses the privacy boundary — the owner learns how
|
||||
// many Readers hold the Series, never which Reader reads what.
|
||||
type seriesDetailView struct {
|
||||
Key string // "<site>:<series_id>", the page's address and the Series' identity
|
||||
Site string
|
||||
Kind string
|
||||
// Title, Cover and Chapter come from the shared Series row; the Cover is
|
||||
// the wire URL of the stored bytes, "" before any exist.
|
||||
Title string
|
||||
Cover string
|
||||
Chapter string // Latest Chapter number, or "—" before the first capture
|
||||
Checked string // how long ago the poller last checked, or "never"
|
||||
Readers int
|
||||
|
||||
// Marks, one per hygiene fact, rendered only while it holds.
|
||||
Unpollable bool // no SeriesURL to fetch
|
||||
NoCover bool
|
||||
Orphan bool // no Reader holds the Series
|
||||
SightingRaised bool // a Reader's Sighting set the Latest Chapter
|
||||
}
|
||||
|
||||
// adminSeriesDetail renders one Series' page, keyed by the composite
|
||||
// "<site>:<series_id>" the list row already shows. The row is read through
|
||||
// the list's own SeriesPage read narrowed to the key's Site: the admin
|
||||
// projection is the privacy boundary, and a second single-row read would be a
|
||||
// second definition of it.
|
||||
// ponytail: one 50-row page scan per detail view; a keyed read alongside
|
||||
// SeriesPage when the library outgrows the page size.
|
||||
func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) {
|
||||
site, seriesID, ok := strings.Cut(r.PathValue("key"), ":")
|
||||
if !ok || site == "" || seriesID == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
page, err := h.store.SeriesPage(store.SeriesFilter{Site: site})
|
||||
if err != nil {
|
||||
log.Printf("series detail %s: %v", site+":"+seriesID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i := range page.Rows {
|
||||
if page.Rows[i].SeriesID == seriesID {
|
||||
h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(page.Rows[i])})
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// seriesDetailView shapes one AdminSeries row for display: every judgement in
|
||||
// Go, the template left to print strings and flags.
|
||||
func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView {
|
||||
v := seriesDetailView{
|
||||
Key: a.Key(),
|
||||
Site: a.Site,
|
||||
Kind: a.Kind,
|
||||
Title: a.Title,
|
||||
Cover: h.store.CoverWireURL(a.CoverAddress),
|
||||
Readers: a.ReaderCount,
|
||||
Unpollable: a.SeriesURL == "",
|
||||
NoCover: a.CoverAddress == "",
|
||||
Orphan: a.ReaderCount == 0,
|
||||
SightingRaised: a.RaisedByReader,
|
||||
}
|
||||
if a.LatestChapterNum == nil {
|
||||
v.Chapter = "—"
|
||||
} else {
|
||||
v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64)
|
||||
}
|
||||
if a.LatestCheckedAt == 0 {
|
||||
v.Checked = "never"
|
||||
} else {
|
||||
v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt))
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
<a href="/admin" class="{{if eq .Page "overview"}}active{{end}}" {{if eq .Page "overview"}}aria-current="page"{{end}}>Overview</a>
|
||||
<a href="/admin/lanes" class="{{if eq .Page "lanes"}}active{{end}}" {{if eq .Page "lanes"}}aria-current="page"{{end}}>Lanes</a>
|
||||
<a href="/admin/readers" class="{{if eq .Page "readers"}}active{{end}}" {{if eq .Page "readers"}}aria-current="page"{{end}}>Readers</a>
|
||||
<a href="/admin/series" class="{{if eq .Page "series"}}active{{end}}" {{if eq .Page "series"}}aria-current="page"{{end}}>Series</a>
|
||||
<a href="/admin/series" class="{{if or (eq .Page "series") (eq .Page "series-detail")}}active{{end}}" {{if or (eq .Page "series") (eq .Page "series-detail")}}aria-current="page"{{end}}>Series</a>
|
||||
</nav>
|
||||
|
||||
<main class="page admin-page">
|
||||
@@ -40,6 +40,8 @@
|
||||
{{template "readers" .}}
|
||||
{{else if eq .Page "series"}}
|
||||
<h2 class="sec">Series</h2>
|
||||
{{else if eq .Page "series-detail"}}
|
||||
{{template "series-detail" .Detail}}
|
||||
{{else}}
|
||||
<h2 class="sec">Overview</h2>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{{/* 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 intervention forms a later ticket adds
|
||||
land inside the empty .detail-grid, so this page edits nothing. */}}
|
||||
{{define "series-detail"}}
|
||||
<a class="ghost detail-back" href="/admin/series">← Series</a>
|
||||
<h1 class="detail-title">{{.Title}}</h1>
|
||||
<p class="detail-key">{{.Key}} · {{.Site}} · {{.Kind}}</p>
|
||||
{{if .Cover}}<div class="cover"><img src="{{.Cover}}" alt="" loading="lazy"></div>
|
||||
{{else}}<div class="cover"></div>{{end}}
|
||||
<div class="detail-meta">
|
||||
<span>ch {{.Chapter}}</span>
|
||||
<span>checked {{.Checked}}</span>
|
||||
<span>{{.Readers}} readers</span>
|
||||
{{if .Unpollable}}<span class="mark">unpollable</span>{{end}}
|
||||
{{if .NoCover}}<span class="mark">no cover</span>{{end}}
|
||||
{{if .Orphan}}<span class="mark">orphan</span>{{end}}
|
||||
{{if .SightingRaised}}<span class="mark">sighting-raised</span>{{end}}
|
||||
</div>
|
||||
<div class="detail-grid"></div>
|
||||
{{end}}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -1840,3 +1841,196 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) {
|
||||
t.Fatal("novel tab=new should render the novel All list")
|
||||
}
|
||||
}
|
||||
|
||||
// The per-Series page renders every Series-level fact the admin read model
|
||||
// holds for the key the list row already shows: title, the composite key with
|
||||
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
|
||||
// count. No Reader identity or progress may appear anywhere in the response.
|
||||
func TestAdminSeriesDetailRendersFacts(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
// Two Readers hold the same Series, so the anonymous count is 2.
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
|
||||
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
|
||||
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
|
||||
})
|
||||
other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash")))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureReader: %v", err)
|
||||
}
|
||||
if _, err := st.Upsert(other, store.Bookmark{
|
||||
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
|
||||
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
|
||||
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
|
||||
}); err != nil {
|
||||
t.Fatalf("Upsert second reader: %v", err)
|
||||
}
|
||||
if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("MarkLatestChecked: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"SP Baby",
|
||||
"kagane:sp-baby · kagane · manga",
|
||||
"ch 45",
|
||||
"checked 2h0m0s ago",
|
||||
"2 readers",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("series detail lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Series the poller has never read renders the never-read state — "ch —" and
|
||||
// "checked never" — rather than a confident zero, and a Series no Reader holds
|
||||
// renders its count as "0 readers" rather than as a blank.
|
||||
func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned",
|
||||
Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned",
|
||||
Kind: "manga",
|
||||
})
|
||||
// Removing the only Bookmark orphans the Series: the row outlives it.
|
||||
if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"ch —", "checked never", "0 readers"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("never-read orphan detail lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "ch 0") {
|
||||
t.Errorf("a never-read Series renders chapter 0:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The meta row renders each hygiene mark exactly when the underlying fact
|
||||
// holds: a Series with no page to fetch, no cover, no Reader and a chapter a
|
||||
// Sighting raised carries all four, and a whole one carries none.
|
||||
func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "demonic:broken", Site: "demonic", SeriesID: "broken",
|
||||
Title: "Broken", Kind: "manga",
|
||||
})
|
||||
if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
|
||||
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
|
||||
})
|
||||
if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp",
|
||||
[]byte("\x00webp-bytes"), "image/webp"); err != nil {
|
||||
t.Fatalf("SetSeriesCover: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
|
||||
if strings.Contains(body, mark) {
|
||||
t.Errorf("whole series detail carries the %q mark:\n%s", mark, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `src="https://bookmarks.test/covers/`) {
|
||||
t.Errorf("whole series detail does not render its stored cover:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed key naming no row is a 404, and so is a key with no ":",
|
||||
// an empty Site or an empty SeriesID — the detail page never answers 500 for
|
||||
// an address nobody can reach.
|
||||
func TestAdminSeriesDetailUnknownKey404(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
|
||||
Kind: "manga",
|
||||
})
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/series/asura:no-such-row",
|
||||
"/admin/series/no-colon",
|
||||
"/admin/series/:empty-site",
|
||||
"/admin/series/asura:",
|
||||
"/admin/series/unknown-site:row",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404", path, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The title and the key line come from the database, so they must render
|
||||
// escaped: a title that is markup stays markup in the response, never HTML.
|
||||
func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:evil", Site: "asura", SeriesID: "evil",
|
||||
Title: `<script>alert("xss")</script>`, SeriesURL: "https://asurascans.com/series/evil",
|
||||
Kind: "manga",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Errorf("title is not escaped:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Errorf("title rendered raw:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user