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>
This commit is contained in:
Kwaku Danso
2026-05-18 12:04:09 +01:00
parent 9842bd4f45
commit e5b187c575
30 changed files with 2310 additions and 199 deletions
+143
View File
@@ -0,0 +1,143 @@
// 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[:])
}
+93
View File
@@ -0,0 +1,93 @@
package uploads
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"strings"
"testing"
)
// makePNG and makeJPEG produce a tiny valid image for the round-trip tests.
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
img.Set(0, 0, color.RGBA{34, 197, 94, 255})
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return b.Bytes()
}
func makeJPEG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
img.Set(0, 0, color.RGBA{34, 197, 94, 255})
var b bytes.Buffer
if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return b.Bytes()
}
func TestDecodeAndReencode_PNG(t *testing.T) {
out, format, err := DecodeAndReencode(makePNG(t))
if err != nil {
t.Fatalf("decode png: %v", err)
}
if format != FormatPNG {
t.Errorf("format: got %q want png", format)
}
// Output must be valid PNG (round-trip decode).
if _, _, err := image.Decode(bytes.NewReader(out)); err != nil {
t.Errorf("re-encoded png is not valid: %v", err)
}
}
func TestDecodeAndReencode_JPEG(t *testing.T) {
out, format, err := DecodeAndReencode(makeJPEG(t))
if err != nil {
t.Fatalf("decode jpeg: %v", err)
}
if format != FormatJPEG {
t.Errorf("format: got %q want jpeg", format)
}
if _, _, err := image.Decode(bytes.NewReader(out)); err != nil {
t.Errorf("re-encoded jpeg is not valid: %v", err)
}
}
func TestDecodeAndReencode_RejectsNonImage(t *testing.T) {
_, _, err := DecodeAndReencode([]byte("hello, world"))
if err == nil {
t.Fatal("expected an error decoding a non-image payload")
}
if !strings.Contains(err.Error(), "not a recognised image") {
t.Errorf("error: got %q want 'not a recognised image'", err)
}
}
func TestDecodeAndReencode_RejectsTooLarge(t *testing.T) {
big := make([]byte, MaxUploadBytes+1)
_, _, err := DecodeAndReencode(big)
if err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Errorf("expected size-limit error, got %v", err)
}
}
func TestLocalFSStore_FilePath_RejectsTraversal(t *testing.T) {
s := &LocalFSStore{Dir: "/tmp/x", PublicBase: "http://localhost/up"}
cases := []string{"../etc/passwd", "a/b.png", "..", ".env"}
for _, in := range cases {
if _, err := s.FilePath(in); err == nil {
t.Errorf("FilePath(%q) should have refused", in)
}
}
// A bare random filename should be accepted.
if _, err := s.FilePath("ok.png"); err != nil {
t.Errorf("FilePath(ok.png) should have accepted: %v", err)
}
}