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.
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
|
|
}
|