diff --git a/cmd/smsapid/cli.go b/cmd/smsapid/cli.go new file mode 100644 index 0000000..497a67e --- /dev/null +++ b/cmd/smsapid/cli.go @@ -0,0 +1,21 @@ +package main + +import ( + "slices" + "strings" +) + +func ArgFields(list string, optionalDelim string, nothings []string) (args []string) { + list = strings.ReplaceAll(list, optionalDelim, " ") + list = strings.TrimSpace(list) + if list == "" || slices.Contains(nothings, list) { + return nil + } + + args = strings.Fields(list) + if len(args) == 1 && args[0] == "" { + return nil + } + + return args +} diff --git a/cmd/smsapid/delimiter.go b/cmd/smsapid/delimiter.go new file mode 100644 index 0000000..a2492b2 --- /dev/null +++ b/cmd/smsapid/delimiter.go @@ -0,0 +1,33 @@ +package main + +import "unicode/utf8" + +const ( + fileSeparator = '\x1c' + groupSeparator = '\x1d' + recordSeparator = '\x1e' + unitSeparator = '\x1f' +) + +func DecodeDelimiter(delimString string) (rune, error) { + switch delimString { + case "^_", "\\x1f": + delimString = string(unitSeparator) + case "^^", "\\x1e": + delimString = string(recordSeparator) + case "^]", "\\x1d": + delimString = string(groupSeparator) + case "^\\", "\\x1c": + delimString = string(fileSeparator) + case "^L", "\\f": + delimString = "\f" + case "^K", "\\v": + delimString = "\v" + case "^I", "\\t": + delimString = "\t" + default: + // it is what it is + } + delim, _ := utf8.DecodeRuneInString(delimString) + return delim, nil +} diff --git a/cmd/smsapid/go.mod b/cmd/smsapid/go.mod index c1b33cd..ea58096 100644 --- a/cmd/smsapid/go.mod +++ b/cmd/smsapid/go.mod @@ -3,14 +3,28 @@ module github.com/therootcompany/golib/cmd/smsapid go 1.25.0 require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/joho/godotenv v1.5.1 + github.com/jszwec/csvutil v1.10.0 github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 + github.com/therootcompany/golib/auth v1.1.1 + github.com/therootcompany/golib/auth/csvauth v1.2.3 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 => ../../auth + 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 ) diff --git a/cmd/smsapid/go.sum b/cmd/smsapid/go.sum index 81d2478..d0fca37 100644 --- a/cmd/smsapid/go.sum +++ b/cmd/smsapid/go.sum @@ -1,5 +1,11 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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= @@ -9,9 +15,9 @@ github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740 h1:CXJI+lliMiiEwzf github.com/simonfrey/jsonl v0.0.0-20240904112901-935399b9a740/go.mod h1:G4w16caPmc6at7u4fmkj/8OAoOnM9mkmJr2fvL0vhaw= 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= diff --git a/cmd/smsapid/main.go b/cmd/smsapid/main.go index f3e8845..b981fcd 100644 --- a/cmd/smsapid/main.go +++ b/cmd/smsapid/main.go @@ -2,50 +2,252 @@ package main import ( "context" + "encoding/hex" "encoding/json" + "errors" + "flag" "fmt" "io" "log" "net/http" "os" + "path/filepath" "strconv" "strings" "sync" + "time" - "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" + "github.com/therootcompany/golib/http/middleware/v2" + + chiware "github.com/go-chi/chi/v5/middleware" + "github.com/jszwec/csvutil" + "github.com/simonfrey/jsonl" ) -var jsonf = colorjson.NewFormatter() +const ( + name = "smsapid" + desc = "for self-hosting android-sms-gateway" + licenseYear = "2026" + licenseOwner = "AJ ONeal (https://therootcompany.com)" + licenseType = "MPL-2.0" +) -var webhookEvents []androidsmsgateway.WebhookEvent -var webhookWriter jsonl.Writer -var webhookMux = sync.Mutex{} +// replaced by goreleaser / ldflags +var ( + version = "0.0.0-dev" + commit = "0000000" + date = "0001-01-01" +) + +// printVersion displays the version, commit, and build date. +func printVersion(w io.Writer) { + _, _ = fmt.Fprintf(w, "%s v%s %s (%s)\n", name, version, commit[:7], date) + _, _ = fmt.Fprintf(w, "%s\n", desc) + _, _ = fmt.Fprintf(w, "Copyright (C) %s %s\n", licenseYear, licenseOwner) + _, _ = fmt.Fprintf(w, "Licensed under %s\n", licenseType) +} + +var ( + ErrNoAuth = errors.New("request missing the required form of authorization") +) + +var ( + jsonf = colorjson.NewFormatter() + + // webhookMux protects webhookWriter, pingWriter, webhookEvents, and pingEvents. + webhookMux = sync.Mutex{} + webhookEvents []androidsmsgateway.WebhookEvent + webhookWriter jsonl.Writer + pingEvents []*androidsmsgateway.WebhookPing + pingWriter jsonl.Writer + + smsgwSigningKey string + smsRequestAuth *auth.BasicRequestAuthenticator + // TODO + // smsgwUsername string + // smsgwPassword string +) + +type MainConfig struct { + Bind string + Port int + credsPath string + credsComma rune + credsCommaString string + AES128KeyPath string + ShowVersion bool + BasicRealm string + AuthorizationHeaderSchemes []string + TokenHeaderNames []string + QueryParamNames []string + tokenSchemeList string + tokenHeaderList string + tokenParamList string + // TODO + // SMSGatewayURL string +} + +func (c *MainConfig) Addr() string { + return fmt.Sprintf("%s:%d", c.Bind, c.Port) +} func main() { + cli := MainConfig{ + Bind: "0.0.0.0", + Port: 8080, + credsPath: "./credentials.tsv", + AES128KeyPath: filepath.Join("~", ".config", "csvauth", "aes-128.key"), + credsComma: '\t', + tokenSchemeList: "", + tokenHeaderList: "", + tokenParamList: "", + BasicRealm: "Basic", + AuthorizationHeaderSchemes: nil, // []string{"Bearer", "Token"} + TokenHeaderNames: nil, // []string{"X-API-Key", "X-Auth-Token", "X-Access-Token"}, + QueryParamNames: nil, // []string{"access_token", "token"}, + } + + // Override defaults from env + if v := os.Getenv("SMSAPID_PORT"); v != "" { + if _, err := fmt.Sscanf(v, "%d", &cli.Port); err != nil { + fmt.Fprintf(os.Stderr, "invalid SMSAPID_PORT value: %s\n", v) + os.Exit(1) + } + } + if v := os.Getenv("SMSAPID_ADDRESS"); v != "" { + cli.Bind = v + } + if v := os.Getenv("SMSAPID_CREDENTIALS_FILE"); v != "" { + cli.credsPath = v + } + + // Flags + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.BoolVar(&cli.ShowVersion, "version", false, "show version and exit") + fs.IntVar(&cli.Port, "port", cli.Port, "port to listen on") + fs.StringVar(&cli.Bind, "address", cli.Bind, "address to bind to (e.g. 127.0.0.1)") + fs.StringVar(&cli.AES128KeyPath, "creds-key-file", cli.AES128KeyPath, "path to credentials TSV/CSV file") + fs.StringVar(&cli.credsPath, "creds-file", cli.credsPath, "path to credentials TSV/CSV file") + fs.StringVar(&cli.credsCommaString, "creds-comma", "\\t", "single-character CSV separator for credentials file (literal characters and escapes accepted)") + fs.StringVar(&cli.tokenSchemeList, "token-schemes", "Bearer,Token", "checks for header 'Authorization: '") + fs.StringVar(&cli.tokenHeaderList, "token-headers", "X-API-Key,X-Auth-Token,X-Access-Token", "checks for header ': '") + fs.StringVar(&cli.tokenParamList, "token-params", "access_token,token", "checks for query param '?='") + // TODO + // fs.StringVar(&cli.SMSGatewayURL, "sms-gateway-url", "", "URL of the phone running android-sms-gateway") + + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "USAGE\n %s [flags]\n\n", name) + fmt.Fprintf(os.Stderr, "FLAGS\n") + fs.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nENVIRONMENT\n") + fmt.Fprintf(os.Stderr, " SMSAPID_PORT port to listen on\n") + fmt.Fprintf(os.Stderr, " SMSAPID_ADDRESS bind address\n") + fmt.Fprintf(os.Stderr, " SMSAPID_CREDENTIALS_FILE path to tokens file\n") + fmt.Fprintf(os.Stderr, " SMS_GATEWAY_USERNAME android-sms-gateway basic auth username\n") + fmt.Fprintf(os.Stderr, " SMS_GATEWAY_PASSWORD android-sms-gateway basic auth password\n") + fmt.Fprintf(os.Stderr, " SMS_GATEWAY_SIGNING_KEY android-sms-gateway signing key for webhooks\n") + } + + // Special handling for version/help + if len(os.Args) > 1 { + arg := os.Args[1] + switch arg { + case "-V", "version", "-version", "--version": + printVersion(os.Stdout) + os.Exit(0) + case "help", "-help", "--help": + printVersion(os.Stdout) + _, _ = fmt.Fprintln(os.Stdout, "") + fs.SetOutput(os.Stdout) + fs.Usage() + os.Exit(0) + } + } + printVersion(os.Stderr) + fmt.Fprintln(os.Stderr, "") + + if err := fs.Parse(os.Args[1:]); err != nil { + if err == flag.ErrHelp { + fs.Usage() + os.Exit(0) + } + log.Fatalf("flag parse error: %v", err) + } + + { + homedir, err := os.UserHomeDir() + if err == nil { + var found bool + if cli.AES128KeyPath, found = strings.CutPrefix(cli.AES128KeyPath, "~"); found { + cli.AES128KeyPath = homedir + cli.AES128KeyPath + } + } + } + + cli.AuthorizationHeaderSchemes = ArgFields(cli.tokenSchemeList, ",", []string{"none"}) + cli.TokenHeaderNames = ArgFields(cli.tokenHeaderList, ",", []string{"none"}) + cli.QueryParamNames = ArgFields(cli.tokenParamList, ",", []string{"none"}) + + // Load credentials for /api/smsgw routes. + var smsAuth *csvauth.Auth + credPath := "./credentials.tsv" + if v := os.Getenv("SMSAPID_CREDENTIALS_FILE"); v != "" { + credPath = v + } + f, err := os.Open(credPath) + if err != nil { + log.Fatalf("failed to load credentials from %q: %v", credPath, err) + } + defer func() { _ = f.Close() }() + + aesKey, err := getAESKey("CSVAUTH_AES_128_KEY", cli.AES128KeyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + smsAuth = csvauth.New(aesKey) + if err := smsAuth.LoadCSV(f, cli.credsComma); err != nil { + log.Fatalf("failed to load credentials from %q: %v\n", credPath, err) + } + smsRequestAuth = auth.NewBasicRequestAuthenticator(smsAuth) + + // Load optional webhook signing key. + smsgwSigningKey = os.Getenv("SMS_GATEWAY_SIGNING_KEY") + // TODO + // smsgwUsername = os.Getenv("SMS_GATEWAY_USERNAME") + // smsgwPassword = os.Getenv("SMS_GATEWAY_PASSWORD") + + // credentials file delimiter + cli.credsComma, err = DecodeDelimiter(cli.credsCommaString) + if err != nil { + log.Fatalf("comma parse error: %v", err) + } + + cli.run() +} + +func (cli *MainConfig) run() { 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,21 +256,211 @@ 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) + } + mux := http.NewServeMux() mux.HandleFunc("GET /api/webhooks", handlerWebhooks) - mux.Handle("GET /", LogRequest(http.HandlerFunc(HandleOK))) mux.Handle("POST /", LogRequest(http.HandlerFunc(handler))) - mux.Handle("PATCH /", LogRequest(http.HandlerFunc(HandleOK))) - mux.Handle("PUT /", LogRequest(http.HandlerFunc(HandleOK))) - mux.Handle("DELETE /", LogRequest(http.HandlerFunc(HandleOK))) - addr := "localhost:8088" + // Protected routes under /api/smsgw, each guarded by its specific sms:* permission. + smsgw := middleware.WithMux(mux, LogRequest) + smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.csv", handlerReceived) + smsgw.With(requireSMSPermission("sms:received")).HandleFunc("GET /api/smsgw/received.json", handlerReceived) + smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.csv", handlerSent) + smsgw.With(requireSMSPermission("sms:sent")).HandleFunc("GET /api/smsgw/sent.json", handlerSent) + smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.csv", handlerPing) + smsgw.With(requireSMSPermission("sms:ping")).HandleFunc("GET /api/smsgw/ping.json", handlerPing) + + addr := cli.Addr() fmt.Printf("Listening on %s...\n\n", addr) - log.Fatal(http.ListenAndServe(addr, mux)) + log.Fatal(http.ListenAndServe(addr, chiware.Logger(chiware.Compress(5)(mux)))) } -func HandleOK(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) +// hasSMSPermission reports whether perms includes the wildcard "sms:*" or the specific permission. +func hasSMSPermission(perms []string, permission string) bool { + for _, p := range perms { + if p == "sms:*" || p == permission { + return true + } + } + return false +} + +// requireSMSPermission returns a middleware that authenticates the request and enforces +// that the credential holds "sms:*" or the given specific permission. +func requireSMSPermission(permission string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cred, err := smsRequestAuth.Authenticate(r) + if err != nil || !hasSMSPermission(cred.Permissions(), permission) { + w.Header().Set("WWW-Authenticate", smsRequestAuth.BasicRealm) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + + } +} + +// getAESKey reads an AES-128 key (32 hex chars) from an environment variable. +func getAESKey(envname, filename string) ([]byte, error) { + envKey := os.Getenv(envname) + if envKey != "" { + key, err := hex.DecodeString(strings.TrimSpace(envKey)) + if err != nil || len(key) != 16 { + return nil, fmt.Errorf("invalid %s: must be 32-char hex string", envname) + } + fmt.Fprintf(os.Stderr, "Found AES Key in %s\n", envname) + return key, nil + } + + if _, err := os.Stat(filename); err != nil { + return nil, err + } + + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %v", filename, err) + } + key, err := hex.DecodeString(strings.TrimSpace(string(data))) + if err != nil || len(key) != 16 { + return nil, fmt.Errorf("invalid key in %s: must be 32-char hex string", filename) + } + // relpath := strings.Replace(filename, homedir, "~", 1) + fmt.Fprintf(os.Stderr, "Found AES Key at %s\n", filename) + return key, nil +} + +// 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 := 10_000 + 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 handlerReceived(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() + + serveCSVOrJSON(w, r, rows) +} + +func handlerSent(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() + + serveCSVOrJSON(w, r, rows) +} + +func handlerPing(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 := ping.PingedAt + if pingedAt.IsZero() { + pingedAt = time.UnixMilli(ping.XTimestamp).UTC() + } + if !since.IsZero() && !pingedAt.After(since) { + continue + } + rows = append(rows, ping) + if len(rows) >= limit { + break + } + } + webhookMux.Unlock() + + serveCSVOrJSON(w, r, rows) +} + +// serveCSVOrJSON writes v as CSV when the request path ends with ".csv", otherwise as JSON. +func serveCSVOrJSON[T any](w http.ResponseWriter, r *http.Request, v []T) { + if strings.HasSuffix(r.URL.Path, ".csv") { + b, err := csvutil.Marshal(v) + 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) + return + } + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) } type ctxKey struct{} @@ -118,7 +510,7 @@ func LogBody(next http.Handler) http.Handler { fmt.Fprintf(os.Stderr, "Unexpected body:\n%q\n", string(body)) } case "POST", "PATCH", "PUT": - // known + // known default: fmt.Fprintf(os.Stderr, "Unexpected method %s\n", r.Method) } @@ -249,6 +641,13 @@ func handler(w http.ResponseWriter, r *http.Request) { webhook.XTimestamp = int64(ts) webhook.XSignature = r.Header.Get("X-Signature") + if smsgwSigningKey != "" { + if !androidsmsgateway.VerifySignature(smsgwSigningKey, string(body), r.Header.Get("X-Timestamp"), webhook.XSignature) { + http.Error(w, `{"error":"invalid signature"}`, http.StatusUnauthorized) + return + } + } + h, err := androidsmsgateway.Decode(&webhook) if err != nil { http.Error(w, `{"error":"failed to parse webhook as a specific event"}`, http.StatusOK) @@ -256,6 +655,16 @@ func handler(w http.ResponseWriter, r *http.Request) { } switch h.GetEvent() { + case "system:ping": + ping := h.(*androidsmsgateway.WebhookPing) + ping.PingedAt = time.UnixMilli(webhook.XTimestamp).UTC() + 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 +673,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 +732,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 +}