08749df050
Swap modernc.org/sqlite for jackc/pgx/v5 with no observable change: same endpoints, same wire format, same updated_at ordering rule. The schema now comes from numbered SQL embedded in the binary and applied on startup, one transaction each, recorded in schema_migrations. That replaces two pieces of SQLite-era machinery, both deleted rather than ported: the column probing (Postgres has ADD COLUMN IF NOT EXISTS, and there is no legacy database left to probe) and the Asura key rewrite, which has run clean on every start for months now that the userscripts strip build hashes before writing. Its regexp survives as latest.asuraBuildHash, where the poller still needs it to scope chapter links to a series whose slug carries a rotating hash. Types get real: favorite is a boolean, chapter numbers double precision, timestamps stay unix-ms bigint. SQLite's null-safe IS NOT becomes IS DISTINCT FROM, which is what implements the rule that only reading progress reorders a list. Inside COALESCE/NULLIF the status and kind parameters need an explicit ::text -- there is no target column to infer from and Postgres refuses to guess. Tests lose their free t.TempDir() database, so Docker is now a hard prerequisite for `go test ./...`: internal/pgtest starts one postgres:17-alpine per test binary and hands each test a database of its own. Also lands CONTEXT.md and the four ADRs written while scoping #18. BREAKING CHANGE: DB_PATH is retired for DATABASE_URL, which is required and has no default. Compose gains a postgres service on an internal network with its own volume; POSTGRES_PASSWORD joins .env. The old bookmarks-data volume is deliberately left undeclared so `docker compose down -v` cannot take the pre-migration database with it. main is not deployable until #25 and #26 land. Closes #20 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
306 lines
10 KiB
Go
306 lines
10 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/userscript"
|
|
"bookmarkmanager/backend/internal/web"
|
|
)
|
|
|
|
// Config holds all runtime settings, sourced from environment variables.
|
|
type Config struct {
|
|
Token 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
|
|
Port string
|
|
// WebPassword gates the browser UI. Empty disables the web routes entirely.
|
|
WebPassword string
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
Interval time.Duration
|
|
Stagger time.Duration
|
|
Batch int
|
|
}
|
|
|
|
const (
|
|
defaultPollCooldown = 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
|
|
}
|
|
|
|
// 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),
|
|
Interval: envDuration("LATEST_CHAPTER_POLL_INTERVAL", defaultPollInterval),
|
|
Stagger: envDuration("LATEST_CHAPTER_POLL_STAGGER", defaultPollStagger),
|
|
Batch: envInt("LATEST_CHAPTER_POLL_BATCH", defaultPollBatch),
|
|
}
|
|
if p.Cooldown < minPollCooldown {
|
|
log.Printf("config: cooldown %s is below the %s floor, clamping", p.Cooldown, minPollCooldown)
|
|
p.Cooldown = minPollCooldown
|
|
}
|
|
// 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{
|
|
Token: os.Getenv("API_TOKEN"),
|
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
|
Port: envOr("PORT", "8080"),
|
|
WebPassword: os.Getenv("WEB_PASSWORD"),
|
|
UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"),
|
|
NovelUserscriptPath: envOr("NOVEL_USERSCRIPT_PATH", "/userscript/novel-bookmark.user.js"),
|
|
LatestPoll: loadLatestPoll(),
|
|
}
|
|
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()
|
|
mux.HandleFunc("GET /healthz", api.Healthz)
|
|
|
|
// Outside httpmw.Auth (the updater sends no Authorization header) and
|
|
// outside the WEB_PASSWORD gate (the script must be installable either
|
|
// way). The path segment carries the token instead.
|
|
mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscript.Handler(cfg.Token, cfg.UserscriptPath))
|
|
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js", userscript.Handler(cfg.Token, cfg.NovelUserscriptPath))
|
|
|
|
h := &api.Handler{Store: s}
|
|
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(cfg.Token, protected)
|
|
mux.Handle("/bookmarks", auth)
|
|
mux.Handle("/bookmarks/", auth)
|
|
|
|
// The browser UI is registered only when a password is configured, so a
|
|
// deployment that forgets WEB_PASSWORD exposes nothing rather than
|
|
// exposing an unprotected list.
|
|
if cfg.WebPassword != "" {
|
|
wh, err := web.New(s, cfg.Token, cfg.WebPassword)
|
|
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.Token == "" {
|
|
log.Fatal("API_TOKEN is required")
|
|
}
|
|
if cfg.DatabaseURL == "" {
|
|
log.Fatal("DATABASE_URL is required")
|
|
}
|
|
|
|
s, err := store.Open(cfg.DatabaseURL)
|
|
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.
|
|
pollCtx, stopPoll := context.WithCancel(context.Background())
|
|
defer stopPoll()
|
|
startLatestPoller(pollCtx, s, cfg.LatestPoll)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
}
|
|
p := &latest.Poller{
|
|
Store: s,
|
|
Fetch: f,
|
|
Now: time.Now,
|
|
Cooldown: cfg.Cooldown,
|
|
Interval: cfg.Interval,
|
|
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)
|
|
}
|