package main import ( "context" "errors" "log" "net/http" "os" "os/signal" "strconv" "strings" "syscall" "time" ) // Config holds all runtime settings, sourced from environment variables. type Config struct { Token string AllowedOrigins []string DBPath string Port string // WebPassword gates the browser UI. Empty disables the web routes entirely. WebPassword 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"), DBPath: envOr("DB_PATH", "/data/bookmarks.db"), Port: envOr("PORT", "8080"), WebPassword: os.Getenv("WEB_PASSWORD"), 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(store *Store, cfg Config) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", healthz) h := &bookmarkHandler{store: store} protected := http.NewServeMux() protected.HandleFunc("GET /bookmarks", h.list) protected.HandleFunc("PUT /bookmarks/{key}", h.put) protected.HandleFunc("DELETE /bookmarks/{key}", h.delete) auth := withAuth(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 != "" { web, err := newWebHandler(store, cfg) if err != nil { log.Fatalf("web handler: %v", err) } web.register(mux) } return withCORS(cfg.AllowedOrigins, mux) } func main() { cfg := loadConfig() if cfg.Token == "" { log.Fatal("API_TOKEN is required") } store, err := OpenStore(cfg.DBPath) if err != nil { log.Fatalf("open store: %v", err) } defer store.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, store, cfg.LatestPoll) srv := &http.Server{ Addr: ":" + cfg.Port, Handler: newRouter(store, cfg), ReadHeaderTimeout: 10 * time.Second, } go func() { log.Printf("listening on :%s (db=%s, origins=%v)", cfg.Port, cfg.DBPath, 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, store *Store, cfg LatestPoll) { if !cfg.Enabled { log.Println("latest-chapter poller: disabled by config") return } f, err := newTLSFetcher() if err != nil { log.Printf("latest-chapter poller: disabled, cannot build client: %v", err) return } p := &latestPoller{ store: store, fetch: f, now: time.Now, cooldown: cfg.Cooldown, interval: cfg.Interval, stagger: cfg.Stagger, batch: cfg.Batch, } go p.Run(ctx) }