Files
Spanglish/photo-api/internal/store/access.go
T
MichilisandClaude Opus 5 733d2459df 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>
2026-07-29 19:07:04 +00:00

151 lines
4.7 KiB
Go

package store
// Every query against Drizzle-owned tables (users, events, tickets,
// payments) lives in this file so the read-coupling surface stays small
// and auditable. Column semantics follow backend/src/db/schema.ts.
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type SessionUser struct {
ID string
Email string
Role string
AccountStatus string
Banned bool
ExpiresAt time.Time
}
// 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 SessionUser{}, ErrNotFound
}
return SessionUser{}, err
}
return SessionUser{
ID: asString(id),
Email: asString(email),
Role: asString(role),
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).
func (db *DB) HasPaidTicket(ctx context.Context, userID, eventID string) (bool, error) {
var v any
err := db.QueryRowContext(ctx, db.Rebind(`
SELECT 1
FROM tickets t
JOIN events e ON e.id = t.event_id
LEFT JOIN payments p ON p.ticket_id = t.id
WHERE t.user_id = ? AND t.event_id = ?
AND t.status IN ('confirmed','checked_in')
AND (p.status = 'paid' OR e.price = 0)
LIMIT 1`), userID, eventID).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
type EventSummary struct {
ID string
Slug string
Title string
TitleEs string
StartDatetime time.Time
Status string
}
func (db *DB) GetEventSummary(ctx context.Context, eventID string) (EventSummary, error) {
return db.getEventWhere(ctx, "id = ?", eventID)
}
// GetEventSummaryBySlug resolves the /events/{slug}/gallery public route.
func (db *DB) GetEventSummaryBySlug(ctx context.Context, slug string) (EventSummary, error) {
return db.getEventWhere(ctx, "slug = ?", slug)
}
func (db *DB) getEventWhere(ctx context.Context, where string, arg any) (EventSummary, error) {
row := db.QueryRowContext(ctx, db.Rebind(
"SELECT id, slug, title, title_es, start_datetime, status FROM events WHERE "+where), arg)
var id, slug, title, titleEs, start, status any
if err := row.Scan(&id, &slug, &title, &titleEs, &start, &status); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return EventSummary{}, ErrNotFound
}
return EventSummary{}, err
}
return EventSummary{
ID: asString(id),
Slug: asString(slug),
Title: asString(title),
TitleEs: asString(titleEs),
StartDatetime: asTime(start),
Status: asString(status),
}, nil
}
// CheckMainSchema fails fast at startup if the columns this service reads
// from Drizzle-owned tables have been renamed or dropped.
func (db *DB) CheckMainSchema(ctx context.Context) error {
checks := []string{
"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",
}
for _, q := range checks {
if _, err := db.ExecContext(ctx, q); err != nil {
return fmt.Errorf("main app schema check failed (%s): %w", q, err)
}
}
return nil
}