Files
Spanglish/photo-api/internal/httpapi/dto.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

164 lines
5.1 KiB
Go

package httpapi
import (
"context"
"time"
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
)
// JSON shapes are camelCase like the backend's responses.
type eventJSON struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
TitleEs string `json:"titleEs,omitempty"`
StartDatetime string `json:"startDatetime"`
Status string `json:"status"`
}
type galleryJSON struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
TitleEs string `json:"titleEs,omitempty"`
Description string `json:"description,omitempty"`
DescriptionEs string `json:"descriptionEs,omitempty"`
EventID string `json:"eventId,omitempty"`
Visibility string `json:"visibility"`
CoverPhotoID string `json:"coverPhotoId,omitempty"`
PhotoCount int `json:"photoCount"`
CoverURL string `json:"coverUrl,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Event *eventJSON `json:"event,omitempty"`
// Admin-only fields:
ShareToken string `json:"shareToken,omitempty"`
ShareURL string `json:"shareUrl,omitempty"`
}
type photoURLs struct {
Thumb string `json:"thumb,omitempty"`
Preview string `json:"preview,omitempty"`
Original string `json:"original"`
}
type photoJSON struct {
ID string `json:"id"`
GalleryID string `json:"galleryId"`
Position int `json:"position"`
OriginalFilename string `json:"originalFilename,omitempty"`
ContentType string `json:"contentType"`
SizeBytes int64 `json:"sizeBytes"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
TakenAt string `json:"takenAt,omitempty"`
Status string `json:"status"`
LastError string `json:"lastError,omitempty"` // admin only
CreatedAt string `json:"createdAt"`
URLs photoURLs `json:"urls"`
}
// viewTokenFor returns the token to embed in a gallery's file URLs: none
// for public galleries (files are anonymously accessible), a short-lived
// gallery-scoped view token otherwise (see viewtoken.go).
func (s *Server) viewTokenFor(g store.Gallery) string {
if g.Visibility == store.VisibilityPublic {
return ""
}
return mintViewToken([]byte(s.cfg.ViewTokenSecret), g.ID)
}
func isoTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
// fileURL builds the access-checked file endpoint URL; token is appended
// for link-mode viewers so the client can use URLs verbatim.
func fileURL(photoID, variant, token string) string {
u := "/api/photos/files/" + photoID + "/" + variant
if token != "" {
u += "?token=" + token
}
return u
}
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
out := photoJSON{
ID: p.ID,
GalleryID: p.GalleryID,
Position: p.Position,
OriginalFilename: p.OriginalFilename,
ContentType: p.ContentType,
SizeBytes: p.SizeBytes,
Width: p.Width,
Height: p.Height,
TakenAt: isoTime(p.TakenAt),
Status: p.Status,
CreatedAt: isoTime(p.CreatedAt),
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
}
if p.ThumbKey != "" {
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
}
if p.PreviewKey != "" {
out.URLs.Preview = fileURL(p.ID, "preview", token)
}
if admin {
out.LastError = p.LastError
}
return out
}
func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON {
out := galleryJSON{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
TitleEs: g.TitleEs,
Description: g.Description,
DescriptionEs: g.DescriptionEs,
EventID: g.EventID,
Visibility: g.Visibility,
CoverPhotoID: g.CoverPhotoID,
PhotoCount: g.PhotoCount,
CreatedAt: isoTime(g.CreatedAt),
UpdatedAt: isoTime(g.UpdatedAt),
}
if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" {
out.CoverURL = fileURL(coverID, "thumb", token)
}
if g.EventID != "" {
if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil {
out.Event = &eventJSON{
ID: ev.ID,
Slug: ev.Slug,
Title: ev.Title,
TitleEs: ev.TitleEs,
StartDatetime: isoTime(ev.StartDatetime),
Status: ev.Status,
}
}
}
if admin {
out.ShareToken = g.ShareToken
// Event-linked galleries live under the event's URL; standalone
// galleries keep their own /photos/<slug> URL.
page := "/photos/" + g.Slug
if out.Event != nil {
page = "/events/" + out.Event.Slug + "/gallery"
}
out.ShareURL = s.cfg.FrontendURL + page
// The token only grants anything in link/ticket mode; public and
// private galleries get a clean URL.
if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket {
out.ShareURL += "?token=" + g.ShareToken
}
}
return out
}