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.
148 lines
4.6 KiB
Go
148 lines
4.6 KiB
Go
package httpmw
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
"bookmarkmanager/backend/internal/token"
|
|
)
|
|
|
|
const bearerPrefix = "Bearer "
|
|
|
|
type ctxKey int
|
|
|
|
// readerCtxKey is where Auth stashes the authenticated Reader id.
|
|
const readerCtxKey ctxKey = iota
|
|
|
|
// ReaderID returns the Reader id Auth authenticated, for handlers that take
|
|
// the acting Reader from the request rather than from a fixed field.
|
|
func ReaderID(r *http.Request) int64 { return r.Context().Value(readerCtxKey).(int64) }
|
|
|
|
// 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. 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
|
|
}
|
|
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, 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, strings.TrimPrefix(h, bearerPrefix))
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), readerCtxKey, readerID)))
|
|
})
|
|
}
|
|
|
|
// gzipWriter compresses the body and drops Content-Length, which no longer
|
|
// describes what goes on the wire. WriteHeader is where the decision is made:
|
|
// only then is Content-Type known.
|
|
type gzipWriter struct {
|
|
http.ResponseWriter
|
|
gz *gzip.Writer
|
|
decided bool
|
|
}
|
|
|
|
// compressible covers what this server actually serves in bulk: HTML, CSS, JS
|
|
// and JSON. Fonts are woff2, which is already compressed — gzipping them costs
|
|
// CPU to add bytes.
|
|
func compressible(contentType string) bool {
|
|
ct, _, _ := strings.Cut(contentType, ";")
|
|
switch strings.TrimSpace(ct) {
|
|
case "text/html", "text/css", "text/javascript", "application/javascript",
|
|
"application/json", "text/plain":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (w *gzipWriter) WriteHeader(status int) {
|
|
if !w.decided {
|
|
w.decided = true
|
|
if compressible(w.Header().Get("Content-Type")) {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
w.Header().Del("Content-Length")
|
|
w.gz = gzip.NewWriter(w.ResponseWriter)
|
|
}
|
|
}
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *gzipWriter) Write(b []byte) (int, error) {
|
|
if !w.decided {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
if w.gz != nil {
|
|
return w.gz.Write(b)
|
|
}
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// Gzip compresses text responses for clients that ask. The templates,
|
|
// stylesheet and htmx together are ~120 KB uncompressed and roughly a quarter
|
|
// of that gzipped, which is the difference between a fast and a slow first load
|
|
// on mobile data.
|
|
func Gzip(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
w.Header().Add("Vary", "Accept-Encoding")
|
|
gw := &gzipWriter{ResponseWriter: w}
|
|
defer func() {
|
|
if gw.gz != nil {
|
|
gw.gz.Close()
|
|
}
|
|
}()
|
|
next.ServeHTTP(gw, r)
|
|
})
|
|
}
|
|
|
|
// CORS reflects the request Origin only when it is in allowed, answers
|
|
// preflight OPTIONS with 204, and passes everything else through. It wraps the
|
|
// auth middleware so preflight (which carries no Authorization header) is never
|
|
// rejected by auth.
|
|
func CORS(allowed []string, next http.Handler) http.Handler {
|
|
set := make(map[string]struct{}, len(allowed))
|
|
for _, o := range allowed {
|
|
set[o] = struct{}{}
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if _, ok := set[origin]; ok && origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,PUT,DELETE,OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|