e8d1cba6c5
Refs #56 ## Summary Moves Kagane cover bytes out of Postgres bytea storage into an immutable, content-addressed filesystem store. Reader-visible behavior remains unchanged: the existing session-gated route serves stored bytes, missing bytes use the existing browser fetch path, and no browser still returns a missing cover. ## Changes - Added migration 0008, which drops the legacy `covers` table and recreates it with only `address`, `path`, and `content_type`. Existing byte rows are intentionally dropped. - Added SHA-256 source-URL addressing with two-level sharding (`ab/cd/<sha256>`). Writes use a temp file plus atomic link; reads validate the stored relative path before opening it. - Made `COVER_DIR` required in runtime config and Compose. Compose passes it as a Docker build argument and volume target, so custom durable paths keep image ownership, runtime config, and the named `cover-data` volume aligned. - Updated every `store.Open` caller and documented configuration, deployment, backup, and troubleshooting behavior. - Added filesystem, restart, migration-drop, no-browser, and content-addressing coverage. ## Verification - `go test ./...` - `CGO_ENABLED=0 go build ./...` - `docker build --build-arg COVER_DIR=/data/covers -t manga-bookmark-cover-check-custom ./backend` - `docker compose config --format json` confirms custom `COVER_DIR` is the volume target - `git diff --check origin/main` - LSP diagnostics clean for touched Go files Parents #47 and #55 remain open as required by #56. Reviewed-on: #65 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
227 lines
7.0 KiB
Go
227 lines
7.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"bookmarkmanager/backend/internal/pgtest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// fakeCovers stands in for the headless browser. It counts calls so the test
|
|
// can prove the store spares the browser after the first navigation.
|
|
type fakeCovers struct {
|
|
body []byte
|
|
contentType string
|
|
err error
|
|
calls atomic.Int32
|
|
lastID atomic.Value
|
|
}
|
|
|
|
func (f *fakeCovers) Image(_ context.Context, imageID string) ([]byte, string, error) {
|
|
f.calls.Add(1)
|
|
f.lastID.Store(imageID)
|
|
if f.err != nil {
|
|
return nil, "", f.err
|
|
}
|
|
return f.body, f.contentType, nil
|
|
}
|
|
|
|
const testCoverID = "019fe11a-84c3-7fc3-a84b-88787374b617"
|
|
|
|
func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// kagane serves its covers behind a Cloudflare challenge and with
|
|
// cross-origin-resource-policy: same-origin, so the UI can only show one by
|
|
// re-serving the bytes from its own origin.
|
|
func TestKaganeCoverPersistsAndReusesStoredBytes(t *testing.T) {
|
|
cf := &fakeCovers{body: []byte("\x00webp-bytes"), contentType: "image/webp"}
|
|
cfg := testConfig()
|
|
cfg.Covers = cf
|
|
srv, st := newWebTestServer(t, cfg)
|
|
cookie := sessionCookie(t, st)
|
|
|
|
rr := getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("first request: status = %d, want 200", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); got != string(cf.body) {
|
|
t.Fatalf("first request: body = %q, want %q", got, cf.body)
|
|
}
|
|
|
|
// A new Handler has no process-local state from the first request. The same
|
|
// store must still answer without navigating the browser again.
|
|
srv = newRouter(st, cfg)
|
|
rr = getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("stored request: status = %d, want 200", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); got != string(cf.body) {
|
|
t.Fatalf("stored request: body = %q, want %q", got, cf.body)
|
|
}
|
|
if got := cf.calls.Load(); got != 1 {
|
|
t.Fatalf("fetcher called %d times, want 1 — stored bytes must survive a new handler", got)
|
|
}
|
|
if got := cf.lastID.Load(); got != testCoverID {
|
|
t.Fatalf("fetched image id = %v, want %s", got, testCoverID)
|
|
}
|
|
}
|
|
|
|
func TestKaganeCoverServesStoredBytesWithoutBrowser(t *testing.T) {
|
|
cf := &fakeCovers{err: errors.New("browser must not be called")}
|
|
cfg := testConfig()
|
|
cfg.Covers = cf
|
|
srv, st := newWebTestServer(t, cfg)
|
|
if err := st.PutKaganeCover(testCoverID, []byte("already-stored"), "image/png"); err != nil {
|
|
t.Fatalf("PutKaganeCover: %v", err)
|
|
}
|
|
|
|
rr := getCover(t, srv, "/img/kagane/"+testCoverID, sessionCookie(t, st))
|
|
if rr.Code != http.StatusOK || rr.Body.String() != "already-stored" {
|
|
t.Fatalf("stored request = (%d, %q), want (200, already-stored)", rr.Code, rr.Body.String())
|
|
}
|
|
if got := cf.calls.Load(); got != 0 {
|
|
t.Fatalf("fetcher called %d times for a stored cover, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestKaganeCoverServesPersistedBytesAfterRestart(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
coverDir := t.TempDir()
|
|
owner := store.Owner{
|
|
DiscordID: "cover-owner",
|
|
TokenHash: sha256.Sum256([]byte("cover-owner-token")),
|
|
}
|
|
first, err := store.Open(url, owner, coverDir)
|
|
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, coverDir)
|
|
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.
|
|
func TestKaganeCoverRequiresSession(t *testing.T) {
|
|
cf := &fakeCovers{body: []byte("x"), contentType: "image/webp"}
|
|
cfg := testConfig()
|
|
cfg.Covers = cf
|
|
srv, _ := newWebTestServer(t, cfg)
|
|
|
|
rr := getCover(t, srv, "/img/kagane/"+testCoverID, nil)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", rr.Code)
|
|
}
|
|
if got := cf.calls.Load(); got != 0 {
|
|
t.Fatalf("fetcher called %d times for an unauthenticated request, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestKaganeCoverRejectsBadInput(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
id string
|
|
fetch *fakeCovers
|
|
}{
|
|
{
|
|
"an id that is not a uuid never reaches the browser",
|
|
"solo-leveling",
|
|
&fakeCovers{body: []byte("x"), contentType: "image/webp"},
|
|
},
|
|
{
|
|
"a uuid-shaped id with a trailing segment is rejected whole",
|
|
testCoverID + "x",
|
|
&fakeCovers{body: []byte("x"), contentType: "image/webp"},
|
|
},
|
|
{
|
|
"a challenged fetch is a missing cover",
|
|
testCoverID,
|
|
&fakeCovers{err: errors.New("challenge held")},
|
|
},
|
|
{
|
|
"a content type outside the image set is not echoed back",
|
|
testCoverID,
|
|
&fakeCovers{body: []byte("<script>"), contentType: "text/html"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.Covers = tc.fetch
|
|
srv, st := newWebTestServer(t, cfg)
|
|
rr := getCover(t, srv, "/img/kagane/"+tc.id, sessionCookie(t, st))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
_, _, ok, err := st.GetKaganeCover(tc.id)
|
|
if err != nil {
|
|
t.Fatalf("GetKaganeCover after rejection: %v", err)
|
|
}
|
|
if ok {
|
|
t.Fatal("rejected cover was persisted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ServeMux path-cleans a traversal into a redirect before the handler runs, so
|
|
// the guarantee to pin down is that no request shaped like one ever gets bytes.
|
|
func TestKaganeCoverTraversalServesNothing(t *testing.T) {
|
|
cf := &fakeCovers{body: []byte("secret"), contentType: "image/webp"}
|
|
cfg := testConfig()
|
|
cfg.Covers = cf
|
|
srv, st := newWebTestServer(t, cfg)
|
|
|
|
rr := getCover(t, srv, "/img/kagane/../../etc/passwd", sessionCookie(t, st))
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("status = 200, want anything but a served body")
|
|
}
|
|
if got := cf.calls.Load(); got != 0 {
|
|
t.Fatalf("fetcher called %d times for a traversal, want 0", got)
|
|
}
|
|
}
|
|
|
|
// Without BROWSER_WS_URL there is no fetcher, and the endpoint must answer
|
|
// rather than reach for a nil one.
|
|
func TestKaganeCoverWithoutFetcher(t *testing.T) {
|
|
srv, st := newWebTestServer(t, testConfig())
|
|
rr := getCover(t, srv, "/img/kagane/"+testCoverID, sessionCookie(t, st))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", rr.Code)
|
|
}
|
|
}
|