Split backend into internal packages by responsibility

All Go files lived flat in backend/ as one package main. Move store,
latest-chapter polling, sessions, HTTP middleware, the JSON API, the
userscript handler, and the web UI (with its templates/static assets)
into backend/internal/{store,latest,session,httpmw,api,userscript,web},
each with an exported API. main.go becomes the composition root wiring
them into newRouter; root-level tests cover the assembled router while
package-local tests cover unit behavior. Update Dockerfile/.dockerignore
for the new internal/ tree and CLAUDE.md to describe the layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:41:38 +07:00
parent e250762ea6
commit eeb601cbe2
36 changed files with 971 additions and 843 deletions
+17 -6
View File
@@ -27,13 +27,24 @@ Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
``` ```
- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`. - **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
Single binary, split into packages under `backend/internal/`: `store`
(Bookmark type, SQLite persistence, migrations), `latest` (background
poller, site parsers, TLS fetcher), `session` (cookie signing, login
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
bookmark handlers), `userscript` (userscript-serving handler), `web`
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
`backend/main.go` is the composition root — the only place that wires
packages together into `newRouter`. Root-level `*_test.go` hold
integration tests that exercise the full router; unit tests for a
package live beside it under `internal/`.
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema and endpoint list in plan. - **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema and endpoint list in plan.
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth). - **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
- **Web UI:** same binary serve password-gated browser UI on second - **Web UI:** same binary serve password-gated browser UI on second
hostname — `GET /` (list, or login page when no session), hostname — `GET /` (list, or login page when no session),
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
under `/ui/*`. Templates + assets `go:embed`-ed, so `backend/Dockerfile` under `/ui/*`. Templates + assets `go:embed`-ed under
must copy `templates/` and `static/` plus `*.go`. Sessions stateless `backend/internal/web/`, so `backend/Dockerfile` must copy the whole
`internal/` tree, not just `*.go`. Sessions stateless
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty, HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty,
web routes not registered at all. UI mutations read-modify-write web routes not registered at all. UI mutations read-modify-write
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
@@ -43,9 +54,9 @@ Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
stylesheet href with `path.resolve(fileDir, href)`, drops directory stylesheet href with `path.resolve(fileDir, href)`, drops directory
on leading `/` and silently skip file. Relative href don't help on leading `/` and silently skip file. Relative href don't help
either: template's directory isn't its served path. So either: template's directory isn't its served path. So
`detect.mjs backend/templates` reports **false clean** — always pass `detect.mjs backend/internal/web/templates` reports **false clean** —
`backend/static` too. One finding there, `overused-font` on "Instrument always pass `backend/internal/web/static` too. One finding there,
Serif", deliberate identity choice, not debt. `overused-font` on "Instrument Serif", deliberate identity choice, not debt.
- **Every action that moves series out of list is confirm-gated.** - **Every action that moves series out of list is confirm-gated.**
Archive, finish, remove each open own `.confirm-row` disclosure Archive, finish, remove each open own `.confirm-row` disclosure
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
@@ -162,7 +173,7 @@ Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTI
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md` Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
— source of truth Claude Design project `mangaBookmark Web UI` — source of truth Claude Design project `mangaBookmark Web UI`
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching (`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
`backend/static/style.css`, `backend/templates/*`, or userscript `backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other `TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
state (busy, error, destruction) may use `--ember`; destruction gets state (busy, error, destruction) may use `--ember`; destruction gets
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens `--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
+4 -6
View File
@@ -1,10 +1,8 @@
# Only go source + module files, plus the go:embed'd templates/static # Only go source + module files, plus internal/ (which carries the
# directories, are needed in the build context. # go:embed'd templates/static directories), are needed in the build context.
* *
!go.mod !go.mod
!go.sum !go.sum
!*.go !*.go
!templates/ !internal/
!templates/** !internal/**
!static/
!static/**
+4 -5
View File
@@ -8,12 +8,11 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Then source (changes often). # Then source (changes often). internal/web carries the go:embed'd
# Source plus the go:embed'd assets. Missing either directory turns the embed # templates/static assets — missing them turns the embed directive into a
# directive into a build error, so both must be copied before `go build`. # build error, so the whole tree must land before `go build`.
COPY *.go ./ COPY *.go ./
COPY templates/ ./templates/ COPY internal/ ./internal/
COPY static/ ./static/
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency. # Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
# -trimpath + -ldflags strip paths and debug info for a smaller image. # -trimpath + -ldflags strip paths and debug info for a smaller image.
+472
View File
@@ -0,0 +1,472 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mangabm/backend/internal/store"
)
const testToken = "s3cret-token"
func testConfig() Config {
return Config{
Token: testToken,
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
Port: "8080",
}
}
func newTestServer(t *testing.T) http.Handler {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return newRouter(s, testConfig())
}
func auth(req *http.Request) *http.Request {
req.Header.Set("Authorization", "Bearer "+testToken)
return req
}
func floatPtr(f float64) *float64 { return &f }
// seedForCheck inserts a bookmark and forces its latest_checked_at.
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
t.Helper()
if _, err := s.Upsert(store.Bookmark{
Key: key,
Site: "asura",
SeriesID: key,
SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
t.Fatalf("seed mark %q: %v", key, err)
}
}
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
t.Helper()
ts, err := s.LatestCheckedAt(key)
if err != nil {
t.Fatalf("LatestCheckedAt %q: %v", key, err)
}
return ts
}
func TestHealthzNoAuth(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", rr.Code)
}
if rr.Body.String() != "ok" {
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
}
}
func TestAuthRequired(t *testing.T) {
srv := newTestServer(t)
cases := []struct {
name string
header string
}{
{"no header", ""},
{"bad token", "Bearer wrong"},
{"not bearer", "Basic " + testToken},
{"empty bearer", "Bearer "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rr.Code)
}
})
}
}
func TestAuthAccepted(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "[]\n" {
t.Fatalf("empty list body = %q, want []", got)
}
}
func TestCORSPreflight(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
req.Header.Set("Origin", "https://asuracomic.net")
req.Header.Set("Access-Control-Request-Method", "PUT")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rr.Code)
}
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
}
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("Allow-Methods missing")
}
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
t.Fatal("Allow-Headers missing")
}
}
func TestCORSDisallowedOrigin(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
}
}
func TestBookmarkRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "asura:solo-leveling-123"
in := store.Bookmark{
Title: "Solo Leveling",
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
Cover: "https://asuracomic.net/cover.jpg",
LastChapter: "Chapter 10",
LastChapterNum: 10,
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
}
body, _ := json.Marshal(in)
// PUT
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200", rr.Code)
}
var stored store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
t.Fatalf("derived fields wrong: %+v", stored)
}
if stored.UpdatedAt == 0 {
t.Fatal("server did not set updated_at")
}
// GET
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
var list []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
t.Fatalf("GET list wrong: %+v", list)
}
// PUT again (upsert, progress advance)
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
body, _ = json.Marshal(in)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("second PUT status = %d", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 1 || list[0].LastChapterNum != 11 {
t.Fatalf("upsert did not update in place: %+v", list)
}
// DELETE
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE status = %d, want 204", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("after delete list = %+v, want empty", list)
}
}
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
// which is the row as actually stored (not the request payload).
func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark {
t.Helper()
body, _ := json.Marshal(b)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
}
var out store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return out
}
func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("GET status = %d", rr.Code)
}
var list []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
return list
}
// updated_at drives list ordering, so it must move only on a real progress
// advance — never on a favorite toggle or a latest-chapter capture.
func TestUpsertConditionalUpdatedAt(t *testing.T) {
cases := []struct {
name string
mutate func(store.Bookmark) store.Bookmark
wantBumped bool
}{
{
name: "unchanged progress",
mutate: func(b store.Bookmark) store.Bookmark { return b },
wantBumped: false,
},
{
name: "changed progress",
mutate: func(b store.Bookmark) store.Bookmark {
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
return b
},
wantBumped: true,
},
{
name: "favorite only",
mutate: func(b store.Bookmark) store.Bookmark {
b.Favorite = true
return b
},
wantBumped: false,
},
{
name: "latest chapter only",
mutate: func(b store.Bookmark) store.Bookmark {
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
return b
},
wantBumped: false,
},
{
name: "unrelated metadata only",
mutate: func(b store.Bookmark) store.Bookmark {
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
return b
},
wantBumped: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := newTestServer(t)
key := fmt.Sprintf("asura:cond-%d", i)
first := putBookmark(t, srv, key, store.Bookmark{
Title: "Test",
LastChapter: "Chapter 10",
LastChapterNum: 10,
})
if first.UpdatedAt == 0 {
t.Fatal("new bookmark did not get updated_at set")
}
// Guarantee a later wall-clock ms so a real bump is observable.
time.Sleep(2 * time.Millisecond)
second := putBookmark(t, srv, key, tc.mutate(first))
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
}
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
}
// The PUT response must match what a subsequent GET reports.
list := getBookmarks(t, srv)
if len(list) != 1 {
t.Fatalf("list = %+v, want 1 item", list)
}
if list[0].UpdatedAt != second.UpdatedAt {
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
}
})
}
}
func TestFavoriteRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "demonic:some-series"
stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true})
if !stored.Favorite {
t.Fatalf("PUT response favorite = false, want true")
}
list := getBookmarks(t, srv)
if len(list) != 1 || !list[0].Favorite {
t.Fatalf("favorite did not round-trip: %+v", list)
}
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false})
if stored.Favorite {
t.Fatal("PUT response favorite = true after unfavorite")
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].Favorite {
t.Fatalf("unfavorite did not round-trip: %+v", list)
}
}
func TestLatestChapterNullable(t *testing.T) {
srv := newTestServer(t)
key := "asura:latest-test"
// Never captured: latest_chapter_num must serialize as JSON null.
body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
}
list := getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum != nil {
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
}
// Once captured it round-trips as a value.
stored := putBookmark(t, srv, key, store.Bookmark{
Title: "No latest yet",
LatestChapter: "Chapter 162",
LatestChapterNum: floatPtr(162),
})
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
t.Fatalf("latest chapter did not round-trip: %+v", list)
}
if list[0].LatestChapter != "Chapter 162" {
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
}
}
func TestLoadConfigWebPassword(t *testing.T) {
t.Setenv("API_TOKEN", "token-abc")
t.Setenv("WEB_PASSWORD", "hunter2")
if got := loadConfig().WebPassword; got != "hunter2" {
t.Fatalf("WebPassword = %q, want hunter2", got)
}
t.Setenv("WEB_PASSWORD", "")
if got := loadConfig().WebPassword; got != "" {
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
}
}
// A userscript PUT body has no latest_checked_at field. If the column is ever
// moved into bookmarkColumns, this test catches it: the PUT would reset the
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
srv := newRouter(s, testConfig())
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
// Exactly what the userscript sends: no latest_checked_at key at all.
body := `{"key":"asura:x","site":"asura","series_id":"x",
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 {
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
}
}
// The userscript route is registered outside the `if cfg.WebPassword != ""`
// block in newRouter, so it must keep working on a deployment that never set
// WEB_PASSWORD — see internal/userscript for the handler's own behaviour.
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "nopass.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
cfg := testConfig() // WebPassword empty
cfg.UserscriptPath = path
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil)
newRouter(s, cfg).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
}
@@ -1,4 +1,4 @@
package main package api
import ( import (
"encoding/json" "encoding/json"
@@ -6,10 +6,13 @@ import (
"net/http" "net/http"
"strings" "strings"
"time" "time"
"mangabm/backend/internal/store"
) )
type bookmarkHandler struct { // Handler serves the userscript-facing JSON bookmark API.
store *Store type Handler struct {
Store *store.Store
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -22,9 +25,9 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
} }
} }
// list returns all bookmarks. GET /bookmarks // List returns all bookmarks. GET /bookmarks
func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) { func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.store.List() items, err := h.Store.List()
if err != nil { if err != nil {
log.Printf("list: %v", err) log.Printf("list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -33,15 +36,15 @@ func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, items) writeJSON(w, http.StatusOK, items)
} }
// put upserts one bookmark. PUT /bookmarks/{key} // Put upserts one bookmark. PUT /bookmarks/{key}
func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) { func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key") key := r.PathValue("key")
if key == "" { if key == "" {
http.Error(w, "missing key", http.StatusBadRequest) http.Error(w, "missing key", http.StatusBadRequest)
return return
} }
var b Bookmark var b store.Bookmark
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest) http.Error(w, "invalid JSON body", http.StatusBadRequest)
return return
@@ -65,9 +68,9 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
// Finishing a series is a web-UI decision, so the JSON API refuses it // Finishing a series is a web-UI decision, so the JSON API refuses it
// rather than trusting every client to leave it alone. // rather than trusting every client to leave it alone.
switch b.Status { switch b.Status {
case "", statusReading, statusArchived: case "", store.StatusReading, store.StatusArchived:
case statusFinished: case store.StatusFinished:
http.Error(w, "status "+statusFinished+" can only be set from the web UI", http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
http.StatusBadRequest) http.StatusBadRequest)
return return
default: default:
@@ -79,7 +82,7 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
// reading progress actually moved. Any client value is ignored. // reading progress actually moved. Any client value is ignored.
b.UpdatedAt = time.Now().UnixMilli() b.UpdatedAt = time.Now().UnixMilli()
stored, err := h.store.Upsert(b) stored, err := h.Store.Upsert(b)
if err != nil { if err != nil {
log.Printf("upsert: %v", err) log.Printf("upsert: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
@@ -90,14 +93,14 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, stored) writeJSON(w, http.StatusOK, stored)
} }
// delete removes one bookmark. DELETE /bookmarks/{key} // Delete removes one bookmark. DELETE /bookmarks/{key}
func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key") key := r.PathValue("key")
if key == "" { if key == "" {
http.Error(w, "missing key", http.StatusBadRequest) http.Error(w, "missing key", http.StatusBadRequest)
return return
} }
if err := h.store.Delete(key); err != nil { if err := h.Store.Delete(key); err != nil {
log.Printf("delete: %v", err) log.Printf("delete: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
@@ -105,7 +108,8 @@ func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
func healthz(w http.ResponseWriter, r *http.Request) { // Healthz answers the unauthenticated liveness check. GET /healthz
func Healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok")) _, _ = w.Write([]byte("ok"))
@@ -1,4 +1,4 @@
package main package httpmw
import ( import (
"compress/gzip" "compress/gzip"
@@ -9,8 +9,8 @@ import (
const bearerPrefix = "Bearer " const bearerPrefix = "Bearer "
// withAuth guards a handler with a constant-time bearer-token check. // Auth guards a handler with a constant-time bearer-token check.
func withAuth(token string, next http.Handler) http.Handler { func Auth(token string, next http.Handler) http.Handler {
want := []byte(token) want := []byte(token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization") h := r.Header.Get("Authorization")
@@ -71,11 +71,11 @@ func (w *gzipWriter) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b) return w.ResponseWriter.Write(b)
} }
// withGzip compresses text responses for clients that ask. The templates, // Gzip compresses text responses for clients that ask. The templates,
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter // stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
// of that gzipped, which is the difference between a fast and a slow first load // of that gzipped, which is the difference between a fast and a slow first load
// on mobile data. // on mobile data.
func withGzip(next http.Handler) http.Handler { func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -92,11 +92,11 @@ func withGzip(next http.Handler) http.Handler {
}) })
} }
// withCORS reflects the request Origin only when it is in allowed, answers // CORS reflects the request Origin only when it is in allowed, answers
// preflight OPTIONS with 204, and passes everything else through. It wraps the // preflight OPTIONS with 204, and passes everything else through. It wraps the
// auth middleware so preflight (which carries no Authorization header) is never // auth middleware so preflight (which carries no Authorization header) is never
// rejected by auth. // rejected by auth.
func withCORS(allowed []string, next http.Handler) http.Handler { func CORS(allowed []string, next http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed)) set := make(map[string]struct{}, len(allowed))
for _, o := range allowed { for _, o := range allowed {
set[o] = struct{}{} set[o] = struct{}{}
@@ -1,4 +1,4 @@
package main package latest
import ( import (
"context" "context"
@@ -20,20 +20,20 @@ const maxBodyBytes = 4 << 20
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " + const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36" "(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"
// tlsFetcher fetches series pages with a Chrome TLS fingerprint. // TLSFetcher fetches series pages with a Chrome TLS fingerprint.
// //
// Plain net/http was verified working against both sites on 2026-07-26, so this // Plain net/http was verified working against both sites on 2026-07-26, so this
// is not fixing an observed block — it is deliberate defence-in-depth against a // is not fixing an observed block — it is deliberate defence-in-depth against a
// future fingerprint-based one, chosen up front rather than reacted to later. // future fingerprint-based one, chosen up front rather than reacted to later.
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the // The library is pure Go, so CGO_ENABLED=0, the static binary, and the
// distroless image are all unaffected. // distroless image are all unaffected.
type tlsFetcher struct { type TLSFetcher struct {
client tls_client.HttpClient client tls_client.HttpClient
} }
var _ fetcher = (*tlsFetcher)(nil) var _ Fetcher = (*TLSFetcher)(nil)
func newTLSFetcher() (*tlsFetcher, error) { func NewTLSFetcher() (*TLSFetcher, error) {
c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
tls_client.WithTimeoutSeconds(30), tls_client.WithTimeoutSeconds(30),
tls_client.WithClientProfile(profiles.Chrome_133), tls_client.WithClientProfile(profiles.Chrome_133),
@@ -41,13 +41,13 @@ func newTLSFetcher() (*tlsFetcher, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("new tls client: %w", err) return nil, fmt.Errorf("new tls client: %w", err)
} }
return &tlsFetcher{client: c}, nil return &TLSFetcher{client: c}, nil
} }
// Get fetches url and returns the body and status. Redirects are followed: the // Get fetches url and returns the body and status. Redirects are followed: the
// demonic chapter anchors are a redirect form, and asura has moved domains // demonic chapter anchors are a redirect form, and asura has moved domains
// before. // before.
func (f *tlsFetcher) Get(ctx context.Context, url string) (string, int, error) { func (f *TLSFetcher) Get(ctx context.Context, url string) (string, int, error) {
req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil) req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil)
if err != nil { if err != nil {
return "", 0, fmt.Errorf("build request %q: %w", url, err) return "", 0, fmt.Errorf("build request %q: %w", url, err)
@@ -1,40 +1,42 @@
package main package latest
import ( import (
"context" "context"
"log" "log"
"net/url" "net/url"
"time" "time"
"mangabm/backend/internal/store"
) )
// fetcher retrieves a series page. It exists as an interface so tests can inject // Fetcher retrieves a series page. It exists as an interface so tests can inject
// a fake: nothing in the test suite may touch the network or the TLS client. // a fake: nothing in the test suite may touch the network or the TLS client.
type fetcher interface { type Fetcher interface {
Get(ctx context.Context, url string) (body string, status int, err error) Get(ctx context.Context, url string) (body string, status int, err error)
} }
// latestPoller re-checks each bookmarked series' newest published chapter on a // Poller re-checks each bookmarked series' newest published chapter on a
// schedule, independent of the userscript's own in-browser checks. The two run // schedule, independent of the userscript's own in-browser checks. The two run
// in parallel and report the same observable fact, so whichever writes last wins // in parallel and report the same observable fact, so whichever writes last wins
// and neither needs to know about the other. // and neither needs to know about the other.
// //
// Two clocks, deliberately independent: // Two clocks, deliberately independent:
// //
// - interval is how often this goroutine wakes up and looks. // - Interval is how often this goroutine wakes up and looks.
// - cooldown is how long one bookmark rests since its own last check. // - Cooldown is how long one bookmark rests since its own last check.
// //
// Only the cooldown is per bookmark, and it is enforced by the WHERE clause in // Only the cooldown is per bookmark, and it is enforced by the WHERE clause in
// DueForLatestCheck rather than by any timer. Shortening interval therefore // DueForLatestCheck rather than by any timer. Shortening Interval therefore
// cannot shorten anyone's cooldown; it only makes the poller wake up and find // cannot shorten anyone's cooldown; it only makes the poller wake up and find
// nothing due more often. // nothing due more often.
type latestPoller struct { type Poller struct {
store *Store Store *store.Store
fetch fetcher Fetch Fetcher
now func() time.Time // injected so tests can freeze it Now func() time.Time // injected so tests can freeze it
cooldown time.Duration Cooldown time.Duration
interval time.Duration Interval time.Duration
stagger time.Duration Stagger time.Duration
batch int Batch int
} }
// Run polls until ctx is cancelled. // Run polls until ctx is cancelled.
@@ -43,10 +45,10 @@ type latestPoller struct {
// next one instead of stacking a second batch on top of it. That is the intended // next one instead of stacking a second batch on top of it. That is the intended
// failure mode for a misconfigured batch x stagger: a slower cadence, never // failure mode for a misconfigured batch x stagger: a slower cadence, never
// concurrent fetch storms. // concurrent fetch storms.
func (p *latestPoller) Run(ctx context.Context) { func (p *Poller) Run(ctx context.Context) {
log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s", log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s",
p.interval, p.cooldown, p.batch, p.stagger) p.Interval, p.Cooldown, p.Batch, p.Stagger)
t := time.NewTicker(p.interval) t := time.NewTicker(p.Interval)
defer t.Stop() defer t.Stop()
for { for {
select { select {
@@ -60,9 +62,9 @@ func (p *latestPoller) Run(ctx context.Context) {
} }
// runOnce processes one batch of due bookmarks. // runOnce processes one batch of due bookmarks.
func (p *latestPoller) runOnce(ctx context.Context) { func (p *Poller) runOnce(ctx context.Context) {
cutoff := p.now().Add(-p.cooldown).UnixMilli() cutoff := p.Now().Add(-p.Cooldown).UnixMilli()
due, err := p.store.DueForLatestCheck(cutoff, p.batch) due, err := p.Store.DueForLatestCheck(cutoff, p.Batch)
if err != nil { if err != nil {
log.Printf("latest poll: due query: %v", err) log.Printf("latest poll: due query: %v", err)
return return
@@ -78,11 +80,11 @@ func (p *latestPoller) runOnce(ctx context.Context) {
// bot score. This is the server-side analogue of the userscript's "one // bot score. This is the server-side analogue of the userscript's "one
// series per navigation ... indistinguishable from browsing" (L455-456). // series per navigation ... indistinguishable from browsing" (L455-456).
stopped := false stopped := false
if i > 0 && p.stagger > 0 { if i > 0 && p.Stagger > 0 {
select { select {
case <-ctx.Done(): case <-ctx.Done():
stopped = true stopped = true
case <-time.After(p.stagger): case <-time.After(p.Stagger):
} }
} }
if stopped { if stopped {
@@ -100,7 +102,7 @@ func (p *latestPoller) runOnce(ctx context.Context) {
// checkOne re-checks one series. Every failure path here is "log and move on": // checkOne re-checks one series. Every failure path here is "log and move on":
// the poller is a best-effort enhancement, and no single bad series may stall a // the poller is a best-effort enhancement, and no single bad series may stall a
// batch or take down the process. // batch or take down the process.
func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) { func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
log.Printf("latest poll %q: recovered from panic: %v", b.Key, r) log.Printf("latest poll %q: recovered from panic: %v", b.Key, r)
@@ -111,7 +113,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
// mid-request still consumes the cooldown. Otherwise a renamed or deleted // mid-request still consumes the cooldown. Otherwise a renamed or deleted
// series would be retried on every single tick forever. The userscript // series would be retried on every single tick forever. The userscript
// stamps in the same order and for the same reason (L471-473). // stamps in the same order and for the same reason (L471-473).
if err := p.store.MarkLatestChecked(b.Key, p.now().UnixMilli()); err != nil { if err := p.Store.MarkLatestChecked(b.Key, p.Now().UnixMilli()); err != nil {
log.Printf("latest poll %q: mark checked: %v", b.Key, err) log.Printf("latest poll %q: mark checked: %v", b.Key, err)
return return
} }
@@ -128,7 +130,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
return return
} }
body, status, err := p.fetch.Get(ctx, b.SeriesURL) body, status, err := p.Fetch.Get(ctx, b.SeriesURL)
if err != nil { if err != nil {
log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err) log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err)
return return
@@ -155,7 +157,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
// progress or a status change, and moving updated_at because the stored // progress or a status change, and moving updated_at because the stored
// value now differs. Accepted for a single-user deployment: the window is // value now differs. Accepted for a single-user deployment: the window is
// milliseconds and the loser is one poll cycle. // milliseconds and the loser is one poll cycle.
cur, found, err := p.store.Get(b.Key) cur, found, err := p.Store.Get(b.Key)
if err != nil { if err != nil {
log.Printf("latest poll %q: reread: %v", b.Key, err) log.Printf("latest poll %q: reread: %v", b.Key, err)
return return
@@ -174,8 +176,8 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
cur.LatestChapterNum = &num cur.LatestChapterNum = &num
// A candidate only. last_chapter_num is untouched, so the CASE in Upsert // A candidate only. last_chapter_num is untouched, so the CASE in Upsert
// keeps the stored updated_at and the bookmark list does not reorder. // keeps the stored updated_at and the bookmark list does not reorder.
cur.UpdatedAt = p.now().UnixMilli() cur.UpdatedAt = p.Now().UnixMilli()
if _, err := p.store.Upsert(cur); err != nil { if _, err := p.Store.Upsert(cur); err != nil {
log.Printf("latest poll %q: upsert: %v", b.Key, err) log.Printf("latest poll %q: upsert: %v", b.Key, err)
return return
} }
@@ -1,13 +1,53 @@
package main package latest
import ( import (
"context" "context"
"errors" "errors"
"path/filepath"
"sync" "sync"
"testing" "testing"
"time" "time"
"mangabm/backend/internal/store"
) )
// newTestStore opens a fresh SQLite store in a temp dir.
func newTestStore(t *testing.T) *store.Store {
t.Helper()
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// seedForCheck inserts a bookmark and forces its latest_checked_at.
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
t.Helper()
if _, err := s.Upsert(store.Bookmark{
Key: key,
Site: "asura",
SeriesID: key,
SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
t.Fatalf("seed mark %q: %v", key, err)
}
}
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
t.Helper()
ts, err := s.LatestCheckedAt(key)
if err != nil {
t.Fatalf("LatestCheckedAt %q: %v", key, err)
}
return ts
}
// fakeFetcher stands in for the network. Every poller test uses it, so nothing // fakeFetcher stands in for the network. Every poller test uses it, so nothing
// in this file can reach tls-client or a real site. // in this file can reach tls-client or a real site.
type fakeFetcher struct { type fakeFetcher struct {
@@ -44,16 +84,16 @@ func (f *fakeFetcher) callCount() int {
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run // newTestPoller wires a poller with a frozen clock and no stagger, so tests run
// instantly and deterministically. // instantly and deterministically.
func newTestPoller(t *testing.T, s *Store, f fetcher, at time.Time) *latestPoller { func newTestPoller(t *testing.T, s *store.Store, f Fetcher, at time.Time) *Poller {
t.Helper() t.Helper()
return &latestPoller{ return &Poller{
store: s, Store: s,
fetch: f, Fetch: f,
now: func() time.Time { return at }, Now: func() time.Time { return at },
cooldown: time.Hour, Cooldown: time.Hour,
interval: 10 * time.Minute, Interval: 10 * time.Minute,
stagger: 0, Stagger: 0,
batch: 14, Batch: 14,
} }
} }
@@ -89,7 +129,7 @@ func TestRunOnceDoesNotReorderList(t *testing.T) {
const key = "asura:chronicles-of-the-demon-faction-f886a8af" const key = "asura:chronicles-of-the-demon-faction-f886a8af"
// "other" is the most recently read, so it must stay at the top of List(). // "other" is the most recently read, so it must stay at the top of List().
if _, err := s.Upsert(Bookmark{ if _, err := s.Upsert(store.Bookmark{
Key: "asura:other", Site: "asura", SeriesID: "other", Key: "asura:other", Site: "asura", SeriesID: "other",
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000, SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
}); err != nil { }); err != nil {
@@ -166,7 +206,7 @@ func TestRunOnceRespectsBatchLimit(t *testing.T) {
f := &fakeFetcher{body: "", status: 200} f := &fakeFetcher{body: "", status: 200}
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000)) p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
p.batch = 5 p.Batch = 5
p.runOnce(context.Background()) p.runOnce(context.Background())
if got := f.callCount(); got != 5 { if got := f.callCount(); got != 5 {
@@ -218,13 +258,13 @@ func TestRunOnceHonoursCooldownAcrossPasses(t *testing.T) {
} }
// Same instant, and again 59 minutes later: both inside the 1h cooldown. // Same instant, and again 59 minutes later: both inside the 1h cooldown.
p.runOnce(context.Background()) p.runOnce(context.Background())
p.now = func() time.Time { return now.Add(59 * time.Minute) } p.Now = func() time.Time { return now.Add(59 * time.Minute) }
p.runOnce(context.Background()) p.runOnce(context.Background())
if got := f.callCount(); got != 1 { if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times inside the cooldown, want 1", got) t.Fatalf("fetched %d times inside the cooldown, want 1", got)
} }
// Past the cooldown, it is due again. // Past the cooldown, it is due again.
p.now = func() time.Time { return now.Add(61 * time.Minute) } p.Now = func() time.Time { return now.Add(61 * time.Minute) }
p.runOnce(context.Background()) p.runOnce(context.Background())
if got := f.callCount(); got != 2 { if got := f.callCount(); got != 2 {
t.Fatalf("fetched %d times after the cooldown, want 2", got) t.Fatalf("fetched %d times after the cooldown, want 2", got)
@@ -239,7 +279,7 @@ func TestRunOnceCorrectsDownward(t *testing.T) {
const key = "demonic:Catastrophic-Necromancer" const key = "demonic:Catastrophic-Necromancer"
high := 400.0 high := 400.0
if _, err := s.Upsert(Bookmark{ if _, err := s.Upsert(store.Bookmark{
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer", Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high, SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
UpdatedAt: 1000, UpdatedAt: 1000,
@@ -278,7 +318,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
s := newTestStore(t) s := newTestStore(t)
key := tt.site + ":x" key := tt.site + ":x"
if _, err := s.Upsert(Bookmark{ if _, err := s.Upsert(store.Bookmark{
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL, Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
UpdatedAt: 1000, UpdatedAt: 1000,
}); err != nil { }); err != nil {
@@ -287,7 +327,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
now := time.UnixMilli(4_000_000) now := time.UnixMilli(4_000_000)
f := &fakeFetcher{body: asuraSeriesFixture, status: 200} f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
newTestPoller(t, s, f, now).checkOne(context.Background(), Bookmark{ newTestPoller(t, s, f, now).checkOne(context.Background(), store.Bookmark{
Key: key, Site: tt.site, SeriesURL: tt.seriesURL, Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
}) })
@@ -1,9 +1,11 @@
package main package latest
import ( import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"mangabm/backend/internal/store"
) )
// latestChapter is the newest chapter a series page advertises. // latestChapter is the newest chapter a series page advertises.
@@ -53,7 +55,7 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
// chapter hrefs in the fetched body carry the current one. Strip to // chapter hrefs in the fetched body carry the current one. Strip to
// the stable ID (same rule as migrateAsuraKeys) and make the hash // the stable ID (same rule as migrateAsuraKeys) and make the hash
// optional in the pattern, so scoping survives rotations. // optional in the pattern, so scoping survives rotations.
slug := asuraBuildHash.ReplaceAllString(m[1], "") slug := store.AsuraBuildHash.ReplaceAllString(m[1], "")
// Compiled per call rather than cached: this runs once per fetch, which // Compiled per call rather than cached: this runs once per fetch, which
// is at most a few times a minute, and the slug varies per series. // is at most a few times a minute, and the slug varies per series.
re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`) re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`)
@@ -1,4 +1,4 @@
package main package latest
import "testing" import "testing"
@@ -1,4 +1,4 @@
package main package session
import ( import (
"crypto/hmac" "crypto/hmac"
@@ -14,7 +14,7 @@ import (
) )
const ( const (
sessionCookieName = "mangabm_session" CookieName = "mangabm_session"
// 60 days: long enough that a phone stays logged in between reading spells. // 60 days: long enough that a phone stays logged in between reading spells.
sessionTTL = 60 * 24 * time.Hour sessionTTL = 60 * 24 * time.Hour
// Domain separation, so the session key can never collide with any other // Domain separation, so the session key can never collide with any other
@@ -23,18 +23,18 @@ const (
sessionKeyPurpose = "mangabm-web-session-v1" sessionKeyPurpose = "mangabm-web-session-v1"
) )
// sessionKey derives the cookie-signing key from both secrets. Sessions are // Key derives the cookie-signing key from both secrets. Sessions are
// stateless — there is no session table — so rotating either API_TOKEN or // stateless — there is no session table — so rotating either API_TOKEN or
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00 // WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
// separator prevents the concatenation ambiguity a bare apiToken+webPassword // separator prevents the concatenation ambiguity a bare apiToken+webPassword
// would have (e.g. "ab"+"c" colliding with "a"+"bc"). // would have (e.g. "ab"+"c" colliding with "a"+"bc").
func sessionKey(apiToken, webPassword string) []byte { func Key(apiToken, webPassword string) []byte {
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose)) sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
return sum[:] return sum[:]
} }
// signSession encodes "<expiryMs>.<base64url HMAC(expiryMs)>". // Sign encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
func signSession(key []byte, expiryMs int64) string { func Sign(key []byte, expiryMs int64) string {
payload := strconv.FormatInt(expiryMs, 10) payload := strconv.FormatInt(expiryMs, 10)
return payload + "." + sessionMAC(key, payload) return payload + "." + sessionMAC(key, payload)
} }
@@ -45,10 +45,10 @@ func sessionMAC(key []byte, payload string) string {
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
} }
// verifySession checks shape, then expiry, then the signature — in that order. // Verify checks shape, then expiry, then the signature — in that order.
// The signature comparison is constant-time; the checks before it only look at // The signature comparison is constant-time; the checks before it only look at
// data the holder already supplied, so their timing leaks nothing. // data the holder already supplied, so their timing leaks nothing.
func verifySession(key []byte, value string, nowMs int64) bool { func Verify(key []byte, value string, nowMs int64) bool {
payload, sig, ok := strings.Cut(value, ".") payload, sig, ok := strings.Cut(value, ".")
if !ok { if !ok {
return false return false
@@ -69,10 +69,10 @@ func isHTTPS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
} }
func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) { func SetCookie(w http.ResponseWriter, r *http.Request, key []byte) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: CookieName,
Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()), Value: Sign(key, time.Now().Add(sessionTTL).UnixMilli()),
Path: "/", Path: "/",
MaxAge: int(sessionTTL / time.Second), MaxAge: int(sessionTTL / time.Second),
HttpOnly: true, HttpOnly: true,
@@ -81,9 +81,9 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) {
}) })
} }
func clearSessionCookie(w http.ResponseWriter, r *http.Request) { func ClearCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: CookieName,
Value: "", Value: "",
Path: "/", Path: "/",
MaxAge: -1, MaxAge: -1,
@@ -94,11 +94,11 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
} }
const ( const (
loginMaxFailures = 10 MaxFailures = 10
loginWindow = 20 * time.Minute Window = 20 * time.Minute
) )
// clientIP returns the address the reverse proxy actually observed. // ClientIP returns the address the reverse proxy actually observed.
// //
// Traefik appends the peer address to whatever X-Forwarded-For the client sent, // Traefik appends the peer address to whatever X-Forwarded-For the client sent,
// so the leftmost entry is attacker-controlled and the rightmost is not. Go's // so the leftmost entry is attacker-controlled and the rightmost is not. Go's
@@ -106,7 +106,7 @@ const (
// by sending its own; Values covers every line so the true last hop is found. // by sending its own; Values covers every line so the true last hop is found.
// RemoteAddr is useless behind the proxy — it is always the Traefik container — // RemoteAddr is useless behind the proxy — it is always the Traefik container —
// so it serves only as the direct-connection fallback for local development. // so it serves only as the direct-connection fallback for local development.
func clientIP(r *http.Request) string { func ClientIP(r *http.Request) string {
if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 { if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 {
hops := strings.Split(vals[len(vals)-1], ",") hops := strings.Split(vals[len(vals)-1], ",")
if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" { if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" {
@@ -120,8 +120,8 @@ func clientIP(r *http.Request) string {
return host return host
} }
// loginLimiter throttles password guessing: loginMaxFailures failures inside a // LoginLimiter throttles password guessing: MaxFailures failures inside a
// rolling loginWindow blocks further attempts from that IP until the oldest one // rolling Window blocks further attempts from that IP until the oldest one
// ages out. There is no permanent ban and no unlock step. // ages out. There is no permanent ban and no unlock step.
// //
// Behind carrier-grade NAT this budget is shared with every other subscriber on // Behind carrier-grade NAT this budget is shared with every other subscriber on
@@ -132,34 +132,34 @@ func clientIP(r *http.Request) string {
// State is in memory and per-process, so a restart clears it. Entries are // State is in memory and per-process, so a restart clears it. Entries are
// pruned lazily on access; for a single-user deployment the map cannot grow // pruned lazily on access; for a single-user deployment the map cannot grow
// past the handful of addresses that ever attempt a login. // past the handful of addresses that ever attempt a login.
type loginLimiter struct { type LoginLimiter struct {
mu sync.Mutex mu sync.Mutex
failures map[string][]time.Time failures map[string][]time.Time
} }
func newLoginLimiter() *loginLimiter { func NewLoginLimiter() *LoginLimiter {
return &loginLimiter{failures: make(map[string][]time.Time)} return &LoginLimiter{failures: make(map[string][]time.Time)}
} }
// retryAfter returns how long ip must wait, or zero when it may try now. // retryAfter returns how long ip must wait, or zero when it may try now.
func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration { func (l *LoginLimiter) RetryAfter(ip string, now time.Time) time.Duration {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
recent := l.pruneLocked(ip, now) recent := l.pruneLocked(ip, now)
if len(recent) < loginMaxFailures { if len(recent) < MaxFailures {
return 0 return 0
} }
return recent[0].Add(loginWindow).Sub(now) return recent[0].Add(Window).Sub(now)
} }
func (l *loginLimiter) fail(ip string, now time.Time) { func (l *LoginLimiter) Fail(ip string, now time.Time) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
l.failures[ip] = append(l.pruneLocked(ip, now), now) l.failures[ip] = append(l.pruneLocked(ip, now), now)
} }
func (l *loginLimiter) reset(ip string) { func (l *LoginLimiter) Reset(ip string) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
delete(l.failures, ip) delete(l.failures, ip)
@@ -167,8 +167,8 @@ func (l *loginLimiter) reset(ip string) {
// pruneLocked drops attempts older than the window and returns what is left. // pruneLocked drops attempts older than the window and returns what is left.
// The caller must hold l.mu. // The caller must hold l.mu.
func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time { func (l *LoginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
cutoff := now.Add(-loginWindow) cutoff := now.Add(-Window)
// In-place filter: kept reuses the backing array of the slice being // In-place filter: kept reuses the backing array of the slice being
// ranged over. Safe to alias because append writes at index len(kept), // ranged over. Safe to alias because append writes at index len(kept),
// which is always <= the range index i, and element i is read before // which is always <= the range index i, and element i is read before
@@ -1,4 +1,4 @@
package main package session
import ( import (
"crypto/tls" "crypto/tls"
@@ -10,18 +10,18 @@ import (
) )
func TestSessionRoundTrip(t *testing.T) { func TestSessionRoundTrip(t *testing.T) {
key := sessionKey("token-abc", "pw-abc") key := Key("token-abc", "pw-abc")
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
value := signSession(key, now+60_000) value := Sign(key, now+60_000)
if !verifySession(key, value, now) { if !Verify(key, value, now) {
t.Fatal("verifySession = false for a freshly signed cookie, want true") t.Fatal("Verify = false for a freshly signed cookie, want true")
} }
} }
func TestSessionRejects(t *testing.T) { func TestSessionRejects(t *testing.T) {
key := sessionKey("token-abc", "pw-abc") key := Key("token-abc", "pw-abc")
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
valid := signSession(key, now+60_000) valid := Sign(key, now+60_000)
payload, sig, _ := strings.Cut(valid, ".") payload, sig, _ := strings.Cut(valid, ".")
cases := []struct { cases := []struct {
@@ -31,15 +31,15 @@ func TestSessionRejects(t *testing.T) {
{"empty", ""}, {"empty", ""},
{"no separator", payload + sig}, {"no separator", payload + sig},
{"unparseable expiry", "notanumber." + sig}, {"unparseable expiry", "notanumber." + sig},
{"expired", signSession(key, now-1)}, {"expired", Sign(key, now-1)},
{"tampered signature", payload + "." + flipLastChar(sig)}, {"tampered signature", payload + "." + flipLastChar(sig)},
{"tampered expiry", "99999999999999." + sig}, {"tampered expiry", "99999999999999." + sig},
{"signed with another key", signSession(sessionKey("other-token", "pw-abc"), now+60_000)}, {"signed with another key", Sign(Key("other-token", "pw-abc"), now+60_000)},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
if verifySession(key, tc.value, now) { if Verify(key, tc.value, now) {
t.Fatalf("verifySession(%q) = true, want false", tc.value) t.Fatalf("Verify(%q) = true, want false", tc.value)
} }
}) })
} }
@@ -57,18 +57,18 @@ func flipLastChar(s string) string {
} }
func TestSessionKeyDependsOnToken(t *testing.T) { func TestSessionKeyDependsOnToken(t *testing.T) {
a := sessionKey("token-a", "pw-abc") a := Key("token-a", "pw-abc")
b := sessionKey("token-b", "pw-abc") b := Key("token-b", "pw-abc")
if string(a) == string(b) { if string(a) == string(b) {
t.Fatal("sessionKey collided for different API tokens") t.Fatal("Key collided for different API tokens")
} }
} }
func TestSessionKeyDependsOnWebPassword(t *testing.T) { func TestSessionKeyDependsOnWebPassword(t *testing.T) {
a := sessionKey("token-abc", "pw-a") a := Key("token-abc", "pw-a")
b := sessionKey("token-abc", "pw-b") b := Key("token-abc", "pw-b")
if string(a) == string(b) { if string(a) == string(b) {
t.Fatal("sessionKey collided for different web passwords with the same API token") t.Fatal("Key collided for different web passwords with the same API token")
} }
} }
@@ -94,15 +94,15 @@ func TestSetSessionCookieAttributes(t *testing.T) {
r.Header.Set("X-Forwarded-Proto", tc.forwarded) r.Header.Set("X-Forwarded-Proto", tc.forwarded)
} }
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc")) SetCookie(rr, r, Key("token-abc", "pw-abc"))
cookies := rr.Result().Cookies() cookies := rr.Result().Cookies()
if len(cookies) != 1 { if len(cookies) != 1 {
t.Fatalf("got %d cookies, want 1", len(cookies)) t.Fatalf("got %d cookies, want 1", len(cookies))
} }
c := cookies[0] c := cookies[0]
if c.Name != sessionCookieName { if c.Name != CookieName {
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName) t.Fatalf("cookie name = %q, want %q", c.Name, CookieName)
} }
if !c.HttpOnly { if !c.HttpOnly {
t.Fatal("cookie HttpOnly = false, want true") t.Fatal("cookie HttpOnly = false, want true")
@@ -126,7 +126,7 @@ func TestSetSessionCookieAttributes(t *testing.T) {
func TestClearSessionCookie(t *testing.T) { func TestClearSessionCookie(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/logout", nil) r := httptest.NewRequest(http.MethodPost, "/logout", nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
clearSessionCookie(rr, r) ClearCookie(rr, r)
cookies := rr.Result().Cookies() cookies := rr.Result().Cookies()
if len(cookies) != 1 { if len(cookies) != 1 {
@@ -168,65 +168,65 @@ func TestClientIP(t *testing.T) {
for _, v := range tc.xff { for _, v := range tc.xff {
r.Header.Add("X-Forwarded-For", v) r.Header.Add("X-Forwarded-For", v)
} }
if got := clientIP(r); got != tc.want { if got := ClientIP(r); got != tc.want {
t.Fatalf("clientIP() = %q, want %q", got, tc.want) t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
} }
}) })
} }
} }
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) { func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
l := newLoginLimiter() l := NewLoginLimiter()
now := time.Now() now := time.Now()
for i := 0; i < loginMaxFailures; i++ { for i := 0; i < MaxFailures; i++ {
if wait := l.retryAfter("1.2.3.4", now); wait != 0 { if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures) t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures)
} }
l.fail("1.2.3.4", now) l.Fail("1.2.3.4", now)
} }
wait := l.retryAfter("1.2.3.4", now) wait := l.RetryAfter("1.2.3.4", now)
if wait <= 0 { if wait <= 0 {
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures) t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures)
} }
if wait > loginWindow { if wait > Window {
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow) t.Fatalf("retryAfter = %v, want <= %v", wait, Window)
} }
} }
func TestLoginLimiterWindowExpires(t *testing.T) { func TestLoginLimiterWindowExpires(t *testing.T) {
l := newLoginLimiter() l := NewLoginLimiter()
start := time.Now() start := time.Now()
for i := 0; i < loginMaxFailures; i++ { for i := 0; i < MaxFailures; i++ {
l.fail("1.2.3.4", start) l.Fail("1.2.3.4", start)
} }
if l.retryAfter("1.2.3.4", start) == 0 { if l.RetryAfter("1.2.3.4", start) == 0 {
t.Fatal("expected block immediately after the failures") t.Fatal("expected block immediately after the failures")
} }
later := start.Add(loginWindow + time.Second) later := start.Add(Window + time.Second)
if wait := l.retryAfter("1.2.3.4", later); wait != 0 { if wait := l.RetryAfter("1.2.3.4", later); wait != 0 {
t.Fatalf("retryAfter = %v once the window passed, want 0", wait) t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
} }
} }
func TestLoginLimiterResetClearsCounter(t *testing.T) { func TestLoginLimiterResetClearsCounter(t *testing.T) {
l := newLoginLimiter() l := NewLoginLimiter()
now := time.Now() now := time.Now()
for i := 0; i < loginMaxFailures; i++ { for i := 0; i < MaxFailures; i++ {
l.fail("1.2.3.4", now) l.Fail("1.2.3.4", now)
} }
l.reset("1.2.3.4") l.Reset("1.2.3.4")
if wait := l.retryAfter("1.2.3.4", now); wait != 0 { if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
t.Fatalf("retryAfter = %v after reset, want 0", wait) t.Fatalf("retryAfter = %v after reset, want 0", wait)
} }
} }
func TestLoginLimiterIsPerIP(t *testing.T) { func TestLoginLimiterIsPerIP(t *testing.T) {
l := newLoginLimiter() l := NewLoginLimiter()
now := time.Now() now := time.Now()
for i := 0; i < loginMaxFailures; i++ { for i := 0; i < MaxFailures; i++ {
l.fail("1.2.3.4", now) l.Fail("1.2.3.4", now)
} }
if wait := l.retryAfter("5.6.7.8", now); wait != 0 { if wait := l.RetryAfter("5.6.7.8", now); wait != 0 {
t.Fatalf("retryAfter for a different IP = %v, want 0", wait) t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
} }
} }
@@ -1,4 +1,4 @@
package main package store
import ( import (
"database/sql" "database/sql"
@@ -86,11 +86,21 @@ func (b Bookmark) ContinueURL() string {
return b.SeriesURL return b.SeriesURL
} }
// Initial is the monogram the web UI shows in place of a cover when the
// source site never gave us an og:image. First rune, uppercased; "?" when even
// the title is missing, so the slot is never empty.
func (b Bookmark) Initial() string {
for _, r := range b.Title {
return strings.ToUpper(string(r))
}
return "?"
}
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal. // Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
const ( const (
statusReading = "reading" StatusReading = "reading"
statusArchived = "archived" StatusArchived = "archived"
statusFinished = "finished" StatusFinished = "finished"
) )
const schema = ` const schema = `
@@ -137,7 +147,7 @@ type Store struct {
} }
// OpenStore opens (or creates) the SQLite database at path and applies the schema. // OpenStore opens (or creates) the SQLite database at path and applies the schema.
func OpenStore(path string) (*Store, error) { func Open(path string) (*Store, error) {
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's // busy_timeout guards against SQLITE_BUSY under the reverse proxy's
// concurrent requests; a single writer connection keeps writes serialized. // concurrent requests; a single writer connection keeps writes serialized.
dsn := path dsn := path
@@ -182,11 +192,11 @@ func migrateColumns(db *sql.DB) error {
return nil return nil
} }
// asuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura // AsuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura
// appends to every series slug. It rotates on each site redeploy, so it // appends to every series slug. It rotates on each site redeploy, so it
// must not be part of series_id. Must stay in sync with stripBuildHash in // must not be part of series_id. Must stay in sync with stripBuildHash in
// userscript/manga-bookmark.user.js. // userscript/manga-bookmark.user.js.
var asuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`) var AsuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`)
// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries // migrateAsuraKeys rewrites asura bookmarks whose series_id still carries
// the build hash to the stable, hashless ID. Rows keyed with a hash are // the build hash to the stable, hashless ID. Rows keyed with a hash are
@@ -218,7 +228,7 @@ func migrateAsuraKeys(db *sql.DB) error {
groups := map[string][]row{} groups := map[string][]row{}
for _, r := range all { for _, r := range all {
stripped := asuraBuildHash.ReplaceAllString(r.id, "") stripped := AsuraBuildHash.ReplaceAllString(r.id, "")
groups[stripped] = append(groups[stripped], r) groups[stripped] = append(groups[stripped], r)
} }
for stripped, g := range groups { for stripped, g := range groups {
@@ -305,8 +315,8 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
// leave the row in no list at all, so anything outside the three known // leave the row in no list at all, so anything outside the three known
// buckets reads as the default rather than being passed through. // buckets reads as the default rather than being passed through.
b.Status = status.String b.Status = status.String
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished { if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
b.Status = statusReading b.Status = StatusReading
} }
return b, nil return b, nil
} }
@@ -481,3 +491,16 @@ func (s *Store) MarkLatestChecked(key string, ts int64) error {
} }
return nil return nil
} }
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
// tests outside this package (the poller's own tests assert on cooldown
// bookkeeping) — see MarkLatestChecked for why the field itself stays off
// Bookmark.
func (s *Store) LatestCheckedAt(key string) (int64, error) {
var ts int64
if err := s.db.QueryRow(
`SELECT latest_checked_at FROM bookmarks WHERE key = ?`, key).Scan(&ts); err != nil {
return 0, fmt.Errorf("latest checked at %q: %w", key, err)
}
return ts, nil
}
@@ -1,42 +1,15 @@
package main package store
import ( import (
"bytes"
"database/sql" "database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
) )
const testToken = "s3cret-token"
func testConfig() Config {
return Config{
Token: testToken,
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
Port: "8080",
}
}
func newTestServer(t *testing.T) http.Handler {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
t.Cleanup(func() { store.Close() })
return newRouter(store, testConfig())
}
func newTestStore(t *testing.T) *Store { func newTestStore(t *testing.T) *Store {
t.Helper() t.Helper()
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) store, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil { if err != nil {
t.Fatalf("OpenStore: %v", err) t.Fatalf("OpenStore: %v", err)
} }
@@ -44,346 +17,6 @@ func newTestStore(t *testing.T) *Store {
return store return store
} }
func auth(req *http.Request) *http.Request {
req.Header.Set("Authorization", "Bearer "+testToken)
return req
}
func TestHealthzNoAuth(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", rr.Code)
}
if rr.Body.String() != "ok" {
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
}
}
func TestAuthRequired(t *testing.T) {
srv := newTestServer(t)
cases := []struct {
name string
header string
}{
{"no header", ""},
{"bad token", "Bearer wrong"},
{"not bearer", "Basic " + testToken},
{"empty bearer", "Bearer "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rr.Code)
}
})
}
}
func TestAuthAccepted(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "[]\n" {
t.Fatalf("empty list body = %q, want []", got)
}
}
func TestCORSPreflight(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
req.Header.Set("Origin", "https://asuracomic.net")
req.Header.Set("Access-Control-Request-Method", "PUT")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rr.Code)
}
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
}
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("Allow-Methods missing")
}
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
t.Fatal("Allow-Headers missing")
}
}
func TestCORSDisallowedOrigin(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
}
}
func TestBookmarkRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "asura:solo-leveling-123"
in := Bookmark{
Title: "Solo Leveling",
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
Cover: "https://asuracomic.net/cover.jpg",
LastChapter: "Chapter 10",
LastChapterNum: 10,
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
}
body, _ := json.Marshal(in)
// PUT
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200", rr.Code)
}
var stored Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
t.Fatalf("derived fields wrong: %+v", stored)
}
if stored.UpdatedAt == 0 {
t.Fatal("server did not set updated_at")
}
// GET
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
var list []Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
t.Fatalf("GET list wrong: %+v", list)
}
// PUT again (upsert, progress advance)
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
body, _ = json.Marshal(in)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("second PUT status = %d", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 1 || list[0].LastChapterNum != 11 {
t.Fatalf("upsert did not update in place: %+v", list)
}
// DELETE
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE status = %d, want 204", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("after delete list = %+v, want empty", list)
}
}
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
// which is the row as actually stored (not the request payload).
func putBookmark(t *testing.T, srv http.Handler, key string, b Bookmark) Bookmark {
t.Helper()
body, _ := json.Marshal(b)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
}
var out Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return out
}
func getBookmarks(t *testing.T, srv http.Handler) []Bookmark {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("GET status = %d", rr.Code)
}
var list []Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
return list
}
func floatPtr(f float64) *float64 { return &f }
// updated_at drives list ordering, so it must move only on a real progress
// advance — never on a favorite toggle or a latest-chapter capture.
func TestUpsertConditionalUpdatedAt(t *testing.T) {
cases := []struct {
name string
mutate func(Bookmark) Bookmark
wantBumped bool
}{
{
name: "unchanged progress",
mutate: func(b Bookmark) Bookmark { return b },
wantBumped: false,
},
{
name: "changed progress",
mutate: func(b Bookmark) Bookmark {
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
return b
},
wantBumped: true,
},
{
name: "favorite only",
mutate: func(b Bookmark) Bookmark {
b.Favorite = true
return b
},
wantBumped: false,
},
{
name: "latest chapter only",
mutate: func(b Bookmark) Bookmark {
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
return b
},
wantBumped: false,
},
{
name: "unrelated metadata only",
mutate: func(b Bookmark) Bookmark {
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
return b
},
wantBumped: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := newTestServer(t)
key := fmt.Sprintf("asura:cond-%d", i)
first := putBookmark(t, srv, key, Bookmark{
Title: "Test",
LastChapter: "Chapter 10",
LastChapterNum: 10,
})
if first.UpdatedAt == 0 {
t.Fatal("new bookmark did not get updated_at set")
}
// Guarantee a later wall-clock ms so a real bump is observable.
time.Sleep(2 * time.Millisecond)
second := putBookmark(t, srv, key, tc.mutate(first))
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
}
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
}
// The PUT response must match what a subsequent GET reports.
list := getBookmarks(t, srv)
if len(list) != 1 {
t.Fatalf("list = %+v, want 1 item", list)
}
if list[0].UpdatedAt != second.UpdatedAt {
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
}
})
}
}
func TestFavoriteRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "demonic:some-series"
stored := putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: true})
if !stored.Favorite {
t.Fatalf("PUT response favorite = false, want true")
}
list := getBookmarks(t, srv)
if len(list) != 1 || !list[0].Favorite {
t.Fatalf("favorite did not round-trip: %+v", list)
}
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
stored = putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: false})
if stored.Favorite {
t.Fatal("PUT response favorite = true after unfavorite")
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].Favorite {
t.Fatalf("unfavorite did not round-trip: %+v", list)
}
}
func TestLatestChapterNullable(t *testing.T) {
srv := newTestServer(t)
key := "asura:latest-test"
// Never captured: latest_chapter_num must serialize as JSON null.
body, _ := json.Marshal(Bookmark{Title: "No latest yet"})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
}
list := getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum != nil {
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
}
// Once captured it round-trips as a value.
stored := putBookmark(t, srv, key, Bookmark{
Title: "No latest yet",
LatestChapter: "Chapter 162",
LatestChapterNum: floatPtr(162),
})
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
t.Fatalf("latest chapter did not round-trip: %+v", list)
}
if list[0].LatestChapter != "Chapter 162" {
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
}
}
// The deployed database predates favorite/latest_chapter*, and CREATE TABLE
// IF NOT EXISTS will not add them — OpenStore must migrate in place.
func TestOpenStoreMigratesLegacySchema(t *testing.T) { func TestOpenStoreMigratesLegacySchema(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy.db") dbPath := filepath.Join(t.TempDir(), "legacy.db")
@@ -415,7 +48,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
t.Fatalf("close legacy db: %v", err) t.Fatalf("close legacy db: %v", err)
} }
store, err := OpenStore(dbPath) store, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("OpenStore on legacy db: %v", err) t.Fatalf("OpenStore on legacy db: %v", err)
} }
@@ -437,7 +70,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
} }
// Reopening an already-migrated database must be a no-op, not an error. // Reopening an already-migrated database must be a no-op, not an error.
store2, err := OpenStore(dbPath) store2, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("OpenStore is not idempotent: %v", err) t.Fatalf("OpenStore is not idempotent: %v", err)
} }
@@ -516,19 +149,6 @@ func TestBookmarkContinueURL(t *testing.T) {
} }
} }
func TestLoadConfigWebPassword(t *testing.T) {
t.Setenv("API_TOKEN", "token-abc")
t.Setenv("WEB_PASSWORD", "hunter2")
if got := loadConfig().WebPassword; got != "hunter2" {
t.Fatalf("WebPassword = %q, want hunter2", got)
}
t.Setenv("WEB_PASSWORD", "")
if got := loadConfig().WebPassword; got != "" {
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
}
}
// readLatestCheckedAt reads the column directly. It is deliberately absent from // readLatestCheckedAt reads the column directly. It is deliberately absent from
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way. // Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 { func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
@@ -672,7 +292,7 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
t.Fatalf("close: %v", err) t.Fatalf("close: %v", err)
} }
s, err := OpenStore(path) s, err := Open(path)
if err != nil { if err != nil {
t.Fatalf("OpenStore on pre-existing db: %v", err) t.Fatalf("OpenStore on pre-existing db: %v", err)
} }
@@ -691,38 +311,6 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
} }
} }
// A userscript PUT body has no latest_checked_at field. If the column is ever
// moved into bookmarkColumns, this test catches it: the PUT would reset the
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
t.Cleanup(func() { store.Close() })
srv := newRouter(store, testConfig())
seedForCheck(t, store, "asura:x", "https://asurascans.com/comics/x", 777)
// Exactly what the userscript sends: no latest_checked_at key at all.
body := `{"key":"asura:x","site":"asura","series_id":"x",
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if got := readLatestCheckedAt(t, store, "asura:x"); got != 777 {
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
}
}
func TestUpsertDefaultsStatusToReading(t *testing.T) { func TestUpsertDefaultsStatusToReading(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
stored, err := store.Upsert(Bookmark{ stored, err := store.Upsert(Bookmark{
@@ -732,8 +320,8 @@ func TestUpsertDefaultsStatusToReading(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Upsert: %v", err) t.Fatalf("Upsert: %v", err)
} }
if stored.Status != statusReading { if stored.Status != StatusReading {
t.Fatalf("Status = %q, want %q", stored.Status, statusReading) t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
} }
} }
@@ -743,7 +331,7 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
base := Bookmark{ base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
} }
if _, err := store.Upsert(base); err != nil { if _, err := store.Upsert(base); err != nil {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
@@ -755,8 +343,8 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Upsert: %v", err) t.Fatalf("Upsert: %v", err)
} }
if stored.Status != statusArchived { if stored.Status != StatusArchived {
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
} }
} }
@@ -768,7 +356,7 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
base := Bookmark{ base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
} }
if _, err := store.Upsert(base); err != nil { if _, err := store.Upsert(base); err != nil {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
@@ -788,8 +376,8 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Upsert: %v", err) t.Fatalf("Upsert: %v", err)
} }
if stored.Status != statusArchived { if stored.Status != StatusArchived {
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
} }
} }
@@ -797,19 +385,19 @@ func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
base := Bookmark{ base := Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(), Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
} }
if _, err := store.Upsert(base); err != nil { if _, err := store.Upsert(base); err != nil {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
} }
base.Status = statusReading base.Status = StatusReading
stored, err := store.Upsert(base) stored, err := store.Upsert(base)
if err != nil { if err != nil {
t.Fatalf("Upsert: %v", err) t.Fatalf("Upsert: %v", err)
} }
if stored.Status != statusReading { if stored.Status != StatusReading {
t.Fatalf("Status = %q, want %q", stored.Status, statusReading) t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
} }
} }
@@ -826,7 +414,7 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
t.Fatalf("seed: %v", err) t.Fatalf("seed: %v", err)
} }
base.Status = statusArchived base.Status = StatusArchived
base.UpdatedAt = first.UpdatedAt + 60_000 base.UpdatedAt = first.UpdatedAt + 60_000
stored, err := store.Upsert(base) stored, err := store.Upsert(base)
if err != nil { if err != nil {
@@ -857,7 +445,7 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
} }
db.Close() db.Close()
store, err := OpenStore(path) store, err := Open(path)
if err != nil { if err != nil {
t.Fatalf("OpenStore: %v", err) t.Fatalf("OpenStore: %v", err)
} }
@@ -867,8 +455,8 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err) t.Fatalf("Get: ok=%v err=%v", ok, err)
} }
if b.Status != statusReading { if b.Status != StatusReading {
t.Fatalf("Status = %q, want %q", b.Status, statusReading) t.Fatalf("Status = %q, want %q", b.Status, StatusReading)
} }
} }
@@ -877,9 +465,9 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) { func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
store := newTestStore(t) store := newTestStore(t)
for _, tc := range []struct{ key, status string }{ for _, tc := range []struct{ key, status string }{
{"asura:reading", statusReading}, {"asura:reading", StatusReading},
{"asura:archived", statusArchived}, {"asura:archived", StatusArchived},
{"asura:finished", statusFinished}, {"asura:finished", StatusFinished},
} { } {
if _, err := store.Upsert(Bookmark{ if _, err := store.Upsert(Bookmark{
Key: tc.key, Site: "asura", SeriesID: tc.key, Key: tc.key, Site: "asura", SeriesID: tc.key,
@@ -912,7 +500,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) { func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hash.db") dbPath := filepath.Join(t.TempDir(), "hash.db")
store, err := OpenStore(dbPath) store, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
@@ -938,7 +526,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
t.Fatalf("close: %v", err) t.Fatalf("close: %v", err)
} }
reopened, err := OpenStore(dbPath) reopened, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("reopen: %v", err) t.Fatalf("reopen: %v", err)
} }
@@ -977,7 +565,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
} }
// Idempotent: a third open changes nothing. // Idempotent: a third open changes nothing.
third, err := OpenStore(dbPath) third, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("third open: %v", err) t.Fatalf("third open: %v", err)
} }
@@ -990,7 +578,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) { func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "collision.db") dbPath := filepath.Join(t.TempDir(), "collision.db")
store, err := OpenStore(dbPath) store, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
@@ -1010,7 +598,7 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
t.Fatalf("close: %v", err) t.Fatalf("close: %v", err)
} }
reopened, err := OpenStore(dbPath) reopened, err := Open(dbPath)
if err != nil { if err != nil {
t.Fatalf("reopen: %v", err) t.Fatalf("reopen: %v", err)
} }
@@ -1,4 +1,4 @@
package main package userscript
import ( import (
"crypto/subtle" "crypto/subtle"
@@ -35,7 +35,7 @@ func stampVersion(src []byte, mod time.Time) []byte {
// //
// The file is read per request — that is what lets a bindmounted copy be edited // The file is read per request — that is what lets a bindmounted copy be edited
// on the host without a restart. It is ~50 KB and polled about once a day. // on the host without a restart. It is ~50 KB and polled about once a day.
func userscriptHandler(token, path string) http.HandlerFunc { func Handler(token, path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 { if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
http.NotFound(w, r) http.NotFound(w, r)
@@ -1,4 +1,4 @@
package main package userscript
import ( import (
"net/http" "net/http"
@@ -10,6 +10,8 @@ import (
"time" "time"
) )
const testToken = "s3cret-token"
// sampleScript is a stand-in for the real userscript: a metadata block with a // sampleScript is a stand-in for the real userscript: a metadata block with a
// @version line, plus a body that must survive the rewrite untouched. // @version line, plus a body that must survive the rewrite untouched.
const sampleScript = `// ==UserScript== const sampleScript = `// ==UserScript==
@@ -35,16 +37,12 @@ func writeScript(t *testing.T, body string) (path, wantVersion string) {
return path, "2026.07.28.1642" return path, "2026.07.28.1642"
} }
func newUserscriptServer(t *testing.T, path string) http.Handler { // newTestMux registers Handler the same way main.go's router does, without
t.Helper() // pulling in the store or the rest of the app.
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) func newTestMux(token, path string) http.Handler {
if err != nil { mux := http.NewServeMux()
t.Fatalf("OpenStore: %v", err) mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
} return mux
t.Cleanup(func() { store.Close() })
cfg := testConfig()
cfg.UserscriptPath = path
return newRouter(store, cfg)
} }
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder { func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
@@ -56,7 +54,7 @@ func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseR
func TestUserscriptServedWithStampedVersion(t *testing.T) { func TestUserscriptServedWithStampedVersion(t *testing.T) {
path, wantVersion := writeScript(t, sampleScript) path, wantVersion := writeScript(t, sampleScript)
rr := getScript(t, newUserscriptServer(t, path), testToken) rr := getScript(t, newTestMux(testToken, path), testToken)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
@@ -83,10 +81,13 @@ func TestUserscriptServedWithStampedVersion(t *testing.T) {
} }
} }
// The empty-token case ("/u//manga-bookmark.user.js") is covered at the
// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to
// "/u/manga-bookmark.user.js" before this handler's own token check ever runs.
func TestUserscriptWrongTokenIs404(t *testing.T) { func TestUserscriptWrongTokenIs404(t *testing.T) {
path, _ := writeScript(t, sampleScript) path, _ := writeScript(t, sampleScript)
srv := newUserscriptServer(t, path) srv := newTestMux(testToken, path)
for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} { for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound { if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
t.Errorf("token %q: status = %d, want 404", tok, got) t.Errorf("token %q: status = %d, want 404", tok, got)
} }
@@ -94,7 +95,7 @@ func TestUserscriptWrongTokenIs404(t *testing.T) {
} }
func TestUserscriptMissingFileIs404(t *testing.T) { func TestUserscriptMissingFileIs404(t *testing.T) {
srv := newUserscriptServer(t, filepath.Join(t.TempDir(), "absent.user.js")) srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound { if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
t.Fatalf("status = %d, want 404", got) t.Fatalf("status = %d, want 404", got)
} }
@@ -103,7 +104,7 @@ func TestUserscriptMissingFileIs404(t *testing.T) {
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) { func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
path, _ := writeScript(t, noVersion) path, _ := writeScript(t, noVersion)
rr := getScript(t, newUserscriptServer(t, path), testToken) rr := getScript(t, newTestMux(testToken, path), testToken)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code) t.Fatalf("status = %d, want 200", rr.Code)
@@ -112,21 +113,3 @@ func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
t.Fatalf("body = %q, want it unmodified", rr.Body.String()) t.Fatalf("body = %q, want it unmodified", rr.Body.String())
} }
} }
// The endpoint must work on a deployment that never set WEB_PASSWORD, since
// the web routes are not registered at all in that case.
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
path, _ := writeScript(t, sampleScript)
store, err := OpenStore(filepath.Join(t.TempDir(), "nopass.db"))
if err != nil {
t.Fatalf("OpenStore: %v", err)
}
t.Cleanup(func() { store.Close() })
cfg := testConfig()
cfg.WebPassword = ""
cfg.UserscriptPath = path
if got := getScript(t, newRouter(store, cfg), testToken).Code; got != http.StatusOK {
t.Fatalf("status = %d, want 200", got)
}
}

Before

Width:  |  Height:  |  Size: 973 B

After

Width:  |  Height:  |  Size: 973 B

+61 -68
View File
@@ -1,4 +1,4 @@
package main package web
import ( import (
"crypto/subtle" "crypto/subtle"
@@ -13,6 +13,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"mangabm/backend/internal/session"
"mangabm/backend/internal/store"
) )
//go:embed templates //go:embed templates
@@ -21,25 +24,25 @@ var templateFS embed.FS
//go:embed static //go:embed static
var staticFS embed.FS var staticFS embed.FS
// recentCount is how many series the "Continue reading" strip shows. // RecentCount is how many series the "Continue reading" strip shows.
const recentCount = 5 const RecentCount = 5
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/. // Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
// It is a separate handler from bookmarkHandler because the two speak different // It is a separate handler from api.Handler because the two speak different
// representations (HTML versus JSON) to different clients under different auth. // representations (HTML versus JSON) to different clients under different auth.
type webHandler struct { type Handler struct {
store *Store store *store.Store
tmpl *template.Template tmpl *template.Template
key []byte key []byte
password string password string
limiter *loginLimiter limiter *session.LoginLimiter
} }
// listView is what every list-rendering template receives. // listView is what every list-rendering template receives.
type listView struct { type listView struct {
Tab string // "all", "fav", or "new" Tab string // "all", "fav", or "new"
Recent []Bookmark Recent []store.Bookmark
Items []Bookmark Items []store.Bookmark
// NewCount is the badge on the Updated tab: how many series being read // NewCount is the badge on the Updated tab: how many series being read
// have a chapter out that has not been read. It is counted over the whole // have a chapter out that has not been read. It is counted over the whole
// reading set, not the active tab, so the badge does not change meaning as // reading set, not the active tab, so the badge does not change meaning as
@@ -50,38 +53,28 @@ type listView struct {
OOB bool OOB bool
} }
// Initial is the monogram the templates show in place of a cover when the
// source site never gave us an og:image. First rune, uppercased; "?" when even
// the title is missing, so the slot is never empty.
func (b Bookmark) Initial() string {
for _, r := range b.Title {
return strings.ToUpper(string(r))
}
return "?"
}
// loginView is what the login template receives. // loginView is what the login template receives.
type loginView struct { type loginView struct {
Error string Error string
} }
// newWebHandler parses every template up front so a broken one kills the // New parses every template up front so a broken one kills the process at
// process at startup rather than the first request that touches it. // startup rather than the first request that touches it.
func newWebHandler(store *Store, cfg Config) (*webHandler, error) { func New(s *store.Store, apiToken, webPassword string) (*Handler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/*.html") tmpl, err := template.ParseFS(templateFS, "templates/*.html")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &webHandler{ return &Handler{
store: store, store: s,
tmpl: tmpl, tmpl: tmpl,
key: sessionKey(cfg.Token, cfg.WebPassword), key: session.Key(apiToken, webPassword),
password: cfg.WebPassword, password: webPassword,
limiter: newLoginLimiter(), limiter: session.NewLoginLimiter(),
}, nil }, nil
} }
func (h *webHandler) register(mux *http.ServeMux) { func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /{$}", h.index) mux.HandleFunc("GET /{$}", h.index)
mux.HandleFunc("POST /login", h.login) mux.HandleFunc("POST /login", h.login)
mux.HandleFunc("POST /logout", h.logout) mux.HandleFunc("POST /logout", h.logout)
@@ -118,15 +111,15 @@ func staticHandler() http.Handler {
} }
// authed reports whether the request carries a valid session cookie. // authed reports whether the request carries a valid session cookie.
func (h *webHandler) authed(r *http.Request) bool { func (h *Handler) authed(r *http.Request) bool {
c, err := r.Cookie(sessionCookieName) c, err := r.Cookie(session.CookieName)
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli()) return err == nil && session.Verify(h.key, c.Value, time.Now().UnixMilli())
} }
// requireSession guards the fragment endpoints. It answers 401 rather than // requireSession guards the fragment endpoints. It answers 401 rather than
// redirecting, because htmx swaps whatever body it receives into the page and a // redirecting, because htmx swaps whatever body it receives into the page and a
// redirected login page would be spliced into the card list. // redirected login page would be spliced into the card list.
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc { func (h *Handler) requireSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) { if !h.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized) http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -136,7 +129,7 @@ func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
} }
} }
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) { func (h *Handler) render(w http.ResponseWriter, status int, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status) w.WriteHeader(status)
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
@@ -148,7 +141,7 @@ func (h *webHandler) render(w http.ResponseWriter, status int, name string, data
// index renders the list, or the login page when there is no session. The login // index renders the list, or the login page when there is no session. The login
// page is served at / with status 200 rather than as a redirect to a separate // page is served at / with status 200 rather than as a redirect to a separate
// URL: one page, no redirect loop to reason about. // URL: one page, no redirect loop to reason about.
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) { func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
if !h.authed(r) { if !h.authed(r) {
h.render(w, http.StatusOK, "login", loginView{}) h.render(w, http.StatusOK, "login", loginView{})
return return
@@ -164,8 +157,8 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
// filterBookmarks returns the subset keep reports true for, preserving order. // filterBookmarks returns the subset keep reports true for, preserving order.
// It always returns a non-nil slice so an empty tab renders its empty state. // It always returns a non-nil slice so an empty tab renders its empty state.
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark { func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []store.Bookmark {
out := []Bookmark{} out := []store.Bookmark{}
for _, b := range all { for _, b := range all {
if keep(b) { if keep(b) {
out = append(out, b) out = append(out, b)
@@ -181,25 +174,25 @@ func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
// in All, not in Updated, not in Favourites, and not in the recent strip. An // in All, not in Updated, not in Favourites, and not in the recent strip. An
// archived favourite therefore shows only under Archived: Favourites means // archived favourite therefore shows only under Archived: Favourites means
// "favourites I am currently reading". // "favourites I am currently reading".
func (h *webHandler) buildListView(tab string) (listView, error) { func (h *Handler) buildListView(tab string) (listView, error) {
all, err := h.store.List() // already ordered updated_at DESC all, err := h.store.List() // already ordered updated_at DESC
if err != nil { if err != nil {
return listView{}, err return listView{}, err
} }
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading }) reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
var items []Bookmark var items []store.Bookmark
switch tab { switch tab {
case "fav": case "fav":
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite }) items = filterBookmarks(reading, func(b store.Bookmark) bool { return b.Favorite })
case "new": case "new":
items = withNew items = withNew
case "archived": case "archived":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived }) items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
case "finished": case "finished":
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished }) items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
default: default:
tab = "all" tab = "all"
items = reading items = reading
@@ -213,17 +206,17 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
// It therefore disappears entirely on a library with nothing new. That is // It therefore disappears entirely on a library with nothing new. That is
// the intended reading: an empty strip has nothing to say, and the ~240px it // the intended reading: an empty strip has nothing to say, and the ~240px it
// costs on a phone belongs to the list. // costs on a phone belongs to the list.
var recent []Bookmark var recent []store.Bookmark
if tab == "all" { if tab == "all" {
recent = withNew recent = withNew
if len(recent) > recentCount { if len(recent) > RecentCount {
recent = recent[:recentCount] recent = recent[:RecentCount]
} }
} }
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
} }
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) { func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
view, err := h.buildListView(r.URL.Query().Get("tab")) view, err := h.buildListView(r.URL.Query().Get("tab"))
if err != nil { if err != nil {
log.Printf("ui list: %v", err) log.Printf("ui list: %v", err)
@@ -253,7 +246,7 @@ func currentTab(r *http.Request) string {
// mutation cannot leave them describing the library as it was before the tap. // mutation cannot leave them describing the library as it was before the tap.
// The key is in here because it is tab-shaped too: archived and finished swap // The key is in here because it is tab-shaped too: archived and finished swap
// Archive for Restore. // Archive for Restore.
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) { func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
view.OOB = true view.OOB = true
for _, name := range []string{"recent", "newcount", "keyrow"} { for _, name := range []string{"recent", "newcount", "keyrow"} {
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil { if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
@@ -266,7 +259,7 @@ func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
// refreshChrome rebuilds the chrome for the reader's current tab after a // refreshChrome rebuilds the chrome for the reader's current tab after a
// mutation and appends it to the response. // mutation and appends it to the response.
func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) { func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
view, err := h.buildListView(currentTab(r)) view, err := h.buildListView(currentTab(r))
if err != nil { if err != nil {
log.Printf("ui chrome: %v", err) log.Printf("ui chrome: %v", err)
@@ -275,9 +268,9 @@ func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
h.writeChromeOOB(w, view) h.writeChromeOOB(w, view)
} }
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r) ip := session.ClientIP(r)
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 { if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 {
secs := int(wait.Seconds()) + 1 secs := int(wait.Seconds()) + 1
w.Header().Set("Retry-After", strconv.Itoa(secs)) w.Header().Set("Retry-After", strconv.Itoa(secs))
h.render(w, http.StatusTooManyRequests, "login", loginView{ h.render(w, http.StatusTooManyRequests, "login", loginView{
@@ -293,38 +286,38 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
} }
got := r.PostFormValue("password") got := r.PostFormValue("password")
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 { if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
h.limiter.fail(ip, time.Now()) h.limiter.Fail(ip, time.Now())
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."}) h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
return return
} }
h.limiter.reset(ip) h.limiter.Reset(ip)
setSessionCookie(w, r, h.key) session.SetCookie(w, r, h.key)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
clearSessionCookie(w, r) session.ClearCookie(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// loadForMutation fetches the row a mutation targets, writing the error // loadForMutation fetches the row a mutation targets, writing the error
// response itself when there is nothing to mutate. // response itself when there is nothing to mutate.
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) { func (h *Handler) loadForMutation(w http.ResponseWriter, r *http.Request) (store.Bookmark, bool) {
key := r.PathValue("key") key := r.PathValue("key")
if key == "" { if key == "" {
http.Error(w, "missing key", http.StatusBadRequest) http.Error(w, "missing key", http.StatusBadRequest)
return Bookmark{}, false return store.Bookmark{}, false
} }
b, ok, err := h.store.Get(key) b, ok, err := h.store.Get(key)
if err != nil { if err != nil {
log.Printf("ui get %q: %v", key, err) log.Printf("ui get %q: %v", key, err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return Bookmark{}, false return store.Bookmark{}, false
} }
if !ok { if !ok {
http.Error(w, "not found", http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return Bookmark{}, false return store.Bookmark{}, false
} }
return b, true return b, true
} }
@@ -338,7 +331,7 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
// state is the feedback for the tap. The strip and the badge are not: they // state is the feedback for the tap. The strip and the badge are not: they
// describe the whole library, so they are rebuilt out of band on every // describe the whole library, so they are rebuilt out of band on every
// mutation, at the cost of one extra list read per toggle. // mutation, at the cost of one extra list read per toggle.
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) { func (h *Handler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b store.Bookmark) {
stored, err := h.store.Upsert(b) stored, err := h.store.Upsert(b)
if err != nil { if err != nil {
log.Printf("ui upsert %q: %v", b.Key, err) log.Printf("ui upsert %q: %v", b.Key, err)
@@ -351,7 +344,7 @@ func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so // uiFavorite flips the favourite flag. last_chapter_num is untouched, so
// Upsert keeps the stored updated_at and the list does not reorder. // Upsert keeps the stored updated_at and the list does not reorder.
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) { func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
b, ok := h.loadForMutation(w, r) b, ok := h.loadForMutation(w, r)
if !ok { if !ok {
return return
@@ -367,7 +360,7 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
// //
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the // last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
// list does not reorder. // list does not reorder.
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
b, ok := h.loadForMutation(w, r) b, ok := h.loadForMutation(w, r)
if !ok { if !ok {
return return
@@ -377,7 +370,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
switch s := r.PostFormValue("status"); s { switch s := r.PostFormValue("status"); s {
case statusReading, statusArchived, statusFinished: case store.StatusReading, store.StatusArchived, store.StatusFinished:
b.Status = s b.Status = s
default: default:
http.Error(w, "invalid status", http.StatusBadRequest) http.Error(w, "invalid status", http.StatusBadRequest)
@@ -398,7 +391,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not // pre-filled, so a bare tap of Save is an easy accidental submit; it must not
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0" // destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
// to "45") behind a frozen updated_at. // to "45") behind a frozen updated_at.
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) { func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
b, ok := h.loadForMutation(w, r) b, ok := h.loadForMutation(w, r)
if !ok { if !ok {
return return
@@ -425,7 +418,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
// uiDelete removes the row and answers with an empty body, which htmx swaps in // uiDelete removes the row and answers with an empty body, which htmx swaps in
// place of the card — removing it from the page. // place of the card — removing it from the page.
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) { func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key") key := r.PathValue("key")
if key == "" { if key == "" {
http.Error(w, "missing key", http.StatusBadRequest) http.Error(w, "missing key", http.StatusBadRequest)
+35 -28
View File
@@ -11,6 +11,13 @@ import (
"strings" "strings"
"syscall" "syscall"
"time" "time"
"mangabm/backend/internal/api"
"mangabm/backend/internal/httpmw"
"mangabm/backend/internal/latest"
"mangabm/backend/internal/store"
"mangabm/backend/internal/userscript"
"mangabm/backend/internal/web"
) )
// Config holds all runtime settings, sourced from environment variables. // Config holds all runtime settings, sourced from environment variables.
@@ -149,22 +156,22 @@ func loadConfig() Config {
// newRouter wires routes and middleware. CORS is the outermost layer so // newRouter wires routes and middleware. CORS is the outermost layer so
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected, // preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
// /healthz is public. // /healthz is public.
func newRouter(store *Store, cfg Config) http.Handler { func newRouter(s *store.Store, cfg Config) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz) mux.HandleFunc("GET /healthz", api.Healthz)
// Outside withAuth (the updater sends no Authorization header) and outside // Outside httpmw.Auth (the updater sends no Authorization header) and
// the WEB_PASSWORD gate (the script must be installable either way). The // outside the WEB_PASSWORD gate (the script must be installable either
// path segment carries the token instead. // way). The path segment carries the token instead.
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath)) mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
h := &bookmarkHandler{store: store} h := &api.Handler{Store: s}
protected := http.NewServeMux() protected := http.NewServeMux()
protected.HandleFunc("GET /bookmarks", h.list) protected.HandleFunc("GET /bookmarks", h.List)
protected.HandleFunc("PUT /bookmarks/{key}", h.put) protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.delete) protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
auth := withAuth(cfg.Token, protected) auth := httpmw.Auth(cfg.Token, protected)
mux.Handle("/bookmarks", auth) mux.Handle("/bookmarks", auth)
mux.Handle("/bookmarks/", auth) mux.Handle("/bookmarks/", auth)
@@ -172,14 +179,14 @@ func newRouter(store *Store, cfg Config) http.Handler {
// deployment that forgets WEB_PASSWORD exposes nothing rather than // deployment that forgets WEB_PASSWORD exposes nothing rather than
// exposing an unprotected list. // exposing an unprotected list.
if cfg.WebPassword != "" { if cfg.WebPassword != "" {
web, err := newWebHandler(store, cfg) wh, err := web.New(s, cfg.Token, cfg.WebPassword)
if err != nil { if err != nil {
log.Fatalf("web handler: %v", err) log.Fatalf("web handler: %v", err)
} }
web.register(mux) wh.Register(mux)
} }
return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux))) return httpmw.CORS(cfg.AllowedOrigins, httpmw.Gzip(guardEmptyUserscriptToken(mux)))
} }
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect: // guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
@@ -203,22 +210,22 @@ func main() {
log.Fatal("API_TOKEN is required") log.Fatal("API_TOKEN is required")
} }
store, err := OpenStore(cfg.DBPath) s, err := store.Open(cfg.DBPath)
if err != nil { if err != nil {
log.Fatalf("open store: %v", err) log.Fatalf("open store: %v", err)
} }
defer store.Close() defer s.Close()
// The poller is off the request path entirely: if it cannot start, the // The poller is off the request path entirely: if it cannot start, the
// service still serves bookmarks and the userscript still captures latest // service still serves bookmarks and the userscript still captures latest
// chapters on its own. // chapters on its own.
pollCtx, stopPoll := context.WithCancel(context.Background()) pollCtx, stopPoll := context.WithCancel(context.Background())
defer stopPoll() defer stopPoll()
startLatestPoller(pollCtx, store, cfg.LatestPoll) startLatestPoller(pollCtx, s, cfg.LatestPoll)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: newRouter(store, cfg), Handler: newRouter(s, cfg),
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
} }
@@ -248,24 +255,24 @@ func main() {
// HTTP client cannot be built. Any problem here is logged and skipped: this // HTTP client cannot be built. Any problem here is logged and skipped: this
// feature going missing degrades the service to userscript-only latest-chapter // feature going missing degrades the service to userscript-only latest-chapter
// tracking, which is exactly how it behaved before. // tracking, which is exactly how it behaved before.
func startLatestPoller(ctx context.Context, store *Store, cfg LatestPoll) { func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
if !cfg.Enabled { if !cfg.Enabled {
log.Println("latest-chapter poller: disabled by config") log.Println("latest-chapter poller: disabled by config")
return return
} }
f, err := newTLSFetcher() f, err := latest.NewTLSFetcher()
if err != nil { if err != nil {
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err) log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
return return
} }
p := &latestPoller{ p := &latest.Poller{
store: store, Store: s,
fetch: f, Fetch: f,
now: time.Now, Now: time.Now,
cooldown: cfg.Cooldown, Cooldown: cfg.Cooldown,
interval: cfg.Interval, Interval: cfg.Interval,
stagger: cfg.Stagger, Stagger: cfg.Stagger,
batch: cfg.Batch, Batch: cfg.Batch,
} }
go p.Run(ctx) go p.Run(ctx)
} }
+4 -2
View File
@@ -10,6 +10,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"mangabm/backend/internal/store"
) )
func TestLoadLatestPollDefaults(t *testing.T) { func TestLoadLatestPollDefaults(t *testing.T) {
@@ -148,7 +150,7 @@ func TestPutStatusValidation(t *testing.T) {
if tc.want != http.StatusOK { if tc.want != http.StatusOK {
return return
} }
var got Bookmark var got store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
@@ -187,7 +189,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
} }
var got Bookmark var got store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
+89 -85
View File
@@ -10,6 +10,10 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"mangabm/backend/internal/session"
"mangabm/backend/internal/store"
"mangabm/backend/internal/web"
) )
const testPassword = "hunter2" const testPassword = "hunter2"
@@ -22,22 +26,22 @@ func webConfig() Config {
// newWebTestServer returns the full router plus the store behind it, so tests // newWebTestServer returns the full router plus the store behind it, so tests
// can seed rows and assert on what the handlers wrote back. // can seed rows and assert on what the handlers wrote back.
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) { func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
t.Helper() t.Helper()
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil { if err != nil {
t.Fatalf("OpenStore: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { store.Close() }) t.Cleanup(func() { st.Close() })
return newRouter(store, cfg), store return newRouter(st, cfg), st
} }
// sessionCookie returns a cookie a handler will accept for cfg's API token. // sessionCookie returns a cookie a handler will accept for cfg's API token.
func sessionCookie(t *testing.T, cfg Config) *http.Cookie { func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
t.Helper() t.Helper()
return &http.Cookie{ return &http.Cookie{
Name: sessionCookieName, Name: session.CookieName,
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()), Value: session.Sign(session.Key(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
} }
} }
@@ -56,8 +60,8 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
func TestIndexWithSessionShowsList(t *testing.T) { func TestIndexWithSessionShowsList(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
if _, err := store.Upsert(Bookmark{ if _, err := st.Upsert(store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: time.Now().UnixMilli(), UpdatedAt: time.Now().UnixMilli(),
@@ -90,8 +94,8 @@ func TestLoginSuccessSetsCookie(t *testing.T) {
t.Fatalf("POST /login status = %d, want 303", rr.Code) t.Fatalf("POST /login status = %d, want 303", rr.Code)
} }
cookies := rr.Result().Cookies() cookies := rr.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName) t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
} }
} }
@@ -122,14 +126,14 @@ func TestLoginRateLimited(t *testing.T) {
srv.ServeHTTP(rr, req) srv.ServeHTTP(rr, req)
return rr return rr
} }
for i := 0; i < loginMaxFailures; i++ { for i := 0; i < session.MaxFailures; i++ {
if code := post().Code; code != http.StatusUnauthorized { if code := post().Code; code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want 401", i+1, code) t.Fatalf("attempt %d status = %d, want 401", i+1, code)
} }
} }
rr := post() rr := post()
if rr.Code != http.StatusTooManyRequests { if rr.Code != http.StatusTooManyRequests {
t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code) t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
} }
if after := rr.Header().Get("Retry-After"); after == "" { if after := rr.Header().Get("Retry-After"); after == "" {
t.Fatal("429 response has no Retry-After header") t.Fatal("429 response has no Retry-After header")
@@ -202,9 +206,9 @@ func TestStaticAssetsServed(t *testing.T) {
} }
// seed inserts one bookmark and returns it as stored. // seed inserts one bookmark and returns it as stored.
func seed(t *testing.T, store *Store, b Bookmark) Bookmark { func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
t.Helper() t.Helper()
stored, err := store.Upsert(b) stored, err := st.Upsert(b)
if err != nil { if err != nil {
t.Fatalf("Upsert: %v", err) t.Fatalf("Upsert: %v", err)
} }
@@ -245,8 +249,8 @@ func TestUIRoutesRequireSession(t *testing.T) {
func TestFavoriteTogglesWithoutReordering(t *testing.T) { func TestFavoriteTogglesWithoutReordering(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{ before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: 1_000_000, UpdatedAt: 1_000_000,
@@ -258,7 +262,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
t.Fatalf("favorite status = %d, want 200", rr.Code) t.Fatalf("favorite status = %d, want 200", rr.Code)
} }
after, ok, err := store.Get("asura:solo") after, ok, err := st.Get("asura:solo")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("Get after favorite: %v ok=%v", err, ok) t.Fatalf("Get after favorite: %v ok=%v", err, ok)
} }
@@ -276,7 +280,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// Toggling again turns it back off. // Toggling again turns it back off.
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
back, _, _ := store.Get("asura:solo") back, _, _ := st.Get("asura:solo")
if back.Favorite { if back.Favorite {
t.Fatal("Favorite = true after a second toggle, want false") t.Fatal("Favorite = true after a second toggle, want false")
} }
@@ -294,8 +298,8 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// selector, just a regression guard against reintroducing the bare-id form. // selector, just a regression guard against reintroducing the bare-id form.
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: 1_000_000, UpdatedAt: 1_000_000,
@@ -320,8 +324,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
func TestChapterOverrideMovesUpdatedAt(t *testing.T) { func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{ before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo", LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
@@ -335,7 +339,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
t.Fatalf("chapter override status = %d, want 200", rr.Code) t.Fatalf("chapter override status = %d, want 200", rr.Code)
} }
after, ok, err := store.Get("asura:solo") after, ok, err := st.Get("asura:solo")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("Get after override: %v ok=%v", err, ok) t.Fatalf("Get after override: %v ok=%v", err, ok)
} }
@@ -355,8 +359,8 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) { func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{ before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45, Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo", LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
@@ -375,7 +379,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
t.Fatalf("chapter no-op status = %d, want 200", rr.Code) t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
} }
after, ok, err := store.Get("asura:solo") after, ok, err := st.Get("asura:solo")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("Get after no-op override: %v ok=%v", err, ok) t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
} }
@@ -395,8 +399,8 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
func TestChapterOverrideRejectsBadInput(t *testing.T) { func TestChapterOverrideRejectsBadInput(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000, Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
}) })
@@ -409,7 +413,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
if rr.Code != http.StatusBadRequest { if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code) t.Fatalf("status = %d, want 400", rr.Code)
} }
after, _, _ := store.Get("asura:solo") after, _, _ := st.Get("asura:solo")
if after.LastChapterNum != 45 { if after.LastChapterNum != 45 {
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum) t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
} }
@@ -440,8 +444,8 @@ func TestMutationsOnMissingKey(t *testing.T) {
func TestUIDeleteRemovesRow(t *testing.T) { func TestUIDeleteRemovesRow(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", UpdatedAt: 1_000_000, Title: "Solo Leveling", UpdatedAt: 1_000_000,
}) })
@@ -460,19 +464,19 @@ func TestUIDeleteRemovesRow(t *testing.T) {
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) { if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
t.Fatalf("delete body = %q, want the out-of-band badge", body) t.Fatalf("delete body = %q, want the out-of-band badge", body)
} }
if _, ok, _ := store.Get("asura:solo"); ok { if _, ok, _ := st.Get("asura:solo"); ok {
t.Fatal("row still present after delete") t.Fatal("row still present after delete")
} }
} }
func TestUIListFavouritesTab(t *testing.T) { func TestUIListFavouritesTab(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000, Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
}) })
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "demonic:tower", Site: "demonic", SeriesID: "tower", Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000, Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
}) })
@@ -493,14 +497,14 @@ func TestUIListFavouritesTab(t *testing.T) {
func TestUIListNewTab(t *testing.T) { func TestUIListNewTab(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 10, Title: "Solo Leveling", LastChapterNum: 10,
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12), LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
UpdatedAt: 2_000_000, UpdatedAt: 2_000_000,
}) })
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "demonic:tower", Site: "demonic", SeriesID: "tower", Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
Title: "Tower of God", LastChapterNum: 5, Title: "Tower of God", LastChapterNum: 5,
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5), LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
@@ -524,22 +528,22 @@ func TestUIListNewTab(t *testing.T) {
// seedStatusRows puts one series in each bucket, the archived one also // seedStatusRows puts one series in each bucket, the archived one also
// favourited and with a new chapter out, so a leak into any reading-bucket tab // favourited and with a new chapter out, so a leak into any reading-bucket tab
// shows up as a failure rather than passing by accident. // shows up as a failure rather than passing by accident.
func seedStatusRows(t *testing.T, store *Store) { func seedStatusRows(t *testing.T, st *store.Store) {
t.Helper() t.Helper()
// floatPtr already exists in store_test.go — same package, reuse it. // floatPtr already exists in store_test.go — same package, reuse it.
rows := []Bookmark{ rows := []store.Bookmark{
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne", {Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
Status: statusReading, LastChapterNum: 10, Favorite: true, Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
LatestChapter: "11", LatestChapterNum: floatPtr(11)}, LatestChapter: "11", LatestChapterNum: floatPtr(11)},
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne", {Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
Status: statusArchived, LastChapterNum: 5, Favorite: true, Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
LatestChapter: "99", LatestChapterNum: floatPtr(99)}, LatestChapter: "99", LatestChapterNum: floatPtr(99)},
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne", {Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
Status: statusFinished, LastChapterNum: 200, Favorite: true}, Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
} }
for _, b := range rows { for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli() b.UpdatedAt = time.Now().UnixMilli()
if _, err := store.Upsert(b); err != nil { if _, err := st.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err) t.Fatalf("seed %s: %v", b.Key, err)
} }
} }
@@ -547,8 +551,8 @@ func seedStatusRows(t *testing.T, store *Store) {
func TestTabsShowOnlyTheirBucket(t *testing.T) { func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, st)
cases := []struct { cases := []struct {
tab string tab string
@@ -604,16 +608,16 @@ func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
// updated_at-ordered list below it does not already say — and only on All. // updated_at-ordered list below it does not already say — and only on All.
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) { func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) // ReadingOne is at 10 with 11 out; the rest are not reading seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
// A reading series that is caught up has nothing waiting, so it stays out. // A reading series that is caught up has nothing waiting, so it stays out.
caught := Bookmark{ caught := store.Bookmark{
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne", Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
Status: statusReading, LastChapterNum: 40, LatestChapter: "40", Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(), LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
} }
if _, err := store.Upsert(caught); err != nil { if _, err := st.Upsert(caught); err != nil {
t.Fatalf("seed %s: %v", caught.Key, err) t.Fatalf("seed %s: %v", caught.Key, err)
} }
@@ -633,12 +637,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
} }
// Nothing new anywhere: the strip has nothing to say and does not render. // Nothing new anywhere: the strip has nothing to say and does not render.
reading, _, err := store.Get("asura:reading") reading, _, err := st.Get("asura:reading")
if err != nil { if err != nil {
t.Fatalf("Get: %v", err) t.Fatalf("Get: %v", err)
} }
reading.LatestChapterNum = floatPtr(reading.LastChapterNum) reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
if _, err := store.Upsert(reading); err != nil { if _, err := st.Upsert(reading); err != nil {
t.Fatalf("Upsert: %v", err) t.Fatalf("Upsert: %v", err)
} }
// The section still ships (an out-of-band swap needs the id to exist) but // The section still ships (an out-of-band swap needs the id to exist) but
@@ -652,23 +656,23 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
} }
} }
// The strip never grows past recentCount, however many series are waiting. // The strip never grows past web.RecentCount, however many series are waiting.
func TestRecentStripCapped(t *testing.T) { func TestRecentStripCapped(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
for i := 0; i <= recentCount; i++ { for i := 0; i <= web.RecentCount; i++ {
b := Bookmark{ b := store.Bookmark{
Key: fmt.Sprintf("asura:new%d", i), Site: "asura", Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i), SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
Status: statusReading, LastChapterNum: 1, LatestChapter: "2", Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i), LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
} }
if _, err := store.Upsert(b); err != nil { if _, err := st.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err) t.Fatalf("seed %s: %v", b.Key, err)
} }
} }
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount { if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
t.Fatalf("strip rendered %d cards, want %d", got, recentCount) t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
} }
} }
@@ -686,14 +690,14 @@ func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string)
func TestUIStatusSetsBucket(t *testing.T) { func TestUIStatusSetsBucket(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, st)
for _, want := range []string{statusArchived, statusFinished, statusReading} { for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK { if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String()) t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
} }
b, ok, err := store.Get("asura:reading") b, ok, err := st.Get("asura:reading")
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err) t.Fatalf("Get: ok=%v err=%v", ok, err)
} }
@@ -705,21 +709,21 @@ func TestUIStatusSetsBucket(t *testing.T) {
func TestUIStatusRejectsUnknownValue(t *testing.T) { func TestUIStatusRejectsUnknownValue(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, st)
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest { if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code) t.Fatalf("status = %d, want 400", rr.Code)
} }
b, _, _ := store.Get("asura:reading") b, _, _ := st.Get("asura:reading")
if b.Status != statusReading { if b.Status != store.StatusReading {
t.Fatalf("stored status = %q, want it untouched", b.Status) t.Fatalf("stored status = %q, want it untouched", b.Status)
} }
} }
func TestUIStatusRequiresSession(t *testing.T) { func TestUIStatusRequiresSession(t *testing.T) {
srv, store := newWebTestServer(t, webConfig()) srv, st := newWebTestServer(t, webConfig())
seedStatusRows(t, store) seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status", req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
strings.NewReader("status=archived")) strings.NewReader("status=archived"))
@@ -734,15 +738,15 @@ func TestUIStatusRequiresSession(t *testing.T) {
func TestUIStatusDoesNotReorderList(t *testing.T) { func TestUIStatusDoesNotReorderList(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, st)
before, _, _ := store.Get("asura:reading") before, _, _ := st.Get("asura:reading")
time.Sleep(2 * time.Millisecond) time.Sleep(2 * time.Millisecond)
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK { if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code) t.Fatalf("status = %d", rr.Code)
} }
after, _, _ := store.Get("asura:reading") after, _, _ := st.Get("asura:reading")
if after.UpdatedAt != before.UpdatedAt { if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt) t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
} }
@@ -750,8 +754,8 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
func TestCardShowsStatusControls(t *testing.T) { func TestCardShowsStatusControls(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, st)
cases := []struct { cases := []struct {
tab string tab string
@@ -788,8 +792,8 @@ func TestCardShowsStatusControls(t *testing.T) {
func TestAppRendersNewTabs(t *testing.T) { func TestAppRendersNewTabs(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, cfg)) req.AddCookie(sessionCookie(t, cfg))
@@ -807,10 +811,10 @@ func TestAppRendersNewTabs(t *testing.T) {
// outside the swapped card, so nothing else would correct them. // outside the swapped card, so nothing else would correct them.
func TestMutationRefreshesChromeOutOfBand(t *testing.T) { func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, st := newWebTestServer(t, cfg)
seed(t, store, Bookmark{ seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11", Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(), LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
}) })
@@ -820,7 +824,7 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
} }
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status", req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
url.Values{"status": {statusArchived}}) url.Values{"status": {store.StatusArchived}})
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all") req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req) srv.ServeHTTP(rr, req)