feat(web): proxy kagane cover images so the UI can render them

kagane serves cover images from behind the same Cloudflare challenge as
its pages and with cross-origin-resource-policy: same-origin. The second
header is the decisive one: no <img> on the web UI's origin can load a
kagane cover even from a browser that already holds the clearance cookie,
verified 2026-08-08 by loading one from a foreign origin with and without
a referrer. Hot-linking cannot be made to work, so every kagane series
rendered the monogram placeholder.

Bookmark.CoverURL rewrites a stored kagane og:image to /img/kagane/{id}
and returns every other cover untouched; the templates render .CoverURL
in place of .Cover. The endpoint is session-gated like every other UI
route, and hands the id to the shared headless browser, whose fetch is
same-origin with kagane and therefore satisfies both the challenge and
the CORP header. Results are memoised in-process, so a cover costs one
navigation per deployment lifetime.

The id is matched against a UUID regex before it reaches the browser.
That gate is load-bearing rather than tidiness: the cover is a stored
client-supplied string, so an unvalidated one turns the endpoint into an
SSRF primitive aimed at the deployment's own network. ServeMux
path-cleans a traversal into a redirect before the handler runs, but the
handler does not rely on that, and a test pins it.

With BROWSER_WS_URL unset there is no browser and the endpoint answers
404 rather than reaching for a nil fetcher - the same degrade-to-
userscript behaviour the poller already has for these sites.
This commit is contained in:
2026-08-08 23:03:58 +07:00
parent 5713e3d04a
commit ec74559ca5
9 changed files with 393 additions and 30 deletions
+32
View File
@@ -543,6 +543,38 @@ func TestDisplayChapter(t *testing.T) {
}
}
func TestCoverURL(t *testing.T) {
cases := []struct {
name string
cover string
want string
}{
{
"kagane routes through the proxy",
"https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
"/img/kagane/019fe11a-84c3-7fc3-a84b-88787374b617",
},
{
"another site is served as stored",
"https://gg.asuracomic.net/storage/media/1/conversions/cover.webp",
"https://gg.asuracomic.net/storage/media/1/conversions/cover.webp",
},
{
"a lookalike host is not rewritten",
"https://evil.example/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
"https://evil.example/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
},
{"no cover stays empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := (Bookmark{Cover: tc.cover}).CoverURL(); got != tc.want {
t.Errorf("CoverURL() = %q, want %q", got, tc.want)
}
})
}
}
func TestUpsertKindDefaultsToManga(t *testing.T) {
store := newTestStore(t)
got, err := store.Upsert(store.OwnerID(), Bookmark{