Persist kagane covers in Postgres #49
+6
-6
@@ -136,12 +136,12 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
|||||||
behind the same challenge as its pages and with
|
behind the same challenge as its pages and with
|
||||||
`cross-origin-resource-policy: same-origin`, so no `<img>` on the web UI's
|
`cross-origin-resource-policy: same-origin`, so no `<img>` on the web UI's
|
||||||
origin can load one — not even from a browser holding the clearance cookie
|
origin can load one — not even from a browser holding the clearance cookie
|
||||||
(verified 2026-08-08). `Bookmark.CoverURL` rewrites a stored kagane
|
`og:image` to `/img/kagane/{id}`. `internal/web/cover.go` reads the persistent
|
||||||
`og:image` to `/img/kagane/{id}`, served by `internal/web/cover.go` through
|
`covers` table first, then fetches a miss through `latest.BrowserFetcher.Image`.
|
||||||
`latest.BrowserFetcher.Image` and memoised in-process. The templates render
|
The templates render `.CoverURL`, never `.Cover`. The id is matched against a
|
||||||
`.CoverURL`, never `.Cover`. The id is matched against a UUID regex before it
|
UUID regex before it reaches the browser: the stored value is client-supplied,
|
||||||
reaches the browser: the stored value is client-supplied, so an unchecked one
|
so an unchecked one is an SSRF primitive pointed at the deployment's own
|
||||||
is an SSRF primitive pointed at the deployment's own network.
|
network.
|
||||||
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
|
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
|
||||||
(renders the bindmounted script with the acting Reader's derived credential
|
(renders the bindmounted script with the acting Reader's derived credential
|
||||||
substituted in — the credential never appears in page markup, the address
|
substituted in — the credential never appears in page markup, the address
|
||||||
|
|||||||
+43
-2
@@ -2,11 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bookmarkmanager/backend/internal/pgtest"
|
||||||
|
"bookmarkmanager/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeCovers stands in for the headless browser. It counts calls so the test
|
// fakeCovers stands in for the headless browser. It counts calls so the test
|
||||||
@@ -95,6 +99,41 @@ func TestKaganeCoverServesStoredBytesWithoutBrowser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) {
|
||||||
|
url := pgtest.URL(t)
|
||||||
|
owner := store.Owner{
|
||||||
|
DiscordID: "cover-owner",
|
||||||
|
TokenHash: sha256.Sum256([]byte("cover-owner-token")),
|
||||||
|
}
|
||||||
|
first, err := store.Open(url, owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
if err := first.PutKaganeCover(testCoverID, []byte("survives-restart"), "image/jpeg"); err != nil {
|
||||||
|
first.Close()
|
||||||
|
t.Fatalf("PutKaganeCover: %v", err)
|
||||||
|
}
|
||||||
|
if err := first.Close(); err != nil {
|
||||||
|
t.Fatalf("close first store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := store.Open(url, owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
defer second.Close()
|
||||||
|
cf := &fakeCovers{err: errors.New("browser must not be called after restart")}
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.Covers = cf
|
||||||
|
rr := getCover(t, newRouter(second, cfg), "/img/kagane/"+testCoverID, sessionCookie(t, second))
|
||||||
|
if rr.Code != http.StatusOK || rr.Body.String() != "survives-restart" {
|
||||||
|
t.Fatalf("restarted request = (%d, %q), want (200, survives-restart)", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if got := cf.calls.Load(); got != 0 {
|
||||||
|
t.Fatalf("fetcher called %d times after restart, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The proxy reaches a headless browser, so it is not open to the internet.
|
// The proxy reaches a headless browser, so it is not open to the internet.
|
||||||
func TestKaganeCoverRequiresSession(t *testing.T) {
|
func TestKaganeCoverRequiresSession(t *testing.T) {
|
||||||
cf := &fakeCovers{body: []byte("x"), contentType: "image/webp"}
|
cf := &fakeCovers{body: []byte("x"), contentType: "image/webp"}
|
||||||
@@ -147,9 +186,11 @@ func TestKaganeCoverRejectsBadInput(t *testing.T) {
|
|||||||
if rr.Code != http.StatusNotFound {
|
if rr.Code != http.StatusNotFound {
|
||||||
t.Fatalf("status = %d, want 404", rr.Code)
|
t.Fatalf("status = %d, want 404", rr.Code)
|
||||||
}
|
}
|
||||||
if _, _, ok, err := st.GetKaganeCover(testCoverID); err != nil {
|
_, _, ok, err := st.GetKaganeCover(tc.id)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("GetKaganeCover after rejection: %v", err)
|
t.Fatalf("GetKaganeCover after rejection: %v", err)
|
||||||
} else if ok {
|
}
|
||||||
|
if ok {
|
||||||
t.Fatal("rejected cover was persisted")
|
t.Fatal("rejected cover was persisted")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CoverFetcher retrieves one kagane cover by image id. Satisfied by
|
// CoverFetcher retrieves one kagane cover by image id. Satisfied by
|
||||||
// latest.BrowserFetcher, and nil when BROWSER_WS_URL is unset — which leaves
|
// latest.BrowserFetcher. It is nil when BROWSER_WS_URL is unset; uncached
|
||||||
// kagane covers exactly as unavailable as they were before this endpoint
|
// covers are then unavailable, while covers already stored by the backend
|
||||||
// existed, rather than hanging a request on a fetcher that cannot run.
|
// remain available without a browser.
|
||||||
type CoverFetcher interface {
|
type CoverFetcher interface {
|
||||||
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
||||||
}
|
}
|
||||||
@@ -52,11 +52,13 @@ func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if body, contentType, ok, err := h.store.GetKaganeCover(id); err != nil {
|
body, contentType, ok, err := h.store.GetKaganeCover(id)
|
||||||
|
if err != nil {
|
||||||
log.Printf("read kagane cover %s: %v", id, err)
|
log.Printf("read kagane cover %s: %v", id, err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
} else if ok {
|
}
|
||||||
|
if ok {
|
||||||
writeCover(w, body, contentType)
|
writeCover(w, body, contentType)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,7 @@ func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), coverTimeout)
|
ctx, cancel := context.WithTimeout(r.Context(), coverTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
body, contentType, err := h.covers.Image(ctx, id)
|
body, contentType, err = h.covers.Image(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("kagane cover %s: %v", id, err)
|
log.Printf("kagane cover %s: %v", id, err)
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
|
|||||||
Reference in New Issue
Block a user