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:
+14
-3
@@ -126,9 +126,20 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
|
|||||||
`/userscript/novel-bookmark.user.js`, both supplied by bindmount; the
|
`/userscript/novel-bookmark.user.js`, both supplied by bindmount; the
|
||||||
`__API_TOKEN__` placeholder inside them is substituted with the requesting
|
`__API_TOKEN__` placeholder inside them is substituted with the requesting
|
||||||
Reader's credential at serve time).
|
Reader's credential at serve time).
|
||||||
`BROWSER_WS_URL` (headless-shell CDP endpoint for kagane and novelfull;
|
`BROWSER_WS_URL` (CDP endpoint of the `chrome/` sidecar, used by the poller
|
||||||
unset disables browser polling and leaves those sites to the userscript
|
for kagane and novelfull *and* by the web UI's kagane cover proxy; unset
|
||||||
alone).
|
disables browser polling and serves 404 from the proxy, leaving those sites
|
||||||
|
to the userscript alone).
|
||||||
|
- **kagane covers are proxied, not hot-linked:** kagane serves cover images
|
||||||
|
behind the same challenge as its pages and with
|
||||||
|
`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
|
||||||
|
(verified 2026-08-08). `Bookmark.CoverURL` rewrites a stored kagane
|
||||||
|
`og:image` to `/img/kagane/{id}`, served by `internal/web/cover.go` through
|
||||||
|
`latest.BrowserFetcher.Image` and memoised in-process. The templates render
|
||||||
|
`.CoverURL`, never `.Cover`. The id is matched against a UUID regex before it
|
||||||
|
reaches the browser: the stored value is client-supplied, so an unchecked one
|
||||||
|
is an SSRF primitive pointed at the deployment's own 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
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeCovers stands in for the headless browser. It counts calls so the test
|
||||||
|
// can prove the cache spares the browser a second 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 TestKaganeCoverProxiesAndCaches(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)
|
||||||
|
|
||||||
|
for i := range 2 {
|
||||||
|
rr := getCover(t, srv, "/img/kagane/"+testCoverID, cookie)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("request %d: status = %d, want 200", i, rr.Code)
|
||||||
|
}
|
||||||
|
if got := rr.Body.String(); got != string(cf.body) {
|
||||||
|
t.Fatalf("request %d: body = %q, want %q", i, got, cf.body)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("Content-Type"); got != "image/webp" {
|
||||||
|
t.Fatalf("request %d: Content-Type = %q, want image/webp", i, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := cf.calls.Load(); got != 1 {
|
||||||
|
t.Fatalf("fetcher called %d times, want 1 — the second read must come from the cache", got)
|
||||||
|
}
|
||||||
|
if got := cf.lastID.Load(); got != testCoverID {
|
||||||
|
t.Fatalf("fetched image id = %v, want %s", got, testCoverID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,22 @@ func (b Bookmark) Initial() string {
|
|||||||
return "?"
|
return "?"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kaganeCoverRe matches the cover URL kagane's og:image carries, which is what
|
||||||
|
// the userscript stores for that site.
|
||||||
|
var kaganeCoverRe = regexp.MustCompile(`^https://kagane\.to/api/v2/image/([0-9a-f-]{36})/compressed$`)
|
||||||
|
|
||||||
|
// CoverURL is the src the web UI puts in an <img>. For every site but kagane
|
||||||
|
// that is Cover as stored. kagane serves its images behind a Cloudflare
|
||||||
|
// challenge *and* with `cross-origin-resource-policy: same-origin`, so no page
|
||||||
|
// on another origin can load one however it asks (verified 2026-08-08); those
|
||||||
|
// go through the backend's own proxy instead.
|
||||||
|
func (b Bookmark) CoverURL() string {
|
||||||
|
if m := kaganeCoverRe.FindStringSubmatch(b.Cover); m != nil {
|
||||||
|
return "/img/kagane/" + m[1]
|
||||||
|
}
|
||||||
|
return b.Cover
|
||||||
|
}
|
||||||
|
|
||||||
// Library buckets. A bookmark is in exactly one. This cannot be derived from
|
// Library buckets. A bookmark is in exactly one. This cannot be derived from
|
||||||
// Site: asurascans serves manga and novels from the same /comics/ path, so the
|
// Site: asurascans serves manga and novels from the same /comics/ path, so the
|
||||||
// userscript that recorded the page is the only party that knows which.
|
// userscript that recorded the page is the only party that knows which.
|
||||||
|
|||||||
@@ -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) {
|
func TestUpsertKindDefaultsToManga(t *testing.T) {
|
||||||
store := newTestStore(t)
|
store := newTestStore(t)
|
||||||
got, err := store.Upsert(store.OwnerID(), Bookmark{
|
got, err := store.Upsert(store.OwnerID(), Bookmark{
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CoverFetcher retrieves one kagane cover by image id. Satisfied by
|
||||||
|
// latest.BrowserFetcher, and nil when BROWSER_WS_URL is unset — which leaves
|
||||||
|
// kagane covers exactly as unavailable as they were before this endpoint
|
||||||
|
// existed, rather than hanging a request on a fetcher that cannot run.
|
||||||
|
type CoverFetcher interface {
|
||||||
|
Image(ctx context.Context, imageID string) (body []byte, contentType string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverIDRe matches the request path segment that becomes part of an outbound
|
||||||
|
// URL. The proxy is session-gated, but the id still reaches a headless browser,
|
||||||
|
// so it is validated at the boundary rather than passed through.
|
||||||
|
var coverIDRe = regexp.MustCompile(`^[0-9a-f-]{36}$`)
|
||||||
|
|
||||||
|
// coverTypes is the set of content types the proxy will echo back. A response
|
||||||
|
// header sourced from a third party is not repeated verbatim: anything outside
|
||||||
|
// this set is treated as "not a cover".
|
||||||
|
var coverTypes = map[string]bool{
|
||||||
|
"image/webp": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/avif": true,
|
||||||
|
"image/gif": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverTimeout bounds one proxied cover. Shorter than the fetcher's own
|
||||||
|
// challenge budget on purpose: a browser page is waiting on this, and a cover
|
||||||
|
// that has not arrived by now is better left as a broken slot than as a request
|
||||||
|
// holding a connection open.
|
||||||
|
const coverTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// coverCacheMax caps the in-memory cover cache. Covers are immutable per image
|
||||||
|
// id and a library holds tens of series, so this is a ceiling that is never
|
||||||
|
// reached in practice; reaching it clears the map rather than evicting by age.
|
||||||
|
//
|
||||||
|
// ponytail: flush-on-full, not LRU. Swap it for an LRU if a library ever grows
|
||||||
|
// past this and the flush starts costing refetches.
|
||||||
|
const coverCacheMax = 500
|
||||||
|
|
||||||
|
type cachedCover struct {
|
||||||
|
body []byte
|
||||||
|
contentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]cachedCover
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *coverCache) get(id string) (cachedCover, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
v, ok := c.m[id]
|
||||||
|
return v, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *coverCache) put(id string, v cachedCover) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.m == nil || len(c.m) >= coverCacheMax {
|
||||||
|
c.m = make(map[string]cachedCover, coverCacheMax)
|
||||||
|
}
|
||||||
|
c.m[id] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// kaganeCover serves a kagane cover from the backend's own origin.
|
||||||
|
//
|
||||||
|
// kagane answers image requests with a Cloudflare challenge and
|
||||||
|
// `cross-origin-resource-policy: same-origin`, so the web UI cannot render one
|
||||||
|
// directly under any combination of referrer policy or crossorigin attribute
|
||||||
|
// (verified 2026-08-08). Fetching it through the headless browser that already
|
||||||
|
// clears the challenge, and re-serving it here, is what puts the bytes on an
|
||||||
|
// origin the page may load from.
|
||||||
|
//
|
||||||
|
// ponytail: covers are fetched on first view, one browser navigation at a time
|
||||||
|
// behind the fetcher's mutex, so a first load of a large kagane library
|
||||||
|
// trickles in over a few seconds. The cache makes it a one-off. Prefetching
|
||||||
|
// during the poll cycle is the upgrade if that ever grates.
|
||||||
|
func (h *Handler) kaganeCover(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if !coverIDRe.MatchString(id) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.covers == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v, ok := h.coverCache.get(id); ok {
|
||||||
|
writeCover(w, v)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), coverTimeout)
|
||||||
|
defer cancel()
|
||||||
|
body, contentType, err := h.covers.Image(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("kagane cover %s: %v", id, err)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !coverTypes[contentType] {
|
||||||
|
log.Printf("kagane cover %s: unexpected content type %q", id, contentType)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := cachedCover{body: body, contentType: contentType}
|
||||||
|
h.coverCache.put(id, v)
|
||||||
|
writeCover(w, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCover sends the bytes with a long cache life: an image id names one
|
||||||
|
// immutable rendering, so a client that has it never needs to ask again.
|
||||||
|
func writeCover(w http.ResponseWriter, v cachedCover) {
|
||||||
|
w.Header().Set("Content-Type", v.contentType)
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=604800, immutable")
|
||||||
|
w.Write(v.body)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
<a class="cover" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer"
|
||||||
tabindex="-1" aria-hidden="true">
|
tabindex="-1" aria-hidden="true">
|
||||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
{{if .CoverURL}}<img src="{{.CoverURL}}" alt="" loading="lazy">
|
||||||
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
|
{{/* aria-hidden on the cover link is not enough — Chromium still exposes
|
||||||
the letter because the link is programmatically focusable — so the
|
the letter because the link is programmatically focusable — so the
|
||||||
monogram carries its own, same as the recent strip's. */}}
|
monogram carries its own, same as the recent strip's. */}}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||||
target="_blank" rel="noopener noreferrer">
|
target="_blank" rel="noopener noreferrer">
|
||||||
<span class="recent-cover">
|
<span class="recent-cover">
|
||||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
{{if .CoverURL}}<img src="{{.CoverURL}}" alt="" loading="lazy">
|
||||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ type Handler struct {
|
|||||||
// httpClient is the plain stdlib client that talks to Discord. It is not
|
// httpClient is the plain stdlib client that talks to Discord. It is not
|
||||||
// an injected interface: tests point APIBase at a stub server instead.
|
// an injected interface: tests point APIBase at a stub server instead.
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
// covers proxies kagane cover images, which no browser can load directly.
|
||||||
|
// Nil disables the endpoint — see CoverFetcher.
|
||||||
|
covers CoverFetcher
|
||||||
|
coverCache coverCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// listView is what every list-rendering template receives.
|
// listView is what every list-rendering template receives.
|
||||||
@@ -111,7 +115,7 @@ type loginView struct {
|
|||||||
|
|
||||||
// New parses every template up front so a broken one kills the process at
|
// New parses every template up front so a broken one kills the process at
|
||||||
// startup rather than the first request that touches it.
|
// startup rather than the first request that touches it.
|
||||||
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string) (*Handler, error) {
|
func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, novelPath string, covers CoverFetcher) (*Handler, error) {
|
||||||
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
tmpl, err := template.ParseFS(templateFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -126,6 +130,7 @@ func New(s *store.Store, discord DiscordConfig, tokenKey []byte, mangaPath, nove
|
|||||||
states: newOAuthStates(),
|
states: newOAuthStates(),
|
||||||
limiter: session.NewLoginLimiter(),
|
limiter: session.NewLoginLimiter(),
|
||||||
httpClient: &http.Client{Timeout: discordTimeout},
|
httpClient: &http.Client{Timeout: discordTimeout},
|
||||||
|
covers: covers,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +147,10 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
|
||||||
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
|
||||||
|
|
||||||
|
// Session-gated like every other UI route: the deployment proxies kagane's
|
||||||
|
// images for its own Readers, not for the internet.
|
||||||
|
mux.HandleFunc("GET /img/kagane/{id}", h.requireSession(h.kaganeCover))
|
||||||
|
|
||||||
// Install endpoints render the script directly under the session: the
|
// Install endpoints render the script directly under the session: the
|
||||||
// credential travels inside the served bytes, never in the address bar or
|
// credential travels inside the served bytes, never in the address bar or
|
||||||
// the page markup. Updates after install use the credential-bearing /u/
|
// the page markup. Updates after install use the credential-bearing /u/
|
||||||
|
|||||||
+35
-24
@@ -47,6 +47,10 @@ type Config struct {
|
|||||||
NovelUserscriptPath string
|
NovelUserscriptPath string
|
||||||
// LatestPoll configures the background latest-chapter fetcher.
|
// LatestPoll configures the background latest-chapter fetcher.
|
||||||
LatestPoll LatestPoll
|
LatestPoll LatestPoll
|
||||||
|
// Covers proxies kagane cover images for the web UI. Not from the
|
||||||
|
// environment: it is the shared headless browser, wired in main once it
|
||||||
|
// connects, and nil in every test router.
|
||||||
|
Covers web.CoverFetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// LatestPoll configures the background latest-chapter poller.
|
// LatestPoll configures the background latest-chapter poller.
|
||||||
@@ -206,7 +210,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
|
|||||||
// The browser UI is always registered; signing in is Discord OAuth, so
|
// The browser UI is always registered; signing in is Discord OAuth, so
|
||||||
// there is no password to forget and no gate to leave unset.
|
// there is no password to forget and no gate to leave unset.
|
||||||
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
|
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
|
||||||
cfg.UserscriptPath, cfg.NovelUserscriptPath)
|
cfg.UserscriptPath, cfg.NovelUserscriptPath, cfg.Covers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("web handler: %v", err)
|
log.Fatalf("web handler: %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,9 +274,26 @@ func main() {
|
|||||||
// The poller is off the request path entirely: if it cannot start, the
|
// The poller is off the request path entirely: if it cannot start, the
|
||||||
// service still serves bookmarks and the userscript still captures latest
|
// service still serves bookmarks and the userscript still captures latest
|
||||||
// chapters on its own.
|
// chapters on its own.
|
||||||
|
//
|
||||||
|
// One headless browser serves both consumers that need a Cloudflare
|
||||||
|
// challenge cleared: the poller's kagane/novelfull fetches and the web
|
||||||
|
// UI's kagane cover proxy. Optional — unset leaves both degraded to what
|
||||||
|
// they were before the sidecar existed.
|
||||||
|
var browser latest.Fetcher
|
||||||
pollCtx, stopPoll := context.WithCancel(context.Background())
|
pollCtx, stopPoll := context.WithCancel(context.Background())
|
||||||
defer stopPoll()
|
defer stopPoll()
|
||||||
startLatestPoller(pollCtx, s, cfg.LatestPoll)
|
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
|
||||||
|
bf, err := latest.NewBrowserFetcher(ws)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("browser fetcher disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
browser = bf
|
||||||
|
cfg.Covers = bf
|
||||||
|
context.AfterFunc(pollCtx, bf.Close)
|
||||||
|
log.Printf("browser fetcher at %s", ws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
@@ -307,7 +328,7 @@ func main() {
|
|||||||
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
||||||
// feature going missing degrades the service to userscript-only latest-chapter
|
// feature going missing degrades the service to userscript-only latest-chapter
|
||||||
// tracking, which is exactly how it behaved before.
|
// tracking, which is exactly how it behaved before.
|
||||||
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
|
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) {
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled {
|
||||||
log.Println("latest-chapter poller: disabled by config")
|
log.Println("latest-chapter poller: disabled by config")
|
||||||
return
|
return
|
||||||
@@ -317,28 +338,18 @@ func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll) {
|
|||||||
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Nil browser: sites behind a JavaScript challenge are simply not polled,
|
||||||
|
// and their latest_chapter comes from the userscript alone — which is how
|
||||||
|
// the service behaved before the sidecar existed.
|
||||||
p := &latest.Poller{
|
p := &latest.Poller{
|
||||||
Store: s,
|
Store: s,
|
||||||
Fetch: f,
|
Fetch: f,
|
||||||
Now: time.Now,
|
BrowserFetch: browser,
|
||||||
Cooldown: cfg.Cooldown,
|
Now: time.Now,
|
||||||
Interval: cfg.Interval,
|
Cooldown: cfg.Cooldown,
|
||||||
Stagger: cfg.Stagger,
|
Interval: cfg.Interval,
|
||||||
Batch: cfg.Batch,
|
Stagger: cfg.Stagger,
|
||||||
}
|
Batch: cfg.Batch,
|
||||||
|
|
||||||
// Optional: without it, sites behind a JavaScript challenge are simply not
|
|
||||||
// polled, and their latest_chapter comes from the userscript alone — which
|
|
||||||
// is how the service behaved before the sidecar existed.
|
|
||||||
if ws := strings.TrimSpace(os.Getenv("BROWSER_WS_URL")); ws != "" {
|
|
||||||
bf, err := latest.NewBrowserFetcher(ws)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("latest-chapter poller: browser fetcher disabled: %v", err)
|
|
||||||
} else {
|
|
||||||
p.BrowserFetch = bf
|
|
||||||
context.AfterFunc(ctx, bf.Close)
|
|
||||||
log.Printf("latest-chapter poller: browser fetcher at %s", ws)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go p.Run(ctx)
|
go p.Run(ctx)
|
||||||
|
|||||||
Reference in New Issue
Block a user