e5b187c575
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>
144 lines
5.1 KiB
Go
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[:])
|
|
}
|