2ef769d421
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>
81 lines
2.7 KiB
Go
81 lines
2.7 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Session is one browser login: an opaque id the cookie carries verbatim,
|
|
// the Reader it belongs to, and when it stops being valid.
|
|
type Session struct {
|
|
ID string
|
|
ReaderID int64
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
// CreateSession stores a new session row for reader. The id is generated by
|
|
// the caller (session.NewID) — the store only persists it. Expired rows that
|
|
// were never looked up are swept in the same transaction: this is the one
|
|
// write every login makes, so the table stays bounded without a background
|
|
// job.
|
|
func (s *Store) CreateSession(id string, readerID int64, ttl time.Duration) (Session, error) {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
expires := time.Now().Add(ttl)
|
|
if _, err := tx.Exec(`INSERT INTO sessions (id, reader_id, expires_at) VALUES ($1, $2, $3)`,
|
|
id, readerID, expires); err != nil {
|
|
return Session{}, err
|
|
}
|
|
if _, err := tx.Exec(`DELETE FROM sessions WHERE expires_at < now()`); err != nil {
|
|
return Session{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return Session{}, err
|
|
}
|
|
return Session{ID: id, ReaderID: readerID, ExpiresAt: expires}, nil
|
|
}
|
|
|
|
// GetSession returns the live session row for id, or ok=false when the id is
|
|
// unknown or expired. An expired row is deleted on the way out, so the table
|
|
// never grows past sessions that are still valid.
|
|
func (s *Store) GetSession(id string, now time.Time) (Session, bool, error) {
|
|
var sess Session
|
|
err := s.db.QueryRow(
|
|
`SELECT id, reader_id, expires_at FROM sessions WHERE id = $1`, id,
|
|
).Scan(&sess.ID, &sess.ReaderID, &sess.ExpiresAt)
|
|
if err == sql.ErrNoRows {
|
|
return Session{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Session{}, false, err
|
|
}
|
|
if !sess.ExpiresAt.After(now) {
|
|
// Best-effort: the row is dead either way; failing the request over a
|
|
// cleanup delete would only hide the real error. CreateSession's
|
|
// sweep catches anything this misses.
|
|
_, _ = s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
|
|
return Session{}, false, nil
|
|
}
|
|
return sess, true, nil
|
|
}
|
|
|
|
// DeleteSession revokes one session. Deleting an unknown id is not an error.
|
|
func (s *Store) DeleteSession(id string) error {
|
|
_, err := s.db.Exec(`DELETE FROM sessions WHERE id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// DeleteReaderSessions revokes every session one Reader holds — the owner's
|
|
// remedy when a Reader's browser must be logged out everywhere at once. The
|
|
// next request carrying any of those cookies finds no row and is rejected.
|
|
func (s *Store) DeleteReaderSessions(readerID int64) error {
|
|
if _, err := s.db.Exec(`DELETE FROM sessions WHERE reader_id = $1`, readerID); err != nil {
|
|
return fmt.Errorf("delete sessions for reader %d: %w", readerID, err)
|
|
}
|
|
return nil
|
|
}
|