diff --git a/config.example.yaml b/config.example.yaml index a34350a..087539f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -26,3 +26,10 @@ email: upload: max_size_mb: 15 max_files: 10 + +webhook: + rsvp_url: "" # optional; POST JSON here on every new RSVP + photo_url: "" # optional; POST JSON here on every photo upload + secret: "" # optional; HMAC-SHA256-signs the payload, sent as X-Webhook-Signature + headers: {} # optional; extra headers sent with every webhook request, e.g. Authorization: "Bearer xxx" + timeout_seconds: 5 diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index a6e9f8b..6b7791a 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -17,16 +17,18 @@ import ( "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 + cfg *config.Config + store *store.Store + mailer *mail.Mailer + webhook *webhook.Client } -func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer) *Server { - return &Server{cfg: cfg, store: st, mailer: mailer} +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) { @@ -105,6 +107,9 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) { if err := s.mailer.SendRSVP(rsvp); 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}) } @@ -275,6 +280,9 @@ func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) { 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}) } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 149f347..563b61a 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { Storage StorageConfig `yaml:"storage"` Email EmailConfig `yaml:"email"` Upload UploadConfig `yaml:"upload"` + Webhook WebhookConfig `yaml:"webhook"` } type ServerConfig struct { @@ -52,6 +53,17 @@ type UploadConfig struct { MaxFiles int `yaml:"max_files"` } +// WebhookConfig is entirely optional: each URL is only called if set, so a +// deployment can wire up RSVP notifications without touching photo uploads +// (or either at all). +type WebhookConfig struct { + RSVPURL string `yaml:"rsvp_url"` + PhotoURL string `yaml:"photo_url"` + Secret string `yaml:"secret"` // signs the payload via HMAC-SHA256, sent in X-Webhook-Signature + Headers map[string]string `yaml:"headers"` // extra headers sent with every webhook request, e.g. Authorization + TimeoutSeconds int `yaml:"timeout_seconds"` +} + // Path resolves the config file location: $CONFIG_PATH, or ./config.yaml. func Path() string { if p := os.Getenv("CONFIG_PATH"); p != "" { @@ -83,6 +95,9 @@ func Load(path string) (*Config, error) { if cfg.Upload.MaxFiles == 0 { cfg.Upload.MaxFiles = 10 } + if cfg.Webhook.TimeoutSeconds == 0 { + cfg.Webhook.TimeoutSeconds = 5 + } if cfg.Wedding.Date.IsZero() { return nil, fmt.Errorf("wedding.date is required in %s", path) } diff --git a/server/internal/webhook/webhook.go b/server/internal/webhook/webhook.go new file mode 100644 index 0000000..6136f5a --- /dev/null +++ b/server/internal/webhook/webhook.go @@ -0,0 +1,98 @@ +package webhook + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "wedding-server/internal/config" + "wedding-server/internal/store" +) + +type Client struct { + cfg config.WebhookConfig + httpClient *http.Client +} + +func New(cfg config.WebhookConfig) *Client { + return &Client{ + cfg: cfg, + httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second}, + } +} + +func (c *Client) post(url, event string, data any) error { + if url == "" { + return nil + } + + body, err := json.Marshal(map[string]any{ + "event": event, + "sentAt": time.Now().UTC(), + "data": data, + }) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range c.cfg.Headers { + req.Header.Set(k, v) + } + if c.cfg.Secret != "" { + req.Header.Set("X-Webhook-Signature", sign(c.cfg.Secret, body)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode >= 300 { + return fmt.Errorf("webhook %s: unexpected status %d", url, resp.StatusCode) + } + return nil +} + +func sign(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +func (c *Client) SendRSVP(r store.RSVP) error { + guests := make([]map[string]any, len(r.Guests)) + for i, g := range r.Guests { + guests[i] = map[string]any{"name": g.Name, "isChild": g.IsChild} + } + return c.post(c.cfg.RSVPURL, "rsvp.created", map[string]any{ + "guests": guests, + "message": r.Message, + }) +} + +func (c *Client) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error { + urls := make([]string, len(files)) + for i, f := range files { + urls[i] = f.URL + } + return c.post(c.cfg.PhotoURL, "photos.uploaded", map[string]any{ + "name": u.Name, + "message": u.Message, + "email": u.Email, + "phone": u.Phone, + "photoUrls": urls, + }) +} diff --git a/server/main.go b/server/main.go index e09e982..b3ce28c 100644 --- a/server/main.go +++ b/server/main.go @@ -14,6 +14,7 @@ import ( "wedding-server/internal/config" "wedding-server/internal/mail" "wedding-server/internal/store" + "wedding-server/internal/webhook" ) //go:embed all:web/dist @@ -37,9 +38,10 @@ func main() { defer st.Close() mailer := mail.New(cfg.Email) + wh := webhook.New(cfg.Webhook) mux := http.NewServeMux() - api.New(cfg, st, mailer).Routes(mux) + api.New(cfg, st, mailer, wh).Routes(mux) uploadsDir := filepath.Join(cfg.Storage.DataDir, "photos") mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsDir))))