mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-02 23:57:59 +00:00
copilot: add CSV and JSON endpoints to smsapid with csvauth role-based auth
This commit is contained in:
parent
8056a07d10
commit
bf3c4a48e4
@ -3,14 +3,26 @@ module github.com/therootcompany/golib/cmd/smsapid
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/jszwec/csvutil v1.10.0
|
||||
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740
|
||||
github.com/therootcompany/golib/auth v1.0.0
|
||||
github.com/therootcompany/golib/auth/csvauth v0.0.0
|
||||
github.com/therootcompany/golib/colorjson v1.0.1
|
||||
github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d
|
||||
github.com/therootcompany/golib/http/middleware/v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
github.com/therootcompany/golib/auth/csvauth => ../../auth/csvauth
|
||||
github.com/therootcompany/golib/http/androidsmsgateway => ../../http/androidsmsgateway
|
||||
github.com/therootcompany/golib/http/middleware/v2 => ../../http/middleware
|
||||
)
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI=
|
||||
github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
@ -7,11 +11,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 h1:CXJI+lliMiiEwzfgE8yt/38K0heYDgQ0L3f/3fxRnQU=
|
||||
github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740/go.mod h1:G4w16caPmc6at7u4fmkj/8OAoOnM9mkmJr2fvL0vhaw=
|
||||
github.com/therootcompany/golib/auth v1.0.0 h1:17hfwcJO/Efc22/8RcCTKUD49mhCc5tyoHiKonA3Slg=
|
||||
github.com/therootcompany/golib/auth v1.0.0/go.mod h1:DSw8llmDkMtvMZWrzrTRtcaLPpPMsT6Sg+qwGf5O2U8=
|
||||
github.com/therootcompany/golib/colorjson v1.0.1 h1:AfBeVr9GX9xMvlJNFmFYzkWFy62yWwwXjX2LLA/Afto=
|
||||
github.com/therootcompany/golib/colorjson v1.0.1/go.mod h1:bE0wCyOsRFQnz22+TnQu4D0+FPl+ZugaaE79bjgDqRw=
|
||||
github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d h1:jKf9QQUiGAHsrjkfpoo4FTQnFJu4UkDkPreZLll7tdE=
|
||||
github.com/therootcompany/golib/http/androidsmsgateway v0.0.0-20260223054429-c8f26aca7c6d/go.mod h1:2O9+uXPc1VAJvveK9eqm9X4e4pTJmFWV6vtJa3sI/CA=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
||||
@ -2,50 +2,76 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
chiMiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/jszwec/csvutil"
|
||||
"github.com/simonfrey/jsonl"
|
||||
"github.com/therootcompany/golib/auth"
|
||||
"github.com/therootcompany/golib/auth/csvauth"
|
||||
"github.com/therootcompany/golib/colorjson"
|
||||
"github.com/therootcompany/golib/http/androidsmsgateway"
|
||||
mwpkg "github.com/therootcompany/golib/http/middleware/v2"
|
||||
)
|
||||
|
||||
var jsonf = colorjson.NewFormatter()
|
||||
|
||||
// webhookMux protects webhookWriter, pingWriter, webhookEvents, and pingEvents.
|
||||
var webhookMux = sync.Mutex{}
|
||||
var webhookEvents []androidsmsgateway.WebhookEvent
|
||||
var webhookWriter jsonl.Writer
|
||||
var webhookMux = sync.Mutex{}
|
||||
var pingEvents []*androidsmsgateway.WebhookPing
|
||||
var pingWriter jsonl.Writer
|
||||
|
||||
var smsAuth *csvauth.Auth
|
||||
|
||||
// pingRow is a flat CSV row for a ping event (x-* headers omitted, health fields inlined).
|
||||
type pingRow struct {
|
||||
DeviceID string `csv:"deviceId"`
|
||||
Event string `csv:"event"`
|
||||
ID string `csv:"id"`
|
||||
WebhookID string `csv:"webhookId"`
|
||||
PingedAt time.Time `csv:"pingedAt"`
|
||||
Status string `csv:"status"`
|
||||
Version string `csv:"version"`
|
||||
BatteryLevel float64 `csv:"batteryLevel"`
|
||||
BatteryCharging float64 `csv:"batteryCharging"`
|
||||
ConnectionStatus float64 `csv:"connectionStatus"`
|
||||
ConnectionTransport float64 `csv:"connectionTransport"`
|
||||
ConnectionCellular float64 `csv:"connectionCellular"`
|
||||
MessagesFailed float64 `csv:"messagesFailed"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
jsonf.Indent = 3
|
||||
|
||||
// TODO manual override via flags
|
||||
// color.NoColor = false
|
||||
|
||||
filePath := "./messages.jsonl"
|
||||
messagesPath := "./messages.jsonl"
|
||||
{
|
||||
file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
file, err := os.OpenFile(messagesPath, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open file '%s': %v", filePath, err)
|
||||
log.Fatalf("failed to open file '%s': %v", messagesPath, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// buf := bufio.NewReader(file)
|
||||
buf := file
|
||||
webhookEvents, err = readWebhooks(buf)
|
||||
webhookEvents, err = readWebhooks(file)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read jsonl file '%s': %v", filePath, err)
|
||||
log.Fatalf("failed to read jsonl file '%s': %v", messagesPath, err)
|
||||
}
|
||||
}
|
||||
{
|
||||
file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
file, err := os.OpenFile(messagesPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to open file: %v", err))
|
||||
}
|
||||
@ -54,6 +80,45 @@ func main() {
|
||||
webhookWriter = jsonl.NewWriter(file)
|
||||
}
|
||||
|
||||
pingsPath := "./pings.jsonl"
|
||||
{
|
||||
file, err := os.OpenFile(pingsPath, os.O_RDONLY|os.O_CREATE, 0644)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to open file '%s': %v", pingsPath, err)
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
pingEvents, err = readPings(file)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read jsonl file '%s': %v", pingsPath, err)
|
||||
}
|
||||
}
|
||||
{
|
||||
file, err := os.OpenFile(pingsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to open file: %v", err))
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
pingWriter = jsonl.NewWriter(file)
|
||||
}
|
||||
|
||||
// Load credentials for /api/smsgw routes.
|
||||
credPath := "./credentials.tsv"
|
||||
if v := os.Getenv("SMSAPID_CREDENTIALS_FILE"); v != "" {
|
||||
credPath = v
|
||||
}
|
||||
aesKey := getAESKey("CSVAUTH_AES_128_KEY")
|
||||
if f, err := os.Open(credPath); err == nil {
|
||||
defer func() { _ = f.Close() }()
|
||||
smsAuth = csvauth.New(aesKey)
|
||||
if err := smsAuth.LoadCSV(f, '\t'); err != nil {
|
||||
log.Fatalf("failed to load credentials from %q: %v", credPath, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("Warning: credentials file %q not found; /api/smsgw routes will return 401: %v", credPath, err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/webhooks", handlerWebhooks)
|
||||
mux.Handle("GET /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
@ -62,9 +127,285 @@ func main() {
|
||||
mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK)))
|
||||
|
||||
// Protected routes under /api/smsgw, authenticated via csvauth with the "sms" role.
|
||||
smsgw := mwpkg.WithMux(mux, smsAuthMiddleware)
|
||||
smsgw.HandleFunc("GET /api/smsgw/received.csv", handlerReceivedCSV)
|
||||
smsgw.HandleFunc("GET /api/smsgw/received.json", handlerReceivedJSON)
|
||||
smsgw.HandleFunc("GET /api/smsgw/sent.csv", handlerSentCSV)
|
||||
smsgw.HandleFunc("GET /api/smsgw/sent.json", handlerSentJSON)
|
||||
smsgw.HandleFunc("GET /api/smsgw/ping.csv", handlerPingCSV)
|
||||
smsgw.HandleFunc("GET /api/smsgw/ping.json", handlerPingJSON)
|
||||
|
||||
addr := "localhost:8088"
|
||||
fmt.Printf("Listening on %s...\n\n", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
log.Fatal(http.ListenAndServe(addr, chiMiddleware.Logger(chiMiddleware.Compress(5)(mux))))
|
||||
}
|
||||
|
||||
// smsAuthMiddleware requires csvauth credentials with the "sms" role.
|
||||
func smsAuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if smsAuth == nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
cred, err := authenticateSMS(r)
|
||||
if err != nil || !slices.Contains(cred.Permissions(), "sms") {
|
||||
w.Header().Set("WWW-Authenticate", `Basic`)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// authenticateSMS extracts and verifies credentials from Basic Auth, Authorization header, or API-Key header.
|
||||
func authenticateSMS(r *http.Request) (auth.BasicPrinciple, error) {
|
||||
// 1. Try Basic Auth
|
||||
if username, password, ok := r.BasicAuth(); ok {
|
||||
cred, err := smsAuth.Authenticate(username, password)
|
||||
if !errors.Is(err, csvauth.ErrNotFound) {
|
||||
if err == nil {
|
||||
return cred, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
// Basic Auth was used to carry a token (username is a token name like "", "api", "apikey").
|
||||
token := password
|
||||
if password == "" {
|
||||
token = username
|
||||
}
|
||||
return smsAuth.Authenticate("", token)
|
||||
}
|
||||
|
||||
// 2. Try Authorization: <scheme> <token>
|
||||
if authHeader := r.Header.Get("Authorization"); authHeader != "" {
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) == 2 {
|
||||
token := strings.TrimSpace(parts[1])
|
||||
return smsAuth.Authenticate("", token)
|
||||
}
|
||||
return nil, csvauth.ErrUnauthorized
|
||||
}
|
||||
|
||||
// 3. Try API-Key / X-API-Key headers
|
||||
for _, h := range []string{"API-Key", "X-API-Key"} {
|
||||
if key := r.Header.Get(h); key != "" {
|
||||
return smsAuth.Authenticate("", key)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, csvauth.ErrNotFound
|
||||
}
|
||||
|
||||
// getAESKey reads an AES-128 key (32 hex chars) from an environment variable.
|
||||
// Returns a zero key if the variable is absent or invalid.
|
||||
func getAESKey(envname string) []byte {
|
||||
if v := os.Getenv(envname); v != "" {
|
||||
key, err := hex.DecodeString(strings.TrimSpace(v))
|
||||
if err == nil && len(key) == 16 {
|
||||
return key
|
||||
}
|
||||
log.Printf("Warning: %s is invalid (must be 32 hex chars); using zero key", envname)
|
||||
}
|
||||
return make([]byte, 16)
|
||||
}
|
||||
|
||||
// parseSinceLimit extracts the "since" (ISO datetime) and "limit" query parameters.
|
||||
func parseSinceLimit(r *http.Request) (time.Time, int) {
|
||||
var since time.Time
|
||||
if s := r.URL.Query().Get("since"); s != "" {
|
||||
for _, format := range []string{time.RFC3339, "2006-01-02T15:04:05-0700", "2006-01-02"} {
|
||||
if t, err := time.Parse(format, s); err == nil {
|
||||
since = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
limit := 10000
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(strings.ReplaceAll(l, "_", "")); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
return since, limit
|
||||
}
|
||||
|
||||
func handlerReceivedCSV(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookReceived, 0, min(len(webhookEvents), limit))
|
||||
for _, event := range webhookEvents {
|
||||
recv, ok := event.(*androidsmsgateway.WebhookReceived)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && !recv.Payload.ReceivedAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, recv)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
b, err := csvutil.Marshal(rows)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func handlerReceivedJSON(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookReceived, 0, min(len(webhookEvents), limit))
|
||||
for _, event := range webhookEvents {
|
||||
recv, ok := event.(*androidsmsgateway.WebhookReceived)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && !recv.Payload.ReceivedAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, recv)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(rows)
|
||||
}
|
||||
|
||||
func handlerSentCSV(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookSent, 0, min(len(webhookEvents), limit))
|
||||
for _, event := range webhookEvents {
|
||||
sent, ok := event.(*androidsmsgateway.WebhookSent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && !sent.Payload.SentAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, sent)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
b, err := csvutil.Marshal(rows)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func handlerSentJSON(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookSent, 0, min(len(webhookEvents), limit))
|
||||
for _, event := range webhookEvents {
|
||||
sent, ok := event.(*androidsmsgateway.WebhookSent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() && !sent.Payload.SentAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, sent)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(rows)
|
||||
}
|
||||
|
||||
func handlerPingCSV(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]pingRow, 0, min(len(pingEvents), limit))
|
||||
for _, ping := range pingEvents {
|
||||
pingedAt := time.UnixMilli(ping.XTimestamp).UTC()
|
||||
if !since.IsZero() && !pingedAt.After(since) {
|
||||
continue
|
||||
}
|
||||
h := ping.Payload.Health
|
||||
row := pingRow{
|
||||
DeviceID: ping.DeviceID,
|
||||
Event: ping.Event,
|
||||
ID: ping.ID,
|
||||
WebhookID: ping.WebhookID,
|
||||
PingedAt: pingedAt,
|
||||
Status: h.Status,
|
||||
Version: h.Version,
|
||||
BatteryLevel: h.Checks["battery:level"].ObservedValue,
|
||||
BatteryCharging: h.Checks["battery:charging"].ObservedValue,
|
||||
ConnectionStatus: h.Checks["connection:status"].ObservedValue,
|
||||
ConnectionTransport: h.Checks["connection:transport"].ObservedValue,
|
||||
ConnectionCellular: h.Checks["connection:cellular"].ObservedValue,
|
||||
MessagesFailed: h.Checks["messages:failed"].ObservedValue,
|
||||
}
|
||||
rows = append(rows, row)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
b, err := csvutil.Marshal(rows)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to encode CSV"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func handlerPingJSON(w http.ResponseWriter, r *http.Request) {
|
||||
since, limit := parseSinceLimit(r)
|
||||
|
||||
webhookMux.Lock()
|
||||
rows := make([]*androidsmsgateway.WebhookPing, 0, min(len(pingEvents), limit))
|
||||
for _, ping := range pingEvents {
|
||||
pingedAt := time.UnixMilli(ping.XTimestamp).UTC()
|
||||
if !since.IsZero() && !pingedAt.After(since) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, ping)
|
||||
if len(rows) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
webhookMux.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(rows)
|
||||
}
|
||||
|
||||
func HandleOK(w http.ResponseWriter, r *http.Request) {
|
||||
@ -256,6 +597,15 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
switch h.GetEvent() {
|
||||
case "system:ping":
|
||||
ping := h.(*androidsmsgateway.WebhookPing)
|
||||
webhookMux.Lock()
|
||||
defer webhookMux.Unlock()
|
||||
if err := pingWriter.Write(ping); err != nil {
|
||||
http.Error(w, `{"error":"failed to save ping"}`, http.StatusOK)
|
||||
return
|
||||
}
|
||||
pingEvents = append(pingEvents, ping)
|
||||
case "mms:received", "sms:received", "sms:data-received", "sms:sent", "sms:delivered", "sms:failed":
|
||||
webhookMux.Lock()
|
||||
defer webhookMux.Unlock()
|
||||
@ -264,8 +614,6 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
webhookEvents = append(webhookEvents, h)
|
||||
case "system:ping":
|
||||
// nothing to do yet
|
||||
default:
|
||||
http.Error(w, `{"error":"unknown webhook event"}`, http.StatusOK)
|
||||
return
|
||||
@ -325,3 +673,24 @@ func readWebhooks(f io.Reader) ([]androidsmsgateway.WebhookEvent, error) {
|
||||
}
|
||||
return webhooks, nil
|
||||
}
|
||||
|
||||
func readPings(f io.Reader) ([]*androidsmsgateway.WebhookPing, error) {
|
||||
var pings []*androidsmsgateway.WebhookPing
|
||||
r := jsonl.NewReader(f)
|
||||
err := r.ReadLines(func(line []byte) error {
|
||||
if len(line) == 0 {
|
||||
return nil
|
||||
}
|
||||
var ping androidsmsgateway.WebhookPing
|
||||
if err := json.Unmarshal(line, &ping); err != nil {
|
||||
return fmt.Errorf("could not unmarshal into WebhookPing: %w", err)
|
||||
}
|
||||
pings = append(pings, &ping)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return pings, fmt.Errorf("failed to read JSONL lines: %w", err)
|
||||
}
|
||||
return pings, nil
|
||||
}
|
||||
|
||||
@ -24,13 +24,13 @@ type Webhook struct {
|
||||
|
||||
// WebhookSent represents a webhook notification for an SMS sent event.
|
||||
type WebhookSent struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Event string `json:"event"`
|
||||
ID string `json:"id"`
|
||||
Payload WebhookSentPayload `json:"payload"`
|
||||
WebhookID string `json:"webhookId"`
|
||||
XSignature string `json:"x-signature"`
|
||||
XTimestamp int64 `json:"x-timestamp"`
|
||||
DeviceID string `json:"deviceId" csv:"deviceId"`
|
||||
Event string `json:"event" csv:"event"`
|
||||
ID string `json:"id" csv:"id"`
|
||||
Payload WebhookSentPayload `json:"payload" csv:",inline"`
|
||||
WebhookID string `json:"webhookId" csv:"webhookId"`
|
||||
XSignature string `json:"x-signature" csv:"-"`
|
||||
XTimestamp int64 `json:"x-timestamp" csv:"-"`
|
||||
}
|
||||
|
||||
// GetEvent marks WebhookSent as part of the WebhookEvent interface.
|
||||
@ -40,10 +40,10 @@ func (w *WebhookSent) GetEvent() string {
|
||||
|
||||
// WebhookSentPayload contains details about the sent SMS.
|
||||
type WebhookSentPayload struct {
|
||||
MessageID string `json:"messageId"`
|
||||
PartsCount int `json:"partsCount"`
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
MessageID string `json:"messageId" csv:"messageId"`
|
||||
PartsCount int `json:"partsCount" csv:"partsCount"`
|
||||
PhoneNumber string `json:"phoneNumber" csv:"phoneNumber"`
|
||||
SentAt time.Time `json:"sentAt" csv:"sentAt"`
|
||||
}
|
||||
|
||||
// WebhookDelivered represents a webhook notification for an SMS delivered event.
|
||||
@ -71,13 +71,13 @@ type WebhookDeliveredPayload struct {
|
||||
|
||||
// WebhookReceived represents a webhook notification for an SMS received event.
|
||||
type WebhookReceived struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Event string `json:"event"`
|
||||
ID string `json:"id"`
|
||||
Payload WebhookReceivedPayload `json:"payload"`
|
||||
WebhookID string `json:"webhookId"`
|
||||
XSignature string `json:"x-signature"`
|
||||
XTimestamp int64 `json:"x-timestamp"`
|
||||
DeviceID string `json:"deviceId" csv:"deviceId"`
|
||||
Event string `json:"event" csv:"event"`
|
||||
ID string `json:"id" csv:"id"`
|
||||
Payload WebhookReceivedPayload `json:"payload" csv:",inline"`
|
||||
WebhookID string `json:"webhookId" csv:"webhookId"`
|
||||
XSignature string `json:"x-signature" csv:"-"`
|
||||
XTimestamp int64 `json:"x-timestamp" csv:"-"`
|
||||
}
|
||||
|
||||
// GetEvent marks WebhookDelivered as part of the WebhookEvent interface.
|
||||
@ -87,11 +87,11 @@ func (w *WebhookReceived) GetEvent() string {
|
||||
|
||||
// WebhookReceivedPayload contains details about the received SMS.
|
||||
type WebhookReceivedPayload struct {
|
||||
Message string `json:"message"`
|
||||
MessageID string `json:"messageId"`
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
SimNumber int `json:"simNumber"`
|
||||
Message string `json:"message" csv:"message"`
|
||||
MessageID string `json:"messageId" csv:"messageId"`
|
||||
PhoneNumber string `json:"phoneNumber" csv:"phoneNumber"`
|
||||
ReceivedAt time.Time `json:"receivedAt" csv:"receivedAt"`
|
||||
SimNumber int `json:"simNumber" csv:"simNumber"`
|
||||
}
|
||||
|
||||
// WebhookPing represents a system:ping webhook event.
|
||||
@ -134,6 +134,18 @@ type HealthCheck struct {
|
||||
// Decode decodes the raw Payload based on the Event field and returns the appropriate WebhookEvent.
|
||||
func Decode(webhook *Webhook) (WebhookEvent, error) {
|
||||
switch webhook.Event {
|
||||
case "system:ping":
|
||||
var ping WebhookPing
|
||||
ping.DeviceID = webhook.DeviceID
|
||||
ping.Event = webhook.Event
|
||||
ping.ID = webhook.ID
|
||||
ping.WebhookID = webhook.WebhookID
|
||||
ping.XSignature = webhook.XSignature
|
||||
ping.XTimestamp = webhook.XTimestamp
|
||||
if err := json.Unmarshal(webhook.Payload, &ping.Payload); err != nil {
|
||||
return nil, errors.New("failed to decode system:ping payload: " + err.Error())
|
||||
}
|
||||
return &ping, nil
|
||||
case "sms:sent":
|
||||
var sent WebhookSent
|
||||
sent.DeviceID = webhook.DeviceID
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user