d6956395c8
Rename the whole project from CommGate to gsmnode and re-skin every surface onto the new signal-green + ink design system (Space Grotesk / IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark, lowercase gsm+node wordmark). - Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme, new logo/favicon assets; both builds verified. - Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive launcher icons; rename Android applicationId/package to app.gsmnode.phone; bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified. - API Server (Go): panel routes, User-Agent, health service id, branding. - Home Assistant Plugin: rename integration domain sms_gateway to gsmnode (folder, DOMAIN, services, entity ids, classes); py_compile verified. - Update all READMEs; add .gitignore (excludes Design/, build artifacts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
171 lines
4.4 KiB
Go
171 lines
4.4 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"smsgateway/apiserver/internal/pb"
|
|
)
|
|
|
|
var validWebhookEvents = map[string]bool{
|
|
"sms:received": true,
|
|
"sms:sent": true,
|
|
"sms:delivered": true,
|
|
"sms:failed": true,
|
|
}
|
|
|
|
type webhookDTO struct {
|
|
ID string `json:"id"`
|
|
Event string `json:"event"`
|
|
URL string `json:"url"`
|
|
DeviceID string `json:"device_id,omitempty"`
|
|
}
|
|
|
|
func recordToWebhook(rec pb.Record) webhookDTO {
|
|
return webhookDTO{
|
|
ID: asString(rec["id"]),
|
|
Event: asString(rec["event"]),
|
|
URL: asString(rec["url"]),
|
|
DeviceID: asString(rec["device"]),
|
|
}
|
|
}
|
|
|
|
// handleListWebhooks lists the user's registered webhooks.
|
|
func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
|
|
uid, _ := userFromCtx(r.Context())
|
|
res, err := s.pb.List(r.Context(), colWebhooks, pb.ListOptions{
|
|
Filter: "owner = " + pbQuote(uid),
|
|
Sort: "-created",
|
|
PerPage: 200,
|
|
})
|
|
if err != nil {
|
|
writeUpstreamError(w, err)
|
|
return
|
|
}
|
|
out := make([]webhookDTO, 0, len(res.Items))
|
|
for _, rec := range res.Items {
|
|
out = append(out, recordToWebhook(rec))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems})
|
|
}
|
|
|
|
type createWebhookRequest struct {
|
|
Event string `json:"event"`
|
|
URL string `json:"url"`
|
|
DeviceID string `json:"device_id"`
|
|
}
|
|
|
|
// handleCreateWebhook registers a webhook for the user.
|
|
func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
|
|
uid, _ := userFromCtx(r.Context())
|
|
|
|
var req createWebhookRequest
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if !validWebhookEvents[req.Event] {
|
|
writeError(w, http.StatusBadRequest, "invalid event")
|
|
return
|
|
}
|
|
if req.URL == "" {
|
|
writeError(w, http.StatusBadRequest, "url is required")
|
|
return
|
|
}
|
|
|
|
fields := pb.Record{"owner": uid, "event": req.Event, "url": req.URL}
|
|
if req.DeviceID != "" {
|
|
fields["device"] = req.DeviceID
|
|
}
|
|
rec, err := s.pb.Create(r.Context(), colWebhooks, fields)
|
|
if err != nil {
|
|
writeUpstreamError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, recordToWebhook(rec))
|
|
}
|
|
|
|
// handleDeleteWebhook removes a webhook owned by the user.
|
|
func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
|
|
uid, _ := userFromCtx(r.Context())
|
|
id := r.PathValue("id")
|
|
|
|
rec, err := s.pb.GetOne(r.Context(), colWebhooks, id)
|
|
if err != nil {
|
|
writeUpstreamError(w, err)
|
|
return
|
|
}
|
|
if asString(rec["owner"]) != uid {
|
|
writeError(w, http.StatusForbidden, "not your webhook")
|
|
return
|
|
}
|
|
if err := s.pb.Delete(r.Context(), colWebhooks, id); err != nil {
|
|
writeUpstreamError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// dispatchWebhooks delivers an event to every matching webhook for the owner.
|
|
// Delivery is best-effort and runs in the background so it never blocks the
|
|
// device/client request.
|
|
func (s *Server) dispatchWebhooks(owner, deviceID, event string, payload map[string]any) {
|
|
if owner == "" {
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
res, err := s.pb.List(ctx, colWebhooks, pb.ListOptions{
|
|
Filter: "owner = " + pbQuote(owner) + " && event = " + pbQuote(event),
|
|
PerPage: 200,
|
|
})
|
|
if err != nil {
|
|
log.Printf("webhook lookup failed for %s/%s: %v", owner, event, err)
|
|
return
|
|
}
|
|
|
|
body := map[string]any{
|
|
"event": event,
|
|
"device_id": deviceID,
|
|
"payload": payload,
|
|
"created_at": time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
raw, _ := json.Marshal(body)
|
|
|
|
for _, hook := range res.Items {
|
|
// A webhook scoped to a specific device only fires for that device.
|
|
if dev := asString(hook["device"]); dev != "" && dev != deviceID {
|
|
continue
|
|
}
|
|
deliverWebhook(ctx, asString(hook["url"]), raw)
|
|
}
|
|
}()
|
|
}
|
|
|
|
func deliverWebhook(ctx context.Context, url string, body []byte) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
log.Printf("webhook build request %s: %v", url, err)
|
|
return
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "gsmnode-api/1.0")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Printf("webhook delivery to %s failed: %v", url, err)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
log.Printf("webhook %s returned %d", url, resp.StatusCode)
|
|
}
|
|
}
|