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`.
|
- **Backend** (`backend/`): stdlib `net/http` (handful routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). Reverse proxy terminates TLS; Go service listens plain `:8080`.
|
||||||
|
Single binary, split into packages under `backend/internal/`: `store`
|
||||||
|
(Bookmark type, SQLite persistence, migrations), `latest` (background
|
||||||
|
poller, site parsers, TLS fetcher), `session` (cookie signing, login
|
||||||
|
rate limiter), `httpmw` (Auth/Gzip/CORS middleware), `api` (JSON
|
||||||
|
bookmark handlers), `userscript` (userscript-serving handler), `web`
|
||||||
|
(browser UI handler + `templates/` + `static/`, `go:embed`-ed).
|
||||||
|
`backend/main.go` is the composition root — the only place that wires
|
||||||
|
packages together into `newRouter`. Root-level `*_test.go` hold
|
||||||
|
integration tests that exercise the full router; unit tests for a
|
||||||
|
package live beside it under `internal/`.
|
||||||
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema and endpoint list in plan.
|
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync **last-write-wins**. Schema and endpoint list in plan.
|
||||||
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
|
||||||
- **Web UI:** same binary serve password-gated browser UI on second
|
- **Web UI:** same binary serve password-gated browser UI on second
|
||||||
hostname — `GET /` (list, or login page when no session),
|
hostname — `GET /` (list, or login page when no session),
|
||||||
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
`POST /login`, `POST /logout`, `GET /static/*`, htmx fragment endpoints
|
||||||
under `/ui/*`. Templates + assets `go:embed`-ed, so `backend/Dockerfile`
|
under `/ui/*`. Templates + assets `go:embed`-ed under
|
||||||
must copy `templates/` and `static/` plus `*.go`. Sessions stateless
|
`backend/internal/web/`, so `backend/Dockerfile` must copy the whole
|
||||||
|
`internal/` tree, not just `*.go`. Sessions stateless
|
||||||
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty,
|
HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them, and when empty,
|
||||||
web routes not registered at all. UI mutations read-modify-write
|
web routes not registered at all. UI mutations read-modify-write
|
||||||
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
through `Store.Get` + `Store.Upsert` so `updated_at` rule stays one
|
||||||
@@ -43,9 +54,9 @@ Violentmonkey userscript (isolated world, per-site adapters, localStorage cache)
|
|||||||
stylesheet href with `path.resolve(fileDir, href)`, drops directory
|
stylesheet href with `path.resolve(fileDir, href)`, drops directory
|
||||||
on leading `/` and silently skip file. Relative href don't help
|
on leading `/` and silently skip file. Relative href don't help
|
||||||
either: template's directory isn't its served path. So
|
either: template's directory isn't its served path. So
|
||||||
`detect.mjs backend/templates` reports **false clean** — always pass
|
`detect.mjs backend/internal/web/templates` reports **false clean** —
|
||||||
`backend/static` too. One finding there, `overused-font` on "Instrument
|
always pass `backend/internal/web/static` too. One finding there,
|
||||||
Serif", deliberate identity choice, not debt.
|
`overused-font` on "Instrument Serif", deliberate identity choice, not debt.
|
||||||
- **Every action that moves series out of list is confirm-gated.**
|
- **Every action that moves series out of list is confirm-gated.**
|
||||||
Archive, finish, remove each open own `.confirm-row` disclosure
|
Archive, finish, remove each open own `.confirm-row` disclosure
|
||||||
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
(`toggleConfirmRow(key, kind)` in `filter.js`, `kind` ∈
|
||||||
@@ -162,7 +173,7 @@ Smoke test: `curl` endpoints with `Authorization: Bearer <token>`; confirm `OPTI
|
|||||||
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
|
Web UI + userscript panel follow **Cinder**, rules in `docs/design-system.md`
|
||||||
— source of truth Claude Design project `mangaBookmark Web UI`
|
— source of truth Claude Design project `mangaBookmark Web UI`
|
||||||
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
|
(`969ac210-fe02-4c01-ae1b-9a271dcc779a`). Read it before touching
|
||||||
`backend/static/style.css`, `backend/templates/*`, or userscript
|
`backend/internal/web/static/style.css`, `backend/internal/web/templates/*`, or userscript
|
||||||
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
|
`TEMPLATE`/`CSS`. Core law: **ember means new chapter only** — no other
|
||||||
state (busy, error, destruction) may use `--ember`; destruction gets
|
state (busy, error, destruction) may use `--ember`; destruction gets
|
||||||
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
|
`--danger`. No cards/corners/shadows, one `--measure: 760px` column, tokens
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
# Only go source + module files, plus the go:embed'd templates/static
|
# Only go source + module files, plus internal/ (which carries the
|
||||||
# directories, are needed in the build context.
|
# go:embed'd templates/static directories), are needed in the build context.
|
||||||
*
|
*
|
||||||
!go.mod
|
!go.mod
|
||||||
!go.sum
|
!go.sum
|
||||||
!*.go
|
!*.go
|
||||||
!templates/
|
!internal/
|
||||||
!templates/**
|
!internal/**
|
||||||
!static/
|
|
||||||
!static/**
|
|
||||||
|
|||||||
+4
-5
@@ -8,12 +8,11 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Then source (changes often).
|
# Then source (changes often). internal/web carries the go:embed'd
|
||||||
# Source plus the go:embed'd assets. Missing either directory turns the embed
|
# templates/static assets — missing them turns the embed directive into a
|
||||||
# directive into a build error, so both must be copied before `go build`.
|
# build error, so the whole tree must land before `go build`.
|
||||||
COPY *.go ./
|
COPY *.go ./
|
||||||
COPY templates/ ./templates/
|
COPY internal/ ./internal/
|
||||||
COPY static/ ./static/
|
|
||||||
|
|
||||||
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
|
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
|
||||||
# -trimpath + -ldflags strip paths and debug info for a smaller image.
|
# -trimpath + -ldflags strip paths and debug info for a smaller image.
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testToken = "s3cret-token"
|
||||||
|
|
||||||
|
func testConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Token: testToken,
|
||||||
|
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
||||||
|
Port: "8080",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
s, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return newRouter(s, testConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
func auth(req *http.Request) *http.Request {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(f float64) *float64 { return &f }
|
||||||
|
|
||||||
|
// seedForCheck inserts a bookmark and forces its latest_checked_at.
|
||||||
|
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
|
Key: key,
|
||||||
|
Site: "asura",
|
||||||
|
SeriesID: key,
|
||||||
|
SeriesURL: seriesURL,
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", key, err)
|
||||||
|
}
|
||||||
|
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
||||||
|
t.Fatalf("seed mark %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ts, err := s.LatestCheckedAt(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestCheckedAt %q: %v", key, err)
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthzNoAuth(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("healthz status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if rr.Body.String() != "ok" {
|
||||||
|
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRequired(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
}{
|
||||||
|
{"no header", ""},
|
||||||
|
{"bad token", "Bearer wrong"},
|
||||||
|
{"not bearer", "Basic " + testToken},
|
||||||
|
{"empty bearer", "Bearer "},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Authorization", tc.header)
|
||||||
|
}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthAccepted(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != "[]\n" {
|
||||||
|
t.Fatalf("empty list body = %q, want []", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSPreflight(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
|
||||||
|
req.Header.Set("Origin", "https://asuracomic.net")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "PUT")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("preflight status = %d, want 204", rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
|
||||||
|
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
||||||
|
t.Fatal("Allow-Methods missing")
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
||||||
|
t.Fatal("Allow-Headers missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSDisallowedOrigin(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
|
||||||
|
req.Header.Set("Origin", "https://evil.example")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, req)
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||||
|
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBookmarkRoundTrip(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := "asura:solo-leveling-123"
|
||||||
|
in := store.Bookmark{
|
||||||
|
Title: "Solo Leveling",
|
||||||
|
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
||||||
|
Cover: "https://asuracomic.net/cover.jpg",
|
||||||
|
LastChapter: "Chapter 10",
|
||||||
|
LastChapterNum: 10,
|
||||||
|
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(in)
|
||||||
|
|
||||||
|
// PUT
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
var stored store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
|
||||||
|
t.Fatalf("decode PUT response: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
|
||||||
|
t.Fatalf("derived fields wrong: %+v", stored)
|
||||||
|
}
|
||||||
|
if stored.UpdatedAt == 0 {
|
||||||
|
t.Fatal("server did not set updated_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
var list []store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatalf("decode list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
|
||||||
|
t.Fatalf("GET list wrong: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT again (upsert, progress advance)
|
||||||
|
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
|
||||||
|
body, _ = json.Marshal(in)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second PUT status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &list)
|
||||||
|
if len(list) != 1 || list[0].LastChapterNum != 11 {
|
||||||
|
t.Fatalf("upsert did not update in place: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
|
||||||
|
if rr.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("DELETE status = %d, want 204", rr.Code)
|
||||||
|
}
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &list)
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("after delete list = %+v, want empty", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
|
||||||
|
// which is the row as actually stored (not the request payload).
|
||||||
|
func putBookmark(t *testing.T, srv http.Handler, key string, b store.Bookmark) store.Bookmark {
|
||||||
|
t.Helper()
|
||||||
|
body, _ := json.Marshal(b)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var out store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode PUT response: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBookmarks(t *testing.T, srv http.Handler) []store.Bookmark {
|
||||||
|
t.Helper()
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
var list []store.Bookmark
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
||||||
|
t.Fatalf("decode list: %v", err)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// updated_at drives list ordering, so it must move only on a real progress
|
||||||
|
// advance — never on a favorite toggle or a latest-chapter capture.
|
||||||
|
func TestUpsertConditionalUpdatedAt(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(store.Bookmark) store.Bookmark
|
||||||
|
wantBumped bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unchanged progress",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark { return b },
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "changed progress",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "favorite only",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.Favorite = true
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "latest chapter only",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated metadata only",
|
||||||
|
mutate: func(b store.Bookmark) store.Bookmark {
|
||||||
|
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
|
||||||
|
return b
|
||||||
|
},
|
||||||
|
wantBumped: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := fmt.Sprintf("asura:cond-%d", i)
|
||||||
|
|
||||||
|
first := putBookmark(t, srv, key, store.Bookmark{
|
||||||
|
Title: "Test",
|
||||||
|
LastChapter: "Chapter 10",
|
||||||
|
LastChapterNum: 10,
|
||||||
|
})
|
||||||
|
if first.UpdatedAt == 0 {
|
||||||
|
t.Fatal("new bookmark did not get updated_at set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee a later wall-clock ms so a real bump is observable.
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
|
||||||
|
second := putBookmark(t, srv, key, tc.mutate(first))
|
||||||
|
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
|
||||||
|
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
|
||||||
|
}
|
||||||
|
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
|
||||||
|
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PUT response must match what a subsequent GET reports.
|
||||||
|
list := getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("list = %+v, want 1 item", list)
|
||||||
|
}
|
||||||
|
if list[0].UpdatedAt != second.UpdatedAt {
|
||||||
|
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFavoriteRoundTrip(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := "demonic:some-series"
|
||||||
|
|
||||||
|
stored := putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: true})
|
||||||
|
if !stored.Favorite {
|
||||||
|
t.Fatalf("PUT response favorite = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || !list[0].Favorite {
|
||||||
|
t.Fatalf("favorite did not round-trip: %+v", list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
|
||||||
|
stored = putBookmark(t, srv, key, store.Bookmark{Title: "Fav", Favorite: false})
|
||||||
|
if stored.Favorite {
|
||||||
|
t.Fatal("PUT response favorite = true after unfavorite")
|
||||||
|
}
|
||||||
|
list = getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || list[0].Favorite {
|
||||||
|
t.Fatalf("unfavorite did not round-trip: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestChapterNullable(t *testing.T) {
|
||||||
|
srv := newTestServer(t)
|
||||||
|
key := "asura:latest-test"
|
||||||
|
|
||||||
|
// Never captured: latest_chapter_num must serialize as JSON null.
|
||||||
|
body, _ := json.Marshal(store.Bookmark{Title: "No latest yet"})
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
|
||||||
|
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
list := getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || list[0].LatestChapterNum != nil {
|
||||||
|
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once captured it round-trips as a value.
|
||||||
|
stored := putBookmark(t, srv, key, store.Bookmark{
|
||||||
|
Title: "No latest yet",
|
||||||
|
LatestChapter: "Chapter 162",
|
||||||
|
LatestChapterNum: floatPtr(162),
|
||||||
|
})
|
||||||
|
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
|
||||||
|
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
|
||||||
|
}
|
||||||
|
list = getBookmarks(t, srv)
|
||||||
|
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
|
||||||
|
t.Fatalf("latest chapter did not round-trip: %+v", list)
|
||||||
|
}
|
||||||
|
if list[0].LatestChapter != "Chapter 162" {
|
||||||
|
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfigWebPassword(t *testing.T) {
|
||||||
|
t.Setenv("API_TOKEN", "token-abc")
|
||||||
|
t.Setenv("WEB_PASSWORD", "hunter2")
|
||||||
|
if got := loadConfig().WebPassword; got != "hunter2" {
|
||||||
|
t.Fatalf("WebPassword = %q, want hunter2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("WEB_PASSWORD", "")
|
||||||
|
if got := loadConfig().WebPassword; got != "" {
|
||||||
|
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A userscript PUT body has no latest_checked_at field. If the column is ever
|
||||||
|
// moved into bookmarkColumns, this test catches it: the PUT would reset the
|
||||||
|
// cooldown and the poller would re-fetch that series on every single tick.
|
||||||
|
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||||
|
s, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
srv := newRouter(s, testConfig())
|
||||||
|
|
||||||
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 777)
|
||||||
|
|
||||||
|
// Exactly what the userscript sends: no latest_checked_at key at all.
|
||||||
|
body := `{"key":"asura:x","site":"asura","series_id":"x",
|
||||||
|
"series_url":"https://asurascans.com/comics/x",
|
||||||
|
"last_chapter":"Chapter 5","last_chapter_num":5}`
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 777 {
|
||||||
|
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The userscript route is registered outside the `if cfg.WebPassword != ""`
|
||||||
|
// block in newRouter, so it must keep working on a deployment that never set
|
||||||
|
// WEB_PASSWORD — see internal/userscript for the handler's own behaviour.
|
||||||
|
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "manga-bookmark.user.js")
|
||||||
|
if err := os.WriteFile(path, []byte("console.log(1);\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "nopass.db")
|
||||||
|
s, err := store.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
cfg := testConfig() // WebPassword empty
|
||||||
|
cfg.UserscriptPath = path
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/u/"+testToken+"/manga-bookmark.user.js", nil)
|
||||||
|
newRouter(s, cfg).ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -6,10 +6,13 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type bookmarkHandler struct {
|
// Handler serves the userscript-facing JSON bookmark API.
|
||||||
store *Store
|
type Handler struct {
|
||||||
|
Store *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -22,9 +25,9 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// list returns all bookmarks. GET /bookmarks
|
// List returns all bookmarks. GET /bookmarks
|
||||||
func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := h.store.List()
|
items, err := h.Store.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("list: %v", err)
|
log.Printf("list: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -33,15 +36,15 @@ func (h *bookmarkHandler) list(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, items)
|
writeJSON(w, http.StatusOK, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// put upserts one bookmark. PUT /bookmarks/{key}
|
// Put upserts one bookmark. PUT /bookmarks/{key}
|
||||||
func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Put(w http.ResponseWriter, r *http.Request) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var b Bookmark
|
var b store.Bookmark
|
||||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&b); err != nil {
|
||||||
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
http.Error(w, "invalid JSON body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -65,9 +68,9 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
// Finishing a series is a web-UI decision, so the JSON API refuses it
|
||||||
// rather than trusting every client to leave it alone.
|
// rather than trusting every client to leave it alone.
|
||||||
switch b.Status {
|
switch b.Status {
|
||||||
case "", statusReading, statusArchived:
|
case "", store.StatusReading, store.StatusArchived:
|
||||||
case statusFinished:
|
case store.StatusFinished:
|
||||||
http.Error(w, "status "+statusFinished+" can only be set from the web UI",
|
http.Error(w, "status "+store.StatusFinished+" can only be set from the web UI",
|
||||||
http.StatusBadRequest)
|
http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
@@ -79,7 +82,7 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
// reading progress actually moved. Any client value is ignored.
|
// reading progress actually moved. Any client value is ignored.
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
stored, err := h.store.Upsert(b)
|
stored, err := h.Store.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("upsert: %v", err)
|
log.Printf("upsert: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
@@ -90,14 +93,14 @@ func (h *bookmarkHandler) put(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, stored)
|
writeJSON(w, http.StatusOK, stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete removes one bookmark. DELETE /bookmarks/{key}
|
// Delete removes one bookmark. DELETE /bookmarks/{key}
|
||||||
func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.store.Delete(key); err != nil {
|
if err := h.Store.Delete(key); err != nil {
|
||||||
log.Printf("delete: %v", err)
|
log.Printf("delete: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -105,7 +108,8 @@ func (h *bookmarkHandler) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func healthz(w http.ResponseWriter, r *http.Request) {
|
// Healthz answers the unauthenticated liveness check. GET /healthz
|
||||||
|
func Healthz(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package httpmw
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
const bearerPrefix = "Bearer "
|
const bearerPrefix = "Bearer "
|
||||||
|
|
||||||
// withAuth guards a handler with a constant-time bearer-token check.
|
// Auth guards a handler with a constant-time bearer-token check.
|
||||||
func withAuth(token string, next http.Handler) http.Handler {
|
func Auth(token string, next http.Handler) http.Handler {
|
||||||
want := []byte(token)
|
want := []byte(token)
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := r.Header.Get("Authorization")
|
h := r.Header.Get("Authorization")
|
||||||
@@ -71,11 +71,11 @@ func (w *gzipWriter) Write(b []byte) (int, error) {
|
|||||||
return w.ResponseWriter.Write(b)
|
return w.ResponseWriter.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withGzip compresses text responses for clients that ask. The templates,
|
// Gzip compresses text responses for clients that ask. The templates,
|
||||||
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
||||||
// of that gzipped, which is the difference between a fast and a slow first load
|
// of that gzipped, which is the difference between a fast and a slow first load
|
||||||
// on mobile data.
|
// on mobile data.
|
||||||
func withGzip(next http.Handler) http.Handler {
|
func Gzip(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -92,11 +92,11 @@ func withGzip(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// withCORS reflects the request Origin only when it is in allowed, answers
|
// CORS reflects the request Origin only when it is in allowed, answers
|
||||||
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
||||||
// auth middleware so preflight (which carries no Authorization header) is never
|
// auth middleware so preflight (which carries no Authorization header) is never
|
||||||
// rejected by auth.
|
// rejected by auth.
|
||||||
func withCORS(allowed []string, next http.Handler) http.Handler {
|
func CORS(allowed []string, next http.Handler) http.Handler {
|
||||||
set := make(map[string]struct{}, len(allowed))
|
set := make(map[string]struct{}, len(allowed))
|
||||||
for _, o := range allowed {
|
for _, o := range allowed {
|
||||||
set[o] = struct{}{}
|
set[o] = struct{}{}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -20,20 +20,20 @@ const maxBodyBytes = 4 << 20
|
|||||||
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " +
|
const chromeUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 " +
|
||||||
"(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"
|
"(KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36"
|
||||||
|
|
||||||
// tlsFetcher fetches series pages with a Chrome TLS fingerprint.
|
// TLSFetcher fetches series pages with a Chrome TLS fingerprint.
|
||||||
//
|
//
|
||||||
// Plain net/http was verified working against both sites on 2026-07-26, so this
|
// Plain net/http was verified working against both sites on 2026-07-26, so this
|
||||||
// is not fixing an observed block — it is deliberate defence-in-depth against a
|
// is not fixing an observed block — it is deliberate defence-in-depth against a
|
||||||
// future fingerprint-based one, chosen up front rather than reacted to later.
|
// future fingerprint-based one, chosen up front rather than reacted to later.
|
||||||
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the
|
// The library is pure Go, so CGO_ENABLED=0, the static binary, and the
|
||||||
// distroless image are all unaffected.
|
// distroless image are all unaffected.
|
||||||
type tlsFetcher struct {
|
type TLSFetcher struct {
|
||||||
client tls_client.HttpClient
|
client tls_client.HttpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ fetcher = (*tlsFetcher)(nil)
|
var _ Fetcher = (*TLSFetcher)(nil)
|
||||||
|
|
||||||
func newTLSFetcher() (*tlsFetcher, error) {
|
func NewTLSFetcher() (*TLSFetcher, error) {
|
||||||
c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
|
c, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
|
||||||
tls_client.WithTimeoutSeconds(30),
|
tls_client.WithTimeoutSeconds(30),
|
||||||
tls_client.WithClientProfile(profiles.Chrome_133),
|
tls_client.WithClientProfile(profiles.Chrome_133),
|
||||||
@@ -41,13 +41,13 @@ func newTLSFetcher() (*tlsFetcher, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new tls client: %w", err)
|
return nil, fmt.Errorf("new tls client: %w", err)
|
||||||
}
|
}
|
||||||
return &tlsFetcher{client: c}, nil
|
return &TLSFetcher{client: c}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get fetches url and returns the body and status. Redirects are followed: the
|
// Get fetches url and returns the body and status. Redirects are followed: the
|
||||||
// demonic chapter anchors are a redirect form, and asura has moved domains
|
// demonic chapter anchors are a redirect form, and asura has moved domains
|
||||||
// before.
|
// before.
|
||||||
func (f *tlsFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
func (f *TLSFetcher) Get(ctx context.Context, url string) (string, int, error) {
|
||||||
req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil)
|
req, err := fhttp.NewRequest(fhttp.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("build request %q: %w", url, err)
|
return "", 0, fmt.Errorf("build request %q: %w", url, err)
|
||||||
@@ -1,40 +1,42 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fetcher retrieves a series page. It exists as an interface so tests can inject
|
// Fetcher retrieves a series page. It exists as an interface so tests can inject
|
||||||
// a fake: nothing in the test suite may touch the network or the TLS client.
|
// a fake: nothing in the test suite may touch the network or the TLS client.
|
||||||
type fetcher interface {
|
type Fetcher interface {
|
||||||
Get(ctx context.Context, url string) (body string, status int, err error)
|
Get(ctx context.Context, url string) (body string, status int, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestPoller re-checks each bookmarked series' newest published chapter on a
|
// Poller re-checks each bookmarked series' newest published chapter on a
|
||||||
// schedule, independent of the userscript's own in-browser checks. The two run
|
// schedule, independent of the userscript's own in-browser checks. The two run
|
||||||
// in parallel and report the same observable fact, so whichever writes last wins
|
// in parallel and report the same observable fact, so whichever writes last wins
|
||||||
// and neither needs to know about the other.
|
// and neither needs to know about the other.
|
||||||
//
|
//
|
||||||
// Two clocks, deliberately independent:
|
// Two clocks, deliberately independent:
|
||||||
//
|
//
|
||||||
// - interval is how often this goroutine wakes up and looks.
|
// - Interval is how often this goroutine wakes up and looks.
|
||||||
// - cooldown is how long one bookmark rests since its own last check.
|
// - Cooldown is how long one bookmark rests since its own last check.
|
||||||
//
|
//
|
||||||
// Only the cooldown is per bookmark, and it is enforced by the WHERE clause in
|
// Only the cooldown is per bookmark, and it is enforced by the WHERE clause in
|
||||||
// DueForLatestCheck rather than by any timer. Shortening interval therefore
|
// DueForLatestCheck rather than by any timer. Shortening Interval therefore
|
||||||
// cannot shorten anyone's cooldown; it only makes the poller wake up and find
|
// cannot shorten anyone's cooldown; it only makes the poller wake up and find
|
||||||
// nothing due more often.
|
// nothing due more often.
|
||||||
type latestPoller struct {
|
type Poller struct {
|
||||||
store *Store
|
Store *store.Store
|
||||||
fetch fetcher
|
Fetch Fetcher
|
||||||
now func() time.Time // injected so tests can freeze it
|
Now func() time.Time // injected so tests can freeze it
|
||||||
cooldown time.Duration
|
Cooldown time.Duration
|
||||||
interval time.Duration
|
Interval time.Duration
|
||||||
stagger time.Duration
|
Stagger time.Duration
|
||||||
batch int
|
Batch int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run polls until ctx is cancelled.
|
// Run polls until ctx is cancelled.
|
||||||
@@ -43,10 +45,10 @@ type latestPoller struct {
|
|||||||
// next one instead of stacking a second batch on top of it. That is the intended
|
// next one instead of stacking a second batch on top of it. That is the intended
|
||||||
// failure mode for a misconfigured batch x stagger: a slower cadence, never
|
// failure mode for a misconfigured batch x stagger: a slower cadence, never
|
||||||
// concurrent fetch storms.
|
// concurrent fetch storms.
|
||||||
func (p *latestPoller) Run(ctx context.Context) {
|
func (p *Poller) Run(ctx context.Context) {
|
||||||
log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s",
|
log.Printf("latest-chapter poller: interval=%s cooldown=%s batch=%d stagger=%s",
|
||||||
p.interval, p.cooldown, p.batch, p.stagger)
|
p.Interval, p.Cooldown, p.Batch, p.Stagger)
|
||||||
t := time.NewTicker(p.interval)
|
t := time.NewTicker(p.Interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -60,9 +62,9 @@ func (p *latestPoller) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runOnce processes one batch of due bookmarks.
|
// runOnce processes one batch of due bookmarks.
|
||||||
func (p *latestPoller) runOnce(ctx context.Context) {
|
func (p *Poller) runOnce(ctx context.Context) {
|
||||||
cutoff := p.now().Add(-p.cooldown).UnixMilli()
|
cutoff := p.Now().Add(-p.Cooldown).UnixMilli()
|
||||||
due, err := p.store.DueForLatestCheck(cutoff, p.batch)
|
due, err := p.Store.DueForLatestCheck(cutoff, p.Batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll: due query: %v", err)
|
log.Printf("latest poll: due query: %v", err)
|
||||||
return
|
return
|
||||||
@@ -78,11 +80,11 @@ func (p *latestPoller) runOnce(ctx context.Context) {
|
|||||||
// bot score. This is the server-side analogue of the userscript's "one
|
// bot score. This is the server-side analogue of the userscript's "one
|
||||||
// series per navigation ... indistinguishable from browsing" (L455-456).
|
// series per navigation ... indistinguishable from browsing" (L455-456).
|
||||||
stopped := false
|
stopped := false
|
||||||
if i > 0 && p.stagger > 0 {
|
if i > 0 && p.Stagger > 0 {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
stopped = true
|
stopped = true
|
||||||
case <-time.After(p.stagger):
|
case <-time.After(p.Stagger):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stopped {
|
if stopped {
|
||||||
@@ -100,7 +102,7 @@ func (p *latestPoller) runOnce(ctx context.Context) {
|
|||||||
// checkOne re-checks one series. Every failure path here is "log and move on":
|
// checkOne re-checks one series. Every failure path here is "log and move on":
|
||||||
// the poller is a best-effort enhancement, and no single bad series may stall a
|
// the poller is a best-effort enhancement, and no single bad series may stall a
|
||||||
// batch or take down the process.
|
// batch or take down the process.
|
||||||
func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
func (p *Poller) checkOne(ctx context.Context, b store.Bookmark) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
log.Printf("latest poll %q: recovered from panic: %v", b.Key, r)
|
log.Printf("latest poll %q: recovered from panic: %v", b.Key, r)
|
||||||
@@ -111,7 +113,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
// mid-request still consumes the cooldown. Otherwise a renamed or deleted
|
// mid-request still consumes the cooldown. Otherwise a renamed or deleted
|
||||||
// series would be retried on every single tick forever. The userscript
|
// series would be retried on every single tick forever. The userscript
|
||||||
// stamps in the same order and for the same reason (L471-473).
|
// stamps in the same order and for the same reason (L471-473).
|
||||||
if err := p.store.MarkLatestChecked(b.Key, p.now().UnixMilli()); err != nil {
|
if err := p.Store.MarkLatestChecked(b.Key, p.Now().UnixMilli()); err != nil {
|
||||||
log.Printf("latest poll %q: mark checked: %v", b.Key, err)
|
log.Printf("latest poll %q: mark checked: %v", b.Key, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
body, status, err := p.fetch.Get(ctx, b.SeriesURL)
|
body, status, err := p.Fetch.Get(ctx, b.SeriesURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err)
|
log.Printf("latest poll %q: fetch %s: %v", b.Key, b.SeriesURL, err)
|
||||||
return
|
return
|
||||||
@@ -155,7 +157,7 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
// progress or a status change, and moving updated_at because the stored
|
// progress or a status change, and moving updated_at because the stored
|
||||||
// value now differs. Accepted for a single-user deployment: the window is
|
// value now differs. Accepted for a single-user deployment: the window is
|
||||||
// milliseconds and the loser is one poll cycle.
|
// milliseconds and the loser is one poll cycle.
|
||||||
cur, found, err := p.store.Get(b.Key)
|
cur, found, err := p.Store.Get(b.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
log.Printf("latest poll %q: reread: %v", b.Key, err)
|
||||||
return
|
return
|
||||||
@@ -174,8 +176,8 @@ func (p *latestPoller) checkOne(ctx context.Context, b Bookmark) {
|
|||||||
cur.LatestChapterNum = &num
|
cur.LatestChapterNum = &num
|
||||||
// A candidate only. last_chapter_num is untouched, so the CASE in Upsert
|
// A candidate only. last_chapter_num is untouched, so the CASE in Upsert
|
||||||
// keeps the stored updated_at and the bookmark list does not reorder.
|
// keeps the stored updated_at and the bookmark list does not reorder.
|
||||||
cur.UpdatedAt = p.now().UnixMilli()
|
cur.UpdatedAt = p.Now().UnixMilli()
|
||||||
if _, err := p.store.Upsert(cur); err != nil {
|
if _, err := p.Store.Upsert(cur); err != nil {
|
||||||
log.Printf("latest poll %q: upsert: %v", b.Key, err)
|
log.Printf("latest poll %q: upsert: %v", b.Key, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,53 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// newTestStore opens a fresh SQLite store in a temp dir.
|
||||||
|
func newTestStore(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedForCheck inserts a bookmark and forces its latest_checked_at.
|
||||||
|
func seedForCheck(t *testing.T, s *store.Store, key, seriesURL string, checkedAt int64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
|
Key: key,
|
||||||
|
Site: "asura",
|
||||||
|
SeriesID: key,
|
||||||
|
SeriesURL: seriesURL,
|
||||||
|
UpdatedAt: 1000,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed %q: %v", key, err)
|
||||||
|
}
|
||||||
|
if err := s.MarkLatestChecked(key, checkedAt); err != nil {
|
||||||
|
t.Fatalf("seed mark %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLatestCheckedAt(t *testing.T, s *store.Store, key string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ts, err := s.LatestCheckedAt(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestCheckedAt %q: %v", key, err)
|
||||||
|
}
|
||||||
|
return ts
|
||||||
|
}
|
||||||
|
|
||||||
// fakeFetcher stands in for the network. Every poller test uses it, so nothing
|
// fakeFetcher stands in for the network. Every poller test uses it, so nothing
|
||||||
// in this file can reach tls-client or a real site.
|
// in this file can reach tls-client or a real site.
|
||||||
type fakeFetcher struct {
|
type fakeFetcher struct {
|
||||||
@@ -44,16 +84,16 @@ func (f *fakeFetcher) callCount() int {
|
|||||||
|
|
||||||
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run
|
// newTestPoller wires a poller with a frozen clock and no stagger, so tests run
|
||||||
// instantly and deterministically.
|
// instantly and deterministically.
|
||||||
func newTestPoller(t *testing.T, s *Store, f fetcher, at time.Time) *latestPoller {
|
func newTestPoller(t *testing.T, s *store.Store, f Fetcher, at time.Time) *Poller {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &latestPoller{
|
return &Poller{
|
||||||
store: s,
|
Store: s,
|
||||||
fetch: f,
|
Fetch: f,
|
||||||
now: func() time.Time { return at },
|
Now: func() time.Time { return at },
|
||||||
cooldown: time.Hour,
|
Cooldown: time.Hour,
|
||||||
interval: 10 * time.Minute,
|
Interval: 10 * time.Minute,
|
||||||
stagger: 0,
|
Stagger: 0,
|
||||||
batch: 14,
|
Batch: 14,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +129,7 @@ func TestRunOnceDoesNotReorderList(t *testing.T) {
|
|||||||
const key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
const key = "asura:chronicles-of-the-demon-faction-f886a8af"
|
||||||
|
|
||||||
// "other" is the most recently read, so it must stay at the top of List().
|
// "other" is the most recently read, so it must stay at the top of List().
|
||||||
if _, err := s.Upsert(Bookmark{
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
Key: "asura:other", Site: "asura", SeriesID: "other",
|
Key: "asura:other", Site: "asura", SeriesID: "other",
|
||||||
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
|
SeriesURL: "https://asurascans.com/comics/other", UpdatedAt: 9_000_000,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -166,7 +206,7 @@ func TestRunOnceRespectsBatchLimit(t *testing.T) {
|
|||||||
|
|
||||||
f := &fakeFetcher{body: "", status: 200}
|
f := &fakeFetcher{body: "", status: 200}
|
||||||
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
|
p := newTestPoller(t, s, f, time.UnixMilli(5_000_000))
|
||||||
p.batch = 5
|
p.Batch = 5
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
|
|
||||||
if got := f.callCount(); got != 5 {
|
if got := f.callCount(); got != 5 {
|
||||||
@@ -218,13 +258,13 @@ func TestRunOnceHonoursCooldownAcrossPasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Same instant, and again 59 minutes later: both inside the 1h cooldown.
|
// Same instant, and again 59 minutes later: both inside the 1h cooldown.
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
p.now = func() time.Time { return now.Add(59 * time.Minute) }
|
p.Now = func() time.Time { return now.Add(59 * time.Minute) }
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
if got := f.callCount(); got != 1 {
|
if got := f.callCount(); got != 1 {
|
||||||
t.Fatalf("fetched %d times inside the cooldown, want 1", got)
|
t.Fatalf("fetched %d times inside the cooldown, want 1", got)
|
||||||
}
|
}
|
||||||
// Past the cooldown, it is due again.
|
// Past the cooldown, it is due again.
|
||||||
p.now = func() time.Time { return now.Add(61 * time.Minute) }
|
p.Now = func() time.Time { return now.Add(61 * time.Minute) }
|
||||||
p.runOnce(context.Background())
|
p.runOnce(context.Background())
|
||||||
if got := f.callCount(); got != 2 {
|
if got := f.callCount(); got != 2 {
|
||||||
t.Fatalf("fetched %d times after the cooldown, want 2", got)
|
t.Fatalf("fetched %d times after the cooldown, want 2", got)
|
||||||
@@ -239,7 +279,7 @@ func TestRunOnceCorrectsDownward(t *testing.T) {
|
|||||||
const key = "demonic:Catastrophic-Necromancer"
|
const key = "demonic:Catastrophic-Necromancer"
|
||||||
|
|
||||||
high := 400.0
|
high := 400.0
|
||||||
if _, err := s.Upsert(Bookmark{
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
|
Key: key, Site: "demonic", SeriesID: "Catastrophic-Necromancer",
|
||||||
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
|
SeriesURL: url, LatestChapter: "Chapter 400", LatestChapterNum: &high,
|
||||||
UpdatedAt: 1000,
|
UpdatedAt: 1000,
|
||||||
@@ -278,7 +318,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
key := tt.site + ":x"
|
key := tt.site + ":x"
|
||||||
if _, err := s.Upsert(Bookmark{
|
if _, err := s.Upsert(store.Bookmark{
|
||||||
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
Key: key, Site: tt.site, SeriesID: "x", SeriesURL: tt.seriesURL,
|
||||||
UpdatedAt: 1000,
|
UpdatedAt: 1000,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -287,7 +327,7 @@ func TestCheckOneValidatesSeriesURLBeforeFetching(t *testing.T) {
|
|||||||
|
|
||||||
now := time.UnixMilli(4_000_000)
|
now := time.UnixMilli(4_000_000)
|
||||||
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
||||||
newTestPoller(t, s, f, now).checkOne(context.Background(), Bookmark{
|
newTestPoller(t, s, f, now).checkOne(context.Background(), store.Bookmark{
|
||||||
Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
|
Key: key, Site: tt.site, SeriesURL: tt.seriesURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// latestChapter is the newest chapter a series page advertises.
|
// latestChapter is the newest chapter a series page advertises.
|
||||||
@@ -53,7 +55,7 @@ func latestChapterFrom(site, seriesURL, body string) (latestChapter, bool) {
|
|||||||
// chapter hrefs in the fetched body carry the current one. Strip to
|
// chapter hrefs in the fetched body carry the current one. Strip to
|
||||||
// the stable ID (same rule as migrateAsuraKeys) and make the hash
|
// the stable ID (same rule as migrateAsuraKeys) and make the hash
|
||||||
// optional in the pattern, so scoping survives rotations.
|
// optional in the pattern, so scoping survives rotations.
|
||||||
slug := asuraBuildHash.ReplaceAllString(m[1], "")
|
slug := store.AsuraBuildHash.ReplaceAllString(m[1], "")
|
||||||
// Compiled per call rather than cached: this runs once per fetch, which
|
// Compiled per call rather than cached: this runs once per fetch, which
|
||||||
// is at most a few times a minute, and the slug varies per series.
|
// is at most a few times a minute, and the slug varies per series.
|
||||||
re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`)
|
re = regexp.MustCompile(`/comics/` + regexp.QuoteMeta(slug) + `(?:-[0-9a-f]{8})?/chapter/([0-9.]+)`)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package latest
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sessionCookieName = "mangabm_session"
|
CookieName = "mangabm_session"
|
||||||
// 60 days: long enough that a phone stays logged in between reading spells.
|
// 60 days: long enough that a phone stays logged in between reading spells.
|
||||||
sessionTTL = 60 * 24 * time.Hour
|
sessionTTL = 60 * 24 * time.Hour
|
||||||
// Domain separation, so the session key can never collide with any other
|
// Domain separation, so the session key can never collide with any other
|
||||||
@@ -23,18 +23,18 @@ const (
|
|||||||
sessionKeyPurpose = "mangabm-web-session-v1"
|
sessionKeyPurpose = "mangabm-web-session-v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sessionKey derives the cookie-signing key from both secrets. Sessions are
|
// Key derives the cookie-signing key from both secrets. Sessions are
|
||||||
// stateless — there is no session table — so rotating either API_TOKEN or
|
// stateless — there is no session table — so rotating either API_TOKEN or
|
||||||
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
|
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
|
||||||
// separator prevents the concatenation ambiguity a bare apiToken+webPassword
|
// separator prevents the concatenation ambiguity a bare apiToken+webPassword
|
||||||
// would have (e.g. "ab"+"c" colliding with "a"+"bc").
|
// would have (e.g. "ab"+"c" colliding with "a"+"bc").
|
||||||
func sessionKey(apiToken, webPassword string) []byte {
|
func Key(apiToken, webPassword string) []byte {
|
||||||
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
|
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
|
||||||
return sum[:]
|
return sum[:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// signSession encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
|
// Sign encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
|
||||||
func signSession(key []byte, expiryMs int64) string {
|
func Sign(key []byte, expiryMs int64) string {
|
||||||
payload := strconv.FormatInt(expiryMs, 10)
|
payload := strconv.FormatInt(expiryMs, 10)
|
||||||
return payload + "." + sessionMAC(key, payload)
|
return payload + "." + sessionMAC(key, payload)
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,10 @@ func sessionMAC(key []byte, payload string) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifySession checks shape, then expiry, then the signature — in that order.
|
// Verify checks shape, then expiry, then the signature — in that order.
|
||||||
// The signature comparison is constant-time; the checks before it only look at
|
// The signature comparison is constant-time; the checks before it only look at
|
||||||
// data the holder already supplied, so their timing leaks nothing.
|
// data the holder already supplied, so their timing leaks nothing.
|
||||||
func verifySession(key []byte, value string, nowMs int64) bool {
|
func Verify(key []byte, value string, nowMs int64) bool {
|
||||||
payload, sig, ok := strings.Cut(value, ".")
|
payload, sig, ok := strings.Cut(value, ".")
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
@@ -69,10 +69,10 @@ func isHTTPS(r *http.Request) bool {
|
|||||||
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
func SetCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: CookieName,
|
||||||
Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()),
|
Value: Sign(key, time.Now().Add(sessionTTL).UnixMilli()),
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: int(sessionTTL / time.Second),
|
MaxAge: int(sessionTTL / time.Second),
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
@@ -81,9 +81,9 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
func ClearCookie(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: CookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
@@ -94,11 +94,11 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
loginMaxFailures = 10
|
MaxFailures = 10
|
||||||
loginWindow = 20 * time.Minute
|
Window = 20 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// clientIP returns the address the reverse proxy actually observed.
|
// ClientIP returns the address the reverse proxy actually observed.
|
||||||
//
|
//
|
||||||
// Traefik appends the peer address to whatever X-Forwarded-For the client sent,
|
// Traefik appends the peer address to whatever X-Forwarded-For the client sent,
|
||||||
// so the leftmost entry is attacker-controlled and the rightmost is not. Go's
|
// so the leftmost entry is attacker-controlled and the rightmost is not. Go's
|
||||||
@@ -106,7 +106,7 @@ const (
|
|||||||
// by sending its own; Values covers every line so the true last hop is found.
|
// by sending its own; Values covers every line so the true last hop is found.
|
||||||
// RemoteAddr is useless behind the proxy — it is always the Traefik container —
|
// RemoteAddr is useless behind the proxy — it is always the Traefik container —
|
||||||
// so it serves only as the direct-connection fallback for local development.
|
// so it serves only as the direct-connection fallback for local development.
|
||||||
func clientIP(r *http.Request) string {
|
func ClientIP(r *http.Request) string {
|
||||||
if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 {
|
if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 {
|
||||||
hops := strings.Split(vals[len(vals)-1], ",")
|
hops := strings.Split(vals[len(vals)-1], ",")
|
||||||
if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" {
|
if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" {
|
||||||
@@ -120,8 +120,8 @@ func clientIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
// loginLimiter throttles password guessing: loginMaxFailures failures inside a
|
// LoginLimiter throttles password guessing: MaxFailures failures inside a
|
||||||
// rolling loginWindow blocks further attempts from that IP until the oldest one
|
// rolling Window blocks further attempts from that IP until the oldest one
|
||||||
// ages out. There is no permanent ban and no unlock step.
|
// ages out. There is no permanent ban and no unlock step.
|
||||||
//
|
//
|
||||||
// Behind carrier-grade NAT this budget is shared with every other subscriber on
|
// Behind carrier-grade NAT this budget is shared with every other subscriber on
|
||||||
@@ -132,34 +132,34 @@ func clientIP(r *http.Request) string {
|
|||||||
// State is in memory and per-process, so a restart clears it. Entries are
|
// State is in memory and per-process, so a restart clears it. Entries are
|
||||||
// pruned lazily on access; for a single-user deployment the map cannot grow
|
// pruned lazily on access; for a single-user deployment the map cannot grow
|
||||||
// past the handful of addresses that ever attempt a login.
|
// past the handful of addresses that ever attempt a login.
|
||||||
type loginLimiter struct {
|
type LoginLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
failures map[string][]time.Time
|
failures map[string][]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLoginLimiter() *loginLimiter {
|
func NewLoginLimiter() *LoginLimiter {
|
||||||
return &loginLimiter{failures: make(map[string][]time.Time)}
|
return &LoginLimiter{failures: make(map[string][]time.Time)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// retryAfter returns how long ip must wait, or zero when it may try now.
|
// retryAfter returns how long ip must wait, or zero when it may try now.
|
||||||
func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration {
|
func (l *LoginLimiter) RetryAfter(ip string, now time.Time) time.Duration {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
recent := l.pruneLocked(ip, now)
|
recent := l.pruneLocked(ip, now)
|
||||||
if len(recent) < loginMaxFailures {
|
if len(recent) < MaxFailures {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return recent[0].Add(loginWindow).Sub(now)
|
return recent[0].Add(Window).Sub(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *loginLimiter) fail(ip string, now time.Time) {
|
func (l *LoginLimiter) Fail(ip string, now time.Time) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
l.failures[ip] = append(l.pruneLocked(ip, now), now)
|
l.failures[ip] = append(l.pruneLocked(ip, now), now)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *loginLimiter) reset(ip string) {
|
func (l *LoginLimiter) Reset(ip string) {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
delete(l.failures, ip)
|
delete(l.failures, ip)
|
||||||
@@ -167,8 +167,8 @@ func (l *loginLimiter) reset(ip string) {
|
|||||||
|
|
||||||
// pruneLocked drops attempts older than the window and returns what is left.
|
// pruneLocked drops attempts older than the window and returns what is left.
|
||||||
// The caller must hold l.mu.
|
// The caller must hold l.mu.
|
||||||
func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
func (l *LoginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
||||||
cutoff := now.Add(-loginWindow)
|
cutoff := now.Add(-Window)
|
||||||
// In-place filter: kept reuses the backing array of the slice being
|
// In-place filter: kept reuses the backing array of the slice being
|
||||||
// ranged over. Safe to alias because append writes at index len(kept),
|
// ranged over. Safe to alias because append writes at index len(kept),
|
||||||
// which is always <= the range index i, and element i is read before
|
// which is always <= the range index i, and element i is read before
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package session
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
@@ -10,18 +10,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionRoundTrip(t *testing.T) {
|
func TestSessionRoundTrip(t *testing.T) {
|
||||||
key := sessionKey("token-abc", "pw-abc")
|
key := Key("token-abc", "pw-abc")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
value := signSession(key, now+60_000)
|
value := Sign(key, now+60_000)
|
||||||
if !verifySession(key, value, now) {
|
if !Verify(key, value, now) {
|
||||||
t.Fatal("verifySession = false for a freshly signed cookie, want true")
|
t.Fatal("Verify = false for a freshly signed cookie, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionRejects(t *testing.T) {
|
func TestSessionRejects(t *testing.T) {
|
||||||
key := sessionKey("token-abc", "pw-abc")
|
key := Key("token-abc", "pw-abc")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
valid := signSession(key, now+60_000)
|
valid := Sign(key, now+60_000)
|
||||||
payload, sig, _ := strings.Cut(valid, ".")
|
payload, sig, _ := strings.Cut(valid, ".")
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -31,15 +31,15 @@ func TestSessionRejects(t *testing.T) {
|
|||||||
{"empty", ""},
|
{"empty", ""},
|
||||||
{"no separator", payload + sig},
|
{"no separator", payload + sig},
|
||||||
{"unparseable expiry", "notanumber." + sig},
|
{"unparseable expiry", "notanumber." + sig},
|
||||||
{"expired", signSession(key, now-1)},
|
{"expired", Sign(key, now-1)},
|
||||||
{"tampered signature", payload + "." + flipLastChar(sig)},
|
{"tampered signature", payload + "." + flipLastChar(sig)},
|
||||||
{"tampered expiry", "99999999999999." + sig},
|
{"tampered expiry", "99999999999999." + sig},
|
||||||
{"signed with another key", signSession(sessionKey("other-token", "pw-abc"), now+60_000)},
|
{"signed with another key", Sign(Key("other-token", "pw-abc"), now+60_000)},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
if verifySession(key, tc.value, now) {
|
if Verify(key, tc.value, now) {
|
||||||
t.Fatalf("verifySession(%q) = true, want false", tc.value)
|
t.Fatalf("Verify(%q) = true, want false", tc.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -57,18 +57,18 @@ func flipLastChar(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionKeyDependsOnToken(t *testing.T) {
|
func TestSessionKeyDependsOnToken(t *testing.T) {
|
||||||
a := sessionKey("token-a", "pw-abc")
|
a := Key("token-a", "pw-abc")
|
||||||
b := sessionKey("token-b", "pw-abc")
|
b := Key("token-b", "pw-abc")
|
||||||
if string(a) == string(b) {
|
if string(a) == string(b) {
|
||||||
t.Fatal("sessionKey collided for different API tokens")
|
t.Fatal("Key collided for different API tokens")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
|
||||||
a := sessionKey("token-abc", "pw-a")
|
a := Key("token-abc", "pw-a")
|
||||||
b := sessionKey("token-abc", "pw-b")
|
b := Key("token-abc", "pw-b")
|
||||||
if string(a) == string(b) {
|
if string(a) == string(b) {
|
||||||
t.Fatal("sessionKey collided for different web passwords with the same API token")
|
t.Fatal("Key collided for different web passwords with the same API token")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,15 +94,15 @@ func TestSetSessionCookieAttributes(t *testing.T) {
|
|||||||
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc"))
|
SetCookie(rr, r, Key("token-abc", "pw-abc"))
|
||||||
|
|
||||||
cookies := rr.Result().Cookies()
|
cookies := rr.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
t.Fatalf("got %d cookies, want 1", len(cookies))
|
t.Fatalf("got %d cookies, want 1", len(cookies))
|
||||||
}
|
}
|
||||||
c := cookies[0]
|
c := cookies[0]
|
||||||
if c.Name != sessionCookieName {
|
if c.Name != CookieName {
|
||||||
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName)
|
t.Fatalf("cookie name = %q, want %q", c.Name, CookieName)
|
||||||
}
|
}
|
||||||
if !c.HttpOnly {
|
if !c.HttpOnly {
|
||||||
t.Fatal("cookie HttpOnly = false, want true")
|
t.Fatal("cookie HttpOnly = false, want true")
|
||||||
@@ -126,7 +126,7 @@ func TestSetSessionCookieAttributes(t *testing.T) {
|
|||||||
func TestClearSessionCookie(t *testing.T) {
|
func TestClearSessionCookie(t *testing.T) {
|
||||||
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
r := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
clearSessionCookie(rr, r)
|
ClearCookie(rr, r)
|
||||||
|
|
||||||
cookies := rr.Result().Cookies()
|
cookies := rr.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
@@ -168,65 +168,65 @@ func TestClientIP(t *testing.T) {
|
|||||||
for _, v := range tc.xff {
|
for _, v := range tc.xff {
|
||||||
r.Header.Add("X-Forwarded-For", v)
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
}
|
}
|
||||||
if got := clientIP(r); got != tc.want {
|
if got := ClientIP(r); got != tc.want {
|
||||||
t.Fatalf("clientIP() = %q, want %q", got, tc.want)
|
t.Fatalf("ClientIP() = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
||||||
t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures)
|
t.Fatalf("blocked after %d failures, want block only after %d", i, MaxFailures)
|
||||||
}
|
}
|
||||||
l.fail("1.2.3.4", now)
|
l.Fail("1.2.3.4", now)
|
||||||
}
|
}
|
||||||
wait := l.retryAfter("1.2.3.4", now)
|
wait := l.RetryAfter("1.2.3.4", now)
|
||||||
if wait <= 0 {
|
if wait <= 0 {
|
||||||
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures)
|
t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, MaxFailures)
|
||||||
}
|
}
|
||||||
if wait > loginWindow {
|
if wait > Window {
|
||||||
t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow)
|
t.Fatalf("retryAfter = %v, want <= %v", wait, Window)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterWindowExpires(t *testing.T) {
|
func TestLoginLimiterWindowExpires(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
l.fail("1.2.3.4", start)
|
l.Fail("1.2.3.4", start)
|
||||||
}
|
}
|
||||||
if l.retryAfter("1.2.3.4", start) == 0 {
|
if l.RetryAfter("1.2.3.4", start) == 0 {
|
||||||
t.Fatal("expected block immediately after the failures")
|
t.Fatal("expected block immediately after the failures")
|
||||||
}
|
}
|
||||||
later := start.Add(loginWindow + time.Second)
|
later := start.Add(Window + time.Second)
|
||||||
if wait := l.retryAfter("1.2.3.4", later); wait != 0 {
|
if wait := l.RetryAfter("1.2.3.4", later); wait != 0 {
|
||||||
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
t.Fatalf("retryAfter = %v once the window passed, want 0", wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
func TestLoginLimiterResetClearsCounter(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
l.fail("1.2.3.4", now)
|
l.Fail("1.2.3.4", now)
|
||||||
}
|
}
|
||||||
l.reset("1.2.3.4")
|
l.Reset("1.2.3.4")
|
||||||
if wait := l.retryAfter("1.2.3.4", now); wait != 0 {
|
if wait := l.RetryAfter("1.2.3.4", now); wait != 0 {
|
||||||
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
t.Fatalf("retryAfter = %v after reset, want 0", wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoginLimiterIsPerIP(t *testing.T) {
|
func TestLoginLimiterIsPerIP(t *testing.T) {
|
||||||
l := newLoginLimiter()
|
l := NewLoginLimiter()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < MaxFailures; i++ {
|
||||||
l.fail("1.2.3.4", now)
|
l.Fail("1.2.3.4", now)
|
||||||
}
|
}
|
||||||
if wait := l.retryAfter("5.6.7.8", now); wait != 0 {
|
if wait := l.RetryAfter("5.6.7.8", now); wait != 0 {
|
||||||
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
t.Fatalf("retryAfter for a different IP = %v, want 0", wait)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
@@ -86,11 +86,21 @@ func (b Bookmark) ContinueURL() string {
|
|||||||
return b.SeriesURL
|
return b.SeriesURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial is the monogram the web UI shows in place of a cover when the
|
||||||
|
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
||||||
|
// the title is missing, so the slot is never empty.
|
||||||
|
func (b Bookmark) Initial() string {
|
||||||
|
for _, r := range b.Title {
|
||||||
|
return strings.ToUpper(string(r))
|
||||||
|
}
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
// Lifecycle buckets. A bookmark is in exactly one; favorite is orthogonal.
|
||||||
const (
|
const (
|
||||||
statusReading = "reading"
|
StatusReading = "reading"
|
||||||
statusArchived = "archived"
|
StatusArchived = "archived"
|
||||||
statusFinished = "finished"
|
StatusFinished = "finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
const schema = `
|
const schema = `
|
||||||
@@ -137,7 +147,7 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
// OpenStore opens (or creates) the SQLite database at path and applies the schema.
|
||||||
func OpenStore(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
// busy_timeout guards against SQLITE_BUSY under the reverse proxy's
|
||||||
// concurrent requests; a single writer connection keeps writes serialized.
|
// concurrent requests; a single writer connection keeps writes serialized.
|
||||||
dsn := path
|
dsn := path
|
||||||
@@ -182,11 +192,11 @@ func migrateColumns(db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura
|
// AsuraBuildHash matches the trailing "-xxxxxxxx" site-wide build ID Asura
|
||||||
// appends to every series slug. It rotates on each site redeploy, so it
|
// appends to every series slug. It rotates on each site redeploy, so it
|
||||||
// must not be part of series_id. Must stay in sync with stripBuildHash in
|
// must not be part of series_id. Must stay in sync with stripBuildHash in
|
||||||
// userscript/manga-bookmark.user.js.
|
// userscript/manga-bookmark.user.js.
|
||||||
var asuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`)
|
var AsuraBuildHash = regexp.MustCompile(`-[0-9a-f]{8}$`)
|
||||||
|
|
||||||
// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries
|
// migrateAsuraKeys rewrites asura bookmarks whose series_id still carries
|
||||||
// the build hash to the stable, hashless ID. Rows keyed with a hash are
|
// the build hash to the stable, hashless ID. Rows keyed with a hash are
|
||||||
@@ -218,7 +228,7 @@ func migrateAsuraKeys(db *sql.DB) error {
|
|||||||
|
|
||||||
groups := map[string][]row{}
|
groups := map[string][]row{}
|
||||||
for _, r := range all {
|
for _, r := range all {
|
||||||
stripped := asuraBuildHash.ReplaceAllString(r.id, "")
|
stripped := AsuraBuildHash.ReplaceAllString(r.id, "")
|
||||||
groups[stripped] = append(groups[stripped], r)
|
groups[stripped] = append(groups[stripped], r)
|
||||||
}
|
}
|
||||||
for stripped, g := range groups {
|
for stripped, g := range groups {
|
||||||
@@ -305,8 +315,8 @@ func scanBookmark(scan func(...any) error) (Bookmark, error) {
|
|||||||
// leave the row in no list at all, so anything outside the three known
|
// leave the row in no list at all, so anything outside the three known
|
||||||
// buckets reads as the default rather than being passed through.
|
// buckets reads as the default rather than being passed through.
|
||||||
b.Status = status.String
|
b.Status = status.String
|
||||||
if b.Status != statusReading && b.Status != statusArchived && b.Status != statusFinished {
|
if b.Status != StatusReading && b.Status != StatusArchived && b.Status != StatusFinished {
|
||||||
b.Status = statusReading
|
b.Status = StatusReading
|
||||||
}
|
}
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
@@ -481,3 +491,16 @@ func (s *Store) MarkLatestChecked(key string, ts int64) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
|
||||||
|
// tests outside this package (the poller's own tests assert on cooldown
|
||||||
|
// bookkeeping) — see MarkLatestChecked for why the field itself stays off
|
||||||
|
// Bookmark.
|
||||||
|
func (s *Store) LatestCheckedAt(key string) (int64, error) {
|
||||||
|
var ts int64
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT latest_checked_at FROM bookmarks WHERE key = ?`, key).Scan(&ts); err != nil {
|
||||||
|
return 0, fmt.Errorf("latest checked at %q: %w", key, err)
|
||||||
|
}
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
@@ -1,42 +1,15 @@
|
|||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testToken = "s3cret-token"
|
|
||||||
|
|
||||||
func testConfig() Config {
|
|
||||||
return Config{
|
|
||||||
Token: testToken,
|
|
||||||
AllowedOrigins: []string{"https://asuracomic.net", "https://demonicscans.org"},
|
|
||||||
Port: "8080",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestServer(t *testing.T) http.Handler {
|
|
||||||
t.Helper()
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
||||||
store, err := OpenStore(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
return newRouter(store, testConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestStore(t *testing.T) *Store {
|
func newTestStore(t *testing.T) *Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
store, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore: %v", err)
|
t.Fatalf("OpenStore: %v", err)
|
||||||
}
|
}
|
||||||
@@ -44,346 +17,6 @@ func newTestStore(t *testing.T) *Store {
|
|||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
|
|
||||||
func auth(req *http.Request) *http.Request {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHealthzNoAuth(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("healthz status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
if rr.Body.String() != "ok" {
|
|
||||||
t.Fatalf("healthz body = %q, want ok", rr.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRequired(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
header string
|
|
||||||
}{
|
|
||||||
{"no header", ""},
|
|
||||||
{"bad token", "Bearer wrong"},
|
|
||||||
{"not bearer", "Basic " + testToken},
|
|
||||||
{"empty bearer", "Bearer "},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil)
|
|
||||||
if tc.header != "" {
|
|
||||||
req.Header.Set("Authorization", tc.header)
|
|
||||||
}
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
if rr.Code != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("status = %d, want 401", rr.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthAccepted(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
if got := rr.Body.String(); got != "[]\n" {
|
|
||||||
t.Fatalf("empty list body = %q, want []", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSPreflight(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/bookmarks/asura:foo-1", nil)
|
|
||||||
req.Header.Set("Origin", "https://asuracomic.net")
|
|
||||||
req.Header.Set("Access-Control-Request-Method", "PUT")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
|
|
||||||
if rr.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("preflight status = %d, want 204", rr.Code)
|
|
||||||
}
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "https://asuracomic.net" {
|
|
||||||
t.Fatalf("Allow-Origin = %q, want reflected origin", got)
|
|
||||||
}
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
|
||||||
t.Fatal("Allow-Methods missing")
|
|
||||||
}
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
|
||||||
t.Fatal("Allow-Headers missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSDisallowedOrigin(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
req := httptest.NewRequest(http.MethodOptions, "/bookmarks", nil)
|
|
||||||
req.Header.Set("Origin", "https://evil.example")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, req)
|
|
||||||
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
||||||
t.Fatalf("Allow-Origin = %q, want empty for disallowed origin", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBookmarkRoundTrip(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := "asura:solo-leveling-123"
|
|
||||||
in := Bookmark{
|
|
||||||
Title: "Solo Leveling",
|
|
||||||
SeriesURL: "https://asuracomic.net/series/solo-leveling-123",
|
|
||||||
Cover: "https://asuracomic.net/cover.jpg",
|
|
||||||
LastChapter: "Chapter 10",
|
|
||||||
LastChapterNum: 10,
|
|
||||||
LastChapterURL: "https://asuracomic.net/series/solo-leveling-123/chapter/10",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(in)
|
|
||||||
|
|
||||||
// PUT
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
|
||||||
}
|
|
||||||
var stored Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &stored); err != nil {
|
|
||||||
t.Fatalf("decode PUT response: %v", err)
|
|
||||||
}
|
|
||||||
if stored.Key != key || stored.Site != "asura" || stored.SeriesID != "solo-leveling-123" {
|
|
||||||
t.Fatalf("derived fields wrong: %+v", stored)
|
|
||||||
}
|
|
||||||
if stored.UpdatedAt == 0 {
|
|
||||||
t.Fatal("server did not set updated_at")
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
var list []Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
||||||
t.Fatalf("decode list: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 || list[0].Key != key || list[0].LastChapterNum != 10 {
|
|
||||||
t.Fatalf("GET list wrong: %+v", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT again (upsert, progress advance)
|
|
||||||
in.LastChapter, in.LastChapterNum = "Chapter 11", 11
|
|
||||||
body, _ = json.Marshal(in)
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("second PUT status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
json.Unmarshal(rr.Body.Bytes(), &list)
|
|
||||||
if len(list) != 1 || list[0].LastChapterNum != 11 {
|
|
||||||
t.Fatalf("upsert did not update in place: %+v", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodDelete, "/bookmarks/"+key, nil)))
|
|
||||||
if rr.Code != http.StatusNoContent {
|
|
||||||
t.Fatalf("DELETE status = %d, want 204", rr.Code)
|
|
||||||
}
|
|
||||||
rr = httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
json.Unmarshal(rr.Body.Bytes(), &list)
|
|
||||||
if len(list) != 0 {
|
|
||||||
t.Fatalf("after delete list = %+v, want empty", list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// putBookmark PUTs b at key and returns the bookmark the server echoes back,
|
|
||||||
// which is the row as actually stored (not the request payload).
|
|
||||||
func putBookmark(t *testing.T, srv http.Handler, key string, b Bookmark) Bookmark {
|
|
||||||
t.Helper()
|
|
||||||
body, _ := json.Marshal(b)
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT %s status = %d, body = %s", key, rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
var out Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
|
||||||
t.Fatalf("decode PUT response: %v", err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBookmarks(t *testing.T, srv http.Handler) []Bookmark {
|
|
||||||
t.Helper()
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil)))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
var list []Bookmark
|
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &list); err != nil {
|
|
||||||
t.Fatalf("decode list: %v", err)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func floatPtr(f float64) *float64 { return &f }
|
|
||||||
|
|
||||||
// updated_at drives list ordering, so it must move only on a real progress
|
|
||||||
// advance — never on a favorite toggle or a latest-chapter capture.
|
|
||||||
func TestUpsertConditionalUpdatedAt(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(Bookmark) Bookmark
|
|
||||||
wantBumped bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unchanged progress",
|
|
||||||
mutate: func(b Bookmark) Bookmark { return b },
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "changed progress",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.LastChapter, b.LastChapterNum = "Chapter 11", 11
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "favorite only",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.Favorite = true
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "latest chapter only",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.LatestChapter, b.LatestChapterNum = "Chapter 15", floatPtr(15)
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unrelated metadata only",
|
|
||||||
mutate: func(b Bookmark) Bookmark {
|
|
||||||
b.Title, b.Cover = "Renamed", "https://example.test/new.jpg"
|
|
||||||
return b
|
|
||||||
},
|
|
||||||
wantBumped: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := fmt.Sprintf("asura:cond-%d", i)
|
|
||||||
|
|
||||||
first := putBookmark(t, srv, key, Bookmark{
|
|
||||||
Title: "Test",
|
|
||||||
LastChapter: "Chapter 10",
|
|
||||||
LastChapterNum: 10,
|
|
||||||
})
|
|
||||||
if first.UpdatedAt == 0 {
|
|
||||||
t.Fatal("new bookmark did not get updated_at set")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee a later wall-clock ms so a real bump is observable.
|
|
||||||
time.Sleep(2 * time.Millisecond)
|
|
||||||
|
|
||||||
second := putBookmark(t, srv, key, tc.mutate(first))
|
|
||||||
if tc.wantBumped && second.UpdatedAt <= first.UpdatedAt {
|
|
||||||
t.Fatalf("updated_at = %d, want > %d", second.UpdatedAt, first.UpdatedAt)
|
|
||||||
}
|
|
||||||
if !tc.wantBumped && second.UpdatedAt != first.UpdatedAt {
|
|
||||||
t.Fatalf("updated_at = %d, want preserved %d", second.UpdatedAt, first.UpdatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The PUT response must match what a subsequent GET reports.
|
|
||||||
list := getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("list = %+v, want 1 item", list)
|
|
||||||
}
|
|
||||||
if list[0].UpdatedAt != second.UpdatedAt {
|
|
||||||
t.Fatalf("GET updated_at = %d, PUT echoed %d", list[0].UpdatedAt, second.UpdatedAt)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFavoriteRoundTrip(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := "demonic:some-series"
|
|
||||||
|
|
||||||
stored := putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: true})
|
|
||||||
if !stored.Favorite {
|
|
||||||
t.Fatalf("PUT response favorite = false, want true")
|
|
||||||
}
|
|
||||||
|
|
||||||
list := getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || !list[0].Favorite {
|
|
||||||
t.Fatalf("favorite did not round-trip: %+v", list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unfavoriting must persist too (guards against a write that only ever ORs in true).
|
|
||||||
stored = putBookmark(t, srv, key, Bookmark{Title: "Fav", Favorite: false})
|
|
||||||
if stored.Favorite {
|
|
||||||
t.Fatal("PUT response favorite = true after unfavorite")
|
|
||||||
}
|
|
||||||
list = getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || list[0].Favorite {
|
|
||||||
t.Fatalf("unfavorite did not round-trip: %+v", list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLatestChapterNullable(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
key := "asura:latest-test"
|
|
||||||
|
|
||||||
// Never captured: latest_chapter_num must serialize as JSON null.
|
|
||||||
body, _ := json.Marshal(Bookmark{Title: "No latest yet"})
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, "/bookmarks/"+key, bytes.NewReader(body))))
|
|
||||||
if rr.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d", rr.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(rr.Body.String(), `"latest_chapter_num":null`) {
|
|
||||||
t.Fatalf("want latest_chapter_num null in response, got %s", rr.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
list := getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || list[0].LatestChapterNum != nil {
|
|
||||||
t.Fatalf("latest_chapter_num = %v, want nil", list[0].LatestChapterNum)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once captured it round-trips as a value.
|
|
||||||
stored := putBookmark(t, srv, key, Bookmark{
|
|
||||||
Title: "No latest yet",
|
|
||||||
LatestChapter: "Chapter 162",
|
|
||||||
LatestChapterNum: floatPtr(162),
|
|
||||||
})
|
|
||||||
if stored.LatestChapterNum == nil || *stored.LatestChapterNum != 162 {
|
|
||||||
t.Fatalf("PUT response latest_chapter_num = %v, want 162", stored.LatestChapterNum)
|
|
||||||
}
|
|
||||||
list = getBookmarks(t, srv)
|
|
||||||
if len(list) != 1 || list[0].LatestChapterNum == nil || *list[0].LatestChapterNum != 162 {
|
|
||||||
t.Fatalf("latest chapter did not round-trip: %+v", list)
|
|
||||||
}
|
|
||||||
if list[0].LatestChapter != "Chapter 162" {
|
|
||||||
t.Fatalf("latest_chapter = %q, want %q", list[0].LatestChapter, "Chapter 162")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The deployed database predates favorite/latest_chapter*, and CREATE TABLE
|
|
||||||
// IF NOT EXISTS will not add them — OpenStore must migrate in place.
|
|
||||||
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
dbPath := filepath.Join(t.TempDir(), "legacy.db")
|
||||||
|
|
||||||
@@ -415,7 +48,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
|||||||
t.Fatalf("close legacy db: %v", err)
|
t.Fatalf("close legacy db: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := OpenStore(dbPath)
|
store, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore on legacy db: %v", err)
|
t.Fatalf("OpenStore on legacy db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -437,7 +70,7 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reopening an already-migrated database must be a no-op, not an error.
|
// Reopening an already-migrated database must be a no-op, not an error.
|
||||||
store2, err := OpenStore(dbPath)
|
store2, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore is not idempotent: %v", err)
|
t.Fatalf("OpenStore is not idempotent: %v", err)
|
||||||
}
|
}
|
||||||
@@ -516,19 +149,6 @@ func TestBookmarkContinueURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadConfigWebPassword(t *testing.T) {
|
|
||||||
t.Setenv("API_TOKEN", "token-abc")
|
|
||||||
t.Setenv("WEB_PASSWORD", "hunter2")
|
|
||||||
if got := loadConfig().WebPassword; got != "hunter2" {
|
|
||||||
t.Fatalf("WebPassword = %q, want hunter2", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("WEB_PASSWORD", "")
|
|
||||||
if got := loadConfig().WebPassword; got != "" {
|
|
||||||
t.Fatalf("WebPassword = %q with the variable unset, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readLatestCheckedAt reads the column directly. It is deliberately absent from
|
// readLatestCheckedAt reads the column directly. It is deliberately absent from
|
||||||
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
|
// Bookmark (see Store.Upsert), so tests cannot assert on it any other way.
|
||||||
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
||||||
@@ -672,7 +292,7 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
|
|||||||
t.Fatalf("close: %v", err)
|
t.Fatalf("close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := OpenStore(path)
|
s, err := Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore on pre-existing db: %v", err)
|
t.Fatalf("OpenStore on pre-existing db: %v", err)
|
||||||
}
|
}
|
||||||
@@ -691,38 +311,6 @@ func TestMigrateAddsLatestCheckedAt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A userscript PUT body has no latest_checked_at field. If the column is ever
|
|
||||||
// moved into bookmarkColumns, this test catches it: the PUT would reset the
|
|
||||||
// cooldown and the poller would re-fetch that series on every single tick.
|
|
||||||
func TestPutDoesNotClobberLatestCheckedAt(t *testing.T) {
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
|
||||||
store, err := OpenStore(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
srv := newRouter(store, testConfig())
|
|
||||||
|
|
||||||
seedForCheck(t, store, "asura:x", "https://asurascans.com/comics/x", 777)
|
|
||||||
|
|
||||||
// Exactly what the userscript sends: no latest_checked_at key at all.
|
|
||||||
body := `{"key":"asura:x","site":"asura","series_id":"x",
|
|
||||||
"series_url":"https://asurascans.com/comics/x",
|
|
||||||
"last_chapter":"Chapter 5","last_chapter_num":5}`
|
|
||||||
req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:x", strings.NewReader(body))
|
|
||||||
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("PUT status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if got := readLatestCheckedAt(t, store, "asura:x"); got != 777 {
|
|
||||||
t.Fatalf("latest_checked_at = %d after client PUT, want 777 preserved", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
stored, err := store.Upsert(Bookmark{
|
stored, err := store.Upsert(Bookmark{
|
||||||
@@ -732,8 +320,8 @@ func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusReading {
|
if stored.Status != StatusReading {
|
||||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,7 +331,7 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(base); err != nil {
|
if _, err := store.Upsert(base); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
@@ -755,8 +343,8 @@ func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusArchived {
|
if stored.Status != StatusArchived {
|
||||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,7 +356,7 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(base); err != nil {
|
if _, err := store.Upsert(base); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
@@ -788,8 +376,8 @@ func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusArchived {
|
if stored.Status != StatusArchived {
|
||||||
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, statusArchived)
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,19 +385,19 @@ func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
|||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
base := Bookmark{
|
base := Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Status: statusArchived, UpdatedAt: time.Now().UnixMilli(),
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(base); err != nil {
|
if _, err := store.Upsert(base); err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Status = statusReading
|
base.Status = StatusReading
|
||||||
stored, err := store.Upsert(base)
|
stored, err := store.Upsert(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if stored.Status != statusReading {
|
if stored.Status != StatusReading {
|
||||||
t.Fatalf("Status = %q, want %q", stored.Status, statusReading)
|
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,7 +414,7 @@ func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base.Status = statusArchived
|
base.Status = StatusArchived
|
||||||
base.UpdatedAt = first.UpdatedAt + 60_000
|
base.UpdatedAt = first.UpdatedAt + 60_000
|
||||||
stored, err := store.Upsert(base)
|
stored, err := store.Upsert(base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -857,7 +445,7 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Close()
|
db.Close()
|
||||||
|
|
||||||
store, err := OpenStore(path)
|
store, err := Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore: %v", err)
|
t.Fatalf("OpenStore: %v", err)
|
||||||
}
|
}
|
||||||
@@ -867,8 +455,8 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
|||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
if b.Status != statusReading {
|
if b.Status != StatusReading {
|
||||||
t.Fatalf("Status = %q, want %q", b.Status, statusReading)
|
t.Fatalf("Status = %q, want %q", b.Status, StatusReading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,9 +465,9 @@ func TestMigrationAddsStatusToLegacyDatabase(t *testing.T) {
|
|||||||
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
for _, tc := range []struct{ key, status string }{
|
for _, tc := range []struct{ key, status string }{
|
||||||
{"asura:reading", statusReading},
|
{"asura:reading", StatusReading},
|
||||||
{"asura:archived", statusArchived},
|
{"asura:archived", StatusArchived},
|
||||||
{"asura:finished", statusFinished},
|
{"asura:finished", StatusFinished},
|
||||||
} {
|
} {
|
||||||
if _, err := store.Upsert(Bookmark{
|
if _, err := store.Upsert(Bookmark{
|
||||||
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
Key: tc.key, Site: "asura", SeriesID: tc.key,
|
||||||
@@ -912,7 +500,7 @@ func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
|||||||
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
dbPath := filepath.Join(t.TempDir(), "hash.db")
|
||||||
|
|
||||||
store, err := OpenStore(dbPath)
|
store, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -938,7 +526,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
|||||||
t.Fatalf("close: %v", err)
|
t.Fatalf("close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reopened, err := OpenStore(dbPath)
|
reopened, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopen: %v", err)
|
t.Fatalf("reopen: %v", err)
|
||||||
}
|
}
|
||||||
@@ -977,7 +565,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent: a third open changes nothing.
|
// Idempotent: a third open changes nothing.
|
||||||
third, err := OpenStore(dbPath)
|
third, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("third open: %v", err)
|
t.Fatalf("third open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -990,7 +578,7 @@ func TestOpenStoreMigratesAsuraBuildHashKeys(t *testing.T) {
|
|||||||
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
||||||
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
dbPath := filepath.Join(t.TempDir(), "collision.db")
|
||||||
|
|
||||||
store, err := OpenStore(dbPath)
|
store, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1010,7 +598,7 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) {
|
|||||||
t.Fatalf("close: %v", err)
|
t.Fatalf("close: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
reopened, err := OpenStore(dbPath)
|
reopened, err := Open(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reopen: %v", err)
|
t.Fatalf("reopen: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package userscript
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -35,7 +35,7 @@ func stampVersion(src []byte, mod time.Time) []byte {
|
|||||||
//
|
//
|
||||||
// The file is read per request — that is what lets a bindmounted copy be edited
|
// The file is read per request — that is what lets a bindmounted copy be edited
|
||||||
// on the host without a restart. It is ~50 KB and polled about once a day.
|
// on the host without a restart. It is ~50 KB and polled about once a day.
|
||||||
func userscriptHandler(token, path string) http.HandlerFunc {
|
func Handler(token, path string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package userscript
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const testToken = "s3cret-token"
|
||||||
|
|
||||||
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
// sampleScript is a stand-in for the real userscript: a metadata block with a
|
||||||
// @version line, plus a body that must survive the rewrite untouched.
|
// @version line, plus a body that must survive the rewrite untouched.
|
||||||
const sampleScript = `// ==UserScript==
|
const sampleScript = `// ==UserScript==
|
||||||
@@ -35,16 +37,12 @@ func writeScript(t *testing.T, body string) (path, wantVersion string) {
|
|||||||
return path, "2026.07.28.1642"
|
return path, "2026.07.28.1642"
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUserscriptServer(t *testing.T, path string) http.Handler {
|
// newTestMux registers Handler the same way main.go's router does, without
|
||||||
t.Helper()
|
// pulling in the store or the rest of the app.
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
func newTestMux(token, path string) http.Handler {
|
||||||
if err != nil {
|
mux := http.NewServeMux()
|
||||||
t.Fatalf("OpenStore: %v", err)
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", Handler(token, path))
|
||||||
}
|
return mux
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
cfg := testConfig()
|
|
||||||
cfg.UserscriptPath = path
|
|
||||||
return newRouter(store, cfg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
|
func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||||
@@ -56,7 +54,7 @@ func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseR
|
|||||||
|
|
||||||
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
||||||
path, wantVersion := writeScript(t, sampleScript)
|
path, wantVersion := writeScript(t, sampleScript)
|
||||||
rr := getScript(t, newUserscriptServer(t, path), testToken)
|
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
@@ -83,10 +81,13 @@ func TestUserscriptServedWithStampedVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The empty-token case ("/u//manga-bookmark.user.js") is covered at the
|
||||||
|
// router level (see backend's guardEmptyUserscriptToken): ServeMux 307s it to
|
||||||
|
// "/u/manga-bookmark.user.js" before this handler's own token check ever runs.
|
||||||
func TestUserscriptWrongTokenIs404(t *testing.T) {
|
func TestUserscriptWrongTokenIs404(t *testing.T) {
|
||||||
path, _ := writeScript(t, sampleScript)
|
path, _ := writeScript(t, sampleScript)
|
||||||
srv := newUserscriptServer(t, path)
|
srv := newTestMux(testToken, path)
|
||||||
for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} {
|
for _, tok := range []string{"wrong", testToken + "x", testToken[:3]} {
|
||||||
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
if got := getScript(t, srv, tok).Code; got != http.StatusNotFound {
|
||||||
t.Errorf("token %q: status = %d, want 404", tok, got)
|
t.Errorf("token %q: status = %d, want 404", tok, got)
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ func TestUserscriptWrongTokenIs404(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUserscriptMissingFileIs404(t *testing.T) {
|
func TestUserscriptMissingFileIs404(t *testing.T) {
|
||||||
srv := newUserscriptServer(t, filepath.Join(t.TempDir(), "absent.user.js"))
|
srv := newTestMux(testToken, filepath.Join(t.TempDir(), "absent.user.js"))
|
||||||
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound {
|
||||||
t.Fatalf("status = %d, want 404", got)
|
t.Fatalf("status = %d, want 404", got)
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,7 @@ func TestUserscriptMissingFileIs404(t *testing.T) {
|
|||||||
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
||||||
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n"
|
||||||
path, _ := writeScript(t, noVersion)
|
path, _ := writeScript(t, noVersion)
|
||||||
rr := getScript(t, newUserscriptServer(t, path), testToken)
|
rr := getScript(t, newTestMux(testToken, path), testToken)
|
||||||
|
|
||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200", rr.Code)
|
t.Fatalf("status = %d, want 200", rr.Code)
|
||||||
@@ -112,21 +113,3 @@ func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) {
|
|||||||
t.Fatalf("body = %q, want it unmodified", rr.Body.String())
|
t.Fatalf("body = %q, want it unmodified", rr.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The endpoint must work on a deployment that never set WEB_PASSWORD, since
|
|
||||||
// the web routes are not registered at all in that case.
|
|
||||||
func TestUserscriptServedWithWebUIDisabled(t *testing.T) {
|
|
||||||
path, _ := writeScript(t, sampleScript)
|
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "nopass.db"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("OpenStore: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { store.Close() })
|
|
||||||
cfg := testConfig()
|
|
||||||
cfg.WebPassword = ""
|
|
||||||
cfg.UserscriptPath = path
|
|
||||||
|
|
||||||
if got := getScript(t, newRouter(store, cfg), testToken).Code; got != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 973 B After Width: | Height: | Size: 973 B |
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/session"
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates
|
//go:embed templates
|
||||||
@@ -21,25 +24,25 @@ var templateFS embed.FS
|
|||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
// recentCount is how many series the "Continue reading" strip shows.
|
// RecentCount is how many series the "Continue reading" strip shows.
|
||||||
const recentCount = 5
|
const RecentCount = 5
|
||||||
|
|
||||||
// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
// Handler serves the browser UI: full pages at / and htmx fragments at /ui/.
|
||||||
// It is a separate handler from bookmarkHandler because the two speak different
|
// It is a separate handler from api.Handler because the two speak different
|
||||||
// representations (HTML versus JSON) to different clients under different auth.
|
// representations (HTML versus JSON) to different clients under different auth.
|
||||||
type webHandler struct {
|
type Handler struct {
|
||||||
store *Store
|
store *store.Store
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
key []byte
|
key []byte
|
||||||
password string
|
password string
|
||||||
limiter *loginLimiter
|
limiter *session.LoginLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// listView is what every list-rendering template receives.
|
// listView is what every list-rendering template receives.
|
||||||
type listView struct {
|
type listView struct {
|
||||||
Tab string // "all", "fav", or "new"
|
Tab string // "all", "fav", or "new"
|
||||||
Recent []Bookmark
|
Recent []store.Bookmark
|
||||||
Items []Bookmark
|
Items []store.Bookmark
|
||||||
// NewCount is the badge on the Updated tab: how many series being read
|
// NewCount is the badge on the Updated tab: how many series being read
|
||||||
// have a chapter out that has not been read. It is counted over the whole
|
// have a chapter out that has not been read. It is counted over the whole
|
||||||
// reading set, not the active tab, so the badge does not change meaning as
|
// reading set, not the active tab, so the badge does not change meaning as
|
||||||
@@ -50,38 +53,28 @@ type listView struct {
|
|||||||
OOB bool
|
OOB bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial is the monogram the templates show in place of a cover when the
|
|
||||||
// source site never gave us an og:image. First rune, uppercased; "?" when even
|
|
||||||
// the title is missing, so the slot is never empty.
|
|
||||||
func (b Bookmark) Initial() string {
|
|
||||||
for _, r := range b.Title {
|
|
||||||
return strings.ToUpper(string(r))
|
|
||||||
}
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
|
|
||||||
// loginView is what the login template receives.
|
// loginView is what the login template receives.
|
||||||
type loginView struct {
|
type loginView struct {
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWebHandler parses every template up front so a broken one kills the
|
// New parses every template up front so a broken one kills the process at
|
||||||
// process at startup rather than the first request that touches it.
|
// startup rather than the first request that touches it.
|
||||||
func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
|
func New(s *store.Store, apiToken, webPassword string) (*Handler, error) {
|
||||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &webHandler{
|
return &Handler{
|
||||||
store: store,
|
store: s,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
key: sessionKey(cfg.Token, cfg.WebPassword),
|
key: session.Key(apiToken, webPassword),
|
||||||
password: cfg.WebPassword,
|
password: webPassword,
|
||||||
limiter: newLoginLimiter(),
|
limiter: session.NewLoginLimiter(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) register(mux *http.ServeMux) {
|
func (h *Handler) Register(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /{$}", h.index)
|
mux.HandleFunc("GET /{$}", h.index)
|
||||||
mux.HandleFunc("POST /login", h.login)
|
mux.HandleFunc("POST /login", h.login)
|
||||||
mux.HandleFunc("POST /logout", h.logout)
|
mux.HandleFunc("POST /logout", h.logout)
|
||||||
@@ -118,15 +111,15 @@ func staticHandler() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// authed reports whether the request carries a valid session cookie.
|
// authed reports whether the request carries a valid session cookie.
|
||||||
func (h *webHandler) authed(r *http.Request) bool {
|
func (h *Handler) authed(r *http.Request) bool {
|
||||||
c, err := r.Cookie(sessionCookieName)
|
c, err := r.Cookie(session.CookieName)
|
||||||
return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli())
|
return err == nil && session.Verify(h.key, c.Value, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireSession guards the fragment endpoints. It answers 401 rather than
|
// requireSession guards the fragment endpoints. It answers 401 rather than
|
||||||
// redirecting, because htmx swaps whatever body it receives into the page and a
|
// redirecting, because htmx swaps whatever body it receives into the page and a
|
||||||
// redirected login page would be spliced into the card list.
|
// redirected login page would be spliced into the card list.
|
||||||
func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
func (h *Handler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.authed(r) {
|
if !h.authed(r) {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
@@ -136,7 +129,7 @@ func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) {
|
func (h *Handler) render(w http.ResponseWriter, status int, name string, data any) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
@@ -148,7 +141,7 @@ func (h *webHandler) render(w http.ResponseWriter, status int, name string, data
|
|||||||
// index renders the list, or the login page when there is no session. The login
|
// index renders the list, or the login page when there is no session. The login
|
||||||
// page is served at / with status 200 rather than as a redirect to a separate
|
// page is served at / with status 200 rather than as a redirect to a separate
|
||||||
// URL: one page, no redirect loop to reason about.
|
// URL: one page, no redirect loop to reason about.
|
||||||
func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
|
||||||
if !h.authed(r) {
|
if !h.authed(r) {
|
||||||
h.render(w, http.StatusOK, "login", loginView{})
|
h.render(w, http.StatusOK, "login", loginView{})
|
||||||
return
|
return
|
||||||
@@ -164,8 +157,8 @@ func (h *webHandler) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// filterBookmarks returns the subset keep reports true for, preserving order.
|
// filterBookmarks returns the subset keep reports true for, preserving order.
|
||||||
// It always returns a non-nil slice so an empty tab renders its empty state.
|
// It always returns a non-nil slice so an empty tab renders its empty state.
|
||||||
func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
func filterBookmarks(all []store.Bookmark, keep func(store.Bookmark) bool) []store.Bookmark {
|
||||||
out := []Bookmark{}
|
out := []store.Bookmark{}
|
||||||
for _, b := range all {
|
for _, b := range all {
|
||||||
if keep(b) {
|
if keep(b) {
|
||||||
out = append(out, b)
|
out = append(out, b)
|
||||||
@@ -181,25 +174,25 @@ func filterBookmarks(all []Bookmark, keep func(Bookmark) bool) []Bookmark {
|
|||||||
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
// in All, not in Updated, not in Favourites, and not in the recent strip. An
|
||||||
// archived favourite therefore shows only under Archived: Favourites means
|
// archived favourite therefore shows only under Archived: Favourites means
|
||||||
// "favourites I am currently reading".
|
// "favourites I am currently reading".
|
||||||
func (h *webHandler) buildListView(tab string) (listView, error) {
|
func (h *Handler) buildListView(tab string) (listView, error) {
|
||||||
all, err := h.store.List() // already ordered updated_at DESC
|
all, err := h.store.List() // already ordered updated_at DESC
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return listView{}, err
|
return listView{}, err
|
||||||
}
|
}
|
||||||
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
|
reading := filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusReading })
|
||||||
|
|
||||||
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
|
withNew := filterBookmarks(reading, func(b store.Bookmark) bool { return b.HasNewChapter() })
|
||||||
|
|
||||||
var items []Bookmark
|
var items []store.Bookmark
|
||||||
switch tab {
|
switch tab {
|
||||||
case "fav":
|
case "fav":
|
||||||
items = filterBookmarks(reading, func(b Bookmark) bool { return b.Favorite })
|
items = filterBookmarks(reading, func(b store.Bookmark) bool { return b.Favorite })
|
||||||
case "new":
|
case "new":
|
||||||
items = withNew
|
items = withNew
|
||||||
case "archived":
|
case "archived":
|
||||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusArchived })
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusArchived })
|
||||||
case "finished":
|
case "finished":
|
||||||
items = filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusFinished })
|
items = filterBookmarks(all, func(b store.Bookmark) bool { return b.Status == store.StatusFinished })
|
||||||
default:
|
default:
|
||||||
tab = "all"
|
tab = "all"
|
||||||
items = reading
|
items = reading
|
||||||
@@ -213,17 +206,17 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
|
|||||||
// It therefore disappears entirely on a library with nothing new. That is
|
// It therefore disappears entirely on a library with nothing new. That is
|
||||||
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
// the intended reading: an empty strip has nothing to say, and the ~240px it
|
||||||
// costs on a phone belongs to the list.
|
// costs on a phone belongs to the list.
|
||||||
var recent []Bookmark
|
var recent []store.Bookmark
|
||||||
if tab == "all" {
|
if tab == "all" {
|
||||||
recent = withNew
|
recent = withNew
|
||||||
if len(recent) > recentCount {
|
if len(recent) > RecentCount {
|
||||||
recent = recent[:recentCount]
|
recent = recent[:RecentCount]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiList(w http.ResponseWriter, r *http.Request) {
|
||||||
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
view, err := h.buildListView(r.URL.Query().Get("tab"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui list: %v", err)
|
log.Printf("ui list: %v", err)
|
||||||
@@ -253,7 +246,7 @@ func currentTab(r *http.Request) string {
|
|||||||
// mutation cannot leave them describing the library as it was before the tap.
|
// mutation cannot leave them describing the library as it was before the tap.
|
||||||
// The key is in here because it is tab-shaped too: archived and finished swap
|
// The key is in here because it is tab-shaped too: archived and finished swap
|
||||||
// Archive for Restore.
|
// Archive for Restore.
|
||||||
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
func (h *Handler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
||||||
view.OOB = true
|
view.OOB = true
|
||||||
for _, name := range []string{"recent", "newcount", "keyrow"} {
|
for _, name := range []string{"recent", "newcount", "keyrow"} {
|
||||||
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
|
||||||
@@ -266,7 +259,7 @@ func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
|
|||||||
|
|
||||||
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
// refreshChrome rebuilds the chrome for the reader's current tab after a
|
||||||
// mutation and appends it to the response.
|
// mutation and appends it to the response.
|
||||||
func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
||||||
view, err := h.buildListView(currentTab(r))
|
view, err := h.buildListView(currentTab(r))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui chrome: %v", err)
|
log.Printf("ui chrome: %v", err)
|
||||||
@@ -275,9 +268,9 @@ func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.writeChromeOOB(w, view)
|
h.writeChromeOOB(w, view)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := clientIP(r)
|
ip := session.ClientIP(r)
|
||||||
if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 {
|
if wait := h.limiter.RetryAfter(ip, time.Now()); wait > 0 {
|
||||||
secs := int(wait.Seconds()) + 1
|
secs := int(wait.Seconds()) + 1
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||||
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
h.render(w, http.StatusTooManyRequests, "login", loginView{
|
||||||
@@ -293,38 +286,38 @@ func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
got := r.PostFormValue("password")
|
got := r.PostFormValue("password")
|
||||||
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 {
|
||||||
h.limiter.fail(ip, time.Now())
|
h.limiter.Fail(ip, time.Now())
|
||||||
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.limiter.reset(ip)
|
h.limiter.Reset(ip)
|
||||||
setSessionCookie(w, r, h.key)
|
session.SetCookie(w, r, h.key)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
clearSessionCookie(w, r)
|
session.ClearCookie(w, r)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadForMutation fetches the row a mutation targets, writing the error
|
// loadForMutation fetches the row a mutation targets, writing the error
|
||||||
// response itself when there is nothing to mutate.
|
// response itself when there is nothing to mutate.
|
||||||
func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
|
func (h *Handler) loadForMutation(w http.ResponseWriter, r *http.Request) (store.Bookmark, bool) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
return Bookmark{}, false
|
return store.Bookmark{}, false
|
||||||
}
|
}
|
||||||
b, ok, err := h.store.Get(key)
|
b, ok, err := h.store.Get(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui get %q: %v", key, err)
|
log.Printf("ui get %q: %v", key, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return Bookmark{}, false
|
return store.Bookmark{}, false
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return Bookmark{}, false
|
return store.Bookmark{}, false
|
||||||
}
|
}
|
||||||
return b, true
|
return b, true
|
||||||
}
|
}
|
||||||
@@ -338,7 +331,7 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
|
|||||||
// state is the feedback for the tap. The strip and the badge are not: they
|
// state is the feedback for the tap. The strip and the badge are not: they
|
||||||
// describe the whole library, so they are rebuilt out of band on every
|
// describe the whole library, so they are rebuilt out of band on every
|
||||||
// mutation, at the cost of one extra list read per toggle.
|
// mutation, at the cost of one extra list read per toggle.
|
||||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
|
func (h *Handler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b store.Bookmark) {
|
||||||
stored, err := h.store.Upsert(b)
|
stored, err := h.store.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui upsert %q: %v", b.Key, err)
|
log.Printf("ui upsert %q: %v", b.Key, err)
|
||||||
@@ -351,7 +344,7 @@ func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b
|
|||||||
|
|
||||||
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
||||||
// Upsert keeps the stored updated_at and the list does not reorder.
|
// Upsert keeps the stored updated_at and the list does not reorder.
|
||||||
func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -367,7 +360,7 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
// last_chapter_num is untouched, so Upsert keeps the stored updated_at and the
|
||||||
// list does not reorder.
|
// list does not reorder.
|
||||||
func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -377,7 +370,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch s := r.PostFormValue("status"); s {
|
switch s := r.PostFormValue("status"); s {
|
||||||
case statusReading, statusArchived, statusFinished:
|
case store.StatusReading, store.StatusArchived, store.StatusFinished:
|
||||||
b.Status = s
|
b.Status = s
|
||||||
default:
|
default:
|
||||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||||
@@ -398,7 +391,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
|
||||||
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
|
||||||
// to "45") behind a frozen updated_at.
|
// to "45") behind a frozen updated_at.
|
||||||
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -425,7 +418,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
// uiDelete removes the row and answers with an empty body, which htmx swaps in
|
||||||
// place of the card — removing it from the page.
|
// place of the card — removing it from the page.
|
||||||
func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
key := r.PathValue("key")
|
key := r.PathValue("key")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
http.Error(w, "missing key", http.StatusBadRequest)
|
http.Error(w, "missing key", http.StatusBadRequest)
|
||||||
+35
-28
@@ -11,6 +11,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/api"
|
||||||
|
"mangabm/backend/internal/httpmw"
|
||||||
|
"mangabm/backend/internal/latest"
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
|
"mangabm/backend/internal/userscript"
|
||||||
|
"mangabm/backend/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all runtime settings, sourced from environment variables.
|
// Config holds all runtime settings, sourced from environment variables.
|
||||||
@@ -149,22 +156,22 @@ func loadConfig() Config {
|
|||||||
// newRouter wires routes and middleware. CORS is the outermost layer so
|
// newRouter wires routes and middleware. CORS is the outermost layer so
|
||||||
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
||||||
// /healthz is public.
|
// /healthz is public.
|
||||||
func newRouter(store *Store, cfg Config) http.Handler {
|
func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", healthz)
|
mux.HandleFunc("GET /healthz", api.Healthz)
|
||||||
|
|
||||||
// Outside withAuth (the updater sends no Authorization header) and outside
|
// Outside httpmw.Auth (the updater sends no Authorization header) and
|
||||||
// the WEB_PASSWORD gate (the script must be installable either way). The
|
// outside the WEB_PASSWORD gate (the script must be installable either
|
||||||
// path segment carries the token instead.
|
// way). The path segment carries the token instead.
|
||||||
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath))
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
|
||||||
|
|
||||||
h := &bookmarkHandler{store: store}
|
h := &api.Handler{Store: s}
|
||||||
protected := http.NewServeMux()
|
protected := http.NewServeMux()
|
||||||
protected.HandleFunc("GET /bookmarks", h.list)
|
protected.HandleFunc("GET /bookmarks", h.List)
|
||||||
protected.HandleFunc("PUT /bookmarks/{key}", h.put)
|
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
|
||||||
protected.HandleFunc("DELETE /bookmarks/{key}", h.delete)
|
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
|
||||||
|
|
||||||
auth := withAuth(cfg.Token, protected)
|
auth := httpmw.Auth(cfg.Token, protected)
|
||||||
mux.Handle("/bookmarks", auth)
|
mux.Handle("/bookmarks", auth)
|
||||||
mux.Handle("/bookmarks/", auth)
|
mux.Handle("/bookmarks/", auth)
|
||||||
|
|
||||||
@@ -172,14 +179,14 @@ func newRouter(store *Store, cfg Config) http.Handler {
|
|||||||
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
||||||
// exposing an unprotected list.
|
// exposing an unprotected list.
|
||||||
if cfg.WebPassword != "" {
|
if cfg.WebPassword != "" {
|
||||||
web, err := newWebHandler(store, cfg)
|
wh, err := web.New(s, cfg.Token, cfg.WebPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web handler: %v", err)
|
log.Fatalf("web handler: %v", err)
|
||||||
}
|
}
|
||||||
web.register(mux)
|
wh.Register(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
return withCORS(cfg.AllowedOrigins, withGzip(guardEmptyUserscriptToken(mux)))
|
return httpmw.CORS(cfg.AllowedOrigins, httpmw.Gzip(guardEmptyUserscriptToken(mux)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
||||||
@@ -203,22 +210,22 @@ func main() {
|
|||||||
log.Fatal("API_TOKEN is required")
|
log.Fatal("API_TOKEN is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
store, err := OpenStore(cfg.DBPath)
|
s, err := store.Open(cfg.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("open store: %v", err)
|
log.Fatalf("open store: %v", err)
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer s.Close()
|
||||||
|
|
||||||
// The poller is off the request path entirely: if it cannot start, the
|
// The poller is off the request path entirely: if it cannot start, the
|
||||||
// service still serves bookmarks and the userscript still captures latest
|
// service still serves bookmarks and the userscript still captures latest
|
||||||
// chapters on its own.
|
// chapters on its own.
|
||||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||||
defer stopPoll()
|
defer stopPoll()
|
||||||
startLatestPoller(pollCtx, store, cfg.LatestPoll)
|
startLatestPoller(pollCtx, s, cfg.LatestPoll)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: newRouter(store, cfg),
|
Handler: newRouter(s, cfg),
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,24 +255,24 @@ func main() {
|
|||||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||||
// feature going missing degrades the service to userscript-only latest-chapter
|
// feature going missing degrades the service to userscript-only latest-chapter
|
||||||
// tracking, which is exactly how it behaved before.
|
// tracking, which is exactly how it behaved before.
|
||||||
func startLatestPoller(ctx context.Context, store *Store, cfg LatestPoll) {
|
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
log.Println("latest-chapter poller: disabled by config")
|
log.Println("latest-chapter poller: disabled by config")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f, err := newTLSFetcher()
|
f, err := latest.NewTLSFetcher()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := &latestPoller{
|
p := &latest.Poller{
|
||||||
store: store,
|
Store: s,
|
||||||
fetch: f,
|
Fetch: f,
|
||||||
now: time.Now,
|
Now: time.Now,
|
||||||
cooldown: cfg.Cooldown,
|
Cooldown: cfg.Cooldown,
|
||||||
interval: cfg.Interval,
|
Interval: cfg.Interval,
|
||||||
stagger: cfg.Stagger,
|
Stagger: cfg.Stagger,
|
||||||
batch: cfg.Batch,
|
Batch: cfg.Batch,
|
||||||
}
|
}
|
||||||
go p.Run(ctx)
|
go p.Run(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadLatestPollDefaults(t *testing.T) {
|
func TestLoadLatestPollDefaults(t *testing.T) {
|
||||||
@@ -148,7 +150,7 @@ func TestPutStatusValidation(t *testing.T) {
|
|||||||
if tc.want != http.StatusOK {
|
if tc.want != http.StatusOK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var got Bookmark
|
var got store.Bookmark
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
@@ -187,7 +189,7 @@ func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var got Bookmark
|
var got store.Bookmark
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||||
t.Fatalf("decode: %v", err)
|
t.Fatalf("decode: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-85
@@ -10,6 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mangabm/backend/internal/session"
|
||||||
|
"mangabm/backend/internal/store"
|
||||||
|
"mangabm/backend/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testPassword = "hunter2"
|
const testPassword = "hunter2"
|
||||||
@@ -22,22 +26,22 @@ func webConfig() Config {
|
|||||||
|
|
||||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||||
// can seed rows and assert on what the handlers wrote back.
|
// can seed rows and assert on what the handlers wrote back.
|
||||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) {
|
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
|
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("OpenStore: %v", err)
|
t.Fatalf("store.Open: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { store.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
return newRouter(store, cfg), store
|
return newRouter(st, cfg), st
|
||||||
}
|
}
|
||||||
|
|
||||||
// sessionCookie returns a cookie a handler will accept for cfg's API token.
|
// sessionCookie returns a cookie a handler will accept for cfg's API token.
|
||||||
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
|
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return &http.Cookie{
|
return &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: session.CookieName,
|
||||||
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
Value: session.Sign(session.Key(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +60,8 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
|
|||||||
|
|
||||||
func TestIndexWithSessionShowsList(t *testing.T) {
|
func TestIndexWithSessionShowsList(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
if _, err := store.Upsert(Bookmark{
|
if _, err := st.Upsert(store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
@@ -90,8 +94,8 @@ func TestLoginSuccessSetsCookie(t *testing.T) {
|
|||||||
t.Fatalf("POST /login status = %d, want 303", rr.Code)
|
t.Fatalf("POST /login status = %d, want 303", rr.Code)
|
||||||
}
|
}
|
||||||
cookies := rr.Result().Cookies()
|
cookies := rr.Result().Cookies()
|
||||||
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
|
if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
|
||||||
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName)
|
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,14 +126,14 @@ func TestLoginRateLimited(t *testing.T) {
|
|||||||
srv.ServeHTTP(rr, req)
|
srv.ServeHTTP(rr, req)
|
||||||
return rr
|
return rr
|
||||||
}
|
}
|
||||||
for i := 0; i < loginMaxFailures; i++ {
|
for i := 0; i < session.MaxFailures; i++ {
|
||||||
if code := post().Code; code != http.StatusUnauthorized {
|
if code := post().Code; code != http.StatusUnauthorized {
|
||||||
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
|
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rr := post()
|
rr := post()
|
||||||
if rr.Code != http.StatusTooManyRequests {
|
if rr.Code != http.StatusTooManyRequests {
|
||||||
t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code)
|
t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
|
||||||
}
|
}
|
||||||
if after := rr.Header().Get("Retry-After"); after == "" {
|
if after := rr.Header().Get("Retry-After"); after == "" {
|
||||||
t.Fatal("429 response has no Retry-After header")
|
t.Fatal("429 response has no Retry-After header")
|
||||||
@@ -202,9 +206,9 @@ func TestStaticAssetsServed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seed inserts one bookmark and returns it as stored.
|
// seed inserts one bookmark and returns it as stored.
|
||||||
func seed(t *testing.T, store *Store, b Bookmark) Bookmark {
|
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
stored, err := store.Upsert(b)
|
stored, err := st.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
@@ -245,8 +249,8 @@ func TestUIRoutesRequireSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
UpdatedAt: 1_000_000,
|
UpdatedAt: 1_000_000,
|
||||||
@@ -258,7 +262,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
t.Fatalf("favorite status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
after, ok, err := store.Get("asura:solo")
|
after, ok, err := st.Get("asura:solo")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
|
||||||
}
|
}
|
||||||
@@ -276,7 +280,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
// Toggling again turns it back off.
|
// Toggling again turns it back off.
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
|
||||||
back, _, _ := store.Get("asura:solo")
|
back, _, _ := st.Get("asura:solo")
|
||||||
if back.Favorite {
|
if back.Favorite {
|
||||||
t.Fatal("Favorite = true after a second toggle, want false")
|
t.Fatal("Favorite = true after a second toggle, want false")
|
||||||
}
|
}
|
||||||
@@ -294,8 +298,8 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
|
|||||||
// selector, just a regression guard against reintroducing the bare-id form.
|
// selector, just a regression guard against reintroducing the bare-id form.
|
||||||
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
UpdatedAt: 1_000_000,
|
UpdatedAt: 1_000_000,
|
||||||
@@ -320,8 +324,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
|
||||||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||||
@@ -335,7 +339,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
t.Fatalf("chapter override status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
after, ok, err := store.Get("asura:solo")
|
after, ok, err := st.Get("asura:solo")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
t.Fatalf("Get after override: %v ok=%v", err, ok)
|
||||||
}
|
}
|
||||||
@@ -355,8 +359,8 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
|
|||||||
|
|
||||||
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
|
||||||
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||||
@@ -375,7 +379,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
|||||||
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
after, ok, err := store.Get("asura:solo")
|
after, ok, err := st.Get("asura:solo")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
|
||||||
}
|
}
|
||||||
@@ -395,8 +399,8 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
|||||||
|
|
||||||
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
@@ -409,7 +413,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
|
|||||||
if rr.Code != http.StatusBadRequest {
|
if rr.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
}
|
}
|
||||||
after, _, _ := store.Get("asura:solo")
|
after, _, _ := st.Get("asura:solo")
|
||||||
if after.LastChapterNum != 45 {
|
if after.LastChapterNum != 45 {
|
||||||
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
|
||||||
}
|
}
|
||||||
@@ -440,8 +444,8 @@ func TestMutationsOnMissingKey(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIDeleteRemovesRow(t *testing.T) {
|
func TestUIDeleteRemovesRow(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
Title: "Solo Leveling", UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
@@ -460,19 +464,19 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
|||||||
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
|
||||||
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
t.Fatalf("delete body = %q, want the out-of-band badge", body)
|
||||||
}
|
}
|
||||||
if _, ok, _ := store.Get("asura:solo"); ok {
|
if _, ok, _ := st.Get("asura:solo"); ok {
|
||||||
t.Fatal("row still present after delete")
|
t.Fatal("row still present after delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIListFavouritesTab(t *testing.T) {
|
func TestUIListFavouritesTab(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
|
||||||
})
|
})
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||||||
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
@@ -493,14 +497,14 @@ func TestUIListFavouritesTab(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIListNewTab(t *testing.T) {
|
func TestUIListNewTab(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||||
Title: "Solo Leveling", LastChapterNum: 10,
|
Title: "Solo Leveling", LastChapterNum: 10,
|
||||||
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
|
||||||
UpdatedAt: 2_000_000,
|
UpdatedAt: 2_000_000,
|
||||||
})
|
})
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
|
||||||
Title: "Tower of God", LastChapterNum: 5,
|
Title: "Tower of God", LastChapterNum: 5,
|
||||||
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
|
||||||
@@ -524,22 +528,22 @@ func TestUIListNewTab(t *testing.T) {
|
|||||||
// seedStatusRows puts one series in each bucket, the archived one also
|
// seedStatusRows puts one series in each bucket, the archived one also
|
||||||
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
// favourited and with a new chapter out, so a leak into any reading-bucket tab
|
||||||
// shows up as a failure rather than passing by accident.
|
// shows up as a failure rather than passing by accident.
|
||||||
func seedStatusRows(t *testing.T, store *Store) {
|
func seedStatusRows(t *testing.T, st *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
// floatPtr already exists in store_test.go — same package, reuse it.
|
// floatPtr already exists in store_test.go — same package, reuse it.
|
||||||
rows := []Bookmark{
|
rows := []store.Bookmark{
|
||||||
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
|
||||||
Status: statusReading, LastChapterNum: 10, Favorite: true,
|
Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
|
||||||
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
|
||||||
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
|
||||||
Status: statusArchived, LastChapterNum: 5, Favorite: true,
|
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
|
||||||
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
|
||||||
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
|
||||||
Status: statusFinished, LastChapterNum: 200, Favorite: true},
|
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
|
||||||
}
|
}
|
||||||
for _, b := range rows {
|
for _, b := range rows {
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
if _, err := store.Upsert(b); err != nil {
|
if _, err := st.Upsert(b); err != nil {
|
||||||
t.Fatalf("seed %s: %v", b.Key, err)
|
t.Fatalf("seed %s: %v", b.Key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,8 +551,8 @@ func seedStatusRows(t *testing.T, store *Store) {
|
|||||||
|
|
||||||
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
func TestTabsShowOnlyTheirBucket(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
tab string
|
tab string
|
||||||
@@ -604,16 +608,16 @@ func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
|
|||||||
// updated_at-ordered list below it does not already say — and only on All.
|
// updated_at-ordered list below it does not already say — and only on All.
|
||||||
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store) // ReadingOne is at 10 with 11 out; the rest are not reading
|
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
|
||||||
|
|
||||||
// A reading series that is caught up has nothing waiting, so it stays out.
|
// A reading series that is caught up has nothing waiting, so it stays out.
|
||||||
caught := Bookmark{
|
caught := store.Bookmark{
|
||||||
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
|
||||||
Status: statusReading, LastChapterNum: 40, LatestChapter: "40",
|
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
|
||||||
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(caught); err != nil {
|
if _, err := st.Upsert(caught); err != nil {
|
||||||
t.Fatalf("seed %s: %v", caught.Key, err)
|
t.Fatalf("seed %s: %v", caught.Key, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,12 +637,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Nothing new anywhere: the strip has nothing to say and does not render.
|
// Nothing new anywhere: the strip has nothing to say and does not render.
|
||||||
reading, _, err := store.Get("asura:reading")
|
reading, _, err := st.Get("asura:reading")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Get: %v", err)
|
t.Fatalf("Get: %v", err)
|
||||||
}
|
}
|
||||||
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
|
||||||
if _, err := store.Upsert(reading); err != nil {
|
if _, err := st.Upsert(reading); err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
// The section still ships (an out-of-band swap needs the id to exist) but
|
// The section still ships (an out-of-band swap needs the id to exist) but
|
||||||
@@ -652,23 +656,23 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The strip never grows past recentCount, however many series are waiting.
|
// The strip never grows past web.RecentCount, however many series are waiting.
|
||||||
func TestRecentStripCapped(t *testing.T) {
|
func TestRecentStripCapped(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
for i := 0; i <= recentCount; i++ {
|
for i := 0; i <= web.RecentCount; i++ {
|
||||||
b := Bookmark{
|
b := store.Bookmark{
|
||||||
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
|
||||||
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
|
||||||
Status: statusReading, LastChapterNum: 1, LatestChapter: "2",
|
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
|
||||||
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
|
||||||
}
|
}
|
||||||
if _, err := store.Upsert(b); err != nil {
|
if _, err := st.Upsert(b); err != nil {
|
||||||
t.Fatalf("seed %s: %v", b.Key, err)
|
t.Fatalf("seed %s: %v", b.Key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount {
|
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
|
||||||
t.Fatalf("strip rendered %d cards, want %d", got, recentCount)
|
t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,14 +690,14 @@ func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string)
|
|||||||
|
|
||||||
func TestUIStatusSetsBucket(t *testing.T) {
|
func TestUIStatusSetsBucket(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
for _, want := range []string{statusArchived, statusFinished, statusReading} {
|
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
|
||||||
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
|
||||||
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
|
||||||
}
|
}
|
||||||
b, ok, err := store.Get("asura:reading")
|
b, ok, err := st.Get("asura:reading")
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
t.Fatalf("Get: ok=%v err=%v", ok, err)
|
||||||
}
|
}
|
||||||
@@ -705,21 +709,21 @@ func TestUIStatusSetsBucket(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
func TestUIStatusRejectsUnknownValue(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400", rr.Code)
|
t.Fatalf("status = %d, want 400", rr.Code)
|
||||||
}
|
}
|
||||||
b, _, _ := store.Get("asura:reading")
|
b, _, _ := st.Get("asura:reading")
|
||||||
if b.Status != statusReading {
|
if b.Status != store.StatusReading {
|
||||||
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
t.Fatalf("stored status = %q, want it untouched", b.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIStatusRequiresSession(t *testing.T) {
|
func TestUIStatusRequiresSession(t *testing.T) {
|
||||||
srv, store := newWebTestServer(t, webConfig())
|
srv, st := newWebTestServer(t, webConfig())
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
|
||||||
strings.NewReader("status=archived"))
|
strings.NewReader("status=archived"))
|
||||||
@@ -734,15 +738,15 @@ func TestUIStatusRequiresSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
func TestUIStatusDoesNotReorderList(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
before, _, _ := store.Get("asura:reading")
|
before, _, _ := st.Get("asura:reading")
|
||||||
time.Sleep(2 * time.Millisecond)
|
time.Sleep(2 * time.Millisecond)
|
||||||
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
|
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d", rr.Code)
|
t.Fatalf("status = %d", rr.Code)
|
||||||
}
|
}
|
||||||
after, _, _ := store.Get("asura:reading")
|
after, _, _ := st.Get("asura:reading")
|
||||||
if after.UpdatedAt != before.UpdatedAt {
|
if after.UpdatedAt != before.UpdatedAt {
|
||||||
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
|
||||||
}
|
}
|
||||||
@@ -750,8 +754,8 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
|
|||||||
|
|
||||||
func TestCardShowsStatusControls(t *testing.T) {
|
func TestCardShowsStatusControls(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
tab string
|
tab string
|
||||||
@@ -788,8 +792,8 @@ func TestCardShowsStatusControls(t *testing.T) {
|
|||||||
|
|
||||||
func TestAppRendersNewTabs(t *testing.T) {
|
func TestAppRendersNewTabs(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seedStatusRows(t, store)
|
seedStatusRows(t, st)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.AddCookie(sessionCookie(t, cfg))
|
req.AddCookie(sessionCookie(t, cfg))
|
||||||
@@ -807,10 +811,10 @@ func TestAppRendersNewTabs(t *testing.T) {
|
|||||||
// outside the swapped card, so nothing else would correct them.
|
// outside the swapped card, so nothing else would correct them.
|
||||||
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
||||||
cfg := webConfig()
|
cfg := webConfig()
|
||||||
srv, store := newWebTestServer(t, cfg)
|
srv, st := newWebTestServer(t, cfg)
|
||||||
seed(t, store, Bookmark{
|
seed(t, st, store.Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
|
||||||
Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
|
||||||
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -820,7 +824,7 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
|
||||||
url.Values{"status": {statusArchived}})
|
url.Values{"status": {store.StatusArchived}})
|
||||||
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, req)
|
srv.ServeHTTP(rr, req)
|
||||||
|
|||||||
Reference in New Issue
Block a user