Files
guestguard/internal/uploads/uploads.go
T
Kwaku Danso e5b187c575 feat(tier2): event branding + UX polish — Block D
Backend
- Migration 0010 adds event_branding (one row per event; all fields
  nullable so a brand-new event renders with defaults)
- BrandingRepo with COALESCE/NULLIF upsert semantics: nil pointer
  preserves the existing value, "" clears the field to NULL
- internal/uploads package: ImageStore interface + LocalFSStore (dev),
  pure-stdlib decode + re-encode that strips EXIF and rejects anything
  that isn't valid JPEG/PNG. Size cap 2 MB, random 16-byte filenames
- GET /events/{id}/branding (viewer+) returns the row plus the
  AllowedFonts list so the frontend picker stays in sync
- PUT /events/{id}/branding (editor+) validates hex colours, font
  allowlist, and refuses image URLs whose path doesn't start with
  /uploads/ (blocks arbitrary-origin <img> smuggling on guest pages)
- POST /uploads/image (authed) → fresh CDN URL; GET /uploads/{file}
  serves with year-long cache (immutable random names)
- GET /access/{token} now embeds the host's branding so the RSVP page
  can render in their colours/font with their logo + cover
- docker-compose mounts a named volume for uploads
- Custom-domain sub-block deferred to Tier 3 per the plan

Frontend
- BrandingCard.vue: colour pickers, font dropdown, logo + cover upload
  with progressive disclosure, live preview pane that re-renders on
  every keystroke
- RSVP page applies branding via CSS vars at the section root, so
  primary colour theme + font cascade through every child card. Cover
  image renders as a banner above the form; logo lands in the header
- Submit button background switches to var(--brand-primary) when set
- Mounted on the event detail page below the guests block

Plus the small UX fixes from the e2e walkthrough:
- Nav: dropped the top-level "Events" link; the logo doubles as the
  home affordance (→ /dashboard when signed in, → / otherwise). Account
  + Billing + Sign out live under a profile dropdown (avatar with
  initials, opens on click, closes on outside-click / Esc / route nav)
- Renamed "Back to dashboard" → "Back to events" across event detail,
  billing, account, and new-event pages

Tests
- TestBrandingGetReturnsDefaults / TestBrandingPutPersists /
  TestBrandingPutRejectsBadInputs / TestUploadAndServeImage /
  TestUploadRejectsNonImage — all pass
- Domain tests for IsValidHexColor + IsAllowedFont
- Full integration suite green (176s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:04:09 +01:00

144 lines
5.1 KiB
Go

// Package uploads handles image uploads for the branding flow (Tier 2 Block D).
//
// In dev we write to the local filesystem and serve via a dedicated HTTP
// handler; in production the same interface should be backed by S3 + CDN
// (left as a deployment task, not application code — same split the project
// uses for backups and DB infra).
//
// Validation philosophy: never trust the Content-Type header from the
// client. We decode the bytes through Go's image stdlib and re-encode them
// to a known format. That strips EXIF, normalises the byte stream, and
// turns a "spoof an exe as a PNG" attempt into a decode error instead of a
// stored payload.
package uploads
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"image"
// Register the standard decoders so image.Decode recognises jpeg/png.
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
)
// MaxUploadBytes caps every accepted upload. 2 MB covers logos + cover
// photos comfortably; the host's browser usually scales further when the
// page actually renders.
const MaxUploadBytes = 2 * 1024 * 1024
// Format names normalised post-decode. Anything else we reject.
const (
FormatJPEG = "jpeg"
FormatPNG = "png"
)
// ImageStore is the production seam. LocalFSStore is the dev impl; a future
// S3Store will satisfy the same interface so handlers don't change.
type ImageStore interface {
// Save persists the (re-encoded) bytes under a fresh random key and
// returns the public URL the frontend should reference.
Save(ctx context.Context, body []byte, format string) (publicURL string, err error)
// FilePath maps a publicly-served filename back to a local path so the
// dev HTTP handler can stream it. S3 impls would return "" + an error
// (their URLs hit the CDN directly, not the API).
FilePath(filename string) (string, error)
}
// LocalFSStore writes each upload to a flat directory on disk. Filenames
// are 16 hex bytes + the canonical extension so two uploads can't collide
// and a guessing attacker has 128 bits to brute-force.
type LocalFSStore struct {
// Dir is the on-disk directory. Created on first call if missing.
Dir string
// PublicBase is the URL prefix the frontend will use to fetch saved
// images, e.g. "http://localhost:8080/uploads". The API exposes a
// matching handler at PublicBase's path.
PublicBase string
}
// Save re-encodes and writes the image to disk. Returns the full public
// URL the host should store in their branding row.
func (s *LocalFSStore) Save(ctx context.Context, body []byte, format string) (string, error) {
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
return "", fmt.Errorf("mkdir uploads: %w", err)
}
ext := ".jpg"
if format == FormatPNG {
ext = ".png"
}
name := randName() + ext
dest := filepath.Join(s.Dir, name)
if err := os.WriteFile(dest, body, 0o644); err != nil {
return "", fmt.Errorf("write upload: %w", err)
}
base := strings.TrimRight(s.PublicBase, "/")
return base + "/" + name, nil
}
// FilePath maps a served filename back to its local path. Refuses any
// path with a separator so we can't be tricked into serving /etc/passwd.
func (s *LocalFSStore) FilePath(filename string) (string, error) {
if filename == "" || strings.ContainsAny(filename, "/\\") || strings.HasPrefix(filename, ".") {
return "", errors.New("invalid upload filename")
}
return filepath.Join(s.Dir, filename), nil
}
// DecodeAndReencode validates `raw` as a recognised image format and
// returns the re-encoded bytes plus the canonical format name. Anything
// that doesn't parse as JPEG/PNG (or that exceeds MaxUploadBytes) returns
// an error.
//
// We deliberately don't trust the Content-Type header here — `image.Decode`
// sniffs the magic bytes. Stripped through the encoder, the output is the
// same image without EXIF / hidden chunks.
func DecodeAndReencode(raw []byte) (out []byte, format string, err error) {
if len(raw) == 0 {
return nil, "", errors.New("empty upload")
}
if len(raw) > MaxUploadBytes {
return nil, "", fmt.Errorf("upload exceeds %d byte limit", MaxUploadBytes)
}
img, gotFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", fmt.Errorf("not a recognised image: %w", err)
}
var buf bytes.Buffer
switch gotFormat {
case "jpeg":
format = FormatJPEG
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 88}); err != nil {
return nil, "", fmt.Errorf("re-encode jpeg: %w", err)
}
case "png":
format = FormatPNG
if err := png.Encode(&buf, img); err != nil {
return nil, "", fmt.Errorf("re-encode png: %w", err)
}
default:
return nil, "", fmt.Errorf("unsupported image format %q (jpeg/png only)", gotFormat)
}
return buf.Bytes(), format, nil
}
// PeekReader reads up to MaxUploadBytes+1 from r. We add one byte so the
// caller can tell "exactly at the limit" from "over the limit" without
// pulling the whole body into RAM up front for size-only rejection.
func PeekReader(r io.Reader) ([]byte, error) {
return io.ReadAll(io.LimitReader(r, MaxUploadBytes+1))
}
func randName() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}