Split backend into internal packages by responsibility

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:41:38 +07:00
parent e250762ea6
commit eeb601cbe2
36 changed files with 971 additions and 843 deletions
+17 -6
View File
@@ -27,13 +27,24 @@ Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
```
- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
Single binary, split into packages under `backend/internal/`: `store`
(Bookmark type, SQLite persistence, migrations), `latest` (background
poller, site parsers, TLS fetcher), `session` (cookie signing, login
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
bookmark handlers), `userscript` (userscript-serving handler), `web`
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
`backend/main.go` is the composition root — the only place that wires
packages together into `newRouter`. Root-level `*_test.go` hold
integration tests that exercise the full router; unit tests for a
package live beside it under `internal/`.
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema and endpoint list in plan.
- **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 <token>`; 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
+4 -6
View File
@@ -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/**
+4 -5
View File
@@ -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.
+472
View File
@@ -0,0 +1,472 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"mangabm/backend/internal/store"
)
const testToken = "s3cret-token"
func testConfig() Config {
return Config{
Token: testToken,
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
Port: "8080",
}
}
func newTestServer(t *testing.T) http.Handler {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return newRouter(s, testConfig())
}
func auth(req *http.Request) *http.Request {
req.Header.Set("Authorization", "Bearer "+testToken)
return req
}
func floatPtr(f float64) *float64 { return &f }
// seedForCheck inserts a bookmark and forces its latest_checked_at.
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
t.Helper()
if _, err := s.Upsert(store.Bookmark{
Key: key,
Site: "asura",
SeriesID: key,
SeriesURL: seriesURL,
UpdatedAt: 1000,
}); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
t.Fatalf("seed mark %q: %v", key, err)
}
}
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
t.Helper()
ts, err := s.LatestCheckedAt(key)
if err != nil {
t.Fatalf("LatestCheckedAt %q: %v", key, err)
}
return ts
}
func TestHealthzNoAuth(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", rr.Code)
}
if rr.Body.String() != "ok" {
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
}
}
func TestAuthRequired(t *testing.T) {
srv := newTestServer(t)
cases := []struct {
name string
header string
}{
{"no header", ""},
{"bad token", "Bearer wrong"},
{"not bearer", "Basic " + testToken},
{"empty bearer", "Bearer "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rr.Code)
}
})
}
}
func TestAuthAccepted(t *testing.T) {
srv := newTestServer(t)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if got := rr.Body.String(); got != "[]\n" {
t.Fatalf("empty list body = %q, want []", got)
}
}
func TestCORSPreflight(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
req.Header.Set("Origin", "https://asuracomic.net")
req.Header.Set("Access-Control-Request-Method", "PUT")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rr.Code)
}
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
}
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Fatal("Allow-Methods missing")
}
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
t.Fatal("Allow-Headers missing")
}
}
func TestCORSDisallowedOrigin(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
req.Header.Set("Origin", "https://evil.example")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
}
}
func TestBookmarkRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "asura:solo-leveling-123"
in := store.Bookmark{
Title: "Solo Leveling",
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
Cover: "https://asuracomic.net/cover.jpg",
LastChapter: "Chapter 10",
LastChapterNum: 10,
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
}
body, _ := json.Marshal(in)
// PUT
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200", rr.Code)
}
var stored store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
t.Fatalf("derived fields wrong: %+v", stored)
}
if stored.UpdatedAt == 0 {
t.Fatal("server did not set updated_at")
}
// GET
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
var list []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
t.Fatalf("GET list wrong: %+v", list)
}
// PUT again (upsert, progress advance)
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
body, _ = json.Marshal(in)
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("second PUT status = %d", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 1 || list[0].LastChapterNum != 11 {
t.Fatalf("upsert did not update in place: %+v", list)
}
// DELETE
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE status = %d, want 204", rr.Code)
}
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
json.Unmarshal(rr.Body.Bytes(), &list)
if len(list) != 0 {
t.Fatalf("after delete list = %+v, want empty", list)
}
}
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
// which is the row as actually stored (not the request payload).
func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark {
t.Helper()
body, _ := json.Marshal(b)
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
}
var out store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode PUT response: %v", err)
}
return out
}
func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark {
t.Helper()
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
if rr.Code != http.StatusOK {
t.Fatalf("GET status = %d", rr.Code)
}
var list []store.Bookmark
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
t.Fatalf("decode list: %v", err)
}
return list
}
// updated_at drives list ordering, so it must move only on a real progress
// advance — never on a favorite toggle or a latest-chapter capture.
func TestUpsertConditionalUpdatedAt(t *testing.T) {
cases := []struct {
name string
mutate func(store.Bookmark) store.Bookmark
wantBumped bool
}{
{
name: "unchanged progress",
mutate: func(b store.Bookmark) store.Bookmark { return b },
wantBumped: false,
},
{
name: "changed progress",
mutate: func(b store.Bookmark) store.Bookmark {
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
return b
},
wantBumped: true,
},
{
name: "favorite only",
mutate: func(b store.Bookmark) store.Bookmark {
b.Favorite = true
return b
},
wantBumped: false,
},
{
name: "latest chapter only",
mutate: func(b store.Bookmark) store.Bookmark {
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
return b
},
wantBumped: false,
},
{
name: "unrelated metadata only",
mutate: func(b store.Bookmark) store.Bookmark {
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
return b
},
wantBumped: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv := newTestServer(t)
key := fmt.Sprintf("asura:cond-%d", i)
first := putBookmark(t, srv, key, store.Bookmark{
Title: "Test",
LastChapter: "Chapter 10",
LastChapterNum: 10,
})
if first.UpdatedAt == 0 {
t.Fatal("new bookmark did not get updated_at set")
}
// Guarantee a later wall-clock ms so a real bump is observable.
time.Sleep(2 * time.Millisecond)
second := putBookmark(t, srv, key, tc.mutate(first))
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
}
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
}
// The PUT response must match what a subsequent GET reports.
list := getBookmarks(t, srv)
if len(list) != 1 {
t.Fatalf("list = %+v, want 1 item", list)
}
if list[0].UpdatedAt != second.UpdatedAt {
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
}
})
}
}
func TestFavoriteRoundTrip(t *testing.T) {
srv := newTestServer(t)
key := "demonic:some-series"
stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true})
if !stored.Favorite {
t.Fatalf("PUT response favorite = false, want true")
}
list := getBookmarks(t, srv)
if len(list) != 1 || !list[0].Favorite {
t.Fatalf("favorite did not round-trip: %+v", list)
}
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false})
if stored.Favorite {
t.Fatal("PUT response favorite = true after unfavorite")
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].Favorite {
t.Fatalf("unfavorite did not round-trip: %+v", list)
}
}
func TestLatestChapterNullable(t *testing.T) {
srv := newTestServer(t)
key := "asura:latest-test"
// Never captured: latest_chapter_num must serialize as JSON null.
body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"})
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
if rr.Code != http.StatusOK {
t.Fatalf("PUT status = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
}
list := getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum != nil {
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
}
// Once captured it round-trips as a value.
stored := putBookmark(t, srv, key, store.Bookmark{
Title: "No latest yet",
LatestChapter: "Chapter 162",
LatestChapterNum: floatPtr(162),
})
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
}
list = getBookmarks(t, srv)
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
t.Fatalf("latest chapter did not round-trip: %+v", list)
}
if list[0].LatestChapter != "Chapter 162" {
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
}
}
func TestLoadConfigWebPassword(t *testing.T) {
t.Setenv("API_TOKEN", "token-abc")
t.Setenv("WEB_PASSWORD", "hunter2")
if got := loadConfig().WebPassword; got != "hunter2" {
t.Fatalf("WebPassword = %q, want hunter2", got)
}
t.Setenv("WEB_PASSWORD", "")
if got := loadConfig().WebPassword; got != "" {
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
}
}
// A userscript PUT body has no latest_checked_at field. If the column is ever
// moved into bookmarkColumns, this test catches it: the PUT would reset the
// cooldown and the poller would re-fetch that series on every single tick.
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
srv := newRouter(s, testConfig())
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
// Exactly what the userscript sends: no latest_checked_at key at all.
body := `{"key":"asura:x","site":"asura","series_id":"x",
"series_url":"https://asurascans.com/comics/x",
"last_chapter":"Chapter 5","last_chapter_num":5}`
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testToken)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 {
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
}
}
// The userscript route is registered outside the `if cfg.WebPassword != ""`
// block in newRouter, so it must keep working on a deployment that never set
// WEB_PASSWORD — see internal/userscript for the handler's own behaviour.
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil {
t.Fatalf("write script: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "nopass.db")
s, err := store.Open(dbPath)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { s.Close() })
cfg := testConfig() // WebPassword empty
cfg.UserscriptPath = path
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil)
newRouter(s, cfg).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
}
@@ -1,4 +1,4 @@
package main
package api
import (
"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"))
@@ -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{}{}
@@ -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)
@@ -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
}
@@ -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,
})
@@ -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.]+)`)
@@ -1,4 +1,4 @@
package main
package latest
import "testing"
@@ -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 "<expiryMs>.<base64url HMAC(expiryMs)>".
func signSession(key []byte, expiryMs int64) string {
// Sign encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
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
@@ -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)
}
}
@@ -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
}
@@ -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)
}
@@ -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)
@@ -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)
}
}

Before

Width:  |  Height:  |  Size: 973 B

After

Width:  |  Height:  |  Size: 973 B

+61 -68
View File
@@ -1,4 +1,4 @@
package main
package web
import (
"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)
+35 -28
View File
@@ -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)
}
+4 -2
View File
@@ -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)
}
+89 -85
View File
@@ -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)