feat: password-gated web UI on the same backend #1

Merged
sulthan merged 14 commits from feat/web-ui into main 2026-07-26 03:59:59 +07:00
24 changed files with 4446 additions and 7 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "CMD=$(python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); case \"$CMD\" in *grep*|*rg\\ *|*ripgrep*|*find\\ *|*fd\\ *|*ack\\ *|*ag\\ *) [ -f graphify-out/graph.json ] && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \\\"<question>\\\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines.\"}}' || true ;; esac"
}
]
},
{
"matcher": "Read|Glob",
"hooks": [
{
"type": "command",
"command": "HIT=$(python3 -c \"import json,sys;d=json.load(sys.stdin);t=d.get('tool_input',d);exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];j=' '.join(vals).lower().replace(chr(92),'/');tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"additionalContext\":\"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \\\"<question>\\\"` (scoped subgraph), `graphify explain \\\"<concept>\\\"`, or `graphify path \\\"<A>\\\" \\\"<B>\\\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too \u2014 include it in every subagent prompt involving code exploration.\"}}'; fi || true"
}
]
}
]
}
}
+13
View File
@@ -16,3 +16,16 @@ ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicsca
# Traefik HTTPS entrypoint + cert resolver names, if yours differ from these.
# TRAEFIK_ENTRYPOINT=websecure
# TRAEFIK_CERTRESOLVER=le
# --- 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 (required by the prod override,
# whether or not WEB_PASSWORD is set). Left commented on purpose: an example
# value here would be a silent wrong-hostname fallback, and Traefik would
# publish the UI router on a domain you do not own. The same container also
# answers on MANGA_API_HOST for the userscript's API.
# MANGA_WEB_HOST=manga.example.com
+11 -2
View File
@@ -26,11 +26,20 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache)
-- fetch() HTTPS --> reverse proxy (TLS + CORS) --> Go net/http --> SQLite (volume)
```
- **Backend** (`backend/`): stdlib `net/http` (3 routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). The reverse proxy terminates TLS; the Go service listens plain `:8080`.
- **Backend** (`backend/`): stdlib `net/http` (a handful of routes, no framework) + `modernc.org/sqlite` (pure Go, `CGO_ENABLED=0` -> static binary -> distroless/scratch image). The reverse proxy terminates TLS; the Go service listens plain `:8080`.
- **Single-user store.** One `bookmarks` table keyed `<site>:<series_id>` (`asura`|`demonic`). Sync is **last-write-wins**. Schema and endpoint list are in the plan.
- **Endpoints:** `GET /bookmarks`, `PUT /bookmarks/{key}` (upsert; see `updated_at` rule below), `DELETE /bookmarks/{key}`, `GET /healthz` (no auth).
- **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`.
- **`updated_at` drives list order, so it moves only on real reading progress:** the server applies its timestamp when the row is new or `last_chapter_num` changes, and otherwise keeps the stored value — favouriting a series or recording a newly published chapter must not reorder the list. `PUT` therefore returns the row **as stored**, and clients must adopt that response rather than their own payload. See `plans/2026-07-25-bookmark-list-favorites-design.md` §4.
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` (default `/data/bookmarks.db`), `PORT` (default `8080`).
- **Config via env:** `API_TOKEN`, `ALLOWED_ORIGINS` (comma list), `DB_PATH` (default `/data/bookmarks.db`), `PORT` (default `8080`), `WEB_PASSWORD` (gates the browser UI; unset disables it).
### Userscript structure (single IIFE, `manga-bookmark.user.js`)
+47 -1
View File
@@ -38,8 +38,11 @@ API_TOKEN=<paste output of: openssl rand -hex 32>
# CORS allowlist — leave as-is unless a site changes hostname.
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
# Required for the Traefik override.
# Required for the Traefik override. Both have no fallback — compose refuses
# to start without them. MANGA_WEB_HOST is required even if you never set
# WEB_PASSWORD; see 1b.
MANGA_API_HOST=manga-api.violetcrown.my.id
MANGA_WEB_HOST=manga.violetcrown.my.id
# Only if your Traefik setup differs from these defaults:
# PROXY_NETWORK=proxy
@@ -60,6 +63,49 @@ grep -E '^API_TOKEN=' .env # copy this — the userscript needs the same value
---
## 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.<yourdomain>` pointing at the server —
the same address as `manga-api.<yourdomain>`.
2. Set both variables in `.env`:
```ini
MANGA_WEB_HOST=manga.violetcrown.my.id
WEB_PASSWORD=<paste output of: openssl rand -base64 18>
```
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.
`MANGA_WEB_HOST` itself is required by the prod override regardless — like
`MANGA_API_HOST`, its Traefik label has no fallback, so `docker compose up`
refuses to start without it even if `WEB_PASSWORD` is unset and the web UI is
otherwise dormant.
Sessions are signed with a key derived from `API_TOKEN` and `WEB_PASSWORD`, so
rotating either one logs every browser out. The session cookie lasts 60 days.
---
## 2. Build + start
```bash
+6 -1
View File
@@ -1,5 +1,10 @@
# Only go source + module files are needed in the build context.
# Only go source + module files, plus the go:embed'd templates/static
# directories, are needed in the build context.
*
!go.mod
!go.sum
!*.go
!templates/
!templates/**
!static/
!static/**
+4
View File
@@ -9,7 +9,11 @@ COPY go.mod go.sum ./
RUN go mod download
# Then source (changes often).
# Source plus the go:embed'd assets. Missing either directory turns the embed
# directive into a build error, so both must be copied before `go build`.
COPY *.go ./
COPY templates/ ./templates/
COPY static/ ./static/
# Static binary: pure-Go sqlite means CGO_ENABLED=0 -> no libc dependency.
# -trimpath + -ldflags strip paths and debug info for a smaller image.
+17 -3
View File
@@ -18,6 +18,8 @@ type Config struct {
AllowedOrigins []string
DBPath string
Port string
// WebPassword gates the browser UI. Empty disables the web routes entirely.
WebPassword string
}
func envOr(key, def string) string {
@@ -29,9 +31,10 @@ func envOr(key, def string) string {
func loadConfig() Config {
c := Config{
Token: os.Getenv("API_TOKEN"),
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
Port: envOr("PORT", "8080"),
Token: os.Getenv("API_TOKEN"),
DBPath: envOr("DB_PATH", "/data/bookmarks.db"),
Port: envOr("PORT", "8080"),
WebPassword: os.Getenv("WEB_PASSWORD"),
}
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
if o = strings.TrimSpace(o); o != "" {
@@ -58,6 +61,17 @@ func newRouter(store *Store, cfg Config) http.Handler {
mux.Handle("/bookmarks", auth)
mux.Handle("/bookmarks/", auth)
// 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)
}
return withCORS(cfg.AllowedOrigins, mux)
}
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"net"
"net/http"
"strconv"
"strings"
"sync"
"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 the secrets it is derived from. Changing this string logs
// everyone out.
sessionKeyPurpose = "mangabm-web-session-v1"
)
// sessionKey derives the cookie-signing key from both secrets. Sessions are
// stateless — there is no session table — so rotating either API_TOKEN or
// WEB_PASSWORD invalidates every outstanding cookie at once. The \x00
// separator prevents the concatenation ambiguity a bare apiToken+webPassword
// would have (e.g. "ab"+"c" colliding with "a"+"bc").
func sessionKey(apiToken, webPassword string) []byte {
sum := sha256.Sum256([]byte(apiToken + "\x00" + webPassword + sessionKeyPurpose))
return sum[:]
}
// signSession encodes "<expiryMs>.<base64url HMAC(expiryMs)>".
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,
})
}
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)
// In-place filter: kept reuses the backing array of the slice being
// ranged over. Safe to alias because append writes at index len(kept),
// which is always <= the range index i, and element i is read before
// that write — the write cursor can never overtake the read cursor.
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
}
+232
View File
@@ -0,0 +1,232 @@
package main
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestSessionRoundTrip(t *testing.T) {
key := sessionKey("token-abc", "pw-abc")
now := time.Now().UnixMilli()
value := signSession(key, now+60_000)
if !verifySession(key, value, now) {
t.Fatal("verifySession = false for a freshly signed cookie, want true")
}
}
func TestSessionRejects(t *testing.T) {
key := sessionKey("token-abc", "pw-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", "pw-abc"), now+60_000)},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if verifySession(key, tc.value, now) {
t.Fatalf("verifySession(%q) = true, want false", tc.value)
}
})
}
}
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", "pw-abc")
b := sessionKey("token-b", "pw-abc")
if string(a) == string(b) {
t.Fatal("sessionKey collided for different API tokens")
}
}
func TestSessionKeyDependsOnWebPassword(t *testing.T) {
a := sessionKey("token-abc", "pw-a")
b := sessionKey("token-abc", "pw-b")
if string(a) == string(b) {
t.Fatal("sessionKey collided for different web passwords with the same API token")
}
}
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", "pw-abc"))
cookies := rr.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("got %d cookies, want 1", len(cookies))
}
c := cookies[0]
if c.Name != sessionCookieName {
t.Fatalf("cookie name = %q, want %q", c.Name, sessionCookieName)
}
if !c.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)
}
}
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)
}
}
+33
View File
@@ -0,0 +1,33 @@
// 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();
}
+1
View File
File diff suppressed because one or more lines are too long
+225
View File
@@ -0,0 +1,225 @@
/* 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. The .chapter-form { display: flex }
rule above has equal specificity to the UA [hidden] rule, so without this
override the form stays visible even when the hidden attribute is set. */
.card[hidden] { display: none; }
.chapter-form[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; }
}
+32
View File
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
"errors"
"fmt"
"strings"
@@ -28,6 +29,22 @@ type Bookmark struct {
UpdatedAt int64 `json:"updated_at"` // unix ms; see Upsert
}
// 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
}
const schema = `
CREATE TABLE IF NOT EXISTS bookmarks (
key TEXT PRIMARY KEY,
@@ -182,6 +199,21 @@ func (s *Store) List() ([]Bookmark, error) {
return out, rows.Err()
}
// 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
}
// Upsert inserts or replaces a bookmark by key (last-write-wins) and returns
// the row as actually stored.
//
+95
View File
@@ -34,6 +34,16 @@ func newTestServer(t *testing.T) http.Handler {
return newRouter(store, testConfig())
}
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
}
func auth(req *http.Request) *http.Request {
req.Header.Set("Authorization", "Bearer "+testToken)
return req
@@ -433,3 +443,88 @@ func TestOpenStoreMigratesLegacySchema(t *testing.T) {
}
store2.Close()
}
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)
}
})
}
}
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)
}
}
+53
View File
@@ -0,0 +1,53 @@
{{define "app"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>mangaBookmark</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/filter.js" defer></script>
</head>
<body>
<header class="topbar">
<h1>mangaBookmark</h1>
<form method="post" action="/logout">
<button type="submit" class="ghost">Log out</button>
</form>
</header>
<input id="search" class="search" type="search" placeholder="Search titles…"
autocomplete="off" aria-label="Search titles">
<nav class="tabs" role="tablist">
<a role="tab" href="/?tab=all" class="{{if eq .Tab "all"}}active{{end}}"
hx-get="/ui/list?tab=all" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=all" hx-on::after-request="setActiveTab(this)">All</a>
<a role="tab" href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
hx-push-url="/?tab=fav" hx-on::after-request="setActiveTab(this)">Favourites</a>
</nav>
{{if .Recent}}
<section class="recent">
<h2>Continue reading</h2>
<div class="recent-strip">
{{range .Recent}}
<a class="recent-card" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}}
<span class="recent-title">{{.Title}}</span>
<span class="recent-chapter">Ch {{.LastChapter}}</span>
</a>
{{end}}
</div>
</section>
{{end}}
<main id="list" class="list">
{{template "list" .}}
</main>
</body>
</html>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "card"}}
<article class="card" id="card-{{.Key}}" data-title="{{.Title}}">
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">{{end}}
</a>
<div class="body">
<h3 class="title">{{.Title}}</h3>
<p class="meta">
<span class="site site-{{.Site}}">{{.Site}}</span>
<span class="chapter">Ch {{.LastChapter}}</span>
{{if .HasNewChapter}}<span class="new">NEW {{.LatestChapter}}</span>{{end}}
</p>
<div class="actions">
<a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a>
<button class="icon {{if .Favorite}}on{{end}}"
title="Favourite" aria-label="Toggle favourite"
hx-post="/ui/bookmarks/{{.Key}}/favorite"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML">
{{if .Favorite}}★{{else}}☆{{end}}
</button>
<button class="icon" title="Set chapter" aria-label="Set chapter"
onclick="toggleChapterForm('{{.Key}}')">✎</button>
<button class="icon danger" title="Remove" aria-label="Remove"
hx-delete="/ui/bookmarks/{{.Key}}"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML"
hx-confirm="Remove {{.Title}} from the list?">🗑</button>
</div>
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
hx-post="/ui/bookmarks/{{.Key}}/chapter"
hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML">
<input name="chapter" type="number" step="0.1" min="0"
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
<button type="submit">Save</button>
</form>
</div>
</article>
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{define "list"}}
{{if .Items}}
{{range .Items}}{{template "card" .}}{{end}}
{{else}}
<p class="empty">
Nothing here yet. Bookmarks appear once the userscript records a chapter.
</p>
{{end}}
{{end}}
+24
View File
@@ -0,0 +1,24 @@
{{define "login"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>mangaBookmark</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<main class="login-card">
<h1>mangaBookmark</h1>
<form method="post" action="/login">
<label for="password">Password</label>
<input id="password" name="password" type="password"
autocomplete="current-password" autofocus required>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Sign in</button>
</form>
</main>
</body>
</html>
{{end}}
+306
View File
@@ -0,0 +1,306 @@
package main
import (
"crypto/subtle"
"embed"
"html/template"
"io/fs"
"log"
"math"
"net/http"
"strconv"
"strings"
"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, cfg.WebPassword),
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))
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))
}
// staticHandler serves the embedded assets. An hour, not longer: assets are
// not fingerprinted, and embed.FS reports a zero ModTime, so http.FileServer
// emits no Last-Modified or ETag and a client has no way to revalidate a
// cached copy after a deploy short of waiting out max-age.
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)
}
// 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.
//
// Writing the number also 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.
//
// A submit that does not change the number touches nothing. The form is
// pre-filled, so a bare tap of Save is an easy accidental submit; it must not
// destroy last_chapter_url, nor rewrite the last_chapter display string ("45.0"
// to "45") behind a frozen updated_at.
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
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 || math.IsNaN(num) || math.IsInf(num, 0) {
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
return
}
if num != b.LastChapterNum {
b.LastChapterURL = ""
b.LastChapter = raw
b.LastChapterNum = num
}
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)
}
+485
View File
@@ -0,0 +1,485 @@
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, cfg.WebPassword), 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)
}
}
}
// 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")
}
}
// TestCardHxTargetIsValidSelectorForColonKey asserts the rendered card's
// hx-target attributes use the fixed-string attribute-selector form
// ([id='card-<key>']) rather than a bare CSS id-selector (#card-<key>).
//
// A key like "asura:solo" makes "#card-asura:solo" an invalid CSS selector:
// the browser parses ":solo" as an unrecognised pseudo-class and htmx's
// querySelectorAll throws SyntaxError, so the button never resolves its
// swap target. httptest never executes htmx, so this only checks the
// rendered attribute's shape — it is not proof the browser accepts the
// selector, just a regression guard against reintroducing the bare-id form.
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
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.MethodGet, "/ui/list", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
want := `hx-target="[id='card-asura:solo']"`
if strings.Count(body, want) != 3 {
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)",
strings.Count(body, want), want)
}
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
t.Fatal("body still uses the bare id CSS selector, which is invalid for a key containing ':'")
}
}
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 TestChapterOverrideNoOpPreservesURLAndUpdatedAt(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.0", LastChapterNum: 45,
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
UpdatedAt: 1_000_000,
})
// The chapter form is pre-filled with the current value, so tapping Save
// without editing resubmits the unchanged number. That must be a no-op: it
// must not clear last_chapter_url, rewrite the last_chapter display string,
// or move updated_at. The seed stores "45.0" against 45 so the display
// string differs from what the form submits back.
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"45"}}))
if rr.Code != http.StatusOK {
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
}
after, ok, err := store.Get("asura:solo")
if err != nil || !ok {
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
}
if after.LastChapterURL != before.LastChapterURL {
t.Fatalf("LastChapterURL = %q, want preserved %q on a no-op save",
after.LastChapterURL, before.LastChapterURL)
}
if after.LastChapter != before.LastChapter {
t.Fatalf("LastChapter = %q, want preserved %q on a no-op save",
after.LastChapter, before.LastChapter)
}
if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt = %d, want unchanged %d on a no-op save",
after.UpdatedAt, before.UpdatedAt)
}
}
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", "NaN", "Infinity", "-Inf"} {
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")
}
}
+9
View File
@@ -5,6 +5,7 @@
#
# Set in .env:
# MANGA_API_HOST=manga-api.example.com # your subdomain (required)
# MANGA_WEB_HOST=manga.example.com # browser UI subdomain, same container (required)
# PROXY_NETWORK=proxy # Traefik's network name, if not "proxy"
# TRAEFIK_ENTRYPOINT=websecure # your HTTPS entrypoint name
# TRAEFIK_CERTRESOLVER=le # your ACME/cert resolver name
@@ -26,6 +27,14 @@ services:
- "traefik.http.routers.mangabm.tls=true"
- "traefik.http.routers.mangabm.tls.certresolver=${TRAEFIK_CERTRESOLVER:-le}"
- "traefik.http.services.mangabm.loadbalancer.server.port=8080"
# 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:?set MANGA_WEB_HOST in .env}`)"
- "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"
networks:
proxy:
+2
View File
@@ -18,6 +18,8 @@ services:
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-https://asuracomic.net,https://asurascans.com,https://demonicscans.org}
DB_PATH: /data/bookmarks.db
PORT: "8080"
# Gates the browser UI. Unset means the web routes are not served at all.
WEB_PASSWORD: ${WEB_PASSWORD:-}
volumes:
- bookmarks-data:/data
# Bound to loopback only: the proxy (or curl during smoke test) reaches it,
@@ -0,0 +1,263 @@
# 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:
```
<expiry_unix_ms> "." base64url(HMAC-SHA256(<expiry_unix_ms>, 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.
File diff suppressed because it is too large Load Diff