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
8 changed files with 88 additions and 28 deletions
Showing only changes of commit 22bf68f12f - Show all commits
+4 -3
View File
@@ -23,6 +23,7 @@ ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicsca
# Generate one: openssl rand -base64 18
WEB_PASSWORD=
# Subdomain Traefik routes to the browser UI (prod override only). The same
# container also answers on MANGA_API_HOST for the userscript's API.
# MANGA_WEB_HOST=manga.example.com
# Subdomain Traefik routes to the browser UI (required by the prod override
# whenever the web UI is enabled). The same container also answers on
# MANGA_API_HOST for the userscript's API.
MANGA_WEB_HOST=manga.example.com
+1 -1
View File
@@ -26,7 +26,7 @@ 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
+7 -2
View File
@@ -93,8 +93,13 @@ The browser UI is served by the same container on a second hostname.
Leaving `WEB_PASSWORD` unset is safe: the web routes are not registered and `/`
returns 404. The userscript's API on `MANGA_API_HOST` is unaffected either way.
Sessions are signed with a key derived from `API_TOKEN`, so rotating the token
logs every browser out. The session cookie lasts 60 days.
`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.
---
+11 -5
View File
@@ -22,11 +22,13 @@ const (
sessionKeyPurpose = "mangabm-web-session-v1"
)
// sessionKey derives the cookie-signing key from the API token. Sessions are
// stateless — there is no session table — so rotating API_TOKEN invalidates
// every outstanding cookie at once.
func sessionKey(apiToken string) []byte {
sum := sha256.Sum256([]byte(apiToken + sessionKeyPurpose))
// 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[:]
}
@@ -166,6 +168,10 @@ func (l *loginLimiter) reset(ip string) {
// 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. The range expression captures the slice header once at
// the start, so the append cursor (kept) can never outrun the read
// cursor (the range index) — safe to alias.
kept := l.failures[ip][:0]
for _, at := range l.failures[ip] {
if at.After(cutoff) {
+14 -6
View File
@@ -10,7 +10,7 @@ import (
)
func TestSessionRoundTrip(t *testing.T) {
key := sessionKey("token-abc")
key := sessionKey("token-abc", "pw-abc")
now := time.Now().UnixMilli()
value := signSession(key, now+60_000)
if !verifySession(key, value, now) {
@@ -19,7 +19,7 @@ func TestSessionRoundTrip(t *testing.T) {
}
func TestSessionRejects(t *testing.T) {
key := sessionKey("token-abc")
key := sessionKey("token-abc", "pw-abc")
now := time.Now().UnixMilli()
valid := signSession(key, now+60_000)
payload, sig, _ := strings.Cut(valid, ".")
@@ -34,7 +34,7 @@ func TestSessionRejects(t *testing.T) {
{"expired", signSession(key, now-1)},
{"tampered signature", payload + "." + flipLastChar(sig)},
{"tampered expiry", "99999999999999." + sig},
{"signed with another key", signSession(sessionKey("other-token"), now+60_000)},
{"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) {
@@ -57,13 +57,21 @@ func flipLastChar(s string) string {
}
func TestSessionKeyDependsOnToken(t *testing.T) {
a := sessionKey("token-a")
b := sessionKey("token-b")
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
@@ -86,7 +94,7 @@ func TestSetSessionCookieAttributes(t *testing.T) {
r.Header.Set("X-Forwarded-Proto", tc.forwarded)
}
rr := httptest.NewRecorder()
setSessionCookie(rr, r, sessionKey("token-abc"))
setSessionCookie(rr, r, sessionKey("token-abc", "pw-abc"))
cookies := rr.Result().Cookies()
if len(cookies) != 1 {
+14 -8
View File
@@ -55,7 +55,7 @@ func newWebHandler(store *Store, cfg Config) (*webHandler, error) {
return &webHandler{
store: store,
tmpl: tmpl,
key: sessionKey(cfg.Token),
key: sessionKey(cfg.Token, cfg.WebPassword),
password: cfg.WebPassword,
limiter: newLoginLimiter(),
}, nil
@@ -73,9 +73,10 @@ func (h *webHandler) register(mux *http.ServeMux) {
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
}
// staticHandler serves the embedded assets. The vendored htmx build and the
// stylesheet change only on deploy, so a long max-age is safe; a redeploy
// changes the binary and the browser revalidates on its own schedule.
// 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 {
@@ -253,9 +254,12 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
// uiChapter forces the read chapter to a value the user typed.
//
// It clears last_chapter_url: that URL points at the chapter actually read, and
// once the number is forced elsewhere it would send the reader backwards.
// ContinueURL then falls back to the series page, which is always right.
// When that value actually changes the number, it 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. Resubmitting the same number — the
// form is pre-filled, so a bare tap of Save is an easy accidental submit —
// leaves last_chapter_url untouched instead of destroying it for no reason.
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
b, ok := h.loadForMutation(w, r)
if !ok {
@@ -272,9 +276,11 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
return
}
if num != b.LastChapterNum {
b.LastChapterURL = ""
}
b.LastChapter = raw
b.LastChapterNum = num
b.LastChapterURL = ""
b.UpdatedAt = time.Now().UnixMilli()
h.saveAndRenderCard(w, b)
}
+35 -1
View File
@@ -36,7 +36,7 @@ func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
t.Helper()
return &http.Cookie{
Name: sessionCookieName,
Value: signSession(sessionKey(cfg.Token), time.Now().Add(time.Hour).UnixMilli()),
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
}
}
@@ -352,6 +352,40 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
}
}
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", 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 silently clear last_chapter_url or move updated_at.
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.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)
+2 -2
View File
@@ -5,7 +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
# 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
@@ -30,7 +30,7 @@ services:
# Second hostname for the browser UI, same container. Traefik needs the
# service named explicitly once more than one router targets it.
- "traefik.http.routers.mangabm.service=mangabm"
- "traefik.http.routers.mangaweb.rule=Host(`${MANGA_WEB_HOST:-manga.example.com}`)"
- "traefik.http.routers.mangaweb.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}"