Files
zoe-shaldon/server/internal/api/handlers.go
T
warkanum b7e88099c8 feat(ui): add RSVP attendance option and admin features
* Implement attendance selection in RSVP form
* Add admin endpoints for managing RSVPs and guests
* Update RSVP handling to include attendance status
2026-09-06 16:02:52 +02:00

514 lines
15 KiB
Go

package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"wedding-server/internal/config"
"wedding-server/internal/mail"
"wedding-server/internal/store"
"wedding-server/internal/webhook"
)
type Server struct {
cfg *config.Config
store *store.Store
mailer *mail.Mailer
webhook *webhook.Client
}
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer, wh *webhook.Client) *Server {
return &Server{cfg: cfg, store: st, mailer: mailer, webhook: wh}
}
func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /api/config", s.handleConfig)
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
mux.HandleFunc("GET /api/guests", s.handleGuestList)
mux.HandleFunc("GET /api/photos", s.handleListPhotos)
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
// Admin RSVP editing. These endpoints are unauthenticated; the UI gates
// them behind ?admin=1 only. Put the server behind auth before exposing it
// on an untrusted network.
mux.HandleFunc("GET /api/admin/rsvps", s.handleAdminListRSVPs)
mux.HandleFunc("POST /api/admin/rsvps", s.handleAdminCreateRSVP)
mux.HandleFunc("PATCH /api/admin/rsvps/{id}", s.handleAdminPatchRSVP)
mux.HandleFunc("DELETE /api/admin/rsvps/{id}", s.handleAdminDeleteRSVP)
mux.HandleFunc("POST /api/admin/rsvps/{id}/guests", s.handleAdminAddGuest)
mux.HandleFunc("PATCH /api/admin/guests/{id}", s.handleAdminPatchGuest)
mux.HandleFunc("DELETE /api/admin/guests/{id}", s.handleAdminDeleteGuest)
}
func pathID(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
type adminGuest struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsChild bool `json:"isChild"`
}
type adminRSVP struct {
ID int64 `json:"id"`
Attending bool `json:"attending"`
Message string `json:"message"`
CreatedAt string `json:"createdAt"`
Guests []adminGuest `json:"guests"`
}
func (s *Server) handleAdminListRSVPs(w http.ResponseWriter, r *http.Request) {
records, err := s.store.ListRSVPs()
if err != nil {
log.Printf("admin: list rsvps failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not load RSVPs")
return
}
out := make([]adminRSVP, 0, len(records))
for _, rec := range records {
guests := make([]adminGuest, 0, len(rec.Guests))
for _, g := range rec.Guests {
guests = append(guests, adminGuest{ID: g.ID, Name: g.Name, IsChild: g.IsChild})
}
out = append(out, adminRSVP{
ID: rec.ID,
Attending: rec.Attending,
Message: rec.Message,
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
Guests: guests,
})
}
writeJSON(w, http.StatusOK, map[string]any{"rsvps": out})
}
func (s *Server) handleAdminCreateRSVP(w http.ResponseWriter, r *http.Request) {
var req rsvpRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var guests []store.Guest
for _, g := range req.Guests {
name := strings.TrimSpace(g.Name)
if name != "" {
guests = append(guests, store.Guest{Name: name, IsChild: g.Type == "child"})
}
}
if len(guests) == 0 {
writeError(w, http.StatusBadRequest, "at least one name is required")
return
}
id, err := s.store.CreateRSVP(store.RSVP{
Guests: guests,
Message: strings.TrimSpace(req.Message),
Attending: req.Attending == nil || *req.Attending,
})
if err != nil {
log.Printf("admin: create rsvp failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save RSVP")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleAdminPatchRSVP(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req struct {
Attending *bool `json:"attending"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil || req.Attending == nil {
writeError(w, http.StatusBadRequest, "attending is required")
return
}
if err := s.store.SetRSVPAttending(id, *req.Attending); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleAdminDeleteRSVP(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteRSVP(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleAdminAddGuest(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req guestRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
guestID, err := s.store.AddGuest(id, store.Guest{Name: name, IsChild: req.Type == "child"})
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": guestID})
}
func (s *Server) handleAdminPatchGuest(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req guestRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.store.UpdateGuest(id, name, req.Type == "child"); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleAdminDeleteGuest(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteGuest(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"siteUrl": s.cfg.Site.URL,
"weddingDate": s.cfg.Wedding.Date.Format(time.RFC3339),
"eventEnd": s.cfg.Wedding.EventEnd.Format(time.RFC3339),
"ceremonyTime": s.cfg.Wedding.CeremonyTime,
"rsvpByDate": s.cfg.Wedding.RSVPBy.Format(time.RFC3339),
"venueName": s.cfg.Wedding.VenueName,
"venueAddress": s.cfg.Wedding.VenueAddress,
"rsvpPhone": s.cfg.Wedding.RSVPPhone,
})
}
type guestRequest struct {
Name string `json:"name"`
Type string `json:"type"`
}
type rsvpRequest struct {
Guests []guestRequest `json:"guests"`
Message string `json:"message"`
Attending *bool `json:"attending"`
}
func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
var req rsvpRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var guests []store.Guest
for _, g := range req.Guests {
name := strings.TrimSpace(g.Name)
if name != "" {
guests = append(guests, store.Guest{Name: name, IsChild: g.Type == "child"})
}
}
if len(guests) == 0 {
writeError(w, http.StatusBadRequest, "at least one name is required")
return
}
attending := req.Attending == nil || *req.Attending
rsvp := store.RSVP{
Guests: guests,
Message: strings.TrimSpace(req.Message),
Attending: attending,
}
if err := s.store.InsertRSVP(rsvp); err != nil {
log.Printf("rsvp: insert failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save RSVP")
return
}
allGuests, err := s.store.ListGuests()
if err != nil {
log.Printf("rsvp: list guests failed: %v", err)
}
if err := s.mailer.SendRSVP(rsvp, allGuests); err != nil {
log.Printf("rsvp: email failed: %v", err)
}
if err := s.webhook.SendRSVP(rsvp); err != nil {
log.Printf("rsvp: webhook failed: %v", err)
}
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
type guestResponse struct {
Name string `json:"name"`
IsChild bool `json:"isChild"`
}
func (s *Server) handleGuestList(w http.ResponseWriter, r *http.Request) {
guests, err := s.store.ListGuests()
if err != nil {
log.Printf("guests: list failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not load guests")
return
}
out := make([]guestResponse, 0, len(guests))
adults, children := 0, 0
for _, g := range guests {
out = append(out, guestResponse{Name: g.Name, IsChild: g.IsChild})
if g.IsChild {
children++
} else {
adults++
}
}
writeJSON(w, http.StatusOK, map[string]any{
"guests": out,
"total": len(out),
"adults": adults,
"children": children,
})
}
type photoResponse struct {
URL string `json:"url"`
UploaderName string `json:"uploaderName"`
}
func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) {
files, err := s.store.ListPhotoFiles()
if err != nil {
log.Printf("photos: list failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not load photos")
return
}
out := make([]photoResponse, 0, len(files))
for _, f := range files {
out = append(out, photoResponse{URL: f.URL, UploaderName: f.UploaderName})
}
writeJSON(w, http.StatusOK, map[string]any{"photos": out})
}
var allowedImageTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
"image/heic": true,
"image/heif": true,
}
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
if err := r.ParseMultipartForm(10 << 20); err != nil {
writeError(w, http.StatusBadRequest, "invalid upload (too large or malformed)")
return
}
defer r.MultipartForm.RemoveAll()
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
message := strings.TrimSpace(r.FormValue("message"))
email := strings.TrimSpace(r.FormValue("email"))
phone := strings.TrimSpace(r.FormValue("phone"))
headers := r.MultipartForm.File["files"]
if len(headers) == 0 {
writeError(w, http.StatusBadRequest, "at least one photo is required")
return
}
if len(headers) > s.cfg.Upload.MaxFiles {
writeError(w, http.StatusBadRequest, fmt.Sprintf("at most %d photos per submission", s.cfg.Upload.MaxFiles))
return
}
maxFileBytes := int64(s.cfg.Upload.MaxSizeMB) * 1024 * 1024
for _, fh := range headers {
if fh.Size > maxFileBytes {
writeError(w, http.StatusBadRequest, fmt.Sprintf("%q is larger than %dMB", fh.Filename, s.cfg.Upload.MaxSizeMB))
return
}
}
// Sniff and validate every file's actual content before creating any
// database row or directory, so a rejected upload leaves no trace.
for _, fh := range headers {
file, err := fh.Open()
if err != nil {
writeError(w, http.StatusBadRequest, "could not read uploaded file")
return
}
sniff := make([]byte, 512)
n, _ := io.ReadFull(file, sniff)
file.Close()
sniff = sniff[:n]
contentType := http.DetectContentType(sniff)
if contentType == "application/octet-stream" && isHEIC(sniff) {
// net/http's sniffer table has no ISOBMFF/HEIC signature.
contentType = "image/heic"
}
if !allowedImageTypes[contentType] {
writeError(w, http.StatusBadRequest, fmt.Sprintf("%q is not a supported image type", fh.Filename))
return
}
}
uploadID, err := s.store.CreatePhotoUpload(store.PhotoUpload{
Name: name, Message: message, Email: email, Phone: phone,
})
if err != nil {
log.Printf("photos: create upload failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save upload")
return
}
uploadDir := filepath.Join(s.cfg.Storage.DataDir, "photos", strconv.FormatInt(uploadID, 10))
if err := os.MkdirAll(uploadDir, 0o755); err != nil {
log.Printf("photos: mkdir failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save upload")
return
}
var saved []store.PhotoFile
for _, fh := range headers {
file, err := fh.Open()
if err != nil {
writeError(w, http.StatusBadRequest, "could not read uploaded file")
return
}
storedName := randomHex(8) + "_" + filepath.Base(fh.Filename)
destPath := filepath.Join(uploadDir, storedName)
dest, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
file.Close()
log.Printf("photos: create file failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save upload")
return
}
_, err = io.Copy(dest, file)
dest.Close()
file.Close()
if err != nil {
log.Printf("photos: write file failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save upload")
return
}
saved = append(saved, store.PhotoFile{
Filename: fh.Filename,
Path: destPath,
URL: fmt.Sprintf("/uploads/%d/%s", uploadID, storedName),
})
}
if err := s.store.AddPhotoFiles(uploadID, saved); err != nil {
log.Printf("photos: add files failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save upload")
return
}
upload := store.PhotoUpload{Name: name, Message: message, Email: email, Phone: phone}
if err := s.mailer.SendPhotoUpload(upload, saved); err != nil {
log.Printf("photos: email failed: %v", err)
}
if err := s.webhook.SendPhotoUpload(upload, saved); err != nil {
log.Printf("photos: webhook failed: %v", err)
}
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
// isHEIC checks for the ISOBMFF "ftyp" box and a HEIC/HEIF brand, since
// net/http.DetectContentType does not recognize this container format.
func isHEIC(b []byte) bool {
if len(b) < 12 || string(b[4:8]) != "ftyp" {
return false
}
switch string(b[8:12]) {
case "heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1":
return true
default:
return false
}
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}