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
6 changed files with 41 additions and 23 deletions
Showing only changes of commit edae491161 - Show all commits
+6 -4
View File
@@ -23,7 +23,9 @@ 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 (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
# 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
+4 -1
View File
@@ -38,8 +38,11 @@ API_TOKEN=<paste output of: openssl rand -hex 32>
# CORS allowlist — leave as-is unless a site changes hostname.
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
# Required for the Traefik override.
# Required for the Traefik override. Both have no fallback — compose refuses
# to start without them. MANGA_WEB_HOST is required even if you never set
# WEB_PASSWORD; see 1b.
MANGA_API_HOST=manga-api.violetcrown.my.id
MANGA_WEB_HOST=manga.violetcrown.my.id
# Only if your Traefik setup differs from these defaults:
# PROXY_NETWORK=proxy
+5 -4
View File
@@ -18,7 +18,8 @@ const (
// 60 days: long enough that a phone stays logged in between reading spells.
sessionTTL = 60 * 24 * time.Hour
// Domain separation, so the session key can never collide with any other
// use of API_TOKEN. Changing this string logs everyone out.
// use of the secrets it is derived from. Changing this string logs
// everyone out.
sessionKeyPurpose = "mangabm-web-session-v1"
)
@@ -169,9 +170,9 @@ func (l *loginLimiter) reset(ip string) {
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.
// 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) {
+10 -7
View File
@@ -254,12 +254,15 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
// uiChapter forces the read chapter to a value the user typed.
//
// 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.
// 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 {
@@ -278,9 +281,9 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
if num != b.LastChapterNum {
b.LastChapterURL = ""
}
b.LastChapter = raw
b.LastChapterNum = num
}
b.UpdatedAt = time.Now().UnixMilli()
h.saveAndRenderCard(w, b)
}
+9 -3
View File
@@ -357,14 +357,16 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
srv, store := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
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 silently clear last_chapter_url or move updated_at.
// 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"}}))
@@ -380,6 +382,10 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
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)
@@ -122,11 +122,14 @@ Name `mangabm_session`. Value:
```
<expiry_unix_ms> "." base64url(HMAC-SHA256(<expiry_unix_ms>, key))
key = SHA256(API_TOKEN || "mangabm-web-session-v1")
key = SHA256(API_TOKEN || 0x00 || WEB_PASSWORD || "mangabm-web-session-v1")
```
Stateless: no session table, sessions survive restarts, and rotating
`API_TOKEN` invalidates every session at once.
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