fix: keep MANGA_WEB_HOST unset in .env.example and make no-op saves inert
Re-review of the fix wave found the .env.example edit defeated the fix
it belonged to: shipping MANGA_WEB_HOST=manga.example.com re-supplied
the value that ${MANGA_WEB_HOST:?} exists to reject, so a fresh
`cp .env.example .env` started fine and Traefik published the UI router
on a domain the operator does not own. Left commented, matching
MANGA_API_HOST; DEPLOY.md 1 now lists it among the required variables.
Also:
- uiChapter leaves last_chapter too, not only last_chapter_url, when the
submitted number is unchanged. It used to rewrite the display string
("45.0" to "45") behind a frozen updated_at.
- Design spec 4.2 documents the two-secret key derivation.
- Corrected the pruneLocked aliasing rationale and the stale
sessionKeyPurpose comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+6
-4
@@ -23,7 +23,9 @@ ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicsca
|
|||||||
# Generate one: openssl rand -base64 18
|
# Generate one: openssl rand -base64 18
|
||||||
WEB_PASSWORD=
|
WEB_PASSWORD=
|
||||||
|
|
||||||
# Subdomain Traefik routes to the browser UI (required by the prod override
|
# Subdomain Traefik routes to the browser UI (required by the prod override,
|
||||||
# whenever the web UI is enabled). The same container also answers on
|
# whether or not WEB_PASSWORD is set). Left commented on purpose: an example
|
||||||
# MANGA_API_HOST for the userscript's API.
|
# value here would be a silent wrong-hostname fallback, and Traefik would
|
||||||
MANGA_WEB_HOST=manga.example.com
|
# 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.
|
# CORS allowlist — leave as-is unless a site changes hostname.
|
||||||
ALLOWED_ORIGINS=https://asuracomic.net,https://asurascans.com,https://demonicscans.org
|
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_API_HOST=manga-api.violetcrown.my.id
|
||||||
|
MANGA_WEB_HOST=manga.violetcrown.my.id
|
||||||
|
|
||||||
# Only if your Traefik setup differs from these defaults:
|
# Only if your Traefik setup differs from these defaults:
|
||||||
# PROXY_NETWORK=proxy
|
# PROXY_NETWORK=proxy
|
||||||
|
|||||||
+5
-4
@@ -18,7 +18,8 @@ const (
|
|||||||
// 60 days: long enough that a phone stays logged in between reading spells.
|
// 60 days: long enough that a phone stays logged in between reading spells.
|
||||||
sessionTTL = 60 * 24 * time.Hour
|
sessionTTL = 60 * 24 * time.Hour
|
||||||
// Domain separation, so the session key can never collide with any other
|
// 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"
|
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 {
|
func (l *loginLimiter) pruneLocked(ip string, now time.Time) []time.Time {
|
||||||
cutoff := now.Add(-loginWindow)
|
cutoff := now.Add(-loginWindow)
|
||||||
// In-place filter: kept reuses the backing array of the slice being
|
// In-place filter: kept reuses the backing array of the slice being
|
||||||
// ranged over. The range expression captures the slice header once at
|
// ranged over. Safe to alias because append writes at index len(kept),
|
||||||
// the start, so the append cursor (kept) can never outrun the read
|
// which is always <= the range index i, and element i is read before
|
||||||
// cursor (the range index) — safe to alias.
|
// that write — the write cursor can never overtake the read cursor.
|
||||||
kept := l.failures[ip][:0]
|
kept := l.failures[ip][:0]
|
||||||
for _, at := range l.failures[ip] {
|
for _, at := range l.failures[ip] {
|
||||||
if at.After(cutoff) {
|
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.
|
// uiChapter forces the read chapter to a value the user typed.
|
||||||
//
|
//
|
||||||
// When that value actually changes the number, it also clears last_chapter_url:
|
// Writing the number also clears last_chapter_url: that URL points at the
|
||||||
// that URL points at the chapter actually read, and once the number is forced
|
// chapter actually read, and once the number is forced elsewhere it would send
|
||||||
// elsewhere it would send the reader backwards. ContinueURL then falls back to
|
// the reader backwards. ContinueURL then falls back to the series page, which
|
||||||
// the series page, which is always right. Resubmitting the same number — the
|
// is always right.
|
||||||
// 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.
|
// 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) {
|
func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
||||||
b, ok := h.loadForMutation(w, r)
|
b, ok := h.loadForMutation(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -278,9 +281,9 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if num != b.LastChapterNum {
|
if num != b.LastChapterNum {
|
||||||
b.LastChapterURL = ""
|
b.LastChapterURL = ""
|
||||||
}
|
|
||||||
b.LastChapter = raw
|
b.LastChapter = raw
|
||||||
b.LastChapterNum = num
|
b.LastChapterNum = num
|
||||||
|
}
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
b.UpdatedAt = time.Now().UnixMilli()
|
||||||
h.saveAndRenderCard(w, b)
|
h.saveAndRenderCard(w, b)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -357,14 +357,16 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
|
|||||||
srv, store := newWebTestServer(t, cfg)
|
srv, store := newWebTestServer(t, cfg)
|
||||||
before := seed(t, store, Bookmark{
|
before := seed(t, store, Bookmark{
|
||||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
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",
|
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
|
||||||
UpdatedAt: 1_000_000,
|
UpdatedAt: 1_000_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// The chapter form is pre-filled with the current value, so tapping Save
|
// 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:
|
// without editing resubmits the unchanged number. That must be a no-op: it
|
||||||
// it must not silently clear last_chapter_url or move updated_at.
|
// 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()
|
rr := httptest.NewRecorder()
|
||||||
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
|
||||||
"/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"45"}}))
|
"/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",
|
t.Fatalf("LastChapterURL = %q, want preserved %q on a no-op save",
|
||||||
after.LastChapterURL, before.LastChapterURL)
|
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 {
|
if after.UpdatedAt != before.UpdatedAt {
|
||||||
t.Fatalf("UpdatedAt = %d, want unchanged %d on a no-op save",
|
t.Fatalf("UpdatedAt = %d, want unchanged %d on a no-op save",
|
||||||
after.UpdatedAt, before.UpdatedAt)
|
after.UpdatedAt, before.UpdatedAt)
|
||||||
|
|||||||
@@ -122,11 +122,14 @@ Name `mangabm_session`. Value:
|
|||||||
|
|
||||||
```
|
```
|
||||||
<expiry_unix_ms> "." base64url(HMAC-SHA256(<expiry_unix_ms>, key))
|
<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
|
Stateless: no session table, sessions survive restarts, and rotating either
|
||||||
`API_TOKEN` invalidates every session at once.
|
`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
|
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
|
stays logged in across long gaps. `Secure` is set when `r.TLS != nil` or
|
||||||
|
|||||||
Reference in New Issue
Block a user