Migrate authentication to Better Auth

Replace the hand-rolled JWT auth with Better Auth 1.6.25 httpOnly cookie
sessions, validated against the database on every request so revocation,
bans and role changes take effect immediately.

Backend:
- betterAuth.ts wires the Drizzle adapter, magic links, Google sign-in and
  the admin plugin; auth-schema.ts maps Better Auth's models onto the
  existing `users` table so user IDs and their foreign keys survive intact.
- routes/auth.ts is gone; Better Auth serves the standard endpoints and
  authExt.ts carries the flows it doesn't cover.
- auth.ts shrinks to session resolution and helpers; sessions/revocation in
  dashboard.ts now read and delete `auth_sessions` rows directly.
- Schema adds the Better Auth core + admin columns (email_verified, image,
  banned, ban_reason, ban_expires), with migrations and tests.
- rateLimit.ts resolves client IPs spoof-resistantly: proxy headers are only
  honoured from loopback/RFC1918 peers plus TRUSTED_PROXIES.
- passwordPolicy.ts centralises password validation.
- Bump drizzle-orm, drizzle-kit and better-sqlite3 to versions compatible
  with Better Auth.

Frontend:
- auth-client.ts plus a reworked AuthContext and api/client.ts move to
  cookie-based sessions; no more bearer tokens in requests or middleware.

photo-api:
- Validate Better Auth session cookies against the shared auth_sessions
  table instead of verifying JWTs; JWT_SECRET is no longer needed for user
  auth, and PHOTO_VIEW_SECRET now signs gallery view tokens.

BETTER_AUTH_SECRET and BETTER_AUTH_URL are required in production; the
deprecated JWT_SECRET stays only as the photo-api view-token fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-07-29 19:07:04 +00:00
co-authored by Claude Opus 5
parent 4afa5d6fa0
commit 733d2459df
47 changed files with 2430 additions and 1585 deletions
+8 -2
View File
@@ -16,8 +16,14 @@ DATABASE_URL=postgresql://spanglish:password@localhost:5432/spanglish_db
# DB_TYPE=sqlite
# DATABASE_URL=../backend/data/spanglish.db
# MUST be identical to JWT_SECRET in backend/.env — photo-api validates the
# same HS256 tokens the backend issues.
# Secret for gallery image view tokens (HMAC). Any strong random value; does
# not need to match any backend secret. Rotating it invalidates outstanding
# image URLs for at most ~1 hour (they are re-minted per page load).
PHOTO_VIEW_SECRET=8b69468724b730dddecba3d04717cf44e6dc5b808773e7e60156d38875f0f6cd
# DEPRECATED: fallback for PHOTO_VIEW_SECRET during the Better Auth migration
# (user auth now validates Better Auth sessions from the shared database, not
# JWTs). Set PHOTO_VIEW_SECRET and remove this.
JWT_SECRET=
# Public site origin, used to build share links and allow dev CORS
+1 -1
View File
@@ -85,7 +85,7 @@ func main() {
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk).Handler(),
Handler: httpapi.New(cfg, db, st, auth.NewVerifier(db), wrk).Handler(),
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
-1
View File
@@ -7,7 +7,6 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.32.31
github.com/aws/aws-sdk-go-v2/credentials v1.19.30
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
-2
View File
@@ -39,8 +39,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+57 -49
View File
@@ -1,27 +1,31 @@
// Package auth validates the JWTs minted by backend/src/lib/auth.ts:
// HS256 with the shared JWT_SECRET, issuer "spanglish", audience
// "spanglish-app", plus the same DB-backed tokenVersion / account_status
// revocation check the backend's getAuthUser performs.
// Package auth validates the Better Auth sessions issued by the backend.
// The session cookie value is "<token>.<signature>" (HMAC-signed with the
// backend's BETTER_AUTH_SECRET); this service resolves the token part against
// the shared auth_sessions table, which is the authoritative validity check
// (the token carries 32+ characters of entropy, and a DB hit is required
// anyway for expiry/ban/role, so revocations apply here instantly).
package auth
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/golang-jwt/jwt/v5"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
const (
issuer = "spanglish"
audience = "spanglish-app"
)
// Cookie names set by the backend: advanced.cookiePrefix "spanglish", with
// the __Secure- prefix added in production (advanced.useSecureCookies).
var sessionCookieNames = []string{
"__Secure-spanglish.session_token",
"spanglish.session_token",
}
var (
ErrNoToken = errors.New("no bearer token")
ErrInvalidToken = errors.New("invalid token")
ErrNoToken = errors.New("no session token")
ErrInvalidToken = errors.New("invalid session")
)
// AdminRoles mirrors backend/src/routes/media.ts: photo administration is
@@ -43,57 +47,61 @@ func (u User) IsAdmin() bool {
return false
}
type claims struct {
Email string `json:"email"`
Role string `json:"role"`
TokenVersion *int `json:"tokenVersion"`
jwt.RegisteredClaims
}
type Verifier struct {
secret []byte
db *store.DB
db *store.DB
}
func NewVerifier(secret string, db *store.DB) *Verifier {
return &Verifier{secret: []byte(secret), db: db}
func NewVerifier(db *store.DB) *Verifier {
return &Verifier{db: db}
}
// FromRequest returns the authenticated user, ErrNoToken when no
// Authorization header is present, or ErrInvalidToken for anything bad.
// FromRequest returns the authenticated user, ErrNoToken when no session
// cookie or bearer token is present, or ErrInvalidToken for anything bad.
func (v *Verifier) FromRequest(r *http.Request) (User, error) {
header := r.Header.Get("Authorization")
if header == "" {
token := sessionTokenFromRequest(r)
if token == "" {
return User{}, ErrNoToken
}
raw, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return User{}, ErrInvalidToken
}
var c claims
_, err := jwt.ParseWithClaims(raw, &c, func(*jwt.Token) (any, error) { return v.secret, nil },
jwt.WithValidMethods([]string{"HS256"}),
jwt.WithIssuer(issuer),
jwt.WithAudience(audience),
jwt.WithExpirationRequired(),
)
if err != nil || c.Subject == "" {
return User{}, ErrInvalidToken
}
// DB-backed revocation, same semantics as backend getAuthUser
// (backend/src/lib/auth.ts:320-327).
ua, err := v.db.GetUserAuth(r.Context(), c.Subject)
su, err := v.db.GetSessionUser(r.Context(), token)
if err != nil {
return User{}, ErrInvalidToken
}
if ua.AccountStatus != "active" {
if !su.ExpiresAt.After(time.Now()) {
return User{}, ErrInvalidToken
}
if c.TokenVersion != nil && *c.TokenVersion != ua.TokenVersion {
// Same semantics as backend getAuthUser: suspended (banned) or unclaimed
// accounts must not retain API access via a live session.
if su.Banned || su.AccountStatus != "active" {
return User{}, ErrInvalidToken
}
// Role from the DB, not the token, so demotions apply immediately.
return User{ID: ua.ID, Email: c.Email, Role: ua.Role}, nil
// Role comes from the users row, so demotions apply immediately.
return User{ID: su.ID, Email: su.Email, Role: su.Role}, nil
}
// sessionTokenFromRequest extracts the raw session token from the Better Auth
// cookie, or from an Authorization: Bearer header (tests and CLI tooling).
func sessionTokenFromRequest(r *http.Request) string {
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil && c.Value != "" {
return tokenPart(c.Value)
}
}
if raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok && raw != "" {
return tokenPart(raw)
}
return ""
}
// tokenPart strips the URL-encoding and HMAC signature from a cookie value:
// "token.base64sig" -> "token". The token itself is alphanumeric, so the
// first '.' always separates it from the signature.
func tokenPart(v string) string {
if dec, err := url.QueryUnescape(v); err == nil {
v = dec
}
if i := strings.IndexByte(v, '.'); i >= 0 {
return v[:i]
}
return v
}
+7 -4
View File
@@ -15,7 +15,10 @@ type Config struct {
Port int
DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE
DatabaseURL string
JWTSecret string
// ViewTokenSecret signs the hour-bucketed gallery view tokens
// (viewtoken.go). PHOTO_VIEW_SECRET, falling back to the legacy
// JWT_SECRET during the Better Auth migration.
ViewTokenSecret string
StoragePath string
S3Endpoint string
@@ -44,7 +47,7 @@ func Load() (Config, error) {
Port: envInt("PORT", 3003),
DBType: strings.ToLower(env("DB_TYPE", "sqlite")),
DatabaseURL: env("DATABASE_URL", ""),
JWTSecret: env("JWT_SECRET", ""),
ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")),
StoragePath: env("STORAGE_PATH", "./data/photos"),
S3Endpoint: env("S3_ENDPOINT", ""),
S3Region: env("S3_REGION", "auto"),
@@ -64,8 +67,8 @@ func Load() (Config, error) {
if cfg.DatabaseURL == "" {
return cfg, fmt.Errorf("DATABASE_URL is required")
}
if cfg.JWTSecret == "" {
return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)")
if cfg.ViewTokenSecret == "" {
return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)")
}
return cfg, nil
}
+1 -1
View File
@@ -27,7 +27,7 @@ func (s *Server) authorize(r *http.Request, g store.Gallery, user *auth.User, to
// expires; it is only ever issued after a successful authorize, and it
// is what lets <img> requests (which cannot carry a Bearer header)
// through for non-public galleries.
if token != "" && verifyViewToken([]byte(s.cfg.JWTSecret), g.ID, token) {
if token != "" && verifyViewToken([]byte(s.cfg.ViewTokenSecret), g.ID, token) {
return nil
}
switch g.Visibility {
+89 -34
View File
@@ -17,8 +17,6 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
@@ -43,6 +41,7 @@ type testEnv struct {
handler http.Handler
db *store.DB
worker *worker.Worker
pg bool
}
// setup migrates a scratch DB (SQLite by default; Postgres when
@@ -55,7 +54,7 @@ func setup(t *testing.T) *testEnv {
Port: 0,
DBType: "sqlite",
DatabaseURL: filepath.Join(dir, "test.db"),
JWTSecret: testSecret,
ViewTokenSecret: testSecret,
StoragePath: filepath.Join(dir, "photos"),
MaxUploadMB: 5,
WorkerConcurrency: 1,
@@ -72,7 +71,7 @@ func setup(t *testing.T) *testEnv {
t.Cleanup(func() { db.Close() })
if cfg.DBType == "postgres" {
// The scratch Postgres DB persists across tests; start clean.
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments CASCADE`); err != nil {
if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments, auth_sessions CASCADE`); err != nil {
t.Fatal(err)
}
}
@@ -81,20 +80,27 @@ func setup(t *testing.T) *testEnv {
}
idType := "text"
expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers
boolType := "integer"
falseLit := "0"
if cfg.DBType == "postgres" {
idType = "uuid"
expiresType = "timestamp"
boolType = "boolean"
falseLit = "FALSE"
}
seed := []string{
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, token_version integer NOT NULL DEFAULT 0, account_status text NOT NULL DEFAULT 'active')`,
`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`,
`CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`,
`CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`,
`CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`,
`CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`,
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin',0,'active')`,
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user',0,'active')`,
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user',0,'active')`,
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user',0,'active')`,
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user',0,'active')`,
`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`,
`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`,
`INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`,
`INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`,
@@ -116,23 +122,27 @@ func setup(t *testing.T) *testEnv {
t.Fatal(err)
}
wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
srv := New(cfg, db, st, auth.NewVerifier(cfg.JWTSecret, db), wrk)
return &testEnv{handler: srv.Handler(), db: db, worker: wrk}
srv := New(cfg, db, st, auth.NewVerifier(db), wrk)
return &testEnv{handler: srv.Handler(), db: db, worker: wrk, pg: cfg.DBType == "postgres"}
}
func makeToken(t *testing.T, sub, email, role string) string {
// makeToken inserts a Better Auth session row for the user and returns a
// bearer value in the cookie's "token.signature" format (the verifier
// resolves the token part against auth_sessions; the signature is unused).
func (e *testEnv) makeToken(t *testing.T, sub string) string {
t.Helper()
tv := 0
claims := jwt.MapClaims{
"sub": sub, "email": email, "role": role, "tokenVersion": tv,
"iss": "spanglish", "aud": "spanglish-app",
"iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(),
token := "tok-" + newID()
expires := time.Now().Add(time.Hour)
var expiresVal any = expires.UnixMilli()
if e.pg {
expiresVal = expires
}
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testSecret))
if err != nil {
if _, err := e.db.Exec(e.db.Rebind(
`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
newID(), sub, token, expiresVal); err != nil {
t.Fatal(err)
}
return s
return token + ".testsig"
}
func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
@@ -193,16 +203,61 @@ func TestAdminGate(t *testing.T) {
if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 {
t.Fatalf("anon create: want 401, got %d", w.Code)
}
member := makeToken(t, uMember, "m@x.py", "user")
member := e.makeToken(t, uMember)
if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 {
t.Fatalf("member create: want 403, got %d", w.Code)
}
unknown := makeToken(t, newID(), "g@x.py", "admin")
unknown := e.makeToken(t, newID())
if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 {
t.Fatalf("unknown-user token: want 401, got %d", w.Code)
}
}
func TestSessionValidation(t *testing.T) {
e := setup(t)
body := map[string]string{"title": "Test"}
// Expired session -> 401
expiredTok := "tok-" + newID()
var expiresVal any = time.Now().Add(-time.Hour).UnixMilli()
if e.pg {
expiresVal = time.Now().Add(-time.Hour)
}
if _, err := e.db.Exec(e.db.Rebind(
`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
newID(), uAdmin, expiredTok, expiresVal); err != nil {
t.Fatal(err)
}
if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 {
t.Fatalf("expired session: want 401, got %d", w.Code)
}
// Banned (suspended) user -> 401 even with a live session
bannedTok := e.makeToken(t, uMember)
if _, err := e.db.Exec(e.db.Rebind(
`UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil {
t.Fatal(err)
}
if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 {
t.Fatalf("suspended user: want 401, got %d", w.Code)
}
if _, err := e.db.Exec(e.db.Rebind(
`UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil {
t.Fatal(err)
}
// Session via the browser cookie (URL-encoded token.signature) -> works
adminTok := e.makeToken(t, uAdmin)
req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`)))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")})
w := httptest.NewRecorder()
e.handler.ServeHTTP(w, req)
if w.Code != 200 && w.Code != 201 {
t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String())
}
}
type galleryResp struct {
Gallery galleryJSON `json:"gallery"`
Photos []photoJSON `json:"photos"`
@@ -265,7 +320,7 @@ func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
func TestUploadProcessAndServe(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"})
if g.Slug != "fiesta-de-junio" {
t.Fatalf("slug: %q", g.Slug)
@@ -325,11 +380,11 @@ func TestUploadProcessAndServe(t *testing.T) {
func TestAccessMatrix(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
member := makeToken(t, uMember, "m@x.py", "user")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
pending := makeToken(t, uPending, "p@x.py", "user")
freeguy := makeToken(t, uFree, "f@x.py", "user")
admin := e.makeToken(t, uAdmin)
member := e.makeToken(t, uMember)
buyer := e.makeToken(t, uBuyer)
pending := e.makeToken(t, uPending)
freeguy := e.makeToken(t, uFree)
get := func(slug, token, bearer string) int {
path := "/api/photos/public/galleries/" + slug
@@ -408,7 +463,7 @@ func TestAccessMatrix(t *testing.T) {
func TestReorderAndVisibilityUpdate(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
jpg := testJPEG(t)
p1 := uploadPhoto(t, e, admin, g.ID, jpg)
@@ -447,7 +502,7 @@ func TestReorderAndVisibilityUpdate(t *testing.T) {
// accepts anonymously.
func TestViewTokens(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"})
p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
@@ -499,8 +554,8 @@ func TestViewTokens(t *testing.T) {
func TestEventGalleryRoute(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
buyer := makeToken(t, uBuyer, "b@x.py", "user")
admin := e.makeToken(t, uAdmin)
buyer := e.makeToken(t, uBuyer)
g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})
@@ -546,7 +601,7 @@ func TestEventGalleryRoute(t *testing.T) {
func TestSlugCollision(t *testing.T) {
e := setup(t)
admin := makeToken(t, uAdmin, "a@x.py", "admin")
admin := e.makeToken(t, uAdmin)
a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
if a.Slug == b.Slug {
+1 -1
View File
@@ -67,7 +67,7 @@ func (s *Server) viewTokenFor(g store.Gallery) string {
if g.Visibility == store.VisibilityPublic {
return ""
}
return mintViewToken([]byte(s.cfg.JWTSecret), g.ID)
return mintViewToken([]byte(s.cfg.ViewTokenSecret), g.ID)
}
func isoTime(t time.Time) string {
+49 -12
View File
@@ -12,31 +12,67 @@ import (
"time"
)
type UserAuth struct {
type SessionUser struct {
ID string
Email string
Role string
TokenVersion int
AccountStatus string
Banned bool
ExpiresAt time.Time
}
func (db *DB) GetUserAuth(ctx context.Context, userID string) (UserAuth, error) {
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT id, role, token_version, account_status FROM users WHERE id = ?"), userID)
var id, role, tv, status any
if err := row.Scan(&id, &role, &tv, &status); err != nil {
// GetSessionUser resolves a Better Auth session token (from the
// spanglish.session_token cookie) to its user. Sessions live in the shared
// auth_sessions table, written by the backend; the row's presence is the
// authoritative validity check, so revocations apply here instantly.
func (db *DB) GetSessionUser(ctx context.Context, token string) (SessionUser, error) {
row := db.QueryRowContext(ctx, db.Rebind(`
SELECT u.id, u.email, u.role, u.account_status, u.banned, s.expires_at
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ?`), token)
var id, email, role, status, banned, expires any
if err := row.Scan(&id, &email, &role, &status, &banned, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return UserAuth{}, ErrNotFound
return SessionUser{}, ErrNotFound
}
return UserAuth{}, err
return SessionUser{}, err
}
return UserAuth{
return SessionUser{
ID: asString(id),
Email: asString(email),
Role: asString(role),
TokenVersion: int(asInt(tv)),
AccountStatus: asString(status),
Banned: asBool(banned),
ExpiresAt: asTimeOrMillis(expires),
}, nil
}
// asBool normalizes booleans across drivers (pg BOOLEAN, sqlite INTEGER 0/1).
func asBool(v any) bool {
switch x := v.(type) {
case bool:
return x
case int64, int, int32, float64:
return asInt(v) != 0
case []byte:
return len(x) > 0 && x[0] != '0' && x[0] != 'f' && x[0] != 'F'
default:
return false
}
}
// asTimeOrMillis parses a timestamp that is a pg TIMESTAMP on postgres but an
// epoch-milliseconds INTEGER on sqlite (Drizzle timestamp_ms mode).
func asTimeOrMillis(v any) time.Time {
switch v.(type) {
case int64, int, int32, float64:
return time.UnixMilli(asInt(v)).UTC()
default:
return asTime(v)
}
}
// HasPaidTicket implements the review decision: a confirmed/checked-in
// ticket whose payment is 'paid', or any confirmed/checked-in ticket when
// the event is free (price = 0).
@@ -99,7 +135,8 @@ func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSu
// from Drizzle-owned tables have been renamed or dropped.
func (db *DB) CheckMainSchema(ctx context.Context) error {
checks := []string{
"SELECT id, role, token_version, account_status FROM users WHERE 1 = 0",
"SELECT id, email, role, account_status, banned FROM users WHERE 1 = 0",
"SELECT id, user_id, token, expires_at FROM auth_sessions WHERE 1 = 0",
"SELECT id, slug, title, title_es, start_datetime, status, price FROM events WHERE 1 = 0",
"SELECT id, user_id, event_id, status FROM tickets WHERE 1 = 0",
"SELECT id, ticket_id, status FROM payments WHERE 1 = 0",