updates
This commit is contained in:
191
internal/sms/http_sender.go
Normal file
191
internal/sms/http_sender.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package sms
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// A provider-agnostic HTTP gateway, and the wiring that installs it.
|
||||
//
|
||||
// This package called itself "the seam, not the integration", with a logging
|
||||
// sink standing in until a gateway was plugged in. The sink was never replaced:
|
||||
// sms.Register had no callers anywhere in the tree, so every customer
|
||||
// verification code since this surface shipped has gone to the application log
|
||||
// and nowhere else. Two consequences, both live in production:
|
||||
//
|
||||
// 1. No customer can complete sign-in without somebody reading the server log
|
||||
// to them. That is the single blocker on the customer app, and it is why
|
||||
// the app's offline dev mode became the only practical way in — which in
|
||||
// turn is why bookings "made" in it never reached the admin console.
|
||||
// 2. Every OTP ever issued is sitting in log storage as plaintext. A
|
||||
// credential in a log file is a credential in the wrong place.
|
||||
//
|
||||
// Rather than hard-coding one vendor, this posts to whatever gateway the
|
||||
// deployment names. The Indian providers this would plausibly use — MSG91,
|
||||
// Gupshup, Textlocal, Fast2SMS — all accept an authenticated POST carrying a
|
||||
// destination and a body, so one templated request covers them and swapping
|
||||
// vendors is configuration rather than a release.
|
||||
//
|
||||
// Configuration, all read once at startup. An absent SMS_GATEWAY_URL leaves the
|
||||
// log sink exactly where it is, so this change cannot break a deployment that
|
||||
// has not been configured yet:
|
||||
//
|
||||
// SMS_GATEWAY_URL endpoint to POST to; absent means "stay on the log sink"
|
||||
// SMS_GATEWAY_METHOD HTTP method, default POST
|
||||
// SMS_GATEWAY_AUTH Authorization header value, for vendors that use one
|
||||
// SMS_GATEWAY_HEADER one extra "Name: value" header, for vendors with their own key header
|
||||
// SMS_GATEWAY_BODY body template; {{phone}}, {{message}} and {{sender}} are substituted
|
||||
// SMS_GATEWAY_TYPE content type, default application/json
|
||||
// SMS_SENDER_ID the registered sender id, substituted as {{sender}}
|
||||
|
||||
const gatewayTimeout = 10 * time.Second
|
||||
|
||||
type httpSender struct {
|
||||
url string
|
||||
method string
|
||||
auth string
|
||||
headerName string
|
||||
headerValue string
|
||||
bodyTmpl string
|
||||
contentType string
|
||||
senderID string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (httpSender) Name() string { return "http-gateway" }
|
||||
|
||||
func (s httpSender) Send(phone, message string) error {
|
||||
body := s.bodyTmpl
|
||||
body = strings.ReplaceAll(body, "{{phone}}", phone)
|
||||
body = strings.ReplaceAll(body, "{{message}}", jsonEscape(message))
|
||||
body = strings.ReplaceAll(body, "{{sender}}", s.senderID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), gatewayTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, s.method, s.url, bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("sms: build gateway request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", s.contentType)
|
||||
if s.auth != "" {
|
||||
req.Header.Set("Authorization", s.auth)
|
||||
}
|
||||
if s.headerName != "" {
|
||||
req.Header.Set(s.headerName, s.headerValue)
|
||||
}
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sms: gateway unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read a bounded slice of the response for the log. The gateway's reason
|
||||
// for refusing — "insufficient balance", "DLT template not approved" — is
|
||||
// the entire diagnosis, and it only ever appears in the body.
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The number is masked and the message is NOT logged: the message
|
||||
// contains the code, which is the thing this whole file exists to keep
|
||||
// out of the log.
|
||||
utils.Error("sms: gateway rejected the send",
|
||||
"status", resp.StatusCode,
|
||||
"phone", maskPhone(phone),
|
||||
"response", strings.TrimSpace(string(snippet)))
|
||||
return fmt.Errorf("sms: gateway returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
utils.Info("sms: code delivered", "phone", maskPhone(phone))
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonEscape makes a message safe to interpolate into a JSON body template.
|
||||
// The OTP text carries no quotes today, but a template is a template and the
|
||||
// next message to go through here will not be this one.
|
||||
func jsonEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Configure installs a real gateway when one is configured, and says plainly
|
||||
// which transport the process ended up with.
|
||||
//
|
||||
// Called once from main() after config load. Deliberately loud in both
|
||||
// directions: a deployment that believes it can send texts and cannot is the
|
||||
// exact failure that has been live in this service since the customer surface
|
||||
// shipped, so it must not be possible to start without the answer appearing in
|
||||
// the boot log.
|
||||
func Configure() {
|
||||
url := strings.TrimSpace(os.Getenv("SMS_GATEWAY_URL"))
|
||||
if url == "" {
|
||||
utils.Warn("SMS: no gateway configured (SMS_GATEWAY_URL is unset). " +
|
||||
"Verification codes are written to THIS LOG and no text is sent. " +
|
||||
"Customer sign-in cannot complete unless somebody reads the code out " +
|
||||
"of here, or CX_STAGING_OTP is set on a non-production deployment.")
|
||||
return
|
||||
}
|
||||
|
||||
method := strings.ToUpper(strings.TrimSpace(os.Getenv("SMS_GATEWAY_METHOD")))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
|
||||
bodyTmpl := os.Getenv("SMS_GATEWAY_BODY")
|
||||
if strings.TrimSpace(bodyTmpl) == "" {
|
||||
bodyTmpl = `{"to":"{{phone}}","message":"{{message}}","sender":"{{sender}}"}`
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(os.Getenv("SMS_GATEWAY_TYPE"))
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
var headerName, headerValue string
|
||||
if raw := strings.TrimSpace(os.Getenv("SMS_GATEWAY_HEADER")); raw != "" {
|
||||
if name, value, ok := strings.Cut(raw, ":"); ok {
|
||||
headerName = strings.TrimSpace(name)
|
||||
headerValue = strings.TrimSpace(value)
|
||||
} else {
|
||||
utils.Warn("SMS: SMS_GATEWAY_HEADER is not in 'Name: value' form and was ignored",
|
||||
"value", raw)
|
||||
}
|
||||
}
|
||||
|
||||
Register(httpSender{
|
||||
url: url,
|
||||
method: method,
|
||||
auth: strings.TrimSpace(os.Getenv("SMS_GATEWAY_AUTH")),
|
||||
headerName: headerName,
|
||||
headerValue: headerValue,
|
||||
bodyTmpl: bodyTmpl,
|
||||
contentType: contentType,
|
||||
senderID: strings.TrimSpace(os.Getenv("SMS_SENDER_ID")),
|
||||
client: &http.Client{Timeout: gatewayTimeout},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user