diff --git a/CLAUDE.md b/CLAUDE.md index b57c00f..7f41000 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. + 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 `:` (`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). - **Web UI:** same binary serve password-gated browser UI on second hostname — `GET /` (list, or login page when no session), `POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints - under `/ui/*`. Templates + assets `go:embed`-ed, so `backend/Dockerfile` - must copy `templates/` and `static/` plus `*.go`. Sessions stateless + under `/ui/*`. Templates + assets `go:embed`-ed under + `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, web routes not registered at all. UI mutations read-modify-write 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 on leading `/` and silently skip file. Relative href don't help either: template's directory isn't its served path. So - `detect.mjs backend/templates` reports **false clean** — always pass - `backend/static` too. One finding there, `overused-font` on "Instrument - Serif", deliberate identity choice, not debt. + `detect.mjs backend/internal/web/templates` reports **false clean** — + always pass `backend/internal/web/static` too. One finding there, + `overused-font` on "Instrument Serif", deliberate identity choice, not debt. - **Every action that moves series out of list is confirm-gated.** Archive, finish, remove each open own `.confirm-row` disclosure (`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈ @@ -162,7 +173,7 @@ Smoke test: `curl` endpoints with `Authorization: Bearer `; confirm `OPTI Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md` — source of truth Claude Design project `mangaBookmark Web UI` (`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 state (busy, error, destruction) may use `--ember`; destruction gets `--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens diff --git a/backend/.dockerignore b/backend/.dockerignore index 84b07d1..cfc6328 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -1,10 +1,8 @@ -# Only go source + module files, plus the go:embed'd templates/static -# directories, are needed in the build context. +# Only go source + module files, plus internal/ (which carries the +# go:embed'd templates/static directories), are needed in the build context. * !go.mod !go.sum !*.go -!templates/ -!templates/** -!static/ -!static/** +!internal/ +!internal/** diff --git a/backend/Dockerfile b/backend/Dockerfile index fb494da..f91faf4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,12 +8,11 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download -# Then source (changes often). -# Source plus the go:embed'd assets. Missing either directory turns the embed -# directive into a build error, so both must be copied before `go build`. +# Then source (changes often). internal/web carries the go:embed'd +# templates/static assets — missing them turns the embed directive into a +# build error, so the whole tree must land before `go build`. COPY *.go ./ -COPY templates/ ./templates/ -COPY static/ ./static/ +COPY internal/ ./internal/ # Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency. # -trimpath + -ldflags strip paths and debug info for a smaller image. diff --git a/backend/api_test.go b/backend/api_test.go new file mode 100644 index 0000000..19e9450 --- /dev/null +++ b/backend/api_test.go @@ -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) + } +} diff --git a/backend/handlers.go b/backend/internal/api/handlers.go similarity index 71% rename from backend/handlers.go rename to backend/internal/api/handlers.go index 025d670..aa6447c 100644 --- a/backend/handlers.go +++ b/backend/internal/api/handlers.go @@ -1,4 +1,4 @@ -package main +package api import ( "encoding/json" @@ -6,10 +6,13 @@ import ( "net/http" "strings" "time" + + "mangabm/backend/internal/store" ) -type bookmarkHandler struct { - store *Store +// Handler serves the userscript-facing JSON bookmark API. +type Handler struct { + Store *store.Store } 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 -func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) { - items, err := h.store.List() +// List returns all bookmarks. GET /bookmarks +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + items, err := h.Store.List() if err != nil { log.Printf("list: %v", err) 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) } -// put upserts one bookmark. PUT /bookmarks/{key} -func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) { +// Put upserts one bookmark. PUT /bookmarks/{key} +func (h *Handler) Put(w http.ResponseWriter, r *http.Request) { key := r.PathValue("key") if key == "" { http.Error(w, "missing key", http.StatusBadRequest) return } - var b Bookmark + var b store.Bookmark if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil { http.Error(w, "invalid JSON body", http.StatusBadRequest) 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 // rather than trusting every client to leave it alone. switch b.Status { - case "", statusReading, statusArchived: - case statusFinished: - http.Error(w, "status "+statusFinished+" can only be set from the web UI", + case "", store.StatusReading, store.StatusArchived: + case store.StatusFinished: + http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI", http.StatusBadRequest) return default: @@ -79,7 +82,7 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) { // reading progress actually moved. Any client value is ignored. b.UpdatedAt = time.Now().UnixMilli() - stored, err := h.store.Upsert(b) + stored, err := h.Store.Upsert(b) if err != nil { log.Printf("upsert: %v", err) 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) } -// delete removes one bookmark. DELETE /bookmarks/{key} -func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) { +// Delete removes one bookmark. DELETE /bookmarks/{key} +func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { key := r.PathValue("key") if key == "" { http.Error(w, "missing key", http.StatusBadRequest) return } - if err := h.store.Delete(key); err != nil { + if err := h.Store.Delete(key); err != nil { log.Printf("delete: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -105,7 +108,8 @@ func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) { 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.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) diff --git a/backend/middleware.go b/backend/internal/httpmw/middleware.go similarity index 88% rename from backend/middleware.go rename to backend/internal/httpmw/middleware.go index 220e4bf..e609531 100644 --- a/backend/middleware.go +++ b/backend/internal/httpmw/middleware.go @@ -1,4 +1,4 @@ -package main +package httpmw import ( "compress/gzip" @@ -9,8 +9,8 @@ import ( const bearerPrefix = "Bearer " -// withAuth guards a handler with a constant-time bearer-token check. -func withAuth(token string, next http.Handler) http.Handler { +// Auth guards a handler with a constant-time bearer-token check. +func Auth(token string, next http.Handler) http.Handler { want := []byte(token) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") @@ -71,11 +71,11 @@ func (w *gzipWriter) Write(b []byte) (int, error) { 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 // of that gzipped, which is the difference between a fast and a slow first load // 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) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 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 // auth middleware so preflight (which carries no Authorization header) is never // 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)) for _, o := range allowed { set[o] = struct{}{} diff --git a/backend/latest_http.go b/backend/internal/latest/fetch.go similarity index 89% rename from backend/latest_http.go rename to backend/internal/latest/fetch.go index d6a34ec..866bed4 100644 --- a/backend/latest_http.go +++ b/backend/internal/latest/fetch.go @@ -1,4 +1,4 @@ -package main +package latest import ( "context" @@ -20,20 +20,20 @@ const maxBodyBytes = 4 << 20 const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/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 // 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. // The library is pure Go, so CGO_ENABLED=0, the static binary, and the // distroless image are all unaffected. -type tlsFetcher struct { +type TLSFetcher struct { 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(), tls_client.WithTimeoutSeconds(30), tls_client.WithClientProfile(profiles.Chrome_133), @@ -41,13 +41,13 @@ func newTLSFetcher() (*tlsFetcher, error) { if err != nil { 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 // demonic chapter anchors are a redirect form, and asura has moved domains // 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) if err != nil { return "", 0, fmt.Errorf("build request %q: %w", url, err) diff --git a/backend/latest.go b/backend/internal/latest/poller.go similarity index 82% rename from backend/latest.go rename to backend/internal/latest/poller.go index 40a36aa..b2214ca 100644 --- a/backend/latest.go +++ b/backend/internal/latest/poller.go @@ -1,40 +1,42 @@ -package main +package latest import ( "context" "log" "net/url" "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. -type fetcher interface { +type Fetcher interface { 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 // in parallel and report the same observable fact, so whichever writes last wins // and neither needs to know about the other. // // Two clocks, deliberately independent: // -// - interval is how often this goroutine wakes up and looks. -// - cooldown is how long one bookmark rests since its own last check. +// - Interval is how often this goroutine wakes up and looks. +// - 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 -// 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 // nothing due more often. -type latestPoller struct { - store *Store - fetch fetcher - now func() time.Time // injected so tests can freeze it - cooldown time.Duration - interval time.Duration - stagger time.Duration - batch int +type Poller struct { + Store *store.Store + Fetch Fetcher + Now func() time.Time // injected so tests can freeze it + Cooldown time.Duration + Interval time.Duration + Stagger time.Duration + Batch int } // 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 // failure mode for a misconfigured batch x stagger: a slower cadence, never // 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", - p.interval, p.cooldown, p.batch, p.stagger) - t := time.NewTicker(p.interval) + p.Interval, p.Cooldown, p.Batch, p.Stagger) + t := time.NewTicker(p.Interval) defer t.Stop() for { select { @@ -60,9 +62,9 @@ func (p *latestPoller) Run(ctx context.Context) { } // runOnce processes one batch of due bookmarks. -func (p *latestPoller) runOnce(ctx context.Context) { - cutoff := p.now().Add(-p.cooldown).UnixMilli() - due, err := p.store.DueForLatestCheck(cutoff, p.batch) +func (p *Poller) runOnce(ctx context.Context) { + cutoff := p.Now().Add(-p.Cooldown).UnixMilli() + due, err := p.Store.DueForLatestCheck(cutoff, p.Batch) if err != nil { log.Printf("latest poll: due query: %v", err) 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 // series per navigation ... indistinguishable from browsing" (L455-456). stopped := false - if i > 0 && p.stagger > 0 { + if i > 0 && p.Stagger > 0 { select { case <-ctx.Done(): stopped = true - case <-time.After(p.stagger): + case <-time.After(p.Stagger): } } 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": // the poller is a best-effort enhancement, and no single bad series may stall a // 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() { if r := recover(); r != nil { 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 // series would be retried on every single tick forever. The userscript // 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) return } @@ -128,7 +130,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) { return } - body, status, err := p.fetch.Get(ctx, b.SeriesURL) + body, status, err := p.Fetch.Get(ctx, b.SeriesURL) if err != nil { log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err) 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 // value now differs. Accepted for a single-user deployment: the window is // 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 { log.Printf("latest poll %q: reread: %v", b.Key, err) return @@ -174,8 +176,8 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) { cur.LatestChapterNum = &num // 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. - cur.UpdatedAt = p.now().UnixMilli() - if _, err := p.store.Upsert(cur); err != nil { + cur.UpdatedAt = p.Now().UnixMilli() + if _, err := p.Store.Upsert(cur); err != nil { log.Printf("latest poll %q: upsert: %v", b.Key, err) return } diff --git a/backend/latest_test.go b/backend/internal/latest/poller_test.go similarity index 85% rename from backend/latest_test.go rename to backend/internal/latest/poller_test.go index 04502e0..d60daa4 100644 --- a/backend/latest_test.go +++ b/backend/internal/latest/poller_test.go @@ -1,13 +1,53 @@ -package main +package latest import ( "context" "errors" + "path/filepath" "sync" "testing" "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 // in this file can reach tls-client or a real site. 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 // 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() - return &latestPoller{ - store: s, - fetch: f, - now: func() time.Time { return at }, - cooldown: time.Hour, - interval: 10 * time.Minute, - stagger: 0, - batch: 14, + return &Poller{ + Store: s, + Fetch: f, + Now: func() time.Time { return at }, + Cooldown: time.Hour, + Interval: 10 * time.Minute, + Stagger: 0, + Batch: 14, } } @@ -89,7 +129,7 @@ func TestRunOnceDoesNotReorderList(t *testing.T) { const key = "asura:chronicles-of-the-demon-faction-f886a8af" // "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", SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000, }); err != nil { @@ -166,7 +206,7 @@ func TestRunOnceRespectsBatchLimit(t *testing.T) { f := &fakeFetcher{body: "", status: 200} p := newTestPoller(t, s, f, time.UnixMilli(5_000_000)) - p.batch = 5 + p.Batch = 5 p.runOnce(context.Background()) 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. 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()) if got := f.callCount(); got != 1 { t.Fatalf("fetched %d times inside the cooldown, want 1", got) } // 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()) if got := f.callCount(); got != 2 { 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" high := 400.0 - if _, err := s.Upsert(Bookmark{ + if _, err := s.Upsert(store.Bookmark{ Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer", SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high, UpdatedAt: 1000, @@ -278,7 +318,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := newTestStore(t) 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, UpdatedAt: 1000, }); err != nil { @@ -287,7 +327,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) { now := time.UnixMilli(4_000_000) 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, }) diff --git a/backend/latest_sites.go b/backend/internal/latest/sites.go similarity index 96% rename from backend/latest_sites.go rename to backend/internal/latest/sites.go index d503dbf..9b7c72f 100644 --- a/backend/latest_sites.go +++ b/backend/internal/latest/sites.go @@ -1,9 +1,11 @@ -package main +package latest import ( "regexp" "strconv" "strings" + + "mangabm/backend/internal/store" ) // 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 // the stable ID (same rule as migrateAsuraKeys) and make the hash // 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 // 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.]+)`) diff --git a/backend/latest_sites_test.go b/backend/internal/latest/sites_test.go similarity index 99% rename from backend/latest_sites_test.go rename to backend/internal/latest/sites_test.go index 443653d..de94005 100644 --- a/backend/latest_sites_test.go +++ b/backend/internal/latest/sites_test.go @@ -1,4 +1,4 @@ -package main +package latest import "testing" diff --git a/backend/session.go b/backend/internal/session/session.go similarity index 75% rename from backend/session.go rename to backend/internal/session/session.go index 34d63c8..3c70592 100644 --- a/backend/session.go +++ b/backend/internal/session/session.go @@ -1,4 +1,4 @@ -package main +package session import ( "crypto/hmac" @@ -14,7 +14,7 @@ import ( ) const ( - sessionCookieName = "mangabm_session" + CookieName = "mangabm_session" // 60 days: long enough that a phone stays logged in between reading spells. sessionTTL = 60 * 24 * time.Hour // Domain separation, so the session key can never collide with any other @@ -23,18 +23,18 @@ const ( 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 // WEB_PASSWORD invalidates every outstanding cookie at once. The \x00 // separator prevents the concatenation ambiguity a bare apiToken+webPassword // 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)) return sum[:] } -// signSession encodes ".". -func signSession(key []byte, expiryMs int64) string { +// Sign encodes ".". +func Sign(key []byte, expiryMs int64) string { payload := strconv.FormatInt(expiryMs, 10) return payload + "." + sessionMAC(key, payload) } @@ -45,10 +45,10 @@ func sessionMAC(key []byte, payload string) string { 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 // 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, ".") if !ok { return false @@ -69,10 +69,10 @@ func isHTTPS(r *http.Request) bool { 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{ - Name: sessionCookieName, - Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()), + Name: CookieName, + Value: Sign(key, time.Now().Add(sessionTTL).UnixMilli()), Path: "/", MaxAge: int(sessionTTL / time.Second), 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{ - Name: sessionCookieName, + Name: CookieName, Value: "", Path: "/", MaxAge: -1, @@ -94,11 +94,11 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) { } const ( - loginMaxFailures = 10 - loginWindow = 20 * time.Minute + MaxFailures = 10 + 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, // 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. // RemoteAddr is useless behind the proxy — it is always the Traefik container — // 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 { hops := strings.Split(vals[len(vals)-1], ",") if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" { @@ -120,8 +120,8 @@ func clientIP(r *http.Request) string { return host } -// loginLimiter throttles password guessing: loginMaxFailures failures inside a -// rolling loginWindow blocks further attempts from that IP until the oldest one +// LoginLimiter throttles password guessing: MaxFailures failures inside a +// rolling Window blocks further attempts from that IP until the oldest one // ages out. There is no permanent ban and no unlock step. // // 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 // pruned lazily on access; for a single-user deployment the map cannot grow // past the handful of addresses that ever attempt a login. -type loginLimiter struct { +type LoginLimiter struct { mu sync.Mutex failures map[string][]time.Time } -func newLoginLimiter() *loginLimiter { - return &loginLimiter{failures: make(map[string][]time.Time)} +func NewLoginLimiter() *LoginLimiter { + return &LoginLimiter{failures: make(map[string][]time.Time)} } // 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() defer l.mu.Unlock() recent := l.pruneLocked(ip, now) - if len(recent) < loginMaxFailures { + if len(recent) < MaxFailures { 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() defer l.mu.Unlock() l.failures[ip] = append(l.pruneLocked(ip, now), now) } -func (l *loginLimiter) reset(ip string) { +func (l *LoginLimiter) Reset(ip string) { l.mu.Lock() defer l.mu.Unlock() 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. // The caller must hold l.mu. -func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time { - cutoff := now.Add(-loginWindow) +func (l *LoginLimiter) pruneLocked(ip string, now time.Time) []time.Time { + cutoff := now.Add(-Window) // In-place filter: kept reuses the backing array of the slice being // 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 diff --git a/backend/session_test.go b/backend/internal/session/session_test.go similarity index 68% rename from backend/session_test.go rename to backend/internal/session/session_test.go index 4c261b4..327d168 100644 --- a/backend/session_test.go +++ b/backend/internal/session/session_test.go @@ -1,4 +1,4 @@ -package main +package session import ( "crypto/tls" @@ -10,18 +10,18 @@ import ( ) func TestSessionRoundTrip(t *testing.T) { - key := sessionKey("token-abc", "pw-abc") + key := Key("token-abc", "pw-abc") now := time.Now().UnixMilli() - value := signSession(key, now+60_000) - if !verifySession(key, value, now) { - t.Fatal("verifySession = false for a freshly signed cookie, want true") + value := Sign(key, now+60_000) + if !Verify(key, value, now) { + t.Fatal("Verify = false for a freshly signed cookie, want true") } } func TestSessionRejects(t *testing.T) { - key := sessionKey("token-abc", "pw-abc") + key := Key("token-abc", "pw-abc") now := time.Now().UnixMilli() - valid := signSession(key, now+60_000) + valid := Sign(key, now+60_000) payload, sig, _ := strings.Cut(valid, ".") cases := []struct { @@ -31,15 +31,15 @@ func TestSessionRejects(t *testing.T) { {"empty", ""}, {"no separator", payload + sig}, {"unparseable expiry", "notanumber." + sig}, - {"expired", signSession(key, now-1)}, + {"expired", Sign(key, now-1)}, {"tampered signature", payload + "." + flipLastChar(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 { t.Run(tc.name, func(t *testing.T) { - if verifySession(key, tc.value, now) { - t.Fatalf("verifySession(%q) = true, want false", tc.value) + if Verify(key, tc.value, now) { + t.Fatalf("Verify(%q) = true, want false", tc.value) } }) } @@ -57,18 +57,18 @@ func flipLastChar(s string) string { } func TestSessionKeyDependsOnToken(t *testing.T) { - a := sessionKey("token-a", "pw-abc") - b := sessionKey("token-b", "pw-abc") + a := Key("token-a", "pw-abc") + b := Key("token-b", "pw-abc") 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) { - a := sessionKey("token-abc", "pw-a") - b := sessionKey("token-abc", "pw-b") + a := Key("token-abc", "pw-a") + b := Key("token-abc", "pw-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) } rr := httptest.NewRecorder() - setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc")) + SetCookie(rr, r, Key("token-abc", "pw-abc")) cookies := rr.Result().Cookies() if len(cookies) != 1 { t.Fatalf("got %d cookies, want 1", len(cookies)) } c := cookies[0] - if c.Name != sessionCookieName { - t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName) + if c.Name != CookieName { + t.Fatalf("cookie name = %q, want %q", c.Name, CookieName) } if !c.HttpOnly { t.Fatal("cookie HttpOnly = false, want true") @@ -126,7 +126,7 @@ func TestSetSessionCookieAttributes(t *testing.T) { func TestClearSessionCookie(t *testing.T) { r := httptest.NewRequest(http.MethodPost, "/logout", nil) rr := httptest.NewRecorder() - clearSessionCookie(rr, r) + ClearCookie(rr, r) cookies := rr.Result().Cookies() if len(cookies) != 1 { @@ -168,65 +168,65 @@ func TestClientIP(t *testing.T) { for _, v := range tc.xff { r.Header.Add("X-Forwarded-For", v) } - if got := clientIP(r); got != tc.want { - t.Fatalf("clientIP() = %q, want %q", got, tc.want) + if got := ClientIP(r); got != tc.want { + t.Fatalf("ClientIP() = %q, want %q", got, tc.want) } }) } } func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) { - l := newLoginLimiter() + l := NewLoginLimiter() now := time.Now() - for i := 0; i < loginMaxFailures; i++ { - if wait := l.retryAfter("1.2.3.4", now); wait != 0 { - t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures) + for i := 0; i < MaxFailures; i++ { + if wait := l.RetryAfter("1.2.3.4", now); wait != 0 { + 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 { - 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 { - t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow) + if wait > Window { + t.Fatalf("retryAfter = %v, want <= %v", wait, Window) } } func TestLoginLimiterWindowExpires(t *testing.T) { - l := newLoginLimiter() + l := NewLoginLimiter() start := time.Now() - for i := 0; i < loginMaxFailures; i++ { - l.fail("1.2.3.4", start) + for i := 0; i < MaxFailures; i++ { + 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") } - later := start.Add(loginWindow + time.Second) - if wait := l.retryAfter("1.2.3.4", later); wait != 0 { + later := start.Add(Window + time.Second) + if wait := l.RetryAfter("1.2.3.4", later); wait != 0 { t.Fatalf("retryAfter = %v once the window passed, want 0", wait) } } func TestLoginLimiterResetClearsCounter(t *testing.T) { - l := newLoginLimiter() + l := NewLoginLimiter() now := time.Now() - for i := 0; i < loginMaxFailures; i++ { - l.fail("1.2.3.4", now) + for i := 0; i < MaxFailures; i++ { + l.Fail("1.2.3.4", now) } - l.reset("1.2.3.4") - if wait := l.retryAfter("1.2.3.4", now); wait != 0 { + l.Reset("1.2.3.4") + if wait := l.RetryAfter("1.2.3.4", now); wait != 0 { t.Fatalf("retryAfter = %v after reset, want 0", wait) } } func TestLoginLimiterIsPerIP(t *testing.T) { - l := newLoginLimiter() + l := NewLoginLimiter() now := time.Now() - for i := 0; i < loginMaxFailures; i++ { - l.fail("1.2.3.4", now) + for i := 0; i < MaxFailures; i++ { + 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) } } diff --git a/backend/store.go b/backend/internal/store/store.go similarity index 93% rename from backend/store.go rename to backend/internal/store/store.go index a911ef5..0658d2b 100644 --- a/backend/store.go +++ b/backend/internal/store/store.go @@ -1,4 +1,4 @@ -package main +package store import ( "database/sql" @@ -86,11 +86,21 @@ func (b Bookmark) ContinueURL() string { 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. const ( - statusReading = "reading" - statusArchived = "archived" - statusFinished = "finished" + StatusReading = "reading" + StatusArchived = "archived" + StatusFinished = "finished" ) const schema = ` @@ -137,7 +147,7 @@ type Store struct { } // 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 // concurrent requests; a single writer connection keeps writes serialized. dsn := path @@ -182,11 +192,11 @@ func migrateColumns(db *sql.DB) error { 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 // must not be part of series_id. Must stay in sync with stripBuildHash in // 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 // 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{} for _, r := range all { - stripped := asuraBuildHash.ReplaceAllString(r.id, "") + stripped := AsuraBuildHash.ReplaceAllString(r.id, "") groups[stripped] = append(groups[stripped], r) } 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 // buckets reads as the default rather than being passed through. b.Status = status.String - if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished { - b.Status = statusReading + if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished { + b.Status = StatusReading } return b, nil } @@ -481,3 +491,16 @@ func (s *Store) MarkLatestChecked(key string, ts int64) error { } 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 +} diff --git a/backend/store_test.go b/backend/internal/store/store_test.go similarity index 57% rename from backend/store_test.go rename to backend/internal/store/store_test.go index 03d4f1b..0653b01 100644 --- a/backend/store_test.go +++ b/backend/internal/store/store_test.go @@ -1,42 +1,15 @@ -package main +package store import ( - "bytes" "database/sql" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" "path/filepath" - "strings" "testing" "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 { t.Helper() - store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + store, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("OpenStore: %v", err) } @@ -44,346 +17,6 @@ func newTestStore(t *testing.T) *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) { dbPath := filepath.Join(t.TempDir(), "legacy.db") @@ -415,7 +48,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) { t.Fatalf("close legacy db: %v", err) } - store, err := OpenStore(dbPath) + store, err := Open(dbPath) if err != nil { 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. - store2, err := OpenStore(dbPath) + store2, err := Open(dbPath) if err != nil { 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 // Bookmark (see Store.Upsert), so tests cannot assert on it any other way. func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 { @@ -672,7 +292,7 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) { t.Fatalf("close: %v", err) } - s, err := OpenStore(path) + s, err := Open(path) if err != nil { 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) { store := newTestStore(t) stored, err := store.Upsert(Bookmark{ @@ -732,8 +320,8 @@ func TestUpsertDefaultsStatusToReading(t *testing.T) { if err != nil { t.Fatalf("Upsert: %v", err) } - if stored.Status != statusReading { - t.Fatalf("Status = %q, want %q", stored.Status, statusReading) + if stored.Status != StatusReading { + t.Fatalf("Status = %q, want %q", stored.Status, StatusReading) } } @@ -743,7 +331,7 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) { store := newTestStore(t) base := Bookmark{ 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 { t.Fatalf("seed: %v", err) @@ -755,8 +343,8 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) { if err != nil { t.Fatalf("Upsert: %v", err) } - if stored.Status != statusArchived { - t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) + if 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) base := Bookmark{ 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 { t.Fatalf("seed: %v", err) @@ -788,8 +376,8 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) { if err != nil { t.Fatalf("Upsert: %v", err) } - if stored.Status != statusArchived { - t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived) + if 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) base := Bookmark{ 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 { t.Fatalf("seed: %v", err) } - base.Status = statusReading + base.Status = StatusReading stored, err := store.Upsert(base) if err != nil { t.Fatalf("Upsert: %v", err) } - if stored.Status != statusReading { - t.Fatalf("Status = %q, want %q", stored.Status, statusReading) + if 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) } - base.Status = statusArchived + base.Status = StatusArchived base.UpdatedAt = first.UpdatedAt + 60_000 stored, err := store.Upsert(base) if err != nil { @@ -857,7 +445,7 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) { } db.Close() - store, err := OpenStore(path) + store, err := Open(path) if err != nil { t.Fatalf("OpenStore: %v", err) } @@ -867,8 +455,8 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) { if err != nil || !ok { t.Fatalf("Get: ok=%v err=%v", ok, err) } - if b.Status != statusReading { - t.Fatalf("Status = %q, want %q", b.Status, statusReading) + if 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) { store := newTestStore(t) for _, tc := range []struct{ key, status string }{ - {"asura:reading", statusReading}, - {"asura:archived", statusArchived}, - {"asura:finished", statusFinished}, + {"asura:reading", StatusReading}, + {"asura:archived", StatusArchived}, + {"asura:finished", StatusFinished}, } { if _, err := store.Upsert(Bookmark{ Key: tc.key, Site: "asura", SeriesID: tc.key, @@ -912,7 +500,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) { func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "hash.db") - store, err := OpenStore(dbPath) + store, err := Open(dbPath) if err != nil { t.Fatalf("open: %v", err) } @@ -938,7 +526,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) { t.Fatalf("close: %v", err) } - reopened, err := OpenStore(dbPath) + reopened, err := Open(dbPath) if err != nil { t.Fatalf("reopen: %v", err) } @@ -977,7 +565,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) { } // Idempotent: a third open changes nothing. - third, err := OpenStore(dbPath) + third, err := Open(dbPath) if err != nil { t.Fatalf("third open: %v", err) } @@ -990,7 +578,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) { func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "collision.db") - store, err := OpenStore(dbPath) + store, err := Open(dbPath) if err != nil { t.Fatalf("open: %v", err) } @@ -1010,7 +598,7 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) { t.Fatalf("close: %v", err) } - reopened, err := OpenStore(dbPath) + reopened, err := Open(dbPath) if err != nil { t.Fatalf("reopen: %v", err) } diff --git a/backend/userscript.go b/backend/internal/userscript/userscript.go similarity index 96% rename from backend/userscript.go rename to backend/internal/userscript/userscript.go index bcb46cd..c714d90 100644 --- a/backend/userscript.go +++ b/backend/internal/userscript/userscript.go @@ -1,4 +1,4 @@ -package main +package userscript import ( "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 // 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) { if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 { http.NotFound(w, r) diff --git a/backend/userscript_test.go b/backend/internal/userscript/userscript_test.go similarity index 71% rename from backend/userscript_test.go rename to backend/internal/userscript/userscript_test.go index fec5b34..3196342 100644 --- a/backend/userscript_test.go +++ b/backend/internal/userscript/userscript_test.go @@ -1,4 +1,4 @@ -package main +package userscript import ( "net/http" @@ -10,6 +10,8 @@ import ( "time" ) +const testToken = "s3cret-token" + // 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. const sampleScript = `// ==UserScript== @@ -35,16 +37,12 @@ func writeScript(t *testing.T, body string) (path, wantVersion string) { return path, "2026.07.28.1642" } -func newUserscriptServer(t *testing.T, path string) http.Handler { - t.Helper() - store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("OpenStore: %v", err) - } - t.Cleanup(func() { store.Close() }) - cfg := testConfig() - cfg.UserscriptPath = path - return newRouter(store, cfg) +// newTestMux registers Handler the same way main.go's router does, without +// pulling in the store or the rest of the app. +func newTestMux(token, path string) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path)) + return mux } 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) { path, wantVersion := writeScript(t, sampleScript) - rr := getScript(t, newUserscriptServer(t, path), testToken) + rr := getScript(t, newTestMux(testToken, path), testToken) if rr.Code != http.StatusOK { 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) { path, _ := writeScript(t, sampleScript) - srv := newUserscriptServer(t, path) - for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} { + srv := newTestMux(testToken, path) + for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} { if got := getScript(t, srv, tok).Code; got != http.StatusNotFound { t.Errorf("token %q: status = %d, want 404", tok, got) } @@ -94,7 +95,7 @@ func TestUserscriptWrongTokenIs404(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 { t.Fatalf("status = %d, want 404", got) } @@ -103,7 +104,7 @@ func TestUserscriptMissingFileIs404(t *testing.T) { func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) { const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" path, _ := writeScript(t, noVersion) - rr := getScript(t, newUserscriptServer(t, path), testToken) + rr := getScript(t, newTestMux(testToken, path), testToken) if rr.Code != http.StatusOK { 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()) } } - -// 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) - } -} diff --git a/backend/static/filter.js b/backend/internal/web/static/filter.js similarity index 100% rename from backend/static/filter.js rename to backend/internal/web/static/filter.js diff --git a/backend/static/fonts/dm-sans-var-latin.woff2 b/backend/internal/web/static/fonts/dm-sans-var-latin.woff2 similarity index 100% rename from backend/static/fonts/dm-sans-var-latin.woff2 rename to backend/internal/web/static/fonts/dm-sans-var-latin.woff2 diff --git a/backend/static/fonts/ibm-plex-mono-500-latin.woff2 b/backend/internal/web/static/fonts/ibm-plex-mono-500-latin.woff2 similarity index 100% rename from backend/static/fonts/ibm-plex-mono-500-latin.woff2 rename to backend/internal/web/static/fonts/ibm-plex-mono-500-latin.woff2 diff --git a/backend/static/fonts/ibm-plex-mono-600-latin.woff2 b/backend/internal/web/static/fonts/ibm-plex-mono-600-latin.woff2 similarity index 100% rename from backend/static/fonts/ibm-plex-mono-600-latin.woff2 rename to backend/internal/web/static/fonts/ibm-plex-mono-600-latin.woff2 diff --git a/backend/static/fonts/instrument-serif-400-italic-latin.woff2 b/backend/internal/web/static/fonts/instrument-serif-400-italic-latin.woff2 similarity index 100% rename from backend/static/fonts/instrument-serif-400-italic-latin.woff2 rename to backend/internal/web/static/fonts/instrument-serif-400-italic-latin.woff2 diff --git a/backend/static/fonts/instrument-serif-400-latin.woff2 b/backend/internal/web/static/fonts/instrument-serif-400-latin.woff2 similarity index 100% rename from backend/static/fonts/instrument-serif-400-latin.woff2 rename to backend/internal/web/static/fonts/instrument-serif-400-latin.woff2 diff --git a/backend/static/htmx.min.js b/backend/internal/web/static/htmx.min.js similarity index 100% rename from backend/static/htmx.min.js rename to backend/internal/web/static/htmx.min.js diff --git a/backend/static/logo.svg b/backend/internal/web/static/logo.svg similarity index 100% rename from backend/static/logo.svg rename to backend/internal/web/static/logo.svg diff --git a/backend/static/style.css b/backend/internal/web/static/style.css similarity index 100% rename from backend/static/style.css rename to backend/internal/web/static/style.css diff --git a/backend/templates/app.html b/backend/internal/web/templates/app.html similarity index 100% rename from backend/templates/app.html rename to backend/internal/web/templates/app.html diff --git a/backend/templates/card.html b/backend/internal/web/templates/card.html similarity index 100% rename from backend/templates/card.html rename to backend/internal/web/templates/card.html diff --git a/backend/templates/chrome.html b/backend/internal/web/templates/chrome.html similarity index 100% rename from backend/templates/chrome.html rename to backend/internal/web/templates/chrome.html diff --git a/backend/templates/icons.html b/backend/internal/web/templates/icons.html similarity index 100% rename from backend/templates/icons.html rename to backend/internal/web/templates/icons.html diff --git a/backend/templates/list.html b/backend/internal/web/templates/list.html similarity index 100% rename from backend/templates/list.html rename to backend/internal/web/templates/list.html diff --git a/backend/templates/login.html b/backend/internal/web/templates/login.html similarity index 100% rename from backend/templates/login.html rename to backend/internal/web/templates/login.html diff --git a/backend/web.go b/backend/internal/web/web.go similarity index 78% rename from backend/web.go rename to backend/internal/web/web.go index e30a4f1..6e8b36e 100644 --- a/backend/web.go +++ b/backend/internal/web/web.go @@ -1,4 +1,4 @@ -package main +package web import ( "crypto/subtle" @@ -13,6 +13,9 @@ import ( "strconv" "strings" "time" + + "mangabm/backend/internal/session" + "mangabm/backend/internal/store" ) //go:embed templates @@ -21,25 +24,25 @@ var templateFS embed.FS //go:embed static var staticFS embed.FS -// recentCount is how many series the "Continue reading" strip shows. -const recentCount = 5 +// RecentCount is how many series the "Continue reading" strip shows. +const RecentCount = 5 -// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/. -// It is a separate handler from bookmarkHandler because the two speak different +// Handler serves the browser UI: full pages at / and htmx fragments at /ui/. +// It is a separate handler from api.Handler because the two speak different // representations (HTML versus JSON) to different clients under different auth. -type webHandler struct { - store *Store +type Handler struct { + store *store.Store tmpl *template.Template key []byte password string - limiter *loginLimiter + limiter *session.LoginLimiter } // listView is what every list-rendering template receives. type listView struct { Tab string // "all", "fav", or "new" - Recent []Bookmark - Items []Bookmark + Recent []store.Bookmark + Items []store.Bookmark // 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 // reading set, not the active tab, so the badge does not change meaning as @@ -50,38 +53,28 @@ type listView struct { 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. type loginView struct { Error string } -// newWebHandler parses every template up front so a broken one kills the -// process at startup rather than the first request that touches it. -func newWebHandler(store *Store, cfg Config) (*webHandler, error) { +// New parses every template up front so a broken one kills the process at +// startup rather than the first request that touches it. +func New(s *store.Store, apiToken, webPassword string) (*Handler, error) { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { return nil, err } - return &webHandler{ - store: store, + return &Handler{ + store: s, tmpl: tmpl, - key: sessionKey(cfg.Token, cfg.WebPassword), - password: cfg.WebPassword, - limiter: newLoginLimiter(), + key: session.Key(apiToken, webPassword), + password: webPassword, + limiter: session.NewLoginLimiter(), }, nil } -func (h *webHandler) register(mux *http.ServeMux) { +func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", h.index) mux.HandleFunc("POST /login", h.login) mux.HandleFunc("POST /logout", h.logout) @@ -118,15 +111,15 @@ func staticHandler() http.Handler { } // authed reports whether the request carries a valid session cookie. -func (h *webHandler) authed(r *http.Request) bool { - c, err := r.Cookie(sessionCookieName) - return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli()) +func (h *Handler) authed(r *http.Request) bool { + c, err := r.Cookie(session.CookieName) + return err == nil && session.Verify(h.key, c.Value, time.Now().UnixMilli()) } // requireSession guards the fragment endpoints. It answers 401 rather than // redirecting, because htmx swaps whatever body it receives into the page and a // 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) { if !h.authed(r) { 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.WriteHeader(status) 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 // page is served at / with status 200 rather than as a redirect to a separate // 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) { h.render(w, http.StatusOK, "login", loginView{}) 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. // It always returns a non-nil slice so an empty tab renders its empty state. -func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark { - out := []Bookmark{} +func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []store.Bookmark { + out := []store.Bookmark{} for _, b := range all { if keep(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 // archived favourite therefore shows only under Archived: Favourites means // "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 if err != nil { 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 { 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": items = withNew 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": - 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: tab = "all" 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 // the intended reading: an empty strip has nothing to say, and the ~240px it // costs on a phone belongs to the list. - var recent []Bookmark + var recent []store.Bookmark if tab == "all" { recent = withNew - if len(recent) > recentCount { - recent = recent[:recentCount] + if len(recent) > RecentCount { + recent = recent[:RecentCount] } } 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")) if err != nil { 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. // The key is in here because it is tab-shaped too: archived and finished swap // Archive for Restore. -func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) { +func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) { view.OOB = true for _, name := range []string{"recent", "newcount", "keyrow"} { 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 // 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)) if err != nil { log.Printf("ui chrome: %v", err) @@ -275,9 +268,9 @@ func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) { h.writeChromeOOB(w, view) } -func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { - ip := clientIP(r) - if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 { +func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + ip := session.ClientIP(r) + if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 { secs := int(wait.Seconds()) + 1 w.Header().Set("Retry-After", strconv.Itoa(secs)) 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") 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."}) return } - h.limiter.reset(ip) - setSessionCookie(w, r, h.key) + h.limiter.Reset(ip) + session.SetCookie(w, r, h.key) http.Redirect(w, r, "/", http.StatusSeeOther) } -func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { - clearSessionCookie(w, r) +func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { + session.ClearCookie(w, r) http.Redirect(w, r, "/", http.StatusSeeOther) } // loadForMutation fetches the row a mutation targets, writing the error // 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") if key == "" { http.Error(w, "missing key", http.StatusBadRequest) - return Bookmark{}, false + return store.Bookmark{}, false } b, ok, err := h.store.Get(key) if err != nil { log.Printf("ui get %q: %v", key, err) http.Error(w, "internal error", http.StatusInternalServerError) - return Bookmark{}, false + return store.Bookmark{}, false } if !ok { http.Error(w, "not found", http.StatusNotFound) - return Bookmark{}, false + return store.Bookmark{}, false } 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 // describe the whole library, so they are rebuilt out of band on every // 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) if err != nil { 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 // 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) if !ok { 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 // 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) if !ok { return @@ -377,7 +370,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) { return } switch s := r.PostFormValue("status"); s { - case statusReading, statusArchived, statusFinished: + case store.StatusReading, store.StatusArchived, store.StatusFinished: b.Status = s default: 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 // destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0" // 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) if !ok { 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 // 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") if key == "" { http.Error(w, "missing key", http.StatusBadRequest) diff --git a/backend/main.go b/backend/main.go index aa8f70d..75db207 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,6 +11,13 @@ import ( "strings" "syscall" "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. @@ -149,22 +156,22 @@ func loadConfig() Config { // newRouter wires routes and middleware. CORS is the outermost layer so // preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected, // /healthz is public. -func newRouter(store *Store, cfg Config) http.Handler { +func newRouter(s *store.Store, cfg Config) http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /healthz", healthz) + mux.HandleFunc("GET /healthz", api.Healthz) - // Outside withAuth (the updater sends no Authorization header) and outside - // the WEB_PASSWORD gate (the script must be installable either way). The - // path segment carries the token instead. - mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath)) + // Outside httpmw.Auth (the updater sends no Authorization header) and + // outside the WEB_PASSWORD gate (the script must be installable either + // way). The path segment carries the token instead. + 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.HandleFunc("GET /bookmarks", h.list) - protected.HandleFunc("PUT /bookmarks/{key}", h.put) - protected.HandleFunc("DELETE /bookmarks/{key}", h.delete) + protected.HandleFunc("GET /bookmarks", h.List) + protected.HandleFunc("PUT /bookmarks/{key}", h.Put) + 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) @@ -172,14 +179,14 @@ func newRouter(store *Store, cfg Config) http.Handler { // deployment that forgets WEB_PASSWORD exposes nothing rather than // exposing an unprotected list. if cfg.WebPassword != "" { - web, err := newWebHandler(store, cfg) + wh, err := web.New(s, cfg.Token, cfg.WebPassword) if err != nil { 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: @@ -203,22 +210,22 @@ func main() { log.Fatal("API_TOKEN is required") } - store, err := OpenStore(cfg.DBPath) + s, err := store.Open(cfg.DBPath) if err != nil { 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 // service still serves bookmarks and the userscript still captures latest // chapters on its own. pollCtx, stopPoll := context.WithCancel(context.Background()) defer stopPoll() - startLatestPoller(pollCtx, store, cfg.LatestPoll) + startLatestPoller(pollCtx, s, cfg.LatestPoll) srv := &http.Server{ Addr: ":" + cfg.Port, - Handler: newRouter(store, cfg), + Handler: newRouter(s, cfg), ReadHeaderTimeout: 10 * time.Second, } @@ -248,24 +255,24 @@ func main() { // HTTP client cannot be built. Any problem here is logged and skipped: this // feature going missing degrades the service to userscript-only latest-chapter // 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 { log.Println("latest-chapter poller: disabled by config") return } - f, err := newTLSFetcher() + f, err := latest.NewTLSFetcher() if err != nil { log.Printf("latest-chapter poller: disabled, cannot build client: %v", err) return } - p := &latestPoller{ - store: store, - fetch: f, - now: time.Now, - cooldown: cfg.Cooldown, - interval: cfg.Interval, - stagger: cfg.Stagger, - batch: cfg.Batch, + p := &latest.Poller{ + Store: s, + Fetch: f, + Now: time.Now, + Cooldown: cfg.Cooldown, + Interval: cfg.Interval, + Stagger: cfg.Stagger, + Batch: cfg.Batch, } go p.Run(ctx) } diff --git a/backend/main_test.go b/backend/main_test.go index 1d89df6..66eaa29 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "mangabm/backend/internal/store" ) func TestLoadLatestPollDefaults(t *testing.T) { @@ -148,7 +150,7 @@ func TestPutStatusValidation(t *testing.T) { if tc.want != http.StatusOK { return } - var got Bookmark + var got store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { 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()) } - var got Bookmark + var got store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } diff --git a/backend/web_test.go b/backend/web_test.go index 44f90fa..4a9a048 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -10,6 +10,10 @@ import ( "strings" "testing" "time" + + "mangabm/backend/internal/session" + "mangabm/backend/internal/store" + "mangabm/backend/internal/web" ) const testPassword = "hunter2" @@ -22,22 +26,22 @@ func webConfig() Config { // newWebTestServer returns the full router plus the store behind it, so tests // 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() - store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { - t.Fatalf("OpenStore: %v", err) + t.Fatalf("store.Open: %v", err) } - t.Cleanup(func() { store.Close() }) - return newRouter(store, cfg), store + t.Cleanup(func() { st.Close() }) + return newRouter(st, cfg), st } // sessionCookie returns a cookie a handler will accept for cfg's API token. func sessionCookie(t *testing.T, cfg Config) *http.Cookie { t.Helper() return &http.Cookie{ - Name: sessionCookieName, - Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()), + Name: session.CookieName, + 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) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - if _, err := store.Upsert(Bookmark{ + srv, st := newWebTestServer(t, cfg) + if _, err := st.Upsert(store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, UpdatedAt: time.Now().UnixMilli(), @@ -90,8 +94,8 @@ func TestLoginSuccessSetsCookie(t *testing.T) { t.Fatalf("POST /login status = %d, want 303", rr.Code) } cookies := rr.Result().Cookies() - if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { - t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName) + if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" { + 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) return rr } - for i := 0; i < loginMaxFailures; i++ { + for i := 0; i < session.MaxFailures; i++ { if code := post().Code; code != http.StatusUnauthorized { t.Fatalf("attempt %d status = %d, want 401", i+1, code) } } rr := post() 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 == "" { 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. -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() - stored, err := store.Upsert(b) + stored, err := st.Upsert(b) if err != nil { t.Fatalf("Upsert: %v", err) } @@ -245,8 +249,8 @@ func TestUIRoutesRequireSession(t *testing.T) { func TestFavoriteTogglesWithoutReordering(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - before := seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + before := seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, UpdatedAt: 1_000_000, @@ -258,7 +262,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) { 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 { 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. rr = httptest.NewRecorder() 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 { 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. func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, UpdatedAt: 1_000_000, @@ -320,8 +324,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) { func TestChapterOverrideMovesUpdatedAt(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - before := seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + before := seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, 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) } - after, ok, err := store.Get("asura:solo") + after, ok, err := st.Get("asura:solo") if err != nil || !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) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - before := seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + before := seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45, 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) } - after, ok, err := store.Get("asura:solo") + after, ok, err := st.Get("asura:solo") if err != nil || !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) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000, }) @@ -409,7 +413,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) { if rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } - after, _, _ := store.Get("asura:solo") + after, _, _ := st.Get("asura:solo") if after.LastChapterNum != 45 { 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) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", 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"`) { 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") } } func TestUIListFavouritesTab(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", 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", Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000, }) @@ -493,14 +497,14 @@ func TestUIListFavouritesTab(t *testing.T) { func TestUIListNewTab(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling", LastChapterNum: 10, LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12), UpdatedAt: 2_000_000, }) - seed(t, store, Bookmark{ + seed(t, st, store.Bookmark{ Key: "demonic:tower", Site: "demonic", SeriesID: "tower", Title: "Tower of God", LastChapterNum: 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 // 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. -func seedStatusRows(t *testing.T, store *Store) { +func seedStatusRows(t *testing.T, st *store.Store) { t.Helper() // 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", - Status: statusReading, LastChapterNum: 10, Favorite: true, + Status: store.StatusReading, LastChapterNum: 10, Favorite: true, LatestChapter: "11", LatestChapterNum: floatPtr(11)}, {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)}, {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 { 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) } } @@ -547,8 +551,8 @@ func seedStatusRows(t *testing.T, store *Store) { func TestTabsShowOnlyTheirBucket(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, cfg) + seedStatusRows(t, st) cases := []struct { 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. func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) // ReadingOne is at 10 with 11 out; the rest are not reading + srv, st := newWebTestServer(t, cfg) + 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. - caught := Bookmark{ + caught := store.Bookmark{ 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(), } - if _, err := store.Upsert(caught); err != nil { + if _, err := st.Upsert(caught); err != nil { 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. - reading, _, err := store.Get("asura:reading") + reading, _, err := st.Get("asura:reading") if err != nil { t.Fatalf("Get: %v", err) } reading.LatestChapterNum = floatPtr(reading.LastChapterNum) - if _, err := store.Upsert(reading); err != nil { + if _, err := st.Upsert(reading); err != nil { t.Fatalf("Upsert: %v", err) } // 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) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - for i := 0; i <= recentCount; i++ { - b := Bookmark{ + srv, st := newWebTestServer(t, cfg) + for i := 0; i <= web.RecentCount; i++ { + b := store.Bookmark{ Key: fmt.Sprintf("asura:new%d", i), Site: "asura", 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), } - if _, err := store.Upsert(b); err != nil { + if _, err := st.Upsert(b); err != nil { t.Fatalf("seed %s: %v", b.Key, err) } } - if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount { - t.Fatalf("strip rendered %d cards, want %d", 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, web.RecentCount) } } @@ -686,14 +690,14 @@ func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string) func TestUIStatusSetsBucket(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, cfg) + 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 { 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 { t.Fatalf("Get: ok=%v err=%v", ok, err) } @@ -705,21 +709,21 @@ func TestUIStatusSetsBucket(t *testing.T) { func TestUIStatusRejectsUnknownValue(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, cfg) + seedStatusRows(t, st) if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rr.Code) } - b, _, _ := store.Get("asura:reading") - if b.Status != statusReading { + b, _, _ := st.Get("asura:reading") + if b.Status != store.StatusReading { t.Fatalf("stored status = %q, want it untouched", b.Status) } } func TestUIStatusRequiresSession(t *testing.T) { - srv, store := newWebTestServer(t, webConfig()) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, webConfig()) + seedStatusRows(t, st) req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status", strings.NewReader("status=archived")) @@ -734,15 +738,15 @@ func TestUIStatusRequiresSession(t *testing.T) { func TestUIStatusDoesNotReorderList(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, cfg) + seedStatusRows(t, st) - before, _, _ := store.Get("asura:reading") + before, _, _ := st.Get("asura:reading") 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) } - after, _, _ := store.Get("asura:reading") + after, _, _ := st.Get("asura:reading") if after.UpdatedAt != before.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) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, cfg) + seedStatusRows(t, st) cases := []struct { tab string @@ -788,8 +792,8 @@ func TestCardShowsStatusControls(t *testing.T) { func TestAppRendersNewTabs(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seedStatusRows(t, store) + srv, st := newWebTestServer(t, cfg) + seedStatusRows(t, st) req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(sessionCookie(t, cfg)) @@ -807,10 +811,10 @@ func TestAppRendersNewTabs(t *testing.T) { // outside the swapped card, so nothing else would correct them. func TestMutationRefreshesChromeOutOfBand(t *testing.T) { cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ + srv, st := newWebTestServer(t, cfg) + seed(t, st, store.Bookmark{ 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(), }) @@ -820,7 +824,7 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) { } 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") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req)