feat: password-gated web UI on the same backend #1
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
@@ -32,6 +34,7 @@ func loadConfig() Config {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -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; }
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user