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