Open registration to guild members (#27) (#36)

Closes #27.

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 a turned-away sign-in leaves no row
behind. `OWNER_DISCORD_ID` still seeds the owner, but only as the
administrator — it no longer gates login.

The cutover grace path goes 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. `userscript.Handler` drops its
re-derivation too — the resolved path segment is already the credential.

New surfaces: an empty library offers both install links (behind the
tab-specific empty states, so "No favourites yet" still wins), and the owner
alone gets a Readers panel with `POST /readers/{id}/revoke`. The owner's own
row is not revocable — 404, not a self-logout.

Isolation is asserted from both directions for read, modify and delete, 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.

Verified: `go test ./...` green; live smoke against a throwaway Postgres —
empty-library state in both colour branches, roster rendering, a real revoke
through the panel (target 401s next request, owner untouched), owner
self-revoke refused 404, per-Reader `/u/<cred>` and bearer auth both 200 with
404 for an unknown credential.

Reviewed-on: #36
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #36.
This commit is contained in:
2026-08-08 20:23:17 +07:00
committed by sulthan
parent c2b47eb05b
commit 2ef769d421
25 changed files with 850 additions and 367 deletions
+14 -50
View File
@@ -23,26 +23,18 @@ import (
// Config holds all runtime settings, sourced from environment variables.
type Config struct {
// Token is the retired global API token, kept only for the cutover grace
// window: while GraceUntil has not passed, it resolves to the owner
// Reader so already-installed scripts keep working. Unset after the
// window closes.
Token string
// TokenKey derives every Reader's userscript credential (internal/token).
// Required: without it no install URL can ever be built.
TokenKey string
// GraceUntil is the moment the retired global token stops resolving to
// the owner Reader. Zero means the token is already dead. Enforced in
// code on every request, not by a runbook note.
GraceUntil time.Time
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 without an owner there is none.
// It is also the only Discord identity allowed to sign in (issue #23).
// 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
@@ -158,29 +150,9 @@ func loadLatestPoll() LatestPoll {
return p
}
// parseGraceUntil reads the retired-token deadline. Both a bare date and a
// full RFC3339 timestamp are accepted; an unparseable value is a
// configuration bug, not a gracefully-degraded feature — the whole point is
// that the window's end is enforced, so fail loud.
func parseGraceUntil(raw string) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, raw); err == nil {
return t
}
}
log.Fatalf("config: API_TOKEN_GRACE_UNTIL=%q is not a date (YYYY-MM-DD) or RFC3339 timestamp", raw)
return time.Time{}
}
func loadConfig() Config {
c := Config{
Token: os.Getenv("API_TOKEN"),
TokenKey: os.Getenv("TOKEN_KEY"),
GraceUntil: parseGraceUntil(os.Getenv("API_TOKEN_GRACE_UNTIL")),
DatabaseURL: os.Getenv("DATABASE_URL"),
Port: envOr("PORT", "8080"),
OwnerDiscordID: os.Getenv("OWNER_DISCORD_ID"),
@@ -189,13 +161,12 @@ func loadConfig() Config {
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"),
OwnerDiscordID: c.OwnerDiscordID,
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 != "" {
@@ -218,9 +189,9 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
// 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, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.UserscriptPath))
userscript.Handler(s, cfg.UserscriptPath))
mux.HandleFunc("GET /u/{token}/novel-bookmark.user.js",
userscript.Handler(s, []byte(cfg.TokenKey), cfg.Token, cfg.GraceUntil, cfg.NovelUserscriptPath))
userscript.Handler(s, cfg.NovelUserscriptPath))
h := &api.Handler{Store: s}
protected := http.NewServeMux()
@@ -228,13 +199,13 @@ func newRouter(s *store.Store, cfg Config) http.Handler {
protected.HandleFunc("PUT /bookmarks/{key}", h.Put)
protected.HandleFunc("DELETE /bookmarks/{key}", h.Delete)
auth := httpmw.Auth(s, cfg.Token, cfg.GraceUntil, protected)
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, s.OwnerID(), cfg.Discord, []byte(cfg.TokenKey),
wh, err := web.New(s, cfg.Discord, []byte(cfg.TokenKey),
cfg.UserscriptPath, cfg.NovelUserscriptPath)
if err != nil {
log.Fatalf("web handler: %v", err)
@@ -282,13 +253,6 @@ func main() {
log.Fatalf("%s is required", key)
}
}
if cfg.Token == "" && !cfg.GraceUntil.IsZero() {
log.Fatal("API_TOKEN_GRACE_UNTIL is set but API_TOKEN is not")
}
if cfg.Token != "" && cfg.GraceUntil.IsZero() {
log.Printf("API_TOKEN is set without API_TOKEN_GRACE_UNTIL: the retired token is dead on arrival")
}
// 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.