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:
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user