b0bf6fe770
Guild membership is now the whole gate: discordCallback checks membership
(and DISCORD_REQUIRED_ROLE when set), then Store.EnsureReader creates the
Reader on first sight and returns the same row on every later login. The
refusal returns before EnsureReader, so nothing is created as a side
effect of being turned away. OWNER_DISCORD_ID keeps seeding the owner, but
only as the administrator — it no longer gates sign-in.
The cutover grace path is gone with it: API_TOKEN, API_TOKEN_GRACE_UNTIL
and the legacy branch in httpmw.ResolveReader are deleted, so a credential
authenticates exactly one Reader or nothing. That also lets
userscript.Handler drop the re-derivation — the resolved path segment is
already the credential to substitute.
New surfaces: an empty library offers both install links instead of
describing a filter (listView.Fresh, which also hides the action key it has
nothing to name), and the owner alone gets a Readers panel with
POST /readers/{id}/revoke (404 for anyone else) to sign a Reader out
everywhere.
Isolation is asserted from both directions rather than by counting one
Reader's rows, and the shared-series invariant is pinned: two Readers on
one series produce one series row, two independent progresses, one poll
per due cycle, and one Reader's delete leaves the other's bookmark and the
poll intact.
346 lines
12 KiB
Go
346 lines
12 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
|
|
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
|
|
}
|
|
|
|
// 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{
|
|
TokenKey: os.Getenv("TOKEN_KEY"),
|
|
DatabaseURL: os.Getenv("DATABASE_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()
|
|
mux.HandleFunc("GET /healthz", api.Healthz)
|
|
|
|
// 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))
|
|
|
|
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(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)
|
|
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")
|
|
}
|
|
// 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)
|
|
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)
|
|
}
|