feat: password-gated web UI on the same backend #1
+6
-4
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user