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>
164 lines
5.1 KiB
Go
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
|
|
}
|