feat(webhook): add webhook client for RSVP and photo uploads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
+3
-1
@@ -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))))
|
||||
|
||||
Reference in New Issue
Block a user