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