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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user