From c601ef54d56189089fc47c8a113ffda219c06e45 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Sun, 26 Jul 2026 13:56:34 +0700 Subject: [PATCH] chore: untrack plans and docs/superpowers dirs Keep local planning docs out of the repo; add to .gitignore. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + .../specs/2026-07-25-web-ui-design.md | 263 -- ...2026-07-25-bookmark-implementation-plan.md | 286 -- ...26-07-25-bookmark-list-favorites-design.md | 231 -- .../2026-07-25-web-ui-implementation-plan.md | 2330 ----------------- plans/mangaBookmark.md | 142 - 6 files changed, 2 insertions(+), 3252 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-25-web-ui-design.md delete mode 100644 plans/2026-07-25-bookmark-implementation-plan.md delete mode 100644 plans/2026-07-25-bookmark-list-favorites-design.md delete mode 100644 plans/2026-07-25-web-ui-implementation-plan.md delete mode 100644 plans/mangaBookmark.md diff --git a/.gitignore b/.gitignore index 5732700..2a2eb8a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ backend/server .playwright-mcp/ graphify-out/ +plans/ +docs/superpowers/ diff --git a/docs/superpowers/specs/2026-07-25-web-ui-design.md b/docs/superpowers/specs/2026-07-25-web-ui-design.md deleted file mode 100644 index e82cd9d..0000000 --- a/docs/superpowers/specs/2026-07-25-web-ui-design.md +++ /dev/null @@ -1,263 +0,0 @@ -# Web UI Design — browser-accessible bookmark list - -Date: 2026-07-25 -Branch: `feat/web-ui` -Status: approved - -## 1. Problem - -The bookmark list is reachable only from inside the userscript, which means it -exists only on pages of asurascans.com and demonicscans.org. There is no way to -open the list on its own — from a desktop, from a phone home screen, or when -neither manga site is loaded. - -This adds a website, served by the existing Go backend, that renders the same -list with the same actions. - -## 2. Scope - -In scope: - -- Password-gated website showing all bookmarks, ordered by `updated_at DESC`. -- All / Favourites tabs. -- A "Continue reading" strip of the five most recent series. -- Per-series actions: continue reading, toggle favourite, manually override the - read chapter, delete. -- Client-side title search. - -Out of scope: - -- A chapter-level reading-event log. The list order already answers "what did I - read last". A `reading_events` table is a separate future spec. -- Any change to `GET /bookmarks`, `PUT /bookmarks/{key}`, - `DELETE /bookmarks/{key}`, or to the userscript. Those stay exactly as they - are, so the website cannot regress phone reading. -- Offline support. The userscript keeps its `localStorage` cache; the website is - server-rendered and requires connectivity. - -## 3. Architecture - -One binary, one container, one SQLite file. The website is added to the running -service rather than deployed alongside it. - -``` -Bromite userscript ──bearer──> /bookmarks* ─┐ - ├─> Store ──> SQLite -Browser (phone/desktop) ──cookie──> / , /ui/*┘ -``` - -New files under `backend/`: - -| File | Purpose | -| --- | --- | -| `web.go` | Page and HTML-fragment handlers | -| `session.go` | Cookie signing/verification, login rate limit | -| `templates/*.html` | `go:embed`-ed templates | -| `static/*` | `go:embed`-ed `style.css`, `htmx.min.js`, `filter.js` | - -Templates and static assets are embedded, so the image stays a single static -binary on distroless and `CGO_ENABLED=0` still holds. - -### 3.1 Routes - -| Route | Auth | Response | -| --- | --- | --- | -| `GET /` | session | List page; login page when no valid session | -| `POST /login` | none | Sets cookie, `303` to `/` | -| `POST /logout` | session | Clears cookie, `303` to `/` | -| `GET /static/{path...}` | none | Embedded asset, long-lived cache header | -| `GET /ui/list?tab=all\|fav` | session | List fragment | -| `POST /ui/bookmarks/{key}/favorite` | session | Re-rendered card | -| `POST /ui/bookmarks/{key}/chapter` | session | Re-rendered card | -| `DELETE /ui/bookmarks/{key}` | session | `200` with empty body | - -`GET /` returns the login page with status `200` rather than redirecting to a -separate login URL. One page, no redirect loop to reason about. - -`/ui/*` returns HTML fragments, not JSON, and is authenticated by cookie. It is -kept separate from `/bookmarks*` deliberately: that API is JSON, authenticated -by bearer token, and consumed by the userscript. Sharing one route for two -representations and two auth schemes would couple the website's needs to the -userscript's contract. - -Middleware layering is unchanged at the top: `withCORS` stays outermost. -`/bookmarks*` keeps `withAuth` (bearer). `/` and `/ui/*` are wrapped in a new -`withSession`. Web routes are same-origin, so CORS is a no-op for them. - -### 3.2 Store change - -`Store` gains one method: - -```go -func (s *Store) Get(key string) (Bookmark, bool, error) -``` - -Every UI mutation is read-modify-write: load the row, change the single field, -call the existing `Upsert`, then render the row `Upsert` returns. This reuses -the conditional-`updated_at` rule rather than reimplementing it — favouriting -does not reorder the list, a chapter override does. Rendering the returned row -(not the request payload) is the same contract `PUT /bookmarks/{key}` already -follows. - -Not adding `Get` and instead patching columns directly would duplicate the -`updated_at` decision in a second place. That rule has already caused one bug; -it lives in exactly one function. - -## 4. Session authentication - -### 4.1 Configuration - -New environment variable `WEB_PASSWORD`. When it is empty the web routes are not -registered at all and `/` returns `404`. Fail-closed: a deployment that forgets -the variable exposes nothing. - -The password is stored in plaintext in `.env`, alongside `API_TOKEN`. This is a -single-user deployment with no user table, and anyone who can read `.env` -already holds the API token, so hashing it protects nothing that is not already -lost. `.env` is gitignored and the repository is private and self-hosted. - -### 4.2 Cookie - -Name `mangabm_session`. Value: - -``` - "." base64url(HMAC-SHA256(, key)) -key = SHA256(API_TOKEN || 0x00 || WEB_PASSWORD || "mangabm-web-session-v1") -``` - -Stateless: no session table, sessions survive restarts, and rotating either -`API_TOKEN` or `WEB_PASSWORD` invalidates every session at once. Both secrets -are bound in so that changing the password actually logs existing browsers out; -the `0x00` separates the two variable-length secrets so no pair of different -inputs can concatenate to the same string. - -Attributes: `HttpOnly`, `SameSite=Lax`, `Path=/`, `Max-Age` 60 days so the phone -stays logged in across long gaps. `Secure` is set when `r.TLS != nil` or -`X-Forwarded-Proto: https`, and omitted otherwise so `http://localhost` -development can still log in. - -Verification order is fixed: split on `.`, parse the expiry, reject if it is in -the past, and only then `subtle.ConstantTimeCompare` the HMAC. Comparing before -validating the shape leaks structure through error timing. - -The password comparison at login is also constant-time. - -### 4.3 CSRF - -All mutations are `POST` or `DELETE` and carry a `SameSite=Lax` cookie, which a -cross-site form post does not send. No separate CSRF token. - -### 4.4 Login rate limit - -In-memory, no persistence. Ten failed attempts within a rolling 20-minute window -for one client IP return `429` with a `Retry-After` header. Entries expire on -their own; there is no permanent ban and no unlock step. A successful login -clears that IP's counter. - -Client IP is the **rightmost** entry of `X-Forwarded-For`. Traefik appends the -peer address it observed to whatever the client sent, so the leftmost entry is -attacker-controlled and the rightmost is not. `r.RemoteAddr` is unusable here — -behind Traefik it is always the proxy's container address, which would turn a -per-IP limit into a global one. - -Known and accepted limitation: behind carrier-grade NAT the limit is shared with -every other subscriber on the same public address, so a stranger exhausting the -budget can lock the owner out for up to 20 minutes. The window self-heals and -ten attempts is generous for a mistyped password, so this is preferred over -removing the limit. - -## 5. Interface - -Mobile-first. Dark by default, honouring `prefers-color-scheme`. Tap targets at -least 44px. At viewports 900px and wider the card list becomes a 2–3 column -grid. - -### 5.1 Login page - -A centered card with a single password field (`type="password"`, -`autocomplete="current-password"`) and a submit button. Failed attempts render -an inline error. A rate-limited attempt renders how long to wait. - -### 5.2 List page - -``` -┌──────────────────────────┐ -│ mangaBookmark [logout]│ -│ [ search… ] │ -│ ( All ) ( Favourites ) │ -├──────────────────────────┤ -│ Continue reading │ -│ [card][card][card] → │ -├──────────────────────────┤ -│ ┌────┬───────────────┐ │ -│ │cvr │ Title ASURA│ │ -│ │ │ Ch 45 · NEW 47│ │ -│ │ │ [Continue]★✎🗑│ │ -│ └────┴───────────────┘ │ -└──────────────────────────┘ -``` - -- The main list is ordered `updated_at DESC`. That ordering is the reading - history; no separate history view exists. -- "Continue reading" shows the top five of the same ordering in a horizontally - scrolling strip. -- A `NEW` badge appears when `latest_chapter_num` is present and greater than - `last_chapter_num`. -- **Continue** opens `last_chapter_url` in a new tab; it falls back to - `series_url` when no chapter URL is stored. -- The favourite control is an htmx `POST`; the swapped-in card shows the new - state. The list does not reorder. -- The chapter override expands an inline number input on the card. Submitting - forces `last_chapter` and `last_chapter_num` to the entered value, which does - move `updated_at` and therefore does reorder the list. -- Delete asks for confirmation, then htmx removes the card from the DOM. -- Search filters cards by title in the browser with roughly fifteen lines of - JavaScript. No request is made. -- The empty list renders a short message pointing at the userscript. - -### 5.3 Tabs - -Switching tabs issues `GET /ui/list?tab=…` and swaps the list container, -pushing the URL so the back button works. Favourites is the same list filtered -to `favorite = true`, in the same order. - -## 6. Testing - -`session_test.go`: - -- A signed cookie round-trips and verifies. -- An expired cookie is rejected. -- A cookie with a tampered HMAC is rejected. -- A cookie with a tampered expiry is rejected. -- A correct password logs in; a wrong one does not. -- Ten failures trip the limiter; the eleventh attempt returns `429`. -- A successful login clears the counter. -- The rightmost `X-Forwarded-For` entry is the one keyed on. - -`web_test.go`: - -- `GET /` without a cookie returns `200` and the login page. -- `/ui/*` without a cookie returns `401`. -- `/ui/list` with a cookie returns the list fragment; `?tab=fav` returns only - favourites. -- Toggling favourite leaves `updated_at` unchanged. -- A chapter override changes `updated_at`. -- Deleting removes the row. -- With `WEB_PASSWORD` empty, `/` returns `404`. - -Templates are parsed once at startup so a broken template fails the process -immediately rather than the first request. - -## 7. Deployment - -- `.env` and `.env.example` gain `WEB_PASSWORD`. -- `docker-compose.prod.yml` gains a second Traefik router label for - `manga.violetcrown.my.id` pointing at the same service on port 8080. Both - routers share one container; no second service, no second certificate - resolver. -- A DNS `A`/`AAAA` record for `manga.violetcrown.my.id`. -- `DEPLOY.md` gains a section covering the DNS record, the new variable, and - generating a password. - -`ALLOWED_ORIGINS` is untouched. The website is same-origin and never triggers -CORS; only the userscript's cross-origin calls do. diff --git a/plans/2026-07-25-bookmark-implementation-plan.md b/plans/2026-07-25-bookmark-implementation-plan.md deleted file mode 100644 index 69f160b..0000000 --- a/plans/2026-07-25-bookmark-implementation-plan.md +++ /dev/null @@ -1,286 +0,0 @@ -# Implementation plan: bookmark reorder, latest-chapter tracking, favorites - -## Context - -Design already approved and committed at -`plans/2026-07-25-bookmark-list-favorites-design.md` on branch -`feat/bookmark-list-favorites-latest`. It covers three requested userscript -features plus one incidental bug found while verifying feasibility live via -Playwright: - -1. Bookmark list reordered so the most-recently-progressed manga is first. -2. Show the latest *available* chapter for a manga, not just the last one - read — including a background same-origin refresh mechanism to get closer - to "live" without server-side polling (Cloudflare blocks that; confirmed - live, and confirmed no JSON API / RSS exists on either site to poll - instead). -3. A favorites mechanism (star toggle + tabs) that doesn't remove a manga - from the normal list. -4. `asuracomic.net` deep links now 301-redirect straight to the - `asurascans.com` homepage (path discarded) — a Cloudflare-edge redirect - confirmed live, with no client-side fix possible. Doc-only correction. - -This plan turns that design into concrete code changes against the actual -current backend (Go/SQLite) and userscript, informed by full reads of -`backend/store.go`, `backend/handlers.go`, `backend/store_test.go`, -`backend/main.go`, and the full 782-line -`userscript/manga-bookmark.user.js`. - -## Key design decision surfaced during planning - -Today `handlers.go`'s `put()` echoes back the client's decoded request -struct as the API response, not what was actually persisted. Once -`updated_at` is sometimes *not* bumped (this whole feature's core mechanic), -echoing the request struct back would return a **wrong** `updated_at` to the -caller on every no-bump write — silently breaking the ordering guarantee the -entire feature depends on, since the userscript's `syncUpsert`/mutation -helpers adopt whatever the server echoes back (`upsertLocal(saved)`) as the -new source of truth. **`Store.Upsert` must therefore return the row as -actually written (read back inside the same transaction), and `handlers.go` -must respond with that**, not the client's payload. This is a correctness -fix required by the design, not a new decision to re-litigate. - -Confirmed via the user: background latest-chapter refresh should fire on -Asura's SPA in-app navigation too (`onNavigate()`), not only true browser -page loads (`init()`) — more refresh opportunities on a client-routed site -that rarely does full reloads, still bounded by the same throttle/batch -limits. - -## Phase 1 — Backend (`backend/`), TDD - -### 1.1 Tests first — `backend/store_test.go` - -Add four tests (all go through the existing `newTestServer(t)` / -`httptest` pattern already used in this file, since there are no direct -`Store`-level unit tests in the current style): - -- **`TestUpsertConditionalUpdatedAt`** — table-driven: new bookmark (bumps), - unchanged progress (no bump), changed progress (bumps), favorite-only - change (no bump), latest-chapter-only change (no bump). Assert on the - `updated_at` returned by each PUT response. -- **`TestFavoriteRoundTrip`** — PUT `favorite: true`, GET list, assert it - round-trips. -- **`TestLatestChapterNullable`** — PUT without `latest_chapter_num`, assert - JSON response has `"latest_chapter_num":null`; PUT again with a value, - assert it round-trips. -- **`TestOpenStoreMigratesLegacySchema`** — hand-create the *old* (10-column) - schema in a temp DB file, seed one row, then call `OpenStore` on it and - assert the row survives with the new columns defaulting cleanly - (`favorite=false`, `latest_chapter=""`, `latest_chapter_num=nil`). This is - the safety net for the already-deployed production DB. - -Run `cd backend && go test ./...` — expect compile failures (red state is -correct/expected before 1.2). - -### 1.2 `backend/store.go` - -- **`Bookmark` struct**: add `Favorite bool `json:"favorite"``, - `LatestChapter string `json:"latest_chapter"``, - `LatestChapterNum *float64 `json:"latest_chapter_num"`` (nullable — only - this one needs to be a pointer, per the design doc's data-model table). -- **`schema`**: extend `CREATE TABLE IF NOT EXISTS` with - `favorite INTEGER NOT NULL DEFAULT 0`, - `latest_chapter TEXT NOT NULL DEFAULT ''`, `latest_chapter_num REAL` - (covers fresh installs only). -- **Idempotent migration for the already-deployed DB**: add a - `migrateColumns(db)` helper using `PRAGMA table_info(bookmarks)` to check - each new column's existence before running its `ALTER TABLE ... ADD - COLUMN` (SQLite has no `ADD COLUMN IF NOT EXISTS`). Call it in - `OpenStore` right after the existing `schema` exec succeeds, same - error-wrapping style as today. -- **Shared `scanBookmark` helper**: centralizes converting the `favorite` - `INTEGER` (0/1) to `bool` and the nullable `latest_chapter_num` `REAL` to - `*float64` via `sql.NullFloat64`, used by both `List()` and `Upsert()`'s - read-back. -- **`List()`**: extend the `SELECT` to the new columns, scan via - `scanBookmark`. -- **`Upsert(b Bookmark) (Bookmark, error)`** — signature changes to return - the stored row. Implementation: wrap in `db.Begin()`/`tx.Commit()` - (explicit "read exactly what I just wrote" guarantee rather than relying - on `SetMaxOpenConns(1)` staying 1 forever). The `INSERT ... ON CONFLICT - DO UPDATE SET` gets a `CASE` expression for `updated_at`: - ```sql - updated_at = CASE - WHEN bookmarks.last_chapter_num IS NOT excluded.last_chapter_num - THEN excluded.updated_at - ELSE bookmarks.updated_at - END - ``` - This is valid SQLite upsert syntax (bare column = pre-update row value, - `excluded.col` = proposed new row) and naturally handles "new row" for - free — `ON CONFLICT DO UPDATE` only fires on the update path, so a - genuinely new row goes through the plain `INSERT ... VALUES` and always - gets the fresh `updated_at`. After the exec, `SELECT` the row back inside - the same transaction and return it via `scanBookmark`. - -### 1.3 `backend/handlers.go` - -In `put()`: keep `b.UpdatedAt = time.Now().UnixMilli()` as a *candidate* -value (update its comment — it's no longer unconditionally authoritative), -then: -```go -stored, err := h.store.Upsert(b) -... -writeJSON(w, http.StatusOK, stored) -``` -No other changes — `Favorite`/`LatestChapter`/`LatestChapterNum` already -flow through untouched from the decoded body, which is correct (they're -fully client-set synced fields). `list()`/`delete()` unchanged. - -### 1.4 Green + build - -```bash -cd backend && go test ./... # all pass, including pre-existing TestBookmarkRoundTrip unmodified -cd backend && CGO_ENABLED=0 go build # static binary still builds -``` - -## Phase 2 — Userscript (`userscript/manga-bookmark.user.js`) - -No JS test harness in this repo — verification is manual (Phase 4). - -1. **Config constants** (near `CACHE_KEY`, ~line 24): `LASTCHECKED_KEY = - "mangabm:lastchecked"`, `LATEST_CHECK_THROTTLE_MS = 4 * 60 * 60 * 1000` - (4h), `LATEST_CHECK_BATCH = 1`. - -2. **Shared anchor extraction** so the exact same per-site chapter-matching - rule runs against both the live DOM and raw fetched HTML text (no HTML - parser available for the fetch path): `anchorsFromDocument(doc)` (via - `querySelectorAll("a[href]")`) and `anchorsFromHTML(html)` (regex-based - `...` extraction). Add near `meta()` (~line 34). - -3. **Per-adapter `latestChapterFromAnchors(anchors)`** added to both `asura` - and `demonic` adapter objects, implementing the regex rules from the - design doc (Asura: href matches `/chapter/([\d.]+)$/` AND text matches - `/Chapter\s+[\d.]+/i`, excluding the "First Chapter" quick-jump button; - Demonic: all `chaptered.php?manga=\d+&chapter=([\d.]+)` matches, take - max — no order assumption). Plus a `computeLatestChapter(site, anchors)` - dispatcher near `keyOf()`. - -4. **`mangabm:lastchecked` local helpers**: `loadLastChecked()` / - `saveLastChecked(map)`, parallel to existing `loadCache`/`saveCache` - (~line 154), storing `{ [bookmarkKey]: timestampMs }`. Client-local only, - never synced. - -5. **`applyLatestChapterIfChanged(existing, latest)`** (~near `syncUpsert`, - line 295): if `latest.num` differs from the bookmark's stored - `latest_chapter_num`, optimistically update local cache + render, then - `apiPut` with `updated_at: Date.now()` as a *candidate* — the backend - (Phase 1) decides whether to actually apply it, and the client adopts - whatever comes back via `upsertLocal(saved)`, same pattern the rest of - the file already uses. No client-side "don't reorder" logic needed - beyond that — the server is the single source of truth for it. Silent - on failure (no toast), per the design doc. - -6. **Live-page capture**: `maybeCaptureLatestOnSeriesPage()` — on a - `type: "series"` page for an already-bookmarked series, scan the live - DOM via `anchorsFromDocument` + `computeLatestChapter`, then - `applyLatestChapterIfChanged`. Hooked into `onNavigate()` (~line 633), - after the existing `maybeAutoUpdate()` call. - -7. **Background opportunistic refresh**: `backgroundRefreshLatest()` — get - `currentSite()` (which adapter matches `window.location`), pick - same-site bookmarks not checked within `LATEST_CHECK_THROTTLE_MS` - (oldest-checked-first), fetch+parse at most `LATEST_CHECK_BATCH` of them - via `fetch(bm.series_url).then(r => r.text())` → `anchorsFromHTML` → - `computeLatestChapter` → `applyLatestChapterIfChanged`. Mark each - attempted bookmark's `lastchecked` timestamp regardless of success/failure - (advances the throttle window either way, avoiding hammering a - consistently-failing fetch). Silent on failure. - **Hook into both `init()` and `onNavigate()`** (per user's confirmed - preference — more refresh opportunities on Asura's SPA navigation, same - throttle/batch caps prevent request bursts either way). - -8. **`toggleFavorite(key)`** (~near `setChapterManual`, line 293): flips - `favorite`, optimistic update, `apiPut` with `Date.now()` candidate - timestamp (again, backend decides), toast on success/failure (consistent - with other explicit user-initiated actions like bookmark/remove). - -9. **Tabs**: new module state `let activeTab = "all";` (~near `panelOpen`, - line 374; not persisted, defaults to "all"). Wire click handlers in - `buildUI()` for new `#tabAll`/`#tabFav` elements. In `render()` (~line - 568), toggle each tab's `.active` class and filter which array feeds the - list-building loop (`activeTab === "favorites" ? state.list.filter(b => - b.favorite) : state.list`) — `state.list` itself is never mutated/filtered, - so a favorited manga always still appears in "All". - -10. **`renderItem(b)`** (~line 579): add a star toggle button (☆/★, - `onclick: () => toggleFavorite(b.key)`) alongside the existing - Continue/Edit/Remove buttons, and change the subtitle line to - `"Read: " + last_chapter + " · Latest: " + latest_chapter` when - `latest_chapter_num` is known and strictly greater than - `last_chapter_num` (avoids showing "Latest: Chapter 12" next to "Read: - Chapter 12" when they're numerically equal); otherwise keep today's - `" · "` text. - -11. **`TEMPLATE`** (~line 679): insert a tabs bar - (`
-
`) between - `#context` and `#list`. - -12. **`CSS`** (~near `.ctx-sub`/`.btn.danger`): add `.tab`/`.tab.active` and - `.btn.star`/`.btn.star.active` rules following the existing dark-theme - `.btn` modifier convention (`.btn.primary`, `.btn.small`, `.btn.danger`). - -13. **Fix the misleading redirect comment** (line 41, in the `asura` - adapter object): replace "asuracomic.net currently 301s to - asurascans.com; match both." with an accurate note that the redirect - now discards the path (goes straight to the asurascans.com root), - happens at the Cloudflare edge before any JS runs, so no client-side - fix is possible, and the user should navigate via asurascans.com links - directly. No change to the `matches()` regex itself. - -14. **`@version`**: bump `1.1.0` → `1.2.0`. - -## Phase 3 — Documentation - -- **`CLAUDE.md`**: update the `PUT /bookmarks/{key}` endpoint description - (currently "upsert, server sets updated_at") to describe the new - conditional rule, referencing - `plans/2026-07-25-bookmark-list-favorites-design.md` §4. -- **`README.md`**: update the matching endpoint-table row, and correct the - "Adapter reference" section's Asura row to note `asuracomic.net` deep - links currently 301 to the asurascans.com root (broken/path discarded) — - use `asurascans.com` links directly. While touching this, also fix - `CLAUDE.md`'s intro line ("asuracomic.net (formerly asurascans.com)"), - which has the relationship backwards and is inconsistent with README's - own phrasing ("asurascans.com (a.k.a. asuracomic.net)") — bundle this - small adjacent correction in since it's directly related to the same - finding. - -## Verification - -**Backend (automated):** -```bash -cd backend && go test ./... -cd backend && CGO_ENABLED=0 go build -``` - -**Backend (manual smoke test, extends the existing curl convention in -CLAUDE.md/README.md):** PUT a new bookmark, then PUT again changing only -`favorite`, then only `latest_chapter*`, then a real `last_chapter_num` -advance — confirm via `jq .updated_at` that only the first and last calls -change `updated_at`. - -**Userscript (manual — no JS test harness exists in this repo, matching -existing project convention):** -- List reorders only on a genuine progress advance, not on plain re-visit, - favorite toggle, or latest-chapter capture. -- Latest-chapter capture fires when visiting a bookmarked series page on - both sites and displays the "Read: X · Latest: Y" subtitle correctly. -- Background refresh: check `localStorage['mangabm:lastchecked']` in - devtools to confirm throttling behavior; confirm via the Network tab that - it never fires a cross-site request (only same-origin as the currently - loaded site). -- Favorite toggle persists across a panel close/reopen and a `refresh()` - round-trip through the backend; favorited manga still shows in "All". -- Fastest iteration path: desktop Tampermonkey/Violentmonkey first (script - stays `GM_*`-free), then confirm on Bromite per existing project - convention. - -## Critical files -- `backend/store.go` -- `backend/handlers.go` -- `backend/store_test.go` -- `userscript/manga-bookmark.user.js` -- `CLAUDE.md` -- `README.md` diff --git a/plans/2026-07-25-bookmark-list-favorites-design.md b/plans/2026-07-25-bookmark-list-favorites-design.md deleted file mode 100644 index da9042e..0000000 --- a/plans/2026-07-25-bookmark-list-favorites-design.md +++ /dev/null @@ -1,231 +0,0 @@ -# Bookmark list ordering, latest-chapter display, and favorites - -Date: 2026-07-25 -Status: Approved by user, pending implementation plan - -## Context - -Three requested additions to the Bromite userscript's bookmark panel -(`userscript/manga-bookmark.user.js`) plus one incidental bug found while -verifying feasibility live: - -1. Bookmark list should show the most-recently-read manga first. -2. Show not just the last chapter *read*, but the latest chapter *available* - for that manga, if feasible. -3. A favorites mechanism: a second list/tab showing only favorited manga, - without removing favorited manga from the normal list. - -## 1. Reorder by latest read (already implemented) - -`reindex()` in `manga-bookmark.user.js` has sorted `state.list` by -`(b.updated_at || 0)` descending since the very first commit -(`f58d113`). `updated_at` is set whenever progress is recorded -(`bookmarkCurrent`, `updateToCurrentChapter`, `setChapterManual`). No code -change needed here — confirmed by decision: only an actual progress advance -should reorder the list (not simply opening/re-reading an old chapter). - -The only risk to this existing behavior is introduced by features 2 and 3 -below, since both add new fields synced through the same PUT endpoint that -currently (per existing CLAUDE.md) has the server set `updated_at` -unconditionally on every upsert. See section 4. - -## 2. Latest available chapter - -### Feasibility (verified live via Playwright, 2026-07-25) - -- **Chapter reader pages only expose immediate neighbors.** On - `asurascans.com/comics/dungeon-odyssey-f886a8af/chapter/160`, the DOM - contains only chapters 159–161 (prev/next nav) — not the full list. -- **Series pages list every chapter, newest first by default.** On - `asurascans.com/comics/dungeon-odyssey-f886a8af`, the series page's chapter - list panel contains one `Chapter N ...` per - chapter (verified: 162 links for a 162-chapter series, first is Chapter - 162). This is therefore the only reliable place to learn the true latest - chapter number. -- Demonic's series page (`demonicscans.org/manga/Dungeon-Odyssey`) similarly - lists every chapter via `chaptered.php?manga=&chapter=` links, - interleaved with a "first chapter" quick-jump link. Taking `max()` of all - parsed chapter numbers (rather than assuming list order) is used for - robustness on this site. - -**Conclusion: capturing latest-available-chapter data requires fetching a -series page's HTML** (no reader-page source, no JSON API — see below). The -backend cannot do this itself (Cloudflare blocks server-side fetch, per -existing CLAUDE.md constraint), so it must happen from the userscript, -running in the user's real browser session. Section "Background opportunistic -refresh" below extends this beyond "only when you open that exact series -page." - -### No stable API exists (checked live, 2026-07-25) - -- The series page is server-rendered HTML (Next.js SSR) — network capture - during a series-page load shows no `_next/data` JSON call and no XHR/fetch - for chapter data (only unrelated `api.asurascans.com` calls for - announcements/promotions banners). Chapter data is embedded directly in - the HTML, not fetched separately. -- No RSS/feed exists: `asurascans.com/feed`, `/rss`, `/rss.xml` all 404. -- Conclusion: HTML scraping (DOM when live on the page, raw-text regex when - background-fetched — see below) is the only available data source on - either site. There is nothing more "API-like" to poll instead. - -### Behavior - -- New adapter capability: on `detect()` returning `type: "series"` for an - already-bookmarked series (`state.byKey[key]` exists), scan the page for - chapter links per the site-specific pattern below and compute the max - chapter number + its display label. - - Asura: `a[href*="/chapter/"]` where the href matches - `/chapter/([\d.]+)$/` and the link text matches `/Chapter\s+[\d.]+/` - (excludes the unrelated "First Chapter" quick-jump button, which lacks - that text pattern). - - Demonic: all `a[href]` matching - `/chaptered\.php\?manga=\d+&chapter=([\d.]+)/`; take the max parsed - chapter number across all matches (list order is not assumed reliable). -- If the computed max differs from the bookmark's stored - `latest_chapter_num`, silently update `latest_chapter` (label) and - `latest_chapter_num` via the API — but this update must **not** change - `updated_at` / list order (see section 4). -- Display: each list item's subtitle line becomes e.g. - `"Read: Chapter 12 · Latest: Chapter 15"` when latest is known and differs - from last-read; otherwise unchanged (`"Chapter 12 · asura"` as today). - Plain inline text, no separate badge/count UI. - -### Background opportunistic refresh (closer to "live") - -Live-page capture above only refreshes a series when the user happens to -open that exact series page. To reduce that gap without server-side -polling (still blocked by Cloudflare — verified: server-side fetch is a -datacenter request with no browser session, this is unchanged and not -being revisited), the userscript also does same-origin background checks -using the user's own real browser session: - -- **Same-origin only.** `fetch()` issued from a page on `asurascans.com` - can only safely reach other `asurascans.com` paths (no CORS trouble, - looks like a normal authenticated browser request). It cannot reach - `demonicscans.org` or vice versa. So visiting any page on a site - opportunistically refreshes only that site's bookmarks — confirmed live - that same-origin `fetch()` from an already-loaded page succeeds cleanly - (tested against `asurascans.com/sitemap.xml` from a `comics/*` page). -- **Trigger:** on every page load, after `init()`/`refresh()`, run - `backgroundRefreshLatest()`. It picks bookmarks belonging to the - *current* site that haven't been checked within a throttle window - (`LATEST_CHECK_THROTTLE_MS`, proposed 4 hours), oldest-checked-first, and - checks at most `LATEST_CHECK_BATCH` of them (proposed 1) per page load — - so a normal reading session gradually keeps bookmarks fresh without ever - bursting requests. -- **Freshness tracking is local-only**, not synced: a small - `mangabm:lastchecked` localStorage map of `{ [key]: timestampMs }`. It's - device-local by nature (each device does its own background checks) and - keeping it out of the synced `Bookmark` record avoids polluting the - cross-device schema with a per-device value. -- **Fetch + parse:** `fetch(bookmark.series_url)` → `res.text()` → apply - the *same* regex rule already defined above (Asura: chapter-link href + - "Chapter N" text; Demonic: max of all `chaptered.php?...chapter=` - matches) against the raw HTML string instead of the live DOM. No - additional parsing logic — this reuses the exact same rule, just fed - fetched text instead of `document`. -- **Update path:** identical to live-page capture — PUT the bookmark with - new `latest_chapter`/`latest_chapter_num` if changed, no `updated_at` - bump (section 4). -- **Failure handling:** a failed/blocked background fetch is silently - skipped (no toast, no retry loop) — next eligible page load tries again - naturally once the throttle window passes. -- **What this buys:** instead of "only fresh if you opened that exact - series page," bookmarks on a site you're actively reading converge to - "checked within the last ~4 hours," which is meaningfully closer to live - given frequent reading — without needing server-side scraping that - Cloudflare would block anyway. It is still not push/instant; nothing - can notify the moment a new chapter is posted without the site itself - offering that (it doesn't — no RSS/webhooks, confirmed above). - -## 3. Favorites - -- New field `favorite: bool` on the bookmark record, synced through the - existing PUT endpoint (chosen over local-only storage so favorites persist - across devices/reinstalls, consistent with how the rest of the data - syncs). -- Each list item gets a star toggle (☆ / ★) that flips `favorite` and PUTs - the updated bookmark. Toggling **must not** change `updated_at` / list - order (see section 4). -- The panel gains two tabs above the bookmark list: **All** and - **★ Favorites**. Both apply the same sort (section 1). Switching tabs is - local UI state (not persisted) defaulting to "All". A favorited manga - continues to appear in "All" — tabs only filter which array is rendered, - favoriting never removes the bookmark from `state.list`. - -## 4. Backend change required: conditional `updated_at` - -Current CLAUDE.md / `store.go` behavior: `PUT /bookmarks/{key}` always sets -`updated_at` server-side on every upsert. Once latest-chapter-capture and -favorite-toggle both PUT through that same endpoint, this would reorder the -list on every series-page visit or star click — contradicting the -"reorder only on progress advance" decision from section 1. - -**Change:** `Store.Upsert` sets `updated_at = now()` only when: -- the bookmark is new (no existing row for that key), or -- `last_chapter_num` in the incoming payload differs from the currently - stored value. - -Otherwise the existing stored `updated_at` is preserved, even though other -fields (`favorite`, `latest_chapter`, `latest_chapter_num`, cover, title, -etc.) are still updated. This centralizes "what counts as a progress -advance" as a single authoritative rule in the backend, applied consistently -regardless of which device/browser performed the write. - -This is a deliberate deviation from the current CLAUDE.md wording ("server -sets `updated_at`" unconditionally) and needs the doc updated to match. - -## 5. Asura redirect bug (bundled into this work) - -Verified live: `https://asuracomic.net/comics/dungeon-odyssey-f886a8af` -returns an HTTP **301** with `Location: https://asurascans.com/` (root, no -path) — a Cloudflare-edge redirect that discards the path *before any JS on -asuracomic.net executes*. This contradicts the existing code comment -("asuracomic.net currently 301s to asurascans.com", implying path -preservation) and means any deep link on `asuracomic.net` currently lands -the user on the asurascans.com homepage with page type `"other"` — -bookmark/progress detection silently does nothing. - -There is no client-side fix: the userscript's `@match` for -`asuracomic.net/*` never gets a chance to run for these URLs, since the -redirect happens at the edge before the browser has a document to inject -into. **Fix is documentation-only**: correct the misleading comment in -`asura.matches()`/adapter notes and README to state that `asuracomic.net` -deep links are currently broken, and the user should navigate via -`asurascans.com` links directly. No behavior change to ship. - -## Data model summary - -`Bookmark` (backend `store.go`) gains: - -| field | type | notes | -|---|---|---| -| `favorite` | `bool` | default `false` | -| `latest_chapter` | `string` | display label, e.g. `"Chapter 162"`; empty if never captured | -| `latest_chapter_num` | `*float64` | nullable; null if never captured | - -SQLite migration: additive `ALTER TABLE bookmarks ADD COLUMN ...` for each, -guarded against "duplicate column" errors so it's safe to run against the -already-deployed database. - -Userscript-local, not synced: `mangabm:lastchecked` localStorage key, a -`{ [bookmarkKey]: timestampMs }` map used only to throttle background -refresh (see section 2). - -## Testing - -- Go: table-driven tests for `Store.Upsert`'s conditional `updated_at` logic - (new bookmark, unchanged progress, changed progress, favorite-only change, - latest-chapter-only change). -- Userscript: manual on-device verification (per existing project - convention — no JS test harness in this repo). Verify: - - list reorders only when a chapter is actually advanced, not on - plain re-visit or favorite toggle. - - latest-chapter capture fires on series-page visits and displays - correctly for both sites. - - background refresh: check one stale same-site bookmark per page load, - skips bookmarks checked within the throttle window, updates silently - without reordering the list, and doesn't fire cross-site. - - favorite toggle persists across a panel close/reopen and a - `refresh()` (i.e. round-trips through the backend correctly). - - favorited manga still appears in "All" tab. diff --git a/plans/2026-07-25-web-ui-implementation-plan.md b/plans/2026-07-25-web-ui-implementation-plan.md deleted file mode 100644 index bea56e9..0000000 --- a/plans/2026-07-25-web-ui-implementation-plan.md +++ /dev/null @@ -1,2330 +0,0 @@ -# Web UI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a password-gated website, served by the existing Go backend on a new subdomain, that shows the bookmark list with continue-reading, favourite, chapter-override, delete, and title search. - -**Architecture:** The existing binary gains server-rendered HTML pages and htmx fragment endpoints, with templates and static assets compiled in via `go:embed`. Browser requests authenticate with a stateless HMAC-signed session cookie; the userscript's bearer-authenticated `/bookmarks*` JSON API is not touched. All UI mutations go read-modify-write through the existing `Store.Upsert`, so the conditional-`updated_at` rule stays in exactly one place. - -**Tech Stack:** Go 1.23 stdlib (`net/http`, `html/template`, `embed`, `crypto/hmac`), `modernc.org/sqlite`, htmx 2.x vendored as a single file, hand-written CSS. No npm, no bundler, no build step. - -**Design doc:** `docs/superpowers/specs/2026-07-25-web-ui-design.md` - -## Global Constraints - -- Go 1.23, module `mangabm/backend`. Everything lives in `package main` under `backend/`. -- `CGO_ENABLED=0` must keep working — the image is `gcr.io/distroless/static:nonroot` and the binary must stay static. No new cgo dependencies. -- **No new Go module dependencies.** Stdlib only. htmx is vendored as a static asset, not a Go dependency. -- Do not modify `GET /bookmarks`, `PUT /bookmarks/{key}`, `DELETE /bookmarks/{key}`, `withAuth`, `withCORS`, or `userscript/manga-bookmark.user.js`. A regression there breaks phone reading. -- `Store.Upsert` is the only place the `updated_at` rule lives. Never write `updated_at` from a UI handler by any other route. -- Every UI handler renders the bookmark that `Upsert` **returned**, never the one it passed in. -- Existing test conventions: table-driven where there is more than one case, `t.TempDir()` for the database, `t.Helper()` on helpers, no external assertion library. -- Secrets (`API_TOKEN`, `WEB_PASSWORD`) come from environment variables only. Never hardcode, never log. -- Session cookie name: `mangabm_session`. HMAC domain-separation string: `mangabm-web-session-v1`. Both are exact — a typo silently invalidates every existing session. -- Run `gofmt -w` on every file you touch before committing. - ---- - -## File Structure - -| File | Responsibility | -| --- | --- | -| `backend/store.go` | Modify: add `Store.Get`, add `Bookmark.HasNewChapter` / `Bookmark.ContinueURL` | -| `backend/main.go` | Modify: `WEB_PASSWORD` config, wire web routes | -| `backend/session.go` | Create: cookie sign/verify, cookie set/clear, client IP, login rate limiter | -| `backend/web.go` | Create: page + fragment handlers, template embedding | -| `backend/templates/login.html` | Create: login page | -| `backend/templates/app.html` | Create: list page shell | -| `backend/templates/list.html` | Create: `list` fragment (cards only) | -| `backend/templates/card.html` | Create: `card` fragment (one series) | -| `backend/static/style.css` | Create: all styling | -| `backend/static/filter.js` | Create: client-side title search | -| `backend/static/htmx.min.js` | Create: vendored htmx 2.x | -| `backend/session_test.go` | Create: cookie, limiter, client IP tests | -| `backend/web_test.go` | Create: route, auth, mutation tests | -| `backend/Dockerfile` | Modify: copy `templates/` and `static/` into the build stage | -| `docker-compose.yml` | Modify: pass `WEB_PASSWORD` | -| `docker-compose.prod.yml` | Modify: second Traefik router for the web host | -| `.env.example`, `DEPLOY.md` | Modify: document `WEB_PASSWORD` and `MANGA_WEB_HOST` | - -Split rationale: `session.go` holds everything security-sensitive (signing, comparison, rate limiting) so it can be reviewed as one unit; `web.go` holds only request routing and rendering. Templates are split so that `card.html` is rendered both standalone (htmx swap after a mutation) and nested inside `list.html`. - ---- - -### Task 1: `Store.Get` and the two `Bookmark` view helpers - -**Files:** -- Modify: `backend/store.go` -- Test: `backend/store_test.go` - -**Interfaces:** -- Consumes: nothing. -- Produces: - - `func (s *Store) Get(key string) (Bookmark, bool, error)` — second return is false when the key does not exist; error is nil in that case. - - `func (b Bookmark) HasNewChapter() bool` - - `func (b Bookmark) ContinueURL() string` - -- [ ] **Step 1: Write the failing tests** - -Append to `backend/store_test.go`. Note `newTestStore` may not exist yet — check the file; if the existing tests only build a store inline, add this helper next to the other helpers at the top of the file: - -```go -func newTestStore(t *testing.T) *Store { - t.Helper() - store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("OpenStore: %v", err) - } - t.Cleanup(func() { store.Close() }) - return store -} -``` - -Then the tests: - -```go -func TestStoreGet(t *testing.T) { - store := newTestStore(t) - if _, err := store.Upsert(Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1000, - }); err != nil { - t.Fatalf("Upsert: %v", err) - } - - got, ok, err := store.Get("asura:solo") - if err != nil { - t.Fatalf("Get: %v", err) - } - if !ok { - t.Fatal("Get ok = false, want true") - } - if got.Title != "Solo Leveling" || got.LastChapterNum != 45 { - t.Fatalf("Get = %+v, want title/chapter preserved", got) - } -} - -func TestStoreGetMissing(t *testing.T) { - store := newTestStore(t) - _, ok, err := store.Get("asura:nope") - if err != nil { - t.Fatalf("Get missing returned error %v, want nil", err) - } - if ok { - t.Fatal("Get ok = true for missing key, want false") - } -} - -func TestBookmarkHasNewChapter(t *testing.T) { - num := func(f float64) *float64 { return &f } - cases := []struct { - name string - b Bookmark - want bool - }{ - {"latest ahead", Bookmark{LastChapterNum: 45, LatestChapterNum: num(47)}, true}, - {"latest equal", Bookmark{LastChapterNum: 45, LatestChapterNum: num(45)}, false}, - {"latest behind", Bookmark{LastChapterNum: 45, LatestChapterNum: num(44)}, false}, - {"latest unknown", Bookmark{LastChapterNum: 45}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := tc.b.HasNewChapter(); got != tc.want { - t.Fatalf("HasNewChapter() = %v, want %v", got, tc.want) - } - }) - } -} - -func TestBookmarkContinueURL(t *testing.T) { - cases := []struct { - name string - b Bookmark - want string - }{ - {"chapter url present", Bookmark{LastChapterURL: "/ch/45", SeriesURL: "/series"}, "/ch/45"}, - {"falls back to series", Bookmark{SeriesURL: "/series"}, "/series"}, - {"both empty", Bookmark{}, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if got := tc.b.ContinueURL(); got != tc.want { - t.Fatalf("ContinueURL() = %q, want %q", got, tc.want) - } - }) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cd backend && go test ./... -run 'TestStoreGet|TestBookmark' -v` -Expected: compile failure — `store.Get undefined`, `b.HasNewChapter undefined`, `b.ContinueURL undefined`. - -- [ ] **Step 3: Implement** - -Add to `backend/store.go`, immediately after the `Bookmark` struct: - -```go -// HasNewChapter reports whether the site has published past the read point. -// A nil LatestChapterNum means nothing has been captured yet, which is not the -// same as "nothing new". -func (b Bookmark) HasNewChapter() bool { - return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum -} - -// ContinueURL is where the Continue button points: the chapter last read, or -// the series page when no chapter URL was ever captured. -func (b Bookmark) ContinueURL() string { - if b.LastChapterURL != "" { - return b.LastChapterURL - } - return b.SeriesURL -} -``` - -Add after `List`: - -```go -// Get returns one bookmark by key. A missing key is not an error: ok is false -// and err is nil. UI mutations read-modify-write through this so they preserve -// the fields they do not touch. -func (s *Store) Get(key string) (Bookmark, bool, error) { - b, err := scanBookmark(s.db.QueryRow( - `SELECT `+bookmarkColumns+` FROM bookmarks WHERE key = ?`, key).Scan) - if errors.Is(err, sql.ErrNoRows) { - return Bookmark{}, false, nil - } - if err != nil { - return Bookmark{}, false, fmt.Errorf("get %q: %w", key, err) - } - return b, true, nil -} -``` - -Add `"errors"` to the import block in `backend/store.go`. - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS, including all pre-existing tests. - -- [ ] **Step 5: Commit** - -```bash -gofmt -w backend/store.go backend/store_test.go -git add backend/store.go backend/store_test.go -git commit -m "feat(backend): add Store.Get and bookmark view helpers" -``` - ---- - -### Task 2: Session cookie signing and verification - -**Files:** -- Create: `backend/session.go` -- Test: `backend/session_test.go` - -**Interfaces:** -- Consumes: nothing. -- Produces: - - `const sessionCookieName = "mangabm_session"` - - `const sessionTTL = 60 * 24 * time.Hour` - - `func sessionKey(apiToken string) []byte` - - `func signSession(key []byte, expiryMs int64) string` - - `func verifySession(key []byte, value string, nowMs int64) bool` - - `func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte)` - - `func clearSessionCookie(w http.ResponseWriter, r *http.Request)` - -- [ ] **Step 1: Write the failing tests** - -Create `backend/session_test.go`: - -```go -package main - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -func TestSessionRoundTrip(t *testing.T) { - key := sessionKey("token-abc") - now := time.Now().UnixMilli() - value := signSession(key, now+60_000) - if !verifySession(key, value, now) { - t.Fatal("verifySession = false for a freshly signed cookie, want true") - } -} - -func TestSessionRejects(t *testing.T) { - key := sessionKey("token-abc") - now := time.Now().UnixMilli() - valid := signSession(key, now+60_000) - payload, sig, _ := strings.Cut(valid, ".") - - cases := []struct { - name string - value string - }{ - {"empty", ""}, - {"no separator", payload + sig}, - {"unparseable expiry", "notanumber." + sig}, - {"expired", signSession(key, now-1)}, - {"tampered signature", payload + "." + flipLastChar(sig)}, - {"tampered expiry", "99999999999999." + sig}, - {"signed with another key", signSession(sessionKey("other-token"), now+60_000)}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - if verifySession(key, tc.value, now) { - t.Fatalf("verifySession(%q) = true, want false", tc.value) - } - }) - } -} - -func flipLastChar(s string) string { - if s == "" { - return "x" - } - last := s[len(s)-1] - if last == 'A' { - return s[:len(s)-1] + "B" - } - return s[:len(s)-1] + "A" -} - -func TestSessionKeyDependsOnToken(t *testing.T) { - a := sessionKey("token-a") - b := sessionKey("token-b") - if string(a) == string(b) { - t.Fatal("sessionKey collided for different API tokens") - } -} - -func TestSetSessionCookieAttributes(t *testing.T) { - cases := []struct { - name string - tls bool - forwarded string - wantSecure bool - }{ - {"plain http dev", false, "", false}, - {"direct tls", true, "", true}, - {"behind https proxy", false, "https", true}, - {"behind http proxy", false, "http", false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - r := httptest.NewRequest(http.MethodPost, "/login", nil) - if tc.tls { - r.TLS = &tls.ConnectionState{} - } - if tc.forwarded != "" { - r.Header.Set("X-Forwarded-Proto", tc.forwarded) - } - rr := httptest.NewRecorder() - setSessionCookie(rr, r, sessionKey("token-abc")) - - cookies := rr.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("got %d cookies, want 1", len(cookies)) - } - c := cookies[0] - if c.Name != sessionCookieName { - t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName) - } - if !c.HttpOnly { - t.Fatal("cookie HttpOnly = false, want true") - } - if c.SameSite != http.SameSiteLaxMode { - t.Fatalf("cookie SameSite = %v, want Lax", c.SameSite) - } - if c.Path != "/" { - t.Fatalf("cookie Path = %q, want /", c.Path) - } - if c.Secure != tc.wantSecure { - t.Fatalf("cookie Secure = %v, want %v", c.Secure, tc.wantSecure) - } - if c.MaxAge != int(sessionTTL/time.Second) { - t.Fatalf("cookie MaxAge = %d, want %d", c.MaxAge, int(sessionTTL/time.Second)) - } - }) - } -} - -func TestClearSessionCookie(t *testing.T) { - r := httptest.NewRequest(http.MethodPost, "/logout", nil) - rr := httptest.NewRecorder() - clearSessionCookie(rr, r) - - cookies := rr.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("got %d cookies, want 1", len(cookies)) - } - if cookies[0].MaxAge >= 0 { - t.Fatalf("cleared cookie MaxAge = %d, want negative", cookies[0].MaxAge) - } -} -``` - -Add `"crypto/tls"` to that file's imports (used by `TestSetSessionCookieAttributes`). - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cd backend && go test ./... -run TestSession -v` -Expected: compile failure — `sessionKey`, `signSession`, `verifySession`, `setSessionCookie`, `clearSessionCookie`, `sessionCookieName`, `sessionTTL` all undefined. - -- [ ] **Step 3: Implement** - -Create `backend/session.go`: - -```go -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "crypto/subtle" - "encoding/base64" - "net/http" - "strconv" - "strings" - "time" -) - -const ( - sessionCookieName = "mangabm_session" - // 60 days: long enough that a phone stays logged in between reading spells. - sessionTTL = 60 * 24 * time.Hour - // Domain separation, so the session key can never collide with any other - // use of API_TOKEN. Changing this string logs everyone out. - sessionKeyPurpose = "mangabm-web-session-v1" -) - -// sessionKey derives the cookie-signing key from the API token. Sessions are -// stateless — there is no session table — so rotating API_TOKEN invalidates -// every outstanding cookie at once. -func sessionKey(apiToken string) []byte { - sum := sha256.Sum256([]byte(apiToken + sessionKeyPurpose)) - return sum[:] -} - -// signSession encodes ".". -func signSession(key []byte, expiryMs int64) string { - payload := strconv.FormatInt(expiryMs, 10) - return payload + "." + sessionMAC(key, payload) -} - -func sessionMAC(key []byte, payload string) string { - mac := hmac.New(sha256.New, key) - mac.Write([]byte(payload)) - return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) -} - -// verifySession checks shape, then expiry, then the signature — in that order. -// The signature comparison is constant-time; the checks before it only look at -// data the holder already supplied, so their timing leaks nothing. -func verifySession(key []byte, value string, nowMs int64) bool { - payload, sig, ok := strings.Cut(value, ".") - if !ok { - return false - } - expiry, err := strconv.ParseInt(payload, 10, 64) - if err != nil || expiry <= nowMs { - return false - } - want := sessionMAC(key, payload) - return subtle.ConstantTimeCompare([]byte(sig), []byte(want)) == 1 -} - -// isHTTPS reports whether the browser's connection is encrypted. Behind Traefik -// the Go server itself speaks plain HTTP, so the forwarded header is the only -// signal; without this check the Secure cookie would never be set in -// production, and setting it unconditionally would break http://localhost dev. -func isHTTPS(r *http.Request) bool { - return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" -} - -func setSessionCookie(w http.ResponseWriter, r *http.Request, key []byte) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: signSession(key, time.Now().Add(sessionTTL).UnixMilli()), - Path: "/", - MaxAge: int(sessionTTL / time.Second), - HttpOnly: true, - Secure: isHTTPS(r), - SameSite: http.SameSiteLaxMode, - }) -} - -func clearSessionCookie(w http.ResponseWriter, r *http.Request) { - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: isHTTPS(r), - SameSite: http.SameSiteLaxMode, - }) -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -gofmt -w backend/session.go backend/session_test.go -git add backend/session.go backend/session_test.go -git commit -m "feat(backend): stateless HMAC session cookies for the web UI" -``` - ---- - -### Task 3: Login rate limiter and client IP extraction - -**Files:** -- Modify: `backend/session.go` -- Test: `backend/session_test.go` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: - - `func clientIP(r *http.Request) string` - - `type loginLimiter struct{ ... }` - - `func newLoginLimiter() *loginLimiter` - - `func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration` — zero when not blocked - - `func (l *loginLimiter) fail(ip string, now time.Time)` - - `func (l *loginLimiter) reset(ip string)` - - `const loginMaxFailures = 10`, `const loginWindow = 20 * time.Minute` - -- [ ] **Step 1: Write the failing tests** - -Append to `backend/session_test.go`: - -```go -func TestClientIP(t *testing.T) { - cases := []struct { - name string - remoteAddr string - xff []string - want string - }{ - {"no header falls back to remote addr", "203.0.113.9:5555", nil, "203.0.113.9"}, - {"single proxy hop", "10.0.0.1:5555", []string{"203.0.113.9"}, "203.0.113.9"}, - { - // The client sent "1.2.3.4" itself; Traefik appended the address it - // actually saw. Only the rightmost entry is trustworthy. - name: "spoofed left entry is ignored", - remoteAddr: "10.0.0.1:5555", - xff: []string{"1.2.3.4, 203.0.113.9"}, - want: "203.0.113.9", - }, - { - name: "spoofed separate header line is ignored", - remoteAddr: "10.0.0.1:5555", - xff: []string{"1.2.3.4", "203.0.113.9"}, - want: "203.0.113.9", - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - r := httptest.NewRequest(http.MethodPost, "/login", nil) - r.RemoteAddr = tc.remoteAddr - for _, v := range tc.xff { - r.Header.Add("X-Forwarded-For", v) - } - if got := clientIP(r); got != tc.want { - t.Fatalf("clientIP() = %q, want %q", got, tc.want) - } - }) - } -} - -func TestLoginLimiterBlocksAfterMaxFailures(t *testing.T) { - l := newLoginLimiter() - now := time.Now() - for i := 0; i < loginMaxFailures; i++ { - if wait := l.retryAfter("1.2.3.4", now); wait != 0 { - t.Fatalf("blocked after %d failures, want block only after %d", i, loginMaxFailures) - } - l.fail("1.2.3.4", now) - } - wait := l.retryAfter("1.2.3.4", now) - if wait <= 0 { - t.Fatalf("retryAfter = %v after %d failures, want > 0", wait, loginMaxFailures) - } - if wait > loginWindow { - t.Fatalf("retryAfter = %v, want <= %v", wait, loginWindow) - } -} - -func TestLoginLimiterWindowExpires(t *testing.T) { - l := newLoginLimiter() - start := time.Now() - for i := 0; i < loginMaxFailures; i++ { - l.fail("1.2.3.4", start) - } - if l.retryAfter("1.2.3.4", start) == 0 { - t.Fatal("expected block immediately after the failures") - } - later := start.Add(loginWindow + time.Second) - if wait := l.retryAfter("1.2.3.4", later); wait != 0 { - t.Fatalf("retryAfter = %v once the window passed, want 0", wait) - } -} - -func TestLoginLimiterResetClearsCounter(t *testing.T) { - l := newLoginLimiter() - now := time.Now() - for i := 0; i < loginMaxFailures; i++ { - l.fail("1.2.3.4", now) - } - l.reset("1.2.3.4") - if wait := l.retryAfter("1.2.3.4", now); wait != 0 { - t.Fatalf("retryAfter = %v after reset, want 0", wait) - } -} - -func TestLoginLimiterIsPerIP(t *testing.T) { - l := newLoginLimiter() - now := time.Now() - for i := 0; i < loginMaxFailures; i++ { - l.fail("1.2.3.4", now) - } - if wait := l.retryAfter("5.6.7.8", now); wait != 0 { - t.Fatalf("retryAfter for a different IP = %v, want 0", wait) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cd backend && go test ./... -run 'TestClientIP|TestLoginLimiter' -v` -Expected: compile failure — `clientIP`, `newLoginLimiter`, `loginMaxFailures`, `loginWindow` undefined. - -- [ ] **Step 3: Implement** - -Append to `backend/session.go`: - -```go -const ( - loginMaxFailures = 10 - loginWindow = 20 * time.Minute -) - -// clientIP returns the address the reverse proxy actually observed. -// -// Traefik appends the peer address to whatever X-Forwarded-For the client sent, -// so the leftmost entry is attacker-controlled and the rightmost is not. Go's -// Header.Get would only read the first header line, which a client can preempt -// by sending its own; Values covers every line so the true last hop is found. -// RemoteAddr is useless behind the proxy — it is always the Traefik container — -// so it serves only as the direct-connection fallback for local development. -func clientIP(r *http.Request) string { - if vals := r.Header.Values("X-Forwarded-For"); len(vals) > 0 { - hops := strings.Split(vals[len(vals)-1], ",") - if ip := strings.TrimSpace(hops[len(hops)-1]); ip != "" { - return ip - } - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - -// loginLimiter throttles password guessing: loginMaxFailures failures inside a -// rolling loginWindow blocks further attempts from that IP until the oldest one -// ages out. There is no permanent ban and no unlock step. -// -// Behind carrier-grade NAT this budget is shared with every other subscriber on -// the same public address, so a stranger can lock the owner out for up to one -// window. That is accepted: the block self-heals, and ten attempts is generous -// for a mistyped password. -// -// State is in memory and per-process, so a restart clears it. Entries are -// pruned lazily on access; for a single-user deployment the map cannot grow -// past the handful of addresses that ever attempt a login. -type loginLimiter struct { - mu sync.Mutex - failures map[string][]time.Time -} - -func newLoginLimiter() *loginLimiter { - return &loginLimiter{failures: make(map[string][]time.Time)} -} - -// retryAfter returns how long ip must wait, or zero when it may try now. -func (l *loginLimiter) retryAfter(ip string, now time.Time) time.Duration { - l.mu.Lock() - defer l.mu.Unlock() - - recent := l.pruneLocked(ip, now) - if len(recent) < loginMaxFailures { - return 0 - } - return recent[0].Add(loginWindow).Sub(now) -} - -func (l *loginLimiter) fail(ip string, now time.Time) { - l.mu.Lock() - defer l.mu.Unlock() - l.failures[ip] = append(l.pruneLocked(ip, now), now) -} - -func (l *loginLimiter) reset(ip string) { - l.mu.Lock() - defer l.mu.Unlock() - delete(l.failures, ip) -} - -// pruneLocked drops attempts older than the window and returns what is left. -// The caller must hold l.mu. -func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time { - cutoff := now.Add(-loginWindow) - kept := l.failures[ip][:0] - for _, at := range l.failures[ip] { - if at.After(cutoff) { - kept = append(kept, at) - } - } - if len(kept) == 0 { - delete(l.failures, ip) - return nil - } - l.failures[ip] = kept - return kept -} -``` - -Add `"net"` and `"sync"` to the import block in `backend/session.go`. - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS. - -- [ ] **Step 5: Run the race detector** - -Run: `cd backend && go test -race ./...` -Expected: PASS, no race warnings. The limiter is shared across concurrent requests, so this matters. - -- [ ] **Step 6: Commit** - -```bash -gofmt -w backend/session.go backend/session_test.go -git add backend/session.go backend/session_test.go -git commit -m "feat(backend): per-IP login rate limit with proxy-aware client IP" -``` - ---- - -### Task 4: Vendor htmx and add the config variable - -**Files:** -- Create: `backend/static/htmx.min.js` -- Modify: `backend/main.go` -- Test: `backend/store_test.go` (extend the existing `testConfig`, add a config test) - -**Interfaces:** -- Consumes: nothing. -- Produces: `Config.WebPassword string`, populated from `WEB_PASSWORD`. - -- [ ] **Step 1: Vendor htmx** - -```bash -mkdir -p backend/static -curl -fsSL https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js -o backend/static/htmx.min.js -wc -c backend/static/htmx.min.js -``` - -Expected: roughly 48000–52000 bytes. If the download fails or the file is under 10000 bytes, stop — do not proceed with a truncated file. Fetch it manually from `https://github.com/bigskysoftware/htmx/releases` instead. The file is committed to the repository on purpose: the Docker build has no network access and there is no npm step. - -- [ ] **Step 2: Write the failing test** - -Append to `backend/store_test.go`: - -```go -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) - } -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `cd backend && go test ./... -run TestLoadConfigWebPassword -v` -Expected: compile failure — `cfg.WebPassword undefined`. - -- [ ] **Step 4: Implement** - -In `backend/main.go`, add the field to `Config`: - -```go - // WebPassword gates the browser UI. Empty disables the web routes entirely. - WebPassword string -``` - -And in `loadConfig`, inside the struct literal: - -```go - WebPassword: os.Getenv("WEB_PASSWORD"), -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -gofmt -w backend/main.go backend/store_test.go -git add backend/static/htmx.min.js backend/main.go backend/store_test.go -git commit -m "chore(backend): vendor htmx 2.0.4 and add WEB_PASSWORD config" -``` - ---- - -### Task 5: Templates and the login flow - -**Files:** -- Create: `backend/web.go`, `backend/templates/login.html`, `backend/templates/app.html`, `backend/templates/list.html`, `backend/templates/card.html` -- Modify: `backend/main.go` -- Test: `backend/web_test.go` - -**Interfaces:** -- Consumes: `sessionKey`, `signSession`, `setSessionCookie`, `clearSessionCookie`, `sessionCookieName`, `verifySession`, `clientIP`, `newLoginLimiter`, `loginMaxFailures` (Tasks 2–3); `Store.List`, `Bookmark.HasNewChapter`, `Bookmark.ContinueURL` (Task 1); `Config.WebPassword` (Task 4). -- Produces: - - `type webHandler struct{ store *Store; tmpl *template.Template; key []byte; password string; limiter *loginLimiter }` - - `func newWebHandler(store *Store, cfg Config) (*webHandler, error)` - - `func (h *webHandler) register(mux *http.ServeMux)` - - `func (h *webHandler) authed(r *http.Request) bool` - - `func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc` - - `type listView struct{ Tab string; Recent []Bookmark; Items []Bookmark }` - -All four templates are created in this task because `newWebHandler` parses the whole set at startup and fails if any is missing. Tasks 6 and 7 fill in the interactive attributes and the styling. - -- [ ] **Step 1: Write the failing tests** - -Create `backend/web_test.go`: - -```go -package main - -import ( - "net/http" - "net/http/httptest" - "net/url" - "path/filepath" - "strconv" - "strings" - "testing" - "time" -) - -const testPassword = "hunter2" - -func webConfig() Config { - cfg := testConfig() - cfg.WebPassword = testPassword - return cfg -} - -// newWebTestServer returns the full router plus the store behind it, so tests -// can seed rows and assert on what the handlers wrote back. -func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) { - t.Helper() - store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("OpenStore: %v", err) - } - t.Cleanup(func() { store.Close() }) - return newRouter(store, cfg), store -} - -// sessionCookie returns a cookie a handler will accept for cfg's API token. -func sessionCookie(t *testing.T, cfg Config) *http.Cookie { - t.Helper() - return &http.Cookie{ - Name: sessionCookieName, - Value: signSession(sessionKey(cfg.Token), time.Now().Add(time.Hour).UnixMilli()), - } -} - -func TestIndexWithoutSessionShowsLogin(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) - - if rr.Code != http.StatusOK { - t.Fatalf("GET / status = %d, want 200", rr.Code) - } - if !strings.Contains(rr.Body.String(), `type="password"`) { - t.Fatal("GET / without a session did not render the password field") - } -} - -func TestIndexWithSessionShowsList(t *testing.T) { - cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - if _, err := store.Upsert(Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, - UpdatedAt: time.Now().UnixMilli(), - }); err != nil { - t.Fatalf("Upsert: %v", err) - } - - req := httptest.NewRequest(http.MethodGet, "/", nil) - req.AddCookie(sessionCookie(t, cfg)) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("GET / status = %d, want 200", rr.Code) - } - if !strings.Contains(rr.Body.String(), "Solo Leveling") { - t.Fatal("GET / with a session did not render the bookmark title") - } -} - -func TestLoginSuccessSetsCookie(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) - req := httptest.NewRequest(http.MethodPost, "/login", - strings.NewReader(url.Values{"password": {testPassword}}.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - - if rr.Code != http.StatusSeeOther { - t.Fatalf("POST /login status = %d, want 303", rr.Code) - } - cookies := rr.Result().Cookies() - if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" { - t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName) - } -} - -func TestLoginWrongPassword(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) - req := httptest.NewRequest(http.MethodPost, "/login", - strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - - if rr.Code != http.StatusUnauthorized { - t.Fatalf("POST /login status = %d, want 401", rr.Code) - } - if len(rr.Result().Cookies()) != 0 { - t.Fatal("a failed login set a cookie") - } -} - -func TestLoginRateLimited(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) - post := func() *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodPost, "/login", - strings.NewReader(url.Values{"password": {"wrong"}}.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-Forwarded-For", "203.0.113.9") - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - return rr - } - for i := 0; i < loginMaxFailures; i++ { - if code := post().Code; code != http.StatusUnauthorized { - t.Fatalf("attempt %d status = %d, want 401", i+1, code) - } - } - rr := post() - if rr.Code != http.StatusTooManyRequests { - t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code) - } - if after := rr.Header().Get("Retry-After"); after == "" { - t.Fatal("429 response has no Retry-After header") - } else if n, err := strconv.Atoi(after); err != nil || n <= 0 { - t.Fatalf("Retry-After = %q, want a positive integer", after) - } -} - -func TestLogoutClearsCookie(t *testing.T) { - cfg := webConfig() - srv, _ := newWebTestServer(t, cfg) - req := httptest.NewRequest(http.MethodPost, "/logout", nil) - req.AddCookie(sessionCookie(t, cfg)) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - - if rr.Code != http.StatusSeeOther { - t.Fatalf("POST /logout status = %d, want 303", rr.Code) - } - cookies := rr.Result().Cookies() - if len(cookies) != 1 || cookies[0].MaxAge >= 0 { - t.Fatalf("POST /logout cookies = %+v, want one expiring cookie", cookies) - } -} - -func TestWebDisabledWhenNoPassword(t *testing.T) { - cfg := testConfig() // WebPassword empty - srv, _ := newWebTestServer(t, cfg) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) - - if rr.Code != http.StatusNotFound { - t.Fatalf("GET / with WEB_PASSWORD unset = %d, want 404", rr.Code) - } -} - -func TestBookmarksAPIStillBearerOnly(t *testing.T) { - cfg := webConfig() - srv, _ := newWebTestServer(t, cfg) - - // A session cookie must not grant access to the userscript's JSON API. - req := httptest.NewRequest(http.MethodGet, "/bookmarks", nil) - req.AddCookie(sessionCookie(t, cfg)) - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, req) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("GET /bookmarks with only a cookie = %d, want 401", rr.Code) - } - - // And the bearer token must still work. - rr = httptest.NewRecorder() - srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodGet, "/bookmarks", nil))) - if rr.Code != http.StatusOK { - t.Fatalf("GET /bookmarks with bearer = %d, want 200", rr.Code) - } -} - -func TestStaticAssetsServed(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) - for _, path := range []string{"/static/style.css", "/static/htmx.min.js", "/static/filter.js"} { - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) - if rr.Code != http.StatusOK { - t.Fatalf("GET %s = %d, want 200", path, rr.Code) - } - if rr.Body.Len() == 0 { - t.Fatalf("GET %s returned an empty body", path) - } - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cd backend && go test ./... -run 'TestIndex|TestLogin|TestLogout|TestWeb|TestStatic|TestBookmarksAPI' -v` -Expected: compile failure — `webConfig` references `Config.WebPassword` (present after Task 4) but `newWebHandler` and the routes do not exist, so `GET /` returns 404 and the login tests fail. - -- [ ] **Step 3: Create the templates** - -`backend/templates/login.html`: - -```html -{{define "login"}} - - - - - - - mangaBookmark - - - -
-

mangaBookmark

-
- - - {{if .Error}}

{{.Error}}

{{end}} - -
-
- - -{{end}} -``` - -`backend/templates/app.html`: - -```html -{{define "app"}} - - - - - - - mangaBookmark - - - - - -
-

mangaBookmark

-
- -
-
- - - - - - {{if .Recent}} -
-

Continue reading

- -
- {{end}} - -
- {{template "list" .}} -
- - -{{end}} -``` - -`backend/templates/list.html`: - -```html -{{define "list"}} -{{if .Items}} - {{range .Items}}{{template "card" .}}{{end}} -{{else}} -

- Nothing here yet. Bookmarks appear once the userscript records a chapter. -

-{{end}} -{{end}} -``` - -`backend/templates/card.html` — the interactive attributes land in Task 6; this is the static shape: - -```html -{{define "card"}} - -{{end}} -``` - -- [ ] **Step 4: Create placeholder static assets** - -`TestStaticAssetsServed` requires both files to exist and be non-empty. Task 7 writes the real content. - -`backend/static/style.css`: - -```css -/* Styling lands in Task 7. */ -:root { color-scheme: dark light; } -``` - -`backend/static/filter.js`: - -```js -// Title search and tab-state handling land in Task 7. -function setActiveTab(el) { - el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) { - t.classList.toggle("active", t === el); - }); -} -``` - -- [ ] **Step 5: Implement `web.go`** - -Create `backend/web.go`: - -```go -package main - -import ( - "crypto/subtle" - "embed" - "html/template" - "io/fs" - "log" - "net/http" - "strconv" - "time" -) - -//go:embed templates -var templateFS embed.FS - -//go:embed static -var staticFS embed.FS - -// recentCount is how many series the "Continue reading" strip shows. -const recentCount = 5 - -// webHandler serves the browser UI: full pages at / and htmx fragments at /ui/. -// It is a separate handler from bookmarkHandler because the two speak different -// representations (HTML versus JSON) to different clients under different auth. -type webHandler struct { - store *Store - tmpl *template.Template - key []byte - password string - limiter *loginLimiter -} - -// listView is what every list-rendering template receives. -type listView struct { - Tab string // "all" or "fav" - Recent []Bookmark - Items []Bookmark -} - -// loginView is what the login template receives. -type loginView struct { - Error string -} - -// newWebHandler parses every template up front so a broken one kills the -// process at startup rather than the first request that touches it. -func newWebHandler(store *Store, cfg Config) (*webHandler, error) { - tmpl, err := template.ParseFS(templateFS, "templates/*.html") - if err != nil { - return nil, err - } - return &webHandler{ - store: store, - tmpl: tmpl, - key: sessionKey(cfg.Token), - password: cfg.WebPassword, - limiter: newLoginLimiter(), - }, nil -} - -func (h *webHandler) register(mux *http.ServeMux) { - mux.HandleFunc("GET /{$}", h.index) - mux.HandleFunc("POST /login", h.login) - mux.HandleFunc("POST /logout", h.logout) - mux.Handle("GET /static/", staticHandler()) - - mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList)) -} - -// staticHandler serves the embedded assets. The vendored htmx build and the -// stylesheet change only on deploy, so a long max-age is safe; a redeploy -// changes the binary and the browser revalidates on its own schedule. -func staticHandler() http.Handler { - sub, err := fs.Sub(staticFS, "static") - if err != nil { - panic("embed static: " + err.Error()) - } - files := http.FileServer(http.FS(sub)) - return http.StripPrefix("/static/", http.HandlerFunc( - func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "public, max-age=3600") - files.ServeHTTP(w, r) - })) -} - -// authed reports whether the request carries a valid session cookie. -func (h *webHandler) authed(r *http.Request) bool { - c, err := r.Cookie(sessionCookieName) - return err == nil && verifySession(h.key, c.Value, time.Now().UnixMilli()) -} - -// requireSession guards the fragment endpoints. It answers 401 rather than -// redirecting, because htmx swaps whatever body it receives into the page and a -// redirected login page would be spliced into the card list. -func (h *webHandler) requireSession(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !h.authed(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - next(w, r) - } -} - -func (h *webHandler) render(w http.ResponseWriter, status int, name string, data any) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(status) - if err := h.tmpl.ExecuteTemplate(w, name, data); err != nil { - // The status line is already sent, so this can only be logged. - log.Printf("render %s: %v", name, err) - } -} - -// index renders the list, or the login page when there is no session. The login -// page is served at / with status 200 rather than as a redirect to a separate -// URL: one page, no redirect loop to reason about. -func (h *webHandler) index(w http.ResponseWriter, r *http.Request) { - if !h.authed(r) { - h.render(w, http.StatusOK, "login", loginView{}) - return - } - view, err := h.buildListView(r.URL.Query().Get("tab")) - if err != nil { - log.Printf("index: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - h.render(w, http.StatusOK, "app", view) -} - -// buildListView loads the list once and derives both the tab-filtered items and -// the recent strip from it. The strip always reflects overall recency, not the -// active tab, so it is built before filtering. -func (h *webHandler) buildListView(tab string) (listView, error) { - all, err := h.store.List() // already ordered updated_at DESC - if err != nil { - return listView{}, err - } - - recent := all - if len(recent) > recentCount { - recent = recent[:recentCount] - } - - items := all - if tab == "fav" { - items = []Bookmark{} - for _, b := range all { - if b.Favorite { - items = append(items, b) - } - } - } else { - tab = "all" - } - return listView{Tab: tab, Recent: recent, Items: items}, nil -} - -func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) { - view, err := h.buildListView(r.URL.Query().Get("tab")) - if err != nil { - log.Printf("ui list: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - h.render(w, http.StatusOK, "list", view) -} - -func (h *webHandler) login(w http.ResponseWriter, r *http.Request) { - ip := clientIP(r) - if wait := h.limiter.retryAfter(ip, time.Now()); wait > 0 { - secs := int(wait.Seconds()) + 1 - w.Header().Set("Retry-After", strconv.Itoa(secs)) - h.render(w, http.StatusTooManyRequests, "login", loginView{ - Error: "Too many attempts. Try again in " + - strconv.Itoa((secs+59)/60) + " min.", - }) - return - } - - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid form", http.StatusBadRequest) - return - } - got := r.PostFormValue("password") - if subtle.ConstantTimeCompare([]byte(got), []byte(h.password)) != 1 { - h.limiter.fail(ip, time.Now()) - h.render(w, http.StatusUnauthorized, "login", loginView{Error: "Wrong password."}) - return - } - - h.limiter.reset(ip) - setSessionCookie(w, r, h.key) - http.Redirect(w, r, "/", http.StatusSeeOther) -} - -func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) { - clearSessionCookie(w, r) - http.Redirect(w, r, "/", http.StatusSeeOther) -} -``` - -- [ ] **Step 6: Wire it into the router** - -In `backend/main.go`, inside `newRouter`, after the existing `mux.Handle("/bookmarks/", auth)` line and before the `return`: - -```go - // The browser UI is registered only when a password is configured, so a - // deployment that forgets WEB_PASSWORD exposes nothing rather than - // exposing an unprotected list. - if cfg.WebPassword != "" { - web, err := newWebHandler(store, cfg) - if err != nil { - log.Fatalf("web handler: %v", err) - } - web.register(mux) - } -``` - -`GET /{$}` matches only the exact path `/`, so registering it does not shadow `/bookmarks` or `/healthz`. - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS, including every pre-existing test. `TestBookmarksAPIStillBearerOnly` is the one that proves the userscript's API is unaffected. - -- [ ] **Step 8: Commit** - -```bash -gofmt -w backend/web.go backend/main.go backend/web_test.go -git add backend/web.go backend/web_test.go backend/main.go backend/templates backend/static -git commit -m "feat(backend): password login, session gate, and list page" -``` - ---- - -### Task 6: Favourite, chapter override, and delete fragments - -**Files:** -- Modify: `backend/web.go`, `backend/templates/card.html` -- Test: `backend/web_test.go` - -**Interfaces:** -- Consumes: `Store.Get`, `Store.Upsert`, `Store.Delete`, `webHandler.requireSession`, `webHandler.render`. -- Produces: routes `POST /ui/bookmarks/{key}/favorite`, `POST /ui/bookmarks/{key}/chapter`, `DELETE /ui/bookmarks/{key}`. - -**Decision recorded here:** a manual chapter override clears `last_chapter_url`. The stored URL points at the chapter that was actually read; once the number is forced to something else, that URL is wrong. Clearing it makes Continue fall back to the series page, which is always correct, instead of linking to a chapter the user has already passed. - -- [ ] **Step 1: Write the failing tests** - -Append to `backend/web_test.go`: - -```go -// seed inserts one bookmark and returns it as stored. -func seed(t *testing.T, store *Store, b Bookmark) Bookmark { - t.Helper() - stored, err := store.Upsert(b) - if err != nil { - t.Fatalf("Upsert: %v", err) - } - return stored -} - -func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *http.Request { - t.Helper() - var req *http.Request - if form == nil { - req = httptest.NewRequest(method, path, nil) - } else { - req = httptest.NewRequest(method, path, strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - req.AddCookie(sessionCookie(t, cfg)) - return req -} - -func TestUIRoutesRequireSession(t *testing.T) { - srv, _ := newWebTestServer(t, webConfig()) - cases := []struct{ method, path string }{ - {http.MethodGet, "/ui/list"}, - {http.MethodPost, "/ui/bookmarks/asura:solo/favorite"}, - {http.MethodPost, "/ui/bookmarks/asura:solo/chapter"}, - {http.MethodDelete, "/ui/bookmarks/asura:solo"}, - } - for _, tc := range cases { - t.Run(tc.method+" "+tc.path, func(t *testing.T) { - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) - if rr.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401", rr.Code) - } - }) - } -} - -func TestFavoriteTogglesWithoutReordering(t *testing.T) { - cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - before := seed(t, store, Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, - UpdatedAt: 1_000_000, - }) - - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("favorite status = %d, want 200", rr.Code) - } - - after, ok, err := store.Get("asura:solo") - if err != nil || !ok { - t.Fatalf("Get after favorite: %v ok=%v", err, ok) - } - if !after.Favorite { - t.Fatal("Favorite = false after toggling, want true") - } - if after.UpdatedAt != before.UpdatedAt { - t.Fatalf("UpdatedAt moved from %d to %d; favouriting must not reorder the list", - before.UpdatedAt, after.UpdatedAt) - } - if !strings.Contains(rr.Body.String(), `id="card-asura:solo"`) { - t.Fatal("favorite response did not render the card fragment") - } - - // Toggling again turns it back off. - rr = httptest.NewRecorder() - srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil)) - back, _, _ := store.Get("asura:solo") - if back.Favorite { - t.Fatal("Favorite = true after a second toggle, want false") - } -} - -func TestChapterOverrideMovesUpdatedAt(t *testing.T) { - cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - before := seed(t, store, Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45, - LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo", - UpdatedAt: 1_000_000, - }) - - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, - "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"60"}})) - if rr.Code != http.StatusOK { - t.Fatalf("chapter override status = %d, want 200", rr.Code) - } - - after, ok, err := store.Get("asura:solo") - if err != nil || !ok { - t.Fatalf("Get after override: %v ok=%v", err, ok) - } - if after.LastChapterNum != 60 || after.LastChapter != "60" { - t.Fatalf("chapter = %q/%v, want 60", after.LastChapter, after.LastChapterNum) - } - if after.UpdatedAt <= before.UpdatedAt { - t.Fatalf("UpdatedAt = %d, want later than %d", after.UpdatedAt, before.UpdatedAt) - } - if after.LastChapterURL != "" { - t.Fatalf("LastChapterURL = %q, want cleared by a manual override", after.LastChapterURL) - } - if after.Title != "Solo Leveling" { - t.Fatalf("Title = %q, want the untouched fields preserved", after.Title) - } -} - -func TestChapterOverrideRejectsBadInput(t *testing.T) { - cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000, - }) - - for _, bad := range []string{"", "abc", "-3"} { - t.Run("input "+bad, func(t *testing.T) { - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, - "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {bad}})) - if rr.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", rr.Code) - } - after, _, _ := store.Get("asura:solo") - if after.LastChapterNum != 45 { - t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum) - } - }) - } -} - -func TestMutationsOnMissingKey(t *testing.T) { - cfg := webConfig() - srv, _ := newWebTestServer(t, cfg) - cases := []struct { - name string - req *http.Request - }{ - {"favorite", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)}, - {"chapter", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, tc.req) - if rr.Code != http.StatusNotFound { - t.Fatalf("status = %d, want 404", rr.Code) - } - }) - } -} - -func TestUIDeleteRemovesRow(t *testing.T) { - cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", UpdatedAt: 1_000_000, - }) - - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodDelete, "/ui/bookmarks/asura:solo", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("delete status = %d, want 200", rr.Code) - } - if rr.Body.Len() != 0 { - t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String()) - } - if _, ok, _ := store.Get("asura:solo"); ok { - t.Fatal("row still present after delete") - } -} - -func TestUIListFavouritesTab(t *testing.T) { - cfg := webConfig() - srv, store := newWebTestServer(t, cfg) - seed(t, store, Bookmark{ - Key: "asura:solo", Site: "asura", SeriesID: "solo", - Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000, - }) - seed(t, store, Bookmark{ - Key: "demonic:tower", Site: "demonic", SeriesID: "tower", - Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000, - }) - - rr := httptest.NewRecorder() - srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=fav", nil)) - if rr.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", rr.Code) - } - body := rr.Body.String() - if !strings.Contains(body, "Solo Leveling") { - t.Fatal("favourites tab omitted the favourited series") - } - if strings.Contains(body, "Tower of God") { - t.Fatal("favourites tab included a non-favourite") - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `cd backend && go test ./... -run 'TestUI|TestFavorite|TestChapter|TestMutations' -v` -Expected: FAIL — the mutation routes are unregistered, so `/ui/bookmarks/...` returns 404 where 401 or 200 is expected. - -- [ ] **Step 3: Register the routes** - -In `backend/web.go`, add to `register`, below the `GET /ui/list` line: - -```go - mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite)) - mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter)) - mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete)) -``` - -- [ ] **Step 4: Implement the handlers** - -Append to `backend/web.go`: - -```go -// loadForMutation fetches the row a mutation targets, writing the error -// response itself when there is nothing to mutate. -func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) { - key := r.PathValue("key") - if key == "" { - http.Error(w, "missing key", http.StatusBadRequest) - return Bookmark{}, false - } - b, ok, err := h.store.Get(key) - if err != nil { - log.Printf("ui get %q: %v", key, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return Bookmark{}, false - } - if !ok { - http.Error(w, "not found", http.StatusNotFound) - return Bookmark{}, false - } - return b, true -} - -// saveAndRenderCard upserts and renders the row as stored. Upsert decides -// whether updated_at moves, so the argument's timestamp is only a candidate and -// the response must come from the return value. -func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) { - stored, err := h.store.Upsert(b) - if err != nil { - log.Printf("ui upsert %q: %v", b.Key, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - h.render(w, http.StatusOK, "card", stored) -} - -// uiFavorite flips the favourite flag. last_chapter_num is untouched, so -// Upsert keeps the stored updated_at and the list does not reorder. -func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) { - b, ok := h.loadForMutation(w, r) - if !ok { - return - } - b.Favorite = !b.Favorite - b.UpdatedAt = time.Now().UnixMilli() - h.saveAndRenderCard(w, b) -} - -// uiChapter forces the read chapter to a value the user typed. -// -// It clears last_chapter_url: that URL points at the chapter actually read, and -// once the number is forced elsewhere it would send the reader backwards. -// ContinueURL then falls back to the series page, which is always right. -func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) { - b, ok := h.loadForMutation(w, r) - if !ok { - return - } - if err := r.ParseForm(); err != nil { - http.Error(w, "invalid form", http.StatusBadRequest) - return - } - raw := strings.TrimSpace(r.PostFormValue("chapter")) - num, err := strconv.ParseFloat(raw, 64) - if err != nil || num < 0 { - http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest) - return - } - - b.LastChapter = raw - b.LastChapterNum = num - b.LastChapterURL = "" - b.UpdatedAt = time.Now().UnixMilli() - h.saveAndRenderCard(w, b) -} - -// uiDelete removes the row and answers with an empty body, which htmx swaps in -// place of the card — removing it from the page. -func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) { - key := r.PathValue("key") - if key == "" { - http.Error(w, "missing key", http.StatusBadRequest) - return - } - if err := h.store.Delete(key); err != nil { - log.Printf("ui delete %q: %v", key, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) -} -``` - -Add `"strings"` to the import block in `backend/web.go`. - -- [ ] **Step 5: Add the controls to the card template** - -Replace the `
` block in `backend/templates/card.html` with: - -```html -
- Continue - - - -
- -``` - -- [ ] **Step 6: Add the toggle helper** - -Append to `backend/static/filter.js`: - -```js -function toggleChapterForm(key) { - var form = document.getElementById("chapter-form-" + key); - if (!form) return; - form.hidden = !form.hidden; - if (!form.hidden) form.querySelector("input").focus(); -} -``` - -- [ ] **Step 7: Run the tests to verify they pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS. `TestFavoriteTogglesWithoutReordering` is the important one — it is the regression guard on the `updated_at` rule. - -- [ ] **Step 8: Commit** - -```bash -gofmt -w backend/web.go backend/web_test.go -git add backend/web.go backend/web_test.go backend/templates/card.html backend/static/filter.js -git commit -m "feat(backend): favourite, chapter override, and delete fragments" -``` - ---- - -### Task 7: Styling and client-side search - -**Files:** -- Modify: `backend/static/style.css`, `backend/static/filter.js` - -**Interfaces:** -- Consumes: the class names in the templates from Tasks 5 and 6 — `login-body`, `login-card`, `error`, `topbar`, `ghost`, `search`, `tabs`, `active`, `recent`, `recent-strip`, `recent-card`, `recent-title`, `recent-chapter`, `list`, `card`, `cover`, `body`, `title`, `meta`, `site`, `chapter`, `new`, `actions`, `primary`, `icon`, `on`, `danger`, `chapter-form`, `empty`. The card carries `data-title` for the search filter. -- Produces: nothing other tasks consume. - -- [ ] **Step 1: Write the stylesheet** - -Replace the whole of `backend/static/style.css`: - -```css -/* Mobile first. Dark by default because manga reading happens at night; the - light branch follows the system preference. */ -:root { - color-scheme: dark light; - --bg: #14161a; - --surface: #1d2026; - --surface-2: #262a32; - --text: #e8eaed; - --muted: #9aa1ac; - --accent: #6aa9ff; - --danger: #ff6a6a; - --star: #ffc857; - --radius: 12px; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f4f5f7; - --surface: #ffffff; - --surface-2: #eceef2; - --text: #1a1d22; - --muted: #5d646e; - } -} - -* { box-sizing: border-box; } - -body { - margin: 0; - padding: 0 12px calc(24px + env(safe-area-inset-bottom)); - background: var(--bg); - color: var(--text); - font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -} - -/* Every interactive element clears the 44px touch-target floor. */ -button, .primary, [role="tab"] { - min-height: 44px; - border-radius: var(--radius); - border: 0; - font: inherit; - cursor: pointer; -} - -/* --- login --- */ - -.login-body { - display: grid; - place-items: center; - min-height: 100dvh; -} - -.login-card { - width: min(380px, 100%); - padding: 24px; - background: var(--surface); - border-radius: var(--radius); -} - -.login-card h1 { margin: 0 0 20px; font-size: 1.25rem; } -.login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; } - -.login-card input { - width: 100%; - min-height: 44px; - padding: 0 12px; - margin-bottom: 12px; - background: var(--surface-2); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; -} - -.login-card input:focus-visible { outline: 2px solid var(--accent); } -.login-card button { width: 100%; background: var(--accent); color: #0b1220; font-weight: 600; } -.error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; } - -/* --- chrome --- */ - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 0; -} - -.topbar h1 { margin: 0; font-size: 1.125rem; } -.ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); } - -.search { - width: 100%; - min-height: 44px; - padding: 0 12px; - margin-bottom: 12px; - background: var(--surface); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; -} - -.search:focus-visible { outline: 2px solid var(--accent); } - -.tabs { display: flex; gap: 8px; margin-bottom: 16px; } - -.tabs [role="tab"] { - flex: 1; - display: grid; - place-items: center; - background: var(--surface); - color: var(--muted); - text-decoration: none; -} - -.tabs [role="tab"].active { background: var(--accent); color: #0b1220; font-weight: 600; } - -/* --- continue reading --- */ - -.recent h2 { margin: 0 0 8px; font-size: .8125rem; text-transform: uppercase; color: var(--muted); } - -.recent-strip { - display: flex; - gap: 10px; - overflow-x: auto; - padding-bottom: 8px; - margin-bottom: 16px; - scroll-snap-type: x mandatory; - -webkit-overflow-scrolling: touch; -} - -.recent-card { - flex: 0 0 110px; - scroll-snap-align: start; - display: block; - padding: 8px; - background: var(--surface); - border-radius: var(--radius); - color: var(--text); - text-decoration: none; -} - -.recent-card img { width: 100%; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } -.recent-title { display: block; margin-top: 6px; font-size: .8125rem; line-height: 1.25; - overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } -.recent-chapter { display: block; color: var(--muted); font-size: .75rem; } - -/* --- list --- */ - -.list { display: grid; gap: 10px; } - -.card { - display: grid; - grid-template-columns: 72px 1fr; - gap: 12px; - padding: 10px; - background: var(--surface); - border-radius: var(--radius); -} - -.card .cover img { width: 72px; aspect-ratio: 3 / 4; object-fit: cover; border-radius: 8px; } -.card .body { min-width: 0; } -.card .title { margin: 0 0 4px; font-size: 1rem; line-height: 1.25; } - -.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px; - font-size: .75rem; color: var(--muted); } - -.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase; } -.new { padding: 2px 6px; background: var(--accent); color: #0b1220; border-radius: 6px; font-weight: 700; } - -.actions { display: flex; flex-wrap: wrap; gap: 8px; } - -.primary { - flex: 1 1 auto; - display: grid; - place-items: center; - padding: 0 14px; - background: var(--accent); - color: #0b1220; - font-weight: 600; - text-decoration: none; -} - -.icon { width: 44px; background: var(--surface-2); color: var(--text); font-size: 1.125rem; } -.icon.on { color: var(--star); } -.icon.danger { color: var(--danger); } - -.chapter-form { display: flex; gap: 8px; margin-top: 8px; } - -.chapter-form input { - flex: 1; - min-height: 44px; - padding: 0 12px; - background: var(--surface-2); - color: var(--text); - border: 1px solid transparent; - border-radius: var(--radius); - font: inherit; -} - -.chapter-form button { padding: 0 14px; background: var(--accent); color: #0b1220; font-weight: 600; } -.empty { padding: 32px 12px; text-align: center; color: var(--muted); } - -/* Cards hidden by the search filter. */ -.card[hidden] { display: none; } - -/* --- wide screens --- */ - -@media (min-width: 900px) { - body { max-width: 1100px; margin: 0 auto; padding-inline: 24px; } - .list { grid-template-columns: repeat(2, 1fr); } - .recent-card { flex-basis: 140px; } -} - -@media (min-width: 1300px) { - .list { grid-template-columns: repeat(3, 1fr); } -} - -@media (prefers-reduced-motion: reduce) { - * { animation: none !important; transition: none !important; } -} -``` - -- [ ] **Step 2: Write the search filter** - -Replace the whole of `backend/static/filter.js`: - -```js -// Title search runs entirely in the browser: the full list is already in the -// DOM, so filtering it needs no request. -(function () { - function applyFilter() { - var box = document.getElementById("search"); - if (!box) return; - var needle = box.value.trim().toLowerCase(); - document.querySelectorAll(".card").forEach(function (card) { - var title = (card.dataset.title || "").toLowerCase(); - card.hidden = needle !== "" && title.indexOf(needle) === -1; - }); - } - - document.addEventListener("input", function (e) { - if (e.target && e.target.id === "search") applyFilter(); - }); - - // htmx replaces the list on a tab switch, so re-apply to the new cards. - document.body.addEventListener("htmx:afterSwap", applyFilter); -})(); - -function setActiveTab(el) { - el.parentElement.querySelectorAll("[role=tab]").forEach(function (t) { - t.classList.toggle("active", t === el); - }); -} - -function toggleChapterForm(key) { - var form = document.getElementById("chapter-form-" + key); - if (!form) return; - form.hidden = !form.hidden; - if (!form.hidden) form.querySelector("input").focus(); -} -``` - -- [ ] **Step 3: Verify the tests still pass** - -Run: `cd backend && go test ./... -v` -Expected: PASS. `TestStaticAssetsServed` confirms both files are still embedded and non-empty. - -- [ ] **Step 4: Look at it in a real browser** - -```bash -cd backend -API_TOKEN=dev-token WEB_PASSWORD=dev-pass DB_PATH=/tmp/mangabm-dev.db PORT=8080 go run . -``` - -Seed a row so there is something to look at: - -```bash -curl -X PUT http://localhost:8080/bookmarks/asura:solo \ - -H "Authorization: Bearer dev-token" -H "Content-Type: application/json" \ - -d '{"title":"Solo Leveling","series_url":"https://example.test/solo","last_chapter":"45","last_chapter_num":45,"latest_chapter":"47","latest_chapter_num":47}' -``` - -Open `http://localhost:8080/`, sign in with `dev-pass`, and confirm by hand: -- the login page rejects a wrong password and accepts the right one; -- the card shows the `NEW 47` badge; -- the star toggles and the card does not jump position; -- ✎ opens the number input and saving updates the chapter; -- 🗑 asks for confirmation and removes the card; -- typing in the search box filters; -- switching to Favourites and back works, and the browser back button follows; -- at a narrow width (device toolbar, 390px) nothing overflows horizontally. - -Stop the server when done. - -- [ ] **Step 5: Commit** - -```bash -git add backend/static/style.css backend/static/filter.js -git commit -m "feat(backend): mobile-first styling and client-side title search" -``` - ---- - -### Task 8: Docker, compose, and deployment docs - -**Files:** -- Modify: `backend/Dockerfile`, `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`, `DEPLOY.md`, `CLAUDE.md` - -**Interfaces:** -- Consumes: `WEB_PASSWORD` (Task 4), the `templates/` and `static/` directories (Tasks 5–7). -- Produces: nothing other tasks consume. - -**This task is required, not optional.** The current Dockerfile copies only `*.go`; without the change the image builds and then panics at startup on the missing embedded directories. - -- [ ] **Step 1: Fix the Dockerfile** - -In `backend/Dockerfile`, replace the line: - -```dockerfile -COPY *.go ./ -``` - -with: - -```dockerfile -# Source plus the go:embed'd assets. Missing either directory turns the embed -# directive into a build error, so both must be copied before `go build`. -COPY *.go ./ -COPY templates/ ./templates/ -COPY static/ ./static/ -``` - -- [ ] **Step 2: Verify the image builds and runs** - -```bash -docker build -t mangabm-backend:test ./backend -docker run --rm -e API_TOKEN=dev-token -e WEB_PASSWORD=dev-pass \ - -e DB_PATH=/tmp/test.db -p 8080:8080 mangabm-backend:test & -sleep 2 -curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/healthz -curl -s http://localhost:8080/ | grep -c 'type="password"' -curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/static/htmx.min.js -``` - -Expected: `200`, then `1`, then `200`. Stop the container afterwards (`docker stop $(docker ps -q --filter ancestor=mangabm-backend:test)`). - -If the build fails with `pattern templates: no matching files found`, the COPY lines are wrong or in the wrong stage. - -- [ ] **Step 3: Pass the password through compose** - -In `docker-compose.yml`, add to the `environment:` block under `manga-api`: - -```yaml - # Gates the browser UI. Unset means the web routes are not served at all. - WEB_PASSWORD: ${WEB_PASSWORD:-} -``` - -- [ ] **Step 4: Add the second Traefik router** - -In `docker-compose.prod.yml`, add to the `labels:` list. Both routers point at the one `mangabm` service, so there is no second container and no second certificate resolver: - -```yaml - # Second hostname for the browser UI, same container. Traefik needs the - # service named explicitly once more than one router targets it. - - "traefik.http.routers.mangabm.service=mangabm" - - "traefik.http.routers.mangaweb.rule=Host(`${MANGA_WEB_HOST:-manga.example.com}`)" - - "traefik.http.routers.mangaweb.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" - - "traefik.http.routers.mangaweb.tls=true" - - "traefik.http.routers.mangaweb.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}" - - "traefik.http.routers.mangaweb.service=mangabm" -``` - -Also update the header comment block in that file to list `MANGA_WEB_HOST` alongside `MANGA_API_HOST`. - -- [ ] **Step 5: Document the variables** - -Append to `.env.example`: - -```ini -# --- Web UI --- -# Password for the browser UI at https://$MANGA_WEB_HOST. Leave unset to -# disable the web UI entirely (the routes are not registered at all). -# Generate one: openssl rand -base64 18 -WEB_PASSWORD= - -# Subdomain Traefik routes to the browser UI (prod override only). The same -# container also answers on MANGA_API_HOST for the userscript's API. -# MANGA_WEB_HOST=manga.example.com -``` - -Add a section to `DEPLOY.md` after the existing `.env` section: - -```markdown -## 1b. Web UI - -The browser UI is served by the same container on a second hostname. - -1. Add a DNS `A`/`AAAA` record for `manga.` pointing at the server — - the same address as `manga-api.`. - -2. Set both variables in `.env`: - - ```ini - MANGA_WEB_HOST=manga.violetcrown.my.id - WEB_PASSWORD= - ``` - - Generate and insert in one line: - - ```bash - sed -i "s|^WEB_PASSWORD=.*|WEB_PASSWORD=$(openssl rand -base64 18)|" .env - grep -E '^WEB_PASSWORD=' .env # this is what you type into the site - ``` - -3. Redeploy and check: - - ```bash - docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build - curl -s -o /dev/null -w '%{http_code}\n' https://manga.violetcrown.my.id/ - ``` - - Expected `200`, serving the login page. - -Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/` -returns 404. The userscript's API on `MANGA_API_HOST` is unaffected either way. - -Sessions are signed with a key derived from `API_TOKEN`, so rotating the token -logs every browser out. The session cookie lasts 60 days. -``` - -- [ ] **Step 6: Update the project instructions** - -In `CLAUDE.md`, under **Architecture**, add after the `Endpoints:` bullet: - -```markdown -- **Web UI:** the same binary serves a password-gated browser UI on a second - hostname — `GET /` (list, or login page when there is no session), - `POST /login`, `POST /logout`, `GET /static/*`, and htmx fragment endpoints - under `/ui/*`. Templates and assets are `go:embed`-ed, so `backend/Dockerfile` - must copy `templates/` and `static/` as well as `*.go`. Sessions are stateless - HMAC cookies keyed off `API_TOKEN`; `WEB_PASSWORD` gates them and, when empty, - the web routes are not registered at all. UI mutations read-modify-write - through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one - place. See `docs/superpowers/specs/2026-07-25-web-ui-design.md`. -``` - -Add `WEB_PASSWORD` to the **Config via env** bullet in the same file. - -- [ ] **Step 7: Full verification** - -```bash -cd backend -gofmt -l . # expect no output -go vet ./... # expect no output -go test -race ./... # expect ok -CGO_ENABLED=0 go build -o /dev/null . -``` - -All four must pass before committing. - -- [ ] **Step 8: Commit** - -```bash -git add backend/Dockerfile docker-compose.yml docker-compose.prod.yml .env.example DEPLOY.md CLAUDE.md -git commit -m "chore: build, route, and document the web UI" -``` - ---- - -## Self-Review Notes - -Spec coverage check against `docs/superpowers/specs/2026-07-25-web-ui-design.md`: - -| Spec section | Task | -| --- | --- | -| §3.1 all eight routes | 5 (`/`, `/login`, `/logout`, `/static/*`, `/ui/list`), 6 (three mutations) | -| §3.2 `Store.Get`, read-modify-write | 1, 6 | -| §4.1 `WEB_PASSWORD`, fail-closed 404 | 4, 5 | -| §4.2 cookie format, attributes, verify order | 2 | -| §4.3 CSRF via SameSite=Lax | 2 (attribute), no extra work | -| §4.4 rate limit, rightmost XFF | 3, 5 | -| §5.1 login page | 5 | -| §5.2 list page, strip, NEW badge, actions, search, empty state | 5, 6, 7 | -| §5.3 tabs with pushed URL | 5 | -| §6 test list | 1, 2, 3, 5, 6 | -| §7 deployment | 8 | - -One decision was added during planning and is not in the spec: **a manual chapter override clears `last_chapter_url`** (Task 6). Recorded in the task and covered by `TestChapterOverrideRejectsBadInput`'s sibling `TestChapterOverrideMovesUpdatedAt`. diff --git a/plans/mangaBookmark.md b/plans/mangaBookmark.md deleted file mode 100644 index 2967047..0000000 --- a/plans/mangaBookmark.md +++ /dev/null @@ -1,142 +0,0 @@ -# Manga Bookmark — Userscript + Self-Hosted Sync Backend - -## Context - -The user reads manga on **asurascans.com** (now serves from `asuracomic.net`) and **demonicscans.org**, on a **mobile browser (Bromite)**. They want to bookmark a series and auto-record the latest chapter they've read, with a UI reachable while those sites are open on the phone. Progress must sync across devices, so it lives in a backend on the user's own server. - -### Hard constraints (drive the whole design) - -Bromite uses Chromium's native userscript engine — **not** Tampermonkey. Per Bromite's wiki / Chromium docs: -- **No `GM_setValue` / `GM_getValue`** → persistence must use page `localStorage`. -- **No `GM_registerMenuCommand`** → UI must be injected on-page (floating button + panel). -- **`GM_xmlhttpRequest` is same-origin only** → cross-origin calls use plain `fetch()`, which works only against a **CORS-enabled** backend. -- Userscripts run in an **isolated world** → the manga site's JS cannot read our embedded API token (safe to embed). -- `asurascans.com` and `demonicscans.org` are **separate origins** with **separate `localStorage`** → the only way to unify bookmarks across both sites is a **shared remote store**. Cloud sync is therefore required, not a nice-to-have. -- The manga sites are `https://`, so the backend **must be HTTPS** (mixed-content block otherwise). User already runs a reverse proxy + domain, so a subdomain (e.g. `manga-api.`) fronts the service. - -### Decisions locked with user -- Backend: **self-hosted, Go** (resource-friendly), Docker Compose, behind existing reverse proxy (TLS handled there). -- Scope: **track read progress only** — no "new chapters available" detection (YAGNI for v1). -- Record last-read: **auto on chapter open + manual override** in the panel. -- UI: **floating button + slide-in panel**. - ---- - -## Architecture - -``` -Bromite (mobile) - userscript (isolated world, per-site adapters) - localStorage cache <--> fetch() over HTTPS - | - reverse proxy (TLS, CORS origin) - | - Go service (net/http) -> SQLite file (volume) -``` - -Two deliverables in this repo: - -``` -mangaBookmark/ - backend/ - main.go # server bootstrap, config from env, router - handlers.go # GET/PUT/DELETE /bookmarks, /healthz - store.go # SQLite open + queries (modernc.org/sqlite, CGO_ENABLED=0) - middleware.go # bearer-token auth + CORS/preflight - store_test.go # handler + store tests (httptest + temp sqlite) - go.mod - Dockerfile # multi-stage: golang:alpine build -> scratch/distroless - docker-compose.yml # service + named volume for the sqlite file - userscript/ - manga-bookmark.user.js - README.md # deploy steps + Bromite install steps + config -``` - ---- - -## Backend (Go) - -**Stack:** stdlib `net/http` (no framework needed for 3 routes) + `modernc.org/sqlite` (pure Go → static binary, `scratch` image). Rust/axum + `rusqlite` is a drop-in alternative if preferred later. - -**Data model** — one table, `key` unique across both sites: -```sql -CREATE TABLE IF NOT EXISTS bookmarks ( - key TEXT PRIMARY KEY, -- ":" - site TEXT NOT NULL, -- "asura" | "demonic" - series_id TEXT NOT NULL, - title TEXT, - series_url TEXT, - cover TEXT, - last_chapter TEXT, -- label as shown, e.g. "Chapter 123" - last_chapter_num REAL, -- parsed for max() comparison - last_chapter_url TEXT, - updated_at INTEGER NOT NULL -- unix ms -); -``` - -**Endpoints** (JSON): -- `GET /bookmarks` → array of all bookmarks (single-user store). -- `PUT /bookmarks/{key}` → upsert one series (body = bookmark object). Server sets `updated_at`. -- `DELETE /bookmarks/{key}` → remove one. -- `GET /healthz` → `200 ok` (no auth). - -**Middleware:** -- **Auth:** require `Authorization: Bearer ` (env) on `/bookmarks*`; 401 otherwise. Constant-time compare. -- **CORS:** reflect `Origin` when it's in `ALLOWED_ORIGINS` (env, comma list: `https://asuracomic.net,https://asurascans.com,https://demonicscans.org`). Allow methods `GET,PUT,DELETE,OPTIONS`, headers `Authorization,Content-Type`. Answer preflight `OPTIONS` with `204`. - -**Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS`, `DB_PATH` (default `/data/bookmarks.db`), `PORT` (default `8080`). - -**Dockerfile:** multi-stage — `golang:1.23-alpine` build with `CGO_ENABLED=0 go build`, final stage `gcr.io/distroless/static` (or `scratch`) copying the binary; `/data` volume for the SQLite file. (See `multi-stage-dockerfile` skill.) - -**docker-compose.yml:** one service, named volume mounted at `/data`, env vars, `restart: unless-stopped`. Attach to the existing reverse-proxy network (or expose a local port the proxy targets) — the proxy terminates TLS and routes `manga-api.` → service `:8080`. (See `docker-compose-orchestration` skill.) - ---- - -## Userscript (`manga-bookmark.user.js`) - -Single Bromite-compatible file. **No `GM_*` calls anywhere** (so it also runs in desktop Tampermonkey/Violentmonkey for faster iteration). Wrapped in an IIFE. - -**Metadata header:** `@match https://asuracomic.net/*`, `https://asurascans.com/*`, `https://demonicscans.org/*`; `@run-at document-idle`; `@name`, `@version`. - -**Config block (top of file, user fills in):** -```js -const API_BASE = "https://manga-api."; -const API_TOKEN = ""; -``` - -**Modules inside the IIFE:** - -1. **Site adapters** — one per host, each exposing `detect(location, document)` → `{ type: 'series'|'chapter'|'other', site, seriesId, title, cover, seriesUrl, chapterLabel, chapterNum, chapterUrl }`. - - Identify page **type + IDs from URL regex** (most stable); pull `title`/`cover` from **`og:title` / `og:image` meta tags** (present and stable on both sites, avoids brittle CSS classes). - - Asura: series `…/series/-`, chapter `…/series/-/chapter/` → `seriesId = -`, `chapterNum = n`. - - Demonic: series `…/manga/`, chapter reader `…/title//` (exact reader path to be confirmed against live DOM). - - **URL patterns + selectors get verified against live pages during implementation** (Cloudflare blocks server-side fetch; confirm via Playwright MCP or on-device devtools before finalizing). - -2. **API client** — `apiGet()`, `apiPut(key, obj)`, `apiDelete(key)` via `fetch` with the bearer header. `localStorage` key `mangabm:cache` holds the last-known bookmark list for instant render + offline fallback. - -3. **Progress logic** — on a chapter page of a **bookmarked** series, auto-upsert `last_chapter` when `chapterNum >= stored last_chapter_num` (so re-reading old chapters doesn't regress progress; unparseable → set current). Manual override in the panel forces any value. Bookmarking a new series is available from both series and chapter pages. - -4. **Floating UI** — rendered inside a **Shadow DOM** root (isolates from site CSS; important on mobile). Fixed circular button bottom-right (respects safe-area insets, high `z-index`); tap toggles a slide-in panel: - - Header: context of current page — "+ Bookmark this" if unbookmarked, else current progress + "Update to this chapter". - - List: bookmarks sorted by `updated_at` desc — title, last chapter, **Continue** link (→ `last_chapter_url` or `series_url`), edit-chapter input, remove. - - Toasts for sync success/failure. - -5. **SPA navigation** — Asura is a Next.js client-routed app (no full reload on chapter change). Patch `history.pushState`/`replaceState` + listen for `popstate`, re-run `detect()` on URL change so auto-update fires without reload. Demonic (classic reloads) works via the initial `document-idle` run. - -**Sync strategy:** last-write-wins (single user). On load: `apiGet()` → render → cache. On mutation: optimistic cache+UI update, then `PUT`/`DELETE`; on failure show a toast, keep local, retry on next load. - ---- - -## Verification - -**Backend** -- `go test ./...` — auth (401 without/with bad token), CORS preflight headers + origin reflection, upsert→get→delete round-trip against a temp SQLite file. -- Local smoke: `docker compose up`, then `curl` `GET/PUT/DELETE` with `Authorization` header; confirm `OPTIONS` preflight returns the CORS headers. -- Deployed: hit `https://manga-api./healthz`; confirm valid TLS (no mixed-content) and preflight from a real site origin. - -**Userscript** -- Confirm adapter URL regex + `og:` extraction on live Asura + Demonic pages (Playwright MCP or on-device devtools) before finalizing selectors. -- Desktop dry-run in Tampermonkey/Violentmonkey (same file, no GM APIs): bookmark a series, open a chapter, verify panel updates and `curl GET /bookmarks` on the server reflects it. -- On Bromite: install per README, repeat the flow on both sites; verify auto-update on chapter open, manual override, Continue link, and cross-site unified list (bookmark on Asura shows when panel opened on Demonic). - -**Open items to confirm during build:** exact Demonic reader URL path + chapter-number source; Asura's live series/chapter path (post-`asuracomic.net` migration); Bromite's current userscript-install steps (documented in README, verified on-device).