Files
doormile_backend/internal/sms/http_sender.go
2026-09-16 11:42:06 +05:30

192 lines
6.5 KiB
Go

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},
})
}