* implement ListGuests method in store * create handleGuestList function in API * add GuestListEntry and GuestList interfaces in UI * create guest list page to display RSVPs
301 lines
8.2 KiB
Go
301 lines
8.2 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"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
store *store.Store
|
|
mailer *mail.Mailer
|
|
}
|
|
|
|
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer) *Server {
|
|
return &Server{cfg: cfg, store: st, mailer: mailer}
|
|
}
|
|
|
|
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("POST /api/photos/upload", s.handlePhotoUpload)
|
|
}
|
|
|
|
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),
|
|
"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"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
rsvp := store.RSVP{
|
|
Guests: guests,
|
|
Message: strings.TrimSpace(req.Message),
|
|
}
|
|
if err := s.store.InsertRSVP(rsvp); err != nil {
|
|
log.Printf("rsvp: insert failed: %v", err)
|
|
writeError(w, http.StatusInternalServerError, "could not save RSVP")
|
|
return
|
|
}
|
|
|
|
if err := s.mailer.SendRSVP(rsvp); err != nil {
|
|
log.Printf("rsvp: email 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,
|
|
})
|
|
}
|
|
|
|
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) {
|
|
if time.Now().Before(s.cfg.Wedding.Date) {
|
|
writeError(w, http.StatusForbidden, "photo uploads open on the wedding day")
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|