feat: register any guild member as a Reader (#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 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.
This commit is contained in:
@@ -3,11 +3,9 @@ package httpmw
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/token"
|
||||
@@ -26,39 +24,29 @@ func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(i
|
||||
|
||||
// ResolveReader maps a presented credential to a Reader. The credential is
|
||||
// hashed and matched against readers.token_sha256 — an equality on 32-byte
|
||||
// values, never a comparison of the credential itself — and, during the
|
||||
// cutover window, the retired global token resolves to the owner. Every
|
||||
// legacy acceptance is logged so the window can be confirmed empty before
|
||||
// the token is removed. The same resolution backs the API bearer header and
|
||||
// the userscript download path, so the window covers both.
|
||||
func ResolveReader(s *store.Store, legacy string, graceUntil time.Time, cred string) (int64, bool) {
|
||||
if readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred)); err != nil {
|
||||
// values, never a comparison of the credential itself. The same resolution
|
||||
// backs the API bearer header and the userscript download path, so a Reader
|
||||
// has exactly one credential with one blast radius.
|
||||
func ResolveReader(s *store.Store, cred string) (int64, bool) {
|
||||
readerID, ok, err := s.ReaderIDForTokenHash(token.Hash(cred))
|
||||
if err != nil {
|
||||
log.Printf("auth: reader lookup: %v", err)
|
||||
return 0, false
|
||||
} else if ok {
|
||||
return readerID, true
|
||||
}
|
||||
|
||||
if legacy != "" && time.Now().Before(graceUntil) &&
|
||||
subtle.ConstantTimeCompare([]byte(cred), []byte(legacy)) == 1 {
|
||||
log.Printf("auth: retired global token accepted for owner reader %d (grace until %s)",
|
||||
s.OwnerID(), graceUntil.Format(time.RFC3339))
|
||||
return s.OwnerID(), true
|
||||
}
|
||||
return 0, false
|
||||
return readerID, ok
|
||||
}
|
||||
|
||||
// Auth guards a handler with a per-Reader bearer credential. The acting
|
||||
// Reader travels in the request context, so a handler scopes every store call
|
||||
// to exactly the Reader that authenticated.
|
||||
func Auth(s *store.Store, legacy string, graceUntil time.Time, next http.Handler) http.Handler {
|
||||
func Auth(s *store.Store, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, bearerPrefix) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
readerID, ok := ResolveReader(s, legacy, graceUntil, strings.TrimPrefix(h, bearerPrefix))
|
||||
readerID, ok := ResolveReader(s, strings.TrimPrefix(h, bearerPrefix))
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user