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) }