feat(backend): give every Bookmark an owner (Reader table) (#22)
A readers table appears, keyed by Discord user ID and carrying the SHA-256 of the owner's userscript token (the global API token today). Startup seeds exactly one Reader from OWNER_DISCORD_ID, idempotently, and a run-once migration (0004, version-table-gated) attaches existing bookmarks to it before reshaping: the surrogate key column is dropped and bookmarks are keyed (reader_id, site, series_id) with an FK to readers ON DELETE CASCADE, so a duplicate bookmark for one Reader and Series is impossible at the database level. Every store read and write is now scoped to the reader it names; handlers act as the seeded owner while the global token remains the only credential. Authentication and the wire format are untouched: the flat JSON still carries key/site/series_id, with key derived on read. OWNER_DISCORD_ID is a new required env var (compose + docs updated).
This commit is contained in:
+15
-3
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -30,6 +31,9 @@ type Config struct {
|
||||
Port string
|
||||
// WebPassword gates the browser UI. Empty disables the web routes entirely.
|
||||
WebPassword string
|
||||
// OwnerDiscordID identifies the seeded owner Reader (issue #22). Required:
|
||||
// bookmarks are scoped to a Reader, and without an owner there is none.
|
||||
OwnerDiscordID 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
|
||||
@@ -148,6 +152,7 @@ func loadConfig() Config {
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
Port: envOr("PORT", "8080"),
|
||||
WebPassword: os.Getenv("WEB_PASSWORD"),
|
||||
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(),
|
||||
@@ -173,7 +178,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
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}
|
||||
h := &api.Handler{Store: s, ReaderID: s.OwnerID()}
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("GET /bookmarks", h.List)
|
||||
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
|
||||
@@ -187,7 +192,7 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
|
||||
// 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)
|
||||
wh, err := web.New(s, s.OwnerID(), cfg.Token, cfg.WebPassword)
|
||||
if err != nil {
|
||||
log.Fatalf("web handler: %v", err)
|
||||
}
|
||||
@@ -217,11 +222,18 @@ func main() {
|
||||
if cfg.Token == "" {
|
||||
log.Fatal("API_TOKEN is required")
|
||||
}
|
||||
if cfg.OwnerDiscordID == "" {
|
||||
log.Fatal("OWNER_DISCORD_ID is required")
|
||||
}
|
||||
if cfg.DatabaseURL == "" {
|
||||
log.Fatal("DATABASE_URL is required")
|
||||
}
|
||||
|
||||
s, err := store.Open(cfg.DatabaseURL)
|
||||
// The owner's userscript token is the global API token today (issue #22);
|
||||
// the readers row carries its SHA-256, not the token itself.
|
||||
owner := store.Owner{DiscordID: cfg.OwnerDiscordID, TokenHash: sha256.Sum256([]byte(cfg.Token))}
|
||||
|
||||
s, err := store.Open(cfg.DatabaseURL, owner)
|
||||
if err != nil {
|
||||
log.Fatalf("open store: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user