feat(webhook): add webhook client for RSVP and photo uploads
This commit is contained in:
@@ -26,3 +26,10 @@ email:
|
|||||||
upload:
|
upload:
|
||||||
max_size_mb: 15
|
max_size_mb: 15
|
||||||
max_files: 10
|
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/config"
|
||||||
"wedding-server/internal/mail"
|
"wedding-server/internal/mail"
|
||||||
"wedding-server/internal/store"
|
"wedding-server/internal/store"
|
||||||
|
"wedding-server/internal/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
store *store.Store
|
store *store.Store
|
||||||
mailer *mail.Mailer
|
mailer *mail.Mailer
|
||||||
|
webhook *webhook.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer) *Server {
|
func New(cfg *config.Config, st *store.Store, mailer *mail.Mailer, wh *webhook.Client) *Server {
|
||||||
return &Server{cfg: cfg, store: st, mailer: mailer}
|
return &Server{cfg: cfg, store: st, mailer: mailer, webhook: wh}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Routes(mux *http.ServeMux) {
|
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 {
|
if err := s.mailer.SendRSVP(rsvp); err != nil {
|
||||||
log.Printf("rsvp: email failed: %v", err)
|
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})
|
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 {
|
if err := s.mailer.SendPhotoUpload(upload, saved); err != nil {
|
||||||
log.Printf("photos: email failed: %v", err)
|
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})
|
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Config struct {
|
|||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage"`
|
||||||
Email EmailConfig `yaml:"email"`
|
Email EmailConfig `yaml:"email"`
|
||||||
Upload UploadConfig `yaml:"upload"`
|
Upload UploadConfig `yaml:"upload"`
|
||||||
|
Webhook WebhookConfig `yaml:"webhook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@@ -52,6 +53,17 @@ type UploadConfig struct {
|
|||||||
MaxFiles int `yaml:"max_files"`
|
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.
|
// Path resolves the config file location: $CONFIG_PATH, or ./config.yaml.
|
||||||
func Path() string {
|
func Path() string {
|
||||||
if p := os.Getenv("CONFIG_PATH"); p != "" {
|
if p := os.Getenv("CONFIG_PATH"); p != "" {
|
||||||
@@ -83,6 +95,9 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Upload.MaxFiles == 0 {
|
if cfg.Upload.MaxFiles == 0 {
|
||||||
cfg.Upload.MaxFiles = 10
|
cfg.Upload.MaxFiles = 10
|
||||||
}
|
}
|
||||||
|
if cfg.Webhook.TimeoutSeconds == 0 {
|
||||||
|
cfg.Webhook.TimeoutSeconds = 5
|
||||||
|
}
|
||||||
if cfg.Wedding.Date.IsZero() {
|
if cfg.Wedding.Date.IsZero() {
|
||||||
return nil, fmt.Errorf("wedding.date is required in %s", path)
|
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/config"
|
||||||
"wedding-server/internal/mail"
|
"wedding-server/internal/mail"
|
||||||
"wedding-server/internal/store"
|
"wedding-server/internal/store"
|
||||||
|
"wedding-server/internal/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:web/dist
|
//go:embed all:web/dist
|
||||||
@@ -37,9 +38,10 @@ func main() {
|
|||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
mailer := mail.New(cfg.Email)
|
mailer := mail.New(cfg.Email)
|
||||||
|
wh := webhook.New(cfg.Webhook)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
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")
|
uploadsDir := filepath.Join(cfg.Storage.DataDir, "photos")
|
||||||
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsDir))))
|
mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsDir))))
|
||||||
|
|||||||
Reference in New Issue
Block a user