78234f3c19
Fixes #62
Browser-backed Sites join the Cover pipeline: kagane and novelfull Series now get their Covers at creation, through the same acquisition path as every other Site, instead of waiting for a poll pass.
## What changed
`latest.Acquirer` (creation-time acquisition, fired by the first Bookmark of a Series) previously skipped kagane and novelfull entirely — their pages only yield a Cloudflare challenge to the TLS client, so the request was spent for nothing. It now routes them like the poller does, with the two Sites split exactly as the issue demands:
- **kagane** — page fetched through the browser sidecar, cover URL extracted from the API JSON, bytes fetched through the browser sidecar (the only path that clears the challenge) into the content-addressed store. With no `BROWSER_WS_URL` configured, acquisition is skipped entirely and nothing falls back to a plain fetch.
- **novelfull** — page fetched through the browser sidecar, cover URL extracted from the HTML, bytes fetched over plain TLS through the ordinary gated fetcher (its image paths answer 200 with `access-control-allow-origin: *`, measured 2026-08-09). With no browser configured, the page fetch falls back to the TLS client — novelfull's challenge is a live time-varying fact (AGENTS.md), so when the page body answers, the Cover still lands; when it is challenged, nothing happens.
The byte-routing rule (kagane → browser, every other Site → TLS) is now one shared function (`latest.fetchCoverBytes`) used by both the Poller and the Acquirer, so the two cannot drift apart.
## Acceptance criteria
- [x] kagane cover bytes are fetched through the browser sidecar and stored in the content-addressed store — `TestAcquireKaganeCoverThroughBrowser`
- [x] novelfull cover URLs are extracted from the browser-fetched HTML, and its bytes are fetched over plain TLS — `TestAcquireNovelfullCoverOverPlainTLS`
- [x] With no browser sidecar configured, kagane Covers are absent and nothing falls back to a plain fetch — `TestAcquireKaganeSkippedWithoutBrowser`
- [x] With no browser sidecar configured, novelfull Covers still work if its page body is available — `TestAcquireNovelfullCoverWithoutBrowser`
- [x] Manually verified on-device: a kagane Series shows its Cover in the panel, not a broken-image glyph — being run by a separate manual-verification agent against a mocked scenario (no prod data); not part of this PR
- [x] `go test ./...` is green, with live-network checks gated behind `SMOKE_BROWSER_WS_URL` like the existing kagane image smoke test — new `TestSmokeAcquireKaganeCover` proves the end-to-end acquire path against the real browser when the env var is set
## Verification
- `go test ./...` green across all packages
- New unit tests exercise every routing decision with fakes — no network in the default suite
- Smoke test gated behind `SMOKE_BROWSER_WS_URL`, skipped by default
## Post-review changes (a66491a)
- **One routing rule for pages too** — `fetcherFor` is now a shared function used by both the Poller and the Acquirer; novelfull falls back to the plain-TLS fetcher in *both* when no browser is configured, so pre-existing (client-scraped) novelfull rows get healed by the poll as well, not just Series created after this change (`TestNovelfullUsesTLSWhenNoBrowserFetcher`).
- **Byte-level no-fallback proof** — `TestAcquireKaganeBytesNeverFallBackToPlainTLS` pins that kagane cover bytes never route to the TLS fetcher even when the page came through a browser.
- **Acquirer wired independent of the TLS client** — if `NewTLSFetcher` fails, kagane/novelfull acquisition still works via the sidecar (`main.go`).
- AGENTS.md (root + backend) updated for the novelfull plain-TLS fallback.
Reviewed-on: #72
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
427 lines
15 KiB
Go
427 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/api"
|
|
"bookmarkmanager/backend/internal/httpmw"
|
|
"bookmarkmanager/backend/internal/latest"
|
|
"bookmarkmanager/backend/internal/store"
|
|
"bookmarkmanager/backend/internal/token"
|
|
"bookmarkmanager/backend/internal/userscript"
|
|
"bookmarkmanager/backend/internal/web"
|
|
)
|
|
|
|
// Config holds all runtime settings, sourced from environment variables.
|
|
type Config struct {
|
|
// TokenKey derives every Reader's userscript credential (internal/token).
|
|
// Required: without it no install URL can ever be built.
|
|
TokenKey string
|
|
AllowedOrigins []string
|
|
// DatabaseURL is the Postgres connection URL; required, no default,
|
|
// because a wrong guess would silently start on an empty database.
|
|
DatabaseURL string
|
|
// CoverDir is the filesystem volume for immutable cover bytes. Required:
|
|
// serving a stored address without durable bytes would be worse than a
|
|
// startup failure.
|
|
CoverDir string
|
|
// PublicBaseURL is the origin this deployment answers on, e.g.
|
|
// "https://bookmarks.example.com". Required: cover URLs go out absolute
|
|
// because the userscript renders them on third-party origins, where a
|
|
// relative path would resolve against the Site (ADR-0007), and there is
|
|
// no way to guess it from a request the poller never sees.
|
|
PublicBaseURL string
|
|
Port string
|
|
// OwnerDiscordID identifies the seeded owner Reader (issue #22). Required:
|
|
// bookmarks are scoped to a Reader, and a fresh deployment needs one
|
|
// before anybody logs in. The owner is also the only Reader who can revoke
|
|
// another Reader's sessions.
|
|
OwnerDiscordID string
|
|
// Discord is the OAuth application the browser UI signs in with.
|
|
Discord web.DiscordConfig
|
|
// UserscriptPath is the file served at /u/{token}/manga-bookmark.user.js.
|
|
// Supplied by a bindmount so the script can be edited without a rebuild.
|
|
UserscriptPath string
|
|
// NovelUserscriptPath is the file served at
|
|
// /u/{token}/novel-bookmark.user.js. Same bindmount, second script: the
|
|
// two libraries are separate installs.
|
|
NovelUserscriptPath string
|
|
// LatestPoll configures the background latest-chapter fetcher.
|
|
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.
|
|
//
|
|
// Sizing: batch x (cooldown / interval) is how many series hold a true cooldown
|
|
// cadence — 14 x (1h / 10m) = 84 with these defaults, which covers this
|
|
// deployment. Past that nothing breaks; the effective cadence stretches to
|
|
// N x interval / batch and the oldest-checked-first ordering keeps it uniform.
|
|
type LatestPoll struct {
|
|
Enabled bool
|
|
Cooldown time.Duration
|
|
BrowserCooldown time.Duration
|
|
Interval time.Duration
|
|
Stagger time.Duration
|
|
Batch int
|
|
}
|
|
|
|
const (
|
|
defaultPollCooldown = time.Hour
|
|
defaultBrowserPollCooldown = 6 * time.Hour
|
|
defaultPollInterval = 10 * time.Minute
|
|
defaultPollStagger = 20 * time.Second
|
|
defaultPollBatch = 14
|
|
// minPollCooldown keeps a typo from turning a polite background check into
|
|
// a hammer against sites that are already bot-scoring us.
|
|
minPollCooldown = 15 * time.Minute
|
|
)
|
|
|
|
func envOr(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
// envBool reads a boolean env var. Anything unrecognised falls back to def.
|
|
func envBool(key string, def bool) bool {
|
|
switch v := strings.ToLower(strings.TrimSpace(os.Getenv(key))); v {
|
|
case "":
|
|
return def
|
|
case "0", "false", "no", "off":
|
|
return false
|
|
case "1", "true", "yes", "on":
|
|
return true
|
|
default:
|
|
log.Printf("config: %s=%q is not a boolean, using %v", key, v, def)
|
|
return def
|
|
}
|
|
}
|
|
|
|
// envDuration reads a duration env var. An unparseable or non-positive value
|
|
// falls back to def and logs rather than failing startup: the poller is an
|
|
// enhancement, and a typo in one of its knobs must not stop bookmark sync.
|
|
func envDuration(key string, def time.Duration) time.Duration {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
d, err := time.ParseDuration(raw)
|
|
if err != nil || d <= 0 {
|
|
log.Printf("config: %s=%q is not a positive duration, using %s", key, raw, def)
|
|
return def
|
|
}
|
|
return d
|
|
}
|
|
|
|
// envInt reads a positive integer env var, with the same fallback policy.
|
|
func envInt(key string, def int) int {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 {
|
|
log.Printf("config: %s=%q is not a positive integer, using %d", key, raw, def)
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
func clampPollCooldown(name string, d time.Duration) time.Duration {
|
|
if d < minPollCooldown {
|
|
log.Printf("config: %s %s is below the %s floor, clamping", name, d, minPollCooldown)
|
|
return minPollCooldown
|
|
}
|
|
return d
|
|
}
|
|
|
|
// loadLatestPoll reads the poller's settings, clamping anything that would make
|
|
// it antisocial.
|
|
func loadLatestPoll() LatestPoll {
|
|
p := LatestPoll{
|
|
Enabled: envBool("LATEST_CHAPTER_POLL_ENABLED", true),
|
|
Cooldown: envDuration("LATEST_CHAPTER_POLL_COOLDOWN", defaultPollCooldown),
|
|
BrowserCooldown: envDuration("LATEST_CHAPTER_POLL_BROWSER_COOLDOWN", defaultBrowserPollCooldown),
|
|
Interval: envDuration("LATEST_CHAPTER_POLL_INTERVAL", defaultPollInterval),
|
|
Stagger: envDuration("LATEST_CHAPTER_POLL_STAGGER", defaultPollStagger),
|
|
Batch: envInt("LATEST_CHAPTER_POLL_BATCH", defaultPollBatch),
|
|
}
|
|
p.Cooldown = clampPollCooldown("cooldown", p.Cooldown)
|
|
p.BrowserCooldown = clampPollCooldown("browser cooldown", p.BrowserCooldown)
|
|
// batch x stagger has to fit inside one tick or a batch is still running
|
|
// when the next one is due. Run() serialises them, so this degrades to a
|
|
// slower cadence rather than to overlapping fetches — worth a warning, not
|
|
// a failure.
|
|
if span := time.Duration(p.Batch) * p.Stagger; span > p.Interval {
|
|
log.Printf("config: batch(%d) x stagger(%s) = %s exceeds interval %s; batches will overrun their tick",
|
|
p.Batch, p.Stagger, span, p.Interval)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func loadConfig() Config {
|
|
c := Config{
|
|
TokenKey: os.Getenv("TOKEN_KEY"),
|
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
|
CoverDir: os.Getenv("COVER_DIR"),
|
|
PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"),
|
|
Port: envOr("PORT", "8080"),
|
|
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
|
|
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
|
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
|
LatestPoll: loadLatestPoll(),
|
|
}
|
|
c.Discord = web.DiscordConfig{
|
|
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
|
|
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
|
|
GuildID: os.Getenv("DISCORD_GUILD_ID"),
|
|
RequiredRole: os.Getenv("DISCORD_REQUIRED_ROLE"),
|
|
APIBase: envOr("DISCORD_API_BASE", "https://discord.com/api/v10"),
|
|
RedirectURI: os.Getenv("DISCORD_REDIRECT_URI"),
|
|
}
|
|
for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") {
|
|
if o = strings.TrimSpace(o); o != "" {
|
|
c.AllowedOrigins = append(c.AllowedOrigins, o)
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// newRouter wires routes and middleware. CORS is the outermost layer so
|
|
// preflight OPTIONS short-circuits before auth; /bookmarks* is auth-protected,
|
|
// /healthz is public.
|
|
func newRouter(s *store.Store, cfg Config) http.Handler {
|
|
mux := http.NewServeMux()
|
|
h := &api.Handler{Store: s}
|
|
mux.HandleFunc("GET /healthz", api.Healthz)
|
|
// Public: cover bytes are rendered by the userscript on origins that may
|
|
// not send our credentials, and the address is the hash of a URL the Site
|
|
// already publishes (ADR-0007).
|
|
mux.HandleFunc("GET /covers/{address}", h.Cover)
|
|
|
|
// Outside httpmw.Auth (the updater sends no Authorization header) and
|
|
// outside the web UI's Discord auth (the script must be installable
|
|
// without a browser session). The path segment carries the credential
|
|
// instead, and the script is rendered with the resolved Reader's
|
|
// credential substituted in.
|
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js",
|
|
userscript.Handler(s, cfg.UserscriptPath))
|
|
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js",
|
|
userscript.Handler(s, cfg.NovelUserscriptPath))
|
|
|
|
protected := http.NewServeMux()
|
|
protected.HandleFunc("GET /bookmarks", h.List)
|
|
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
|
|
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
|
|
|
|
auth := httpmw.Auth(s, protected)
|
|
mux.Handle("/bookmarks", auth)
|
|
mux.Handle("/bookmarks/", auth)
|
|
|
|
// The browser UI is always registered; signing in is Discord OAuth, so
|
|
// there is no password to forget and no gate to leave unset.
|
|
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
|
|
cfg.UserscriptPath, cfg.NovelUserscriptPath, cfg.Covers)
|
|
if err != nil {
|
|
log.Fatalf("web handler: %v", err)
|
|
}
|
|
wh.Register(mux)
|
|
|
|
return httpmw.CORS(cfg.AllowedOrigins, httpmw.Gzip(guardEmptyUserscriptToken(mux)))
|
|
}
|
|
|
|
// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect:
|
|
// an empty {token} segment makes the request path "/u//manga-bookmark.user.js",
|
|
// and ServeMux 307s that to "/u/manga-bookmark.user.js" before pattern
|
|
// matching ever runs. The endpoint's contract is 404 for any wrong token,
|
|
// including this one, so catch it ahead of the mux.
|
|
func guardEmptyUserscriptToken(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/u//") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
cfg := loadConfig()
|
|
if cfg.TokenKey == "" {
|
|
log.Fatal("TOKEN_KEY is required")
|
|
}
|
|
if cfg.OwnerDiscordID == "" {
|
|
log.Fatal("OWNER_DISCORD_ID is required")
|
|
}
|
|
if cfg.DatabaseURL == "" {
|
|
log.Fatal("DATABASE_URL is required")
|
|
}
|
|
if cfg.CoverDir == "" {
|
|
log.Fatal("COVER_DIR is required")
|
|
}
|
|
if cfg.PublicBaseURL == "" {
|
|
log.Fatal("PUBLIC_BASE_URL is required")
|
|
}
|
|
// The web UI signs in through Discord, so a deployment without the OAuth
|
|
// application is misconfigured rather than passwordless.
|
|
for key, v := range map[string]string{
|
|
"DISCORD_CLIENT_ID": cfg.Discord.ClientID,
|
|
"DISCORD_CLIENT_SECRET": cfg.Discord.ClientSecret,
|
|
"DISCORD_GUILD_ID": cfg.Discord.GuildID,
|
|
"DISCORD_REDIRECT_URI": cfg.Discord.RedirectURI,
|
|
} {
|
|
if v == "" {
|
|
log.Fatalf("%s is required", key)
|
|
}
|
|
}
|
|
// The owner's userscript credential is derived from TOKEN_KEY at epoch 0
|
|
// (internal/token); the readers row carries its SHA-256, not the
|
|
// credential itself.
|
|
owner := store.Owner{
|
|
DiscordID: cfg.OwnerDiscordID,
|
|
TokenHash: token.Hash(token.Token([]byte(cfg.TokenKey), cfg.OwnerDiscordID, 0)),
|
|
}
|
|
|
|
s, err := store.Open(cfg.DatabaseURL, owner, cfg.CoverDir, cfg.PublicBaseURL)
|
|
if err != nil {
|
|
log.Fatalf("open store: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
// The poller is off the request path entirely: if it cannot start, the
|
|
// service still serves bookmarks and the userscript still captures latest
|
|
// 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())
|
|
defer stopPoll()
|
|
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)
|
|
}
|
|
}
|
|
// A Series nobody had bookmarked before gets its Latest Chapter and its
|
|
// Cover from one fetch, at creation, instead of waiting out a poll queue
|
|
// ordered by Reader count. Off the write path: the hook returns as soon
|
|
// as the goroutine is started.
|
|
var tlsFetch latest.Fetcher
|
|
if f, err := latest.NewTLSFetcher(); err != nil {
|
|
log.Printf("creation-time acquisition: plain-TLS Sites disabled, cannot build client: %v", err)
|
|
} else {
|
|
tlsFetch = f
|
|
}
|
|
var browserCover latest.BrowserCoverFetcher
|
|
if b, ok := browser.(latest.BrowserCoverFetcher); ok {
|
|
browserCover = b
|
|
}
|
|
// The Acquirer must survive a TLS client failure: kagane needs only the
|
|
// sidecar, and novelfull degrades to whatever is left.
|
|
if tlsFetch != nil || browser != nil {
|
|
acq := &latest.Acquirer{
|
|
Store: s,
|
|
Fetch: tlsFetch,
|
|
BrowserFetch: browser,
|
|
BrowserCoverFetch: browserCover,
|
|
Covers: latest.NewCoverFetcher(),
|
|
Ctx: pollCtx,
|
|
}
|
|
s.OnSeriesCreated = acq.Acquire
|
|
}
|
|
startLatestPoller(pollCtx, s, cfg.LatestPoll, browser)
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: newRouter(s, cfg),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
// The connection URL carries a password, so it stays out of the log.
|
|
log.Printf("listening on :%s (origins=%v)", cfg.Port, cfg.AllowedOrigins)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
log.Fatalf("serve: %v", err)
|
|
}
|
|
}()
|
|
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
|
<-stop
|
|
|
|
log.Println("shutting down")
|
|
// Stop polling before draining requests, so an in-flight series fetch does
|
|
// not hold the process open past the shutdown deadline.
|
|
stopPoll()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
log.Printf("shutdown: %v", err)
|
|
}
|
|
}
|
|
|
|
// newLatestPoller wires the configured cooldowns and fetchers into the poller.
|
|
func newLatestPoller(s *store.Store, cfg LatestPoll, fetch, browser latest.Fetcher) *latest.Poller {
|
|
var covers latest.BrowserCoverFetcher
|
|
if f, ok := browser.(latest.BrowserCoverFetcher); ok {
|
|
covers = f
|
|
}
|
|
return &latest.Poller{
|
|
Store: s,
|
|
Fetch: fetch,
|
|
BrowserFetch: browser,
|
|
CoverFetch: covers,
|
|
CoverBytesFetch: latest.NewCoverFetcher(),
|
|
Now: time.Now,
|
|
Cooldown: cfg.Cooldown,
|
|
BrowserCooldown: cfg.BrowserCooldown,
|
|
Interval: cfg.Interval,
|
|
Stagger: cfg.Stagger,
|
|
Batch: cfg.Batch,
|
|
}
|
|
}
|
|
|
|
// startLatestPoller launches the background poller unless it is disabled or its
|
|
// HTTP client cannot be built. Any problem here is logged and skipped: this
|
|
// feature going missing degrades the service to userscript-only latest-chapter
|
|
// tracking, which is exactly how it behaved before.
|
|
func startLatestPoller(ctx context.Context, s *store.Store, cfg LatestPoll, browser latest.Fetcher) {
|
|
if !cfg.Enabled {
|
|
log.Println("latest-chapter poller: disabled by config")
|
|
return
|
|
}
|
|
f, err := latest.NewTLSFetcher()
|
|
if err != nil {
|
|
log.Printf("latest-chapter poller: disabled, cannot build client: %v", err)
|
|
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 := newLatestPoller(s, cfg, f, browser)
|
|
|
|
go p.Run(ctx)
|
|
}
|