diff --git a/.env.example b/.env.example
new file mode 100644
index 000000000..f58512b41
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,22 @@
+# Copy to .env and replace every placeholder before starting the stack.
+# Do not commit .env. None of these values are real credentials.
+
+POSTGRES_USER=godesk
+POSTGRES_PASSWORD=change-me
+POSTGRES_DB=godesk
+DATABASE_URL=postgres://godesk:change-me@postgres:5432/godesk?sslmode=disable
+
+# At least 32 characters. Also used to seal device passwords unless CREDENTIALS_KEY is set.
+JWT_SECRET=change-me-to-a-long-random-string
+CREDENTIALS_KEY=
+
+# Public origin the phones can reach. HTTPS is required by SMS Gateway for Android
+# except for http://127.0.0.1. Example: https://sms.example.com
+PUBLIC_URL=http://godesk.localhost
+GODESK_HOST=godesk.localhost
+
+# Optional Authentik (or any OIDC) login. Leave the issuer empty to use email and password only.
+OIDC_ISSUER=
+OIDC_CLIENT_ID=
+OIDC_CLIENT_SECRET=
+OIDC_REDIRECT_URL=http://godesk.localhost/v1/auth/oidc/callback
diff --git a/.github/workflows/selfhosted.yml b/.github/workflows/selfhosted.yml
new file mode 100644
index 000000000..907703a5a
--- /dev/null
+++ b/.github/workflows/selfhosted.yml
@@ -0,0 +1,27 @@
+name: selfhosted
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: Self-hosted unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Set up Go
+ uses: actions/setup-go@v7
+ with:
+ go-version-file: selfhosted/go.mod
+
+ - name: Test
+ working-directory: selfhosted
+ run: go test ./...
diff --git a/.gitignore b/.gitignore
index cc68ad44b..03c77c3ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,11 @@
.env*
!.env.docker
!.env.production
+!.env.example
+certs/
+*.keystore
+*.jks
+*.p12
*serviceAccountKey.json
android/app/debug/
*main.exe*
diff --git a/README.md b/README.md
index 42a6b0d36..f8e8d63cf 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,48 @@
+# GoDesk SMS
+
+GoDesk SMS is a self-hosted SMS gateway. The phone app for this self-hosted mode is [SMS Gateway for Android](https://github.com/capcom6/android-sms-gateway) (Apache-2.0). The original product in this repository is [httpSMS](https://github.com/NdoleStudio/httpsms) by Ndole Studio, licensed under the GNU Affero General Public License v3 (see [LICENSE](LICENSE)). The optional private server from [android-sms-gateway/server](https://github.com/android-sms-gateway/server) is Apache-2.0; this tree does not vendor that server.
+
+Self-hosted mode does not use Firebase. The design is in [docs/godesk-sms-architecture.md](docs/godesk-sms-architecture.md). The httpSMS README continues below for the existing Firebase deployment.
+
+## GoDesk SMS self-hosted quickstart
+
+1. Copy the example env and replace every `change-me` value. `JWT_SECRET` must be at least 32 characters.
+
+```bash
+cp .env.example .env
+```
+
+2. Start the stack. Only Traefik publishes ports (80 and 443). Postgres and the API stay on the compose network.
+
+```bash
+docker compose -f docker-compose.selfhosted.yml up --build
+```
+
+3. Open the dashboard at the host in `GODESK_HOST` (the example is `http://godesk.localhost`). Sign up, then register one SMS Gateway for Android phone in **local server** mode:
+
+ - On the phone, turn on Local Server and start it.
+ - Copy the origin, username, and password into the device form. The origin is an `http` or `https` URL with no path, for example `https://phone.example:8080`.
+ - The API registers webhooks on the phone. `PUBLIC_URL` must be an address the phone can reach. The official app requires HTTPS except for `http://127.0.0.1`.
+
+4. Send a message with the dashboard or with the httpsms-shaped API:
+
+```bash
+curl -X POST "$PUBLIC_URL/v1/messages/send" \
+ -H "Content-Type: application/json" \
+ -H "x-api-key: $API_KEY" \
+ -d '{"from":"+18005550199","to":"+18005550100","content":"hello"}'
+```
+
+`from` selects the registered device. Inbound SMS from the phone is delivered to webhooks subscribed to `message.phone.received`.
+
+Optional OIDC (Authentik): set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, and `OIDC_REDIRECT_URL`. The redirect URL is `{PUBLIC_URL}/v1/auth/oidc/callback`. Leave `OIDC_ISSUER` empty to use email and password only.
+
+Run the unit tests from `selfhosted/`:
+
+```bash
+cd selfhosted && go test ./...
+```
+
# httpSMS
[](https://github.com/NdoleStudio/httpsms/actions/workflows/web.yml)
diff --git a/docker-compose.selfhosted.yml b/docker-compose.selfhosted.yml
new file mode 100644
index 000000000..0a4b1b04c
--- /dev/null
+++ b/docker-compose.selfhosted.yml
@@ -0,0 +1,74 @@
+# GoDesk SMS self-hosted stack.
+# Postgres and the API are reachable only on the compose network.
+# Traefik publishes ports 80 and 443.
+
+name: godesk-sms
+
+services:
+ traefik:
+ image: traefik:v3.6
+ command:
+ - --providers.docker=true
+ - --providers.docker.exposedbydefault=false
+ - --entrypoints.web.address=:80
+ - --entrypoints.websecure.address=:443
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ restart: unless-stopped
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB}
+ volumes:
+ - godesk_postgres:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ restart: unless-stopped
+
+ api:
+ build:
+ context: ./selfhosted
+ environment:
+ HTTP_ADDR: ":8080"
+ DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
+ JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET}
+ CREDENTIALS_KEY: ${CREDENTIALS_KEY:-}
+ PUBLIC_URL: ${PUBLIC_URL:?Set PUBLIC_URL}
+ OIDC_ISSUER: ${OIDC_ISSUER:-}
+ OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
+ OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
+ OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ labels:
+ - traefik.enable=true
+ - traefik.http.routers.godesk.rule=Host(`${GODESK_HOST:?Set GODESK_HOST}`)
+ - traefik.http.routers.godesk.entrypoints=web
+ - traefik.http.routers.godesk.service=godesk
+ - traefik.http.routers.godesk-secure.rule=Host(`${GODESK_HOST}`)
+ - traefik.http.routers.godesk-secure.entrypoints=websecure
+ - traefik.http.routers.godesk-secure.tls=true
+ - traefik.http.routers.godesk-secure.service=godesk
+ - traefik.http.services.godesk.loadbalancer.server.port=8080
+ - traefik.http.services.godesk.loadbalancer.healthcheck.path=/health
+ - traefik.http.services.godesk.loadbalancer.healthcheck.interval=10s
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ restart: unless-stopped
+
+volumes:
+ godesk_postgres:
diff --git a/docs/godesk-sms-architecture.md b/docs/godesk-sms-architecture.md
new file mode 100644
index 000000000..445501321
--- /dev/null
+++ b/docs/godesk-sms-architecture.md
@@ -0,0 +1,66 @@
+# GoDesk SMS architecture
+
+GoDesk SMS is a self-hosted SMS gateway. It keeps the httpSMS send, receive, and webhook ideas, and it uses [SMS Gateway for Android](https://github.com/capcom6/android-sms-gateway) as the phone app. The hard requirement is no Firebase and no other Google service.
+
+## Option A โ official private server
+
+Run [android-sms-gateway/server](https://github.com/android-sms-gateway/server) in private mode and point the Android app at it.
+
+What it already does:
+
+- Device registration protected by a private token
+- The same 3rdparty REST API as the public SMSGate cloud
+- Multi-device routing, message state, and webhooks
+- A separate web dashboard image
+
+What it does not do:
+
+- The private server still wakes phones by relaying a push through `api.sms-gate.app` and Firebase Cloud Messaging. The project documents an SSE path, but the supported install still depends on that upstream for push. That fails the "no Firebase or Google services" rule.
+- Storage is MariaDB or MySQL, not Postgres.
+- The API is the SMSGate API (`/api/3rdparty/v1/...`), not the httpsms API (`/v1/messages/send`, `x-api-key`, `message.phone.received` webhooks).
+- The httpSMS Nuxt dashboard signs in with Firebase. It cannot talk to this server without a new auth layer and a new client.
+
+## Option B โ GoDesk API talks to the phones
+
+Keep a Go API, Postgres, and an httpsms-compatible edge. Replace Firebase Auth with local accounts, and replace FCM by calling SMS Gateway for Android directly.
+
+Two phone modes exist:
+
+- **Local server mode** runs an HTTP server on the phone. The API sends SMS with HTTP basic auth and receives `sms:received` webhooks from the phone. No Firebase, no Google, no public upstream.
+- **Private server mode** keeps message text on a server you run, but the official server still uses the public push relay. A later polling or SSE channel could remove that relay. It is not the path that is free of Google services today.
+
+## Decision
+
+Option B, with **local server mode** as the device integration.
+
+Local mode is the only supported SMS Gateway mode that is fully off Firebase. The GoDesk service then keeps the pieces this deployment needs: Postgres, Traefik on the LAN, more than one phone, the httpsms send/receive/webhook shape, and email/password auth with optional OIDC (Authentik).
+
+The tradeoff is reachability. The API must be able to open a connection to each phone, and each phone must be able to POST webhooks back. Phones that are not reachable inbound cannot send until a later polling design exists. That limitation is accepted for this slice because it is what makes the system independent of Google.
+
+The existing httpSMS API and Nuxt app stay in the tree for the current Firebase deployment. They are not started by `docker-compose.selfhosted.yml`.
+
+## What this slice runs
+
+`docker-compose.selfhosted.yml` starts:
+
+- Traefik, publishing only ports 80 and 443
+- Postgres 16, with no published port
+- `selfhosted/`, a Go service
+
+On startup the service applies `selfhosted/internal/store/pg/migrations`. Accounts are email and password (bcrypt). Sessions are HS256 JWTs. Each user also has an `x-api-key` for httpsms-style clients. When `OIDC_ISSUER` is set, `/v1/auth/oidc/start` performs an authorization-code login with PKCE against that issuer.
+
+A device row stores the phone's E.164 number, the gateway origin, basic-auth username, and the password sealed with AES-GCM. The key comes from `CREDENTIALS_KEY`, or from `JWT_SECRET` when that is unset. Registering a device asks the phone to webhook `sms:received`, `sms:sent`, `sms:delivered`, and `sms:failed` to `PUBLIC_URL/v1/gateway/events/{token}`.
+
+`POST /v1/messages/send` with `from`, `to`, and `content` selects the device whose phone number matches `from` and POSTs to the phone's `/message` endpoint (local) or `/api/3rdparty/v1/messages` (private URL shape). `POST /v1/gateway/events/{token}` stores an inbound SMS and POSTs a CloudEvents JSON body of type `message.phone.received` to the user's webhooks, with `X-Event-Type` set. A signing key produces an `Authorization: Bearer` JWT whose audience is the webhook URL.
+
+The same process serves a small dashboard at `/` for sign-up, login, device registration, send, and webhooks. `http://godesk.localhost` is the generic host used in the example env file. Replace it before exposing the stack.
+
+## Remaining work
+
+- Port the Nuxt app in `web/` off Firebase and onto this login, and retire the small dashboard.
+- Wake phones that cannot accept inbound connections, without FCM. That is device polling or SSE, not the official private-server push relay.
+- Finish private-server mode as a first-class deploy (the client already knows the URL shape).
+- httpsms features not in this slice: bulk send, MMS, end-to-end encryption, search, heartbeats, schedules, and the full webhook event set.
+- Retry and signing parity for every outbound webhook.
+- Store API keys as hashes and show them once.
+- Bring your own TLS material for Traefik. Port 443 currently uses Traefik's default certificate.
diff --git a/selfhosted/Dockerfile b/selfhosted/Dockerfile
new file mode 100644
index 000000000..988d96d34
--- /dev/null
+++ b/selfhosted/Dockerfile
@@ -0,0 +1,16 @@
+FROM golang:1.23-alpine AS build
+
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -o /godesk-sms ./cmd/godesk-sms
+
+FROM alpine:3.22
+
+RUN apk add --no-cache ca-certificates wget \
+ && adduser -D -H -u 65532 godesk
+COPY --from=build /godesk-sms /godesk-sms
+USER godesk
+EXPOSE 8080
+ENTRYPOINT ["/godesk-sms"]
diff --git a/selfhosted/cmd/godesk-sms/main.go b/selfhosted/cmd/godesk-sms/main.go
new file mode 100644
index 000000000..f2e5db618
--- /dev/null
+++ b/selfhosted/cmd/godesk-sms/main.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/auth"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/httpapi"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/secretbox"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store/pg"
+)
+
+func main() {
+ cfg, err := loadConfig()
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ database, err := pg.Open(ctx, cfg.databaseURL)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer database.Close()
+
+ tokens, err := auth.NewTokens(cfg.jwtSecret)
+ if err != nil {
+ log.Fatal(err)
+ }
+ boxSecret := cfg.credentialsKey
+ if boxSecret == "" {
+ boxSecret = cfg.jwtSecret
+ }
+ box, err := secretbox.New(boxSecret)
+ if err != nil {
+ log.Fatal(err)
+ }
+ var oidc *auth.OIDC
+ if cfg.oidcIssuer != "" {
+ oidc = &auth.OIDC{
+ Issuer: cfg.oidcIssuer,
+ ClientID: cfg.oidcClientID,
+ ClientSecret: cfg.oidcClientSecret,
+ RedirectURL: cfg.oidcRedirectURL,
+ }
+ }
+ api := httpapi.New(httpapi.Server{
+ Store: database,
+ Tokens: tokens,
+ Box: box,
+ PublicURL: cfg.publicURL,
+ OIDC: oidc,
+ })
+ server := &http.Server{
+ Addr: cfg.httpAddr,
+ Handler: api.Handler(),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 60 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _ = server.Shutdown(shutdownCtx)
+ }()
+ log.Printf("godesk sms listening on %s", cfg.httpAddr)
+ if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatal(err)
+ }
+}
+
+type config struct {
+ httpAddr string
+ databaseURL string
+ jwtSecret string
+ credentialsKey string
+ publicURL string
+ oidcIssuer string
+ oidcClientID string
+ oidcClientSecret string
+ oidcRedirectURL string
+}
+
+func loadConfig() (config, error) {
+ cfg := config{
+ httpAddr: env("HTTP_ADDR", ":8080"),
+ databaseURL: os.Getenv("DATABASE_URL"),
+ jwtSecret: os.Getenv("JWT_SECRET"),
+ credentialsKey: os.Getenv("CREDENTIALS_KEY"),
+ publicURL: env("PUBLIC_URL", "http://godesk.localhost"),
+ oidcIssuer: os.Getenv("OIDC_ISSUER"),
+ oidcClientID: os.Getenv("OIDC_CLIENT_ID"),
+ oidcClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
+ oidcRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
+ }
+ if cfg.databaseURL == "" {
+ return config{}, errors.New("DATABASE_URL is required")
+ }
+ if len(cfg.jwtSecret) < 32 {
+ return config{}, errors.New("JWT_SECRET must be at least 32 characters")
+ }
+ if cfg.oidcIssuer != "" && (cfg.oidcClientID == "" || cfg.oidcClientSecret == "" || cfg.oidcRedirectURL == "") {
+ return config{}, errors.New("OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URL are required when OIDC_ISSUER is set")
+ }
+ return cfg, nil
+}
+
+func env(key, fallback string) string {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ return fallback
+}
diff --git a/selfhosted/go.mod b/selfhosted/go.mod
new file mode 100644
index 000000000..35a83b5d2
--- /dev/null
+++ b/selfhosted/go.mod
@@ -0,0 +1,18 @@
+module github.com/GoDeskio/GoDesk-SMS/selfhosted
+
+go 1.23.0
+
+require (
+ github.com/golang-jwt/jwt/v5 v5.2.2
+ github.com/google/uuid v1.6.0
+ github.com/jackc/pgx/v5 v5.7.4
+ golang.org/x/crypto v0.36.0
+)
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.12.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+)
diff --git a/selfhosted/go.sum b/selfhosted/go.sum
new file mode 100644
index 000000000..3b757e1b4
--- /dev/null
+++ b/selfhosted/go.sum
@@ -0,0 +1,32 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
+github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/selfhosted/internal/auth/oidc.go b/selfhosted/internal/auth/oidc.go
new file mode 100644
index 000000000..ad287972d
--- /dev/null
+++ b/selfhosted/internal/auth/oidc.go
@@ -0,0 +1,140 @@
+package auth
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// OIDC is an authorization-code client for an issuer such as Authentik.
+type OIDC struct {
+ Issuer string
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+ HTTP *http.Client
+}
+
+type provider struct {
+ AuthorizationEndpoint string `json:"authorization_endpoint"`
+ TokenEndpoint string `json:"token_endpoint"`
+ UserinfoEndpoint string `json:"userinfo_endpoint"`
+}
+
+func (o *OIDC) client() *http.Client {
+ if o.HTTP != nil {
+ return o.HTTP
+ }
+ return &http.Client{Timeout: 15 * time.Second}
+}
+
+func (o *OIDC) discover(ctx context.Context) (provider, error) {
+ endpoint := strings.TrimRight(o.Issuer, "/") + "/.well-known/openid-configuration"
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return provider{}, err
+ }
+ response, err := o.client().Do(request)
+ if err != nil {
+ return provider{}, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return provider{}, fmt.Errorf("oidc discovery returned %d", response.StatusCode)
+ }
+ var found provider
+ if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&found); err != nil {
+ return provider{}, err
+ }
+ if found.AuthorizationEndpoint == "" || found.TokenEndpoint == "" || found.UserinfoEndpoint == "" {
+ return provider{}, fmt.Errorf("oidc discovery document is incomplete")
+ }
+ return found, nil
+}
+
+// AuthCodeURL returns the browser redirect for a login.
+func (o *OIDC) AuthCodeURL(ctx context.Context, state, challenge string) (string, error) {
+ found, err := o.discover(ctx)
+ if err != nil {
+ return "", err
+ }
+ values := url.Values{}
+ values.Set("response_type", "code")
+ values.Set("client_id", o.ClientID)
+ values.Set("redirect_uri", o.RedirectURL)
+ values.Set("scope", "openid email")
+ values.Set("state", state)
+ values.Set("code_challenge", challenge)
+ values.Set("code_challenge_method", "S256")
+ return found.AuthorizationEndpoint + "?" + values.Encode(), nil
+}
+
+// Exchange trades an authorization code for the user's email.
+func (o *OIDC) Exchange(ctx context.Context, code, verifier string) (string, error) {
+ found, err := o.discover(ctx)
+ if err != nil {
+ return "", err
+ }
+ form := url.Values{}
+ form.Set("grant_type", "authorization_code")
+ form.Set("code", code)
+ form.Set("redirect_uri", o.RedirectURL)
+ form.Set("client_id", o.ClientID)
+ form.Set("code_verifier", verifier)
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, found.TokenEndpoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ request.SetBasicAuth(o.ClientID, o.ClientSecret)
+ response, err := o.client().Do(request)
+ if err != nil {
+ return "", err
+ }
+ defer response.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
+ if response.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("oidc token returned %d", response.StatusCode)
+ }
+ var token struct {
+ AccessToken string `json:"access_token"`
+ }
+ if err := json.Unmarshal(body, &token); err != nil {
+ return "", err
+ }
+ if token.AccessToken == "" {
+ return "", fmt.Errorf("oidc token response has no access_token")
+ }
+ infoReq, err := http.NewRequestWithContext(ctx, http.MethodGet, found.UserinfoEndpoint, nil)
+ if err != nil {
+ return "", err
+ }
+ infoReq.Header.Set("Authorization", "Bearer "+token.AccessToken)
+ infoResp, err := o.client().Do(infoReq)
+ if err != nil {
+ return "", err
+ }
+ defer infoResp.Body.Close()
+ if infoResp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("oidc userinfo returned %d", infoResp.StatusCode)
+ }
+ var info struct {
+ Email string `json:"email"`
+ EmailVerified *bool `json:"email_verified"`
+ }
+ if err := json.NewDecoder(io.LimitReader(infoResp.Body, 1<<20)).Decode(&info); err != nil {
+ return "", err
+ }
+ if info.Email == "" {
+ return "", fmt.Errorf("oidc userinfo has no email")
+ }
+ if info.EmailVerified != nil && !*info.EmailVerified {
+ return "", fmt.Errorf("oidc email is not verified")
+ }
+ return strings.ToLower(strings.TrimSpace(info.Email)), nil
+}
diff --git a/selfhosted/internal/auth/oidc_test.go b/selfhosted/internal/auth/oidc_test.go
new file mode 100644
index 000000000..bd36987b3
--- /dev/null
+++ b/selfhosted/internal/auth/oidc_test.go
@@ -0,0 +1,52 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestOIDCExchange(t *testing.T) {
+ var server *httptest.Server
+ server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/.well-known/openid-configuration":
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"authorization_endpoint":"` + server.URL + `/auth","token_endpoint":"` + server.URL + `/token","userinfo_endpoint":"` + server.URL + `/userinfo"}`))
+ case "/token":
+ if user, pass, ok := r.BasicAuth(); !ok || user != "client" || pass != "secret" {
+ t.Fatalf("client auth %q %q", user, pass)
+ }
+ if err := r.ParseForm(); err != nil {
+ t.Fatal(err)
+ }
+ if r.Form.Get("code") != "code-1" || r.Form.Get("code_verifier") == "" {
+ t.Fatalf("form %v", r.Form)
+ }
+ _, _ = w.Write([]byte(`{"access_token":"access-1"}`))
+ case "/userinfo":
+ if r.Header.Get("Authorization") != "Bearer access-1" {
+ t.Fatalf("userinfo auth %s", r.Header.Get("Authorization"))
+ }
+ _, _ = w.Write([]byte(`{"email":"Owner@Example.com","email_verified":true}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ client := &OIDC{
+ Issuer: server.URL,
+ ClientID: "client",
+ ClientSecret: "secret",
+ RedirectURL: "http://godesk.test/v1/auth/oidc/callback",
+ }
+ email, err := client.Exchange(context.Background(), "code-1", "verifier")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if email != "owner@example.com" {
+ t.Fatalf("email %s", email)
+ }
+}
diff --git a/selfhosted/internal/auth/password.go b/selfhosted/internal/auth/password.go
new file mode 100644
index 000000000..b36e76833
--- /dev/null
+++ b/selfhosted/internal/auth/password.go
@@ -0,0 +1,17 @@
+package auth
+
+import "golang.org/x/crypto/bcrypt"
+
+// HashPassword hashes a password with the given bcrypt cost.
+func HashPassword(password string, cost int) (string, error) {
+ hashed, err := bcrypt.GenerateFromPassword([]byte(password), cost)
+ if err != nil {
+ return "", err
+ }
+ return string(hashed), nil
+}
+
+// CheckPassword reports whether password matches the bcrypt hash.
+func CheckPassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
diff --git a/selfhosted/internal/auth/token.go b/selfhosted/internal/auth/token.go
new file mode 100644
index 000000000..b2e894ee3
--- /dev/null
+++ b/selfhosted/internal/auth/token.go
@@ -0,0 +1,138 @@
+package auth
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// Tokens issues session JWTs and signs outbound webhooks.
+type Tokens struct {
+ secret []byte
+}
+
+// NewTokens rejects secrets shorter than 32 characters.
+func NewTokens(secret string) (*Tokens, error) {
+ if len(secret) < 32 {
+ return nil, errors.New("JWT_SECRET must be at least 32 characters")
+ }
+ return &Tokens{secret: []byte(secret)}, nil
+}
+
+type sessionClaims struct {
+ Email string `json:"email"`
+ jwt.RegisteredClaims
+}
+
+type oidcStateClaims struct {
+ Verifier string `json:"verifier"`
+ jwt.RegisteredClaims
+}
+
+// SessionToken is a bearer token for the dashboard and API.
+func (t *Tokens) SessionToken(userID, email string) (string, error) {
+ now := time.Now().UTC()
+ claims := sessionClaims{
+ Email: email,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: userID,
+ Issuer: "godesk-sms",
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)),
+ },
+ }
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret)
+}
+
+// ParseSession returns the user id and email from a session token.
+func (t *Tokens) ParseSession(token string) (string, string, error) {
+ parsed, err := jwt.ParseWithClaims(token, &sessionClaims{}, func(token *jwt.Token) (any, error) {
+ if token.Method != jwt.SigningMethodHS256 {
+ return nil, fmt.Errorf("unexpected signing method %v", token.Header["alg"])
+ }
+ return t.secret, nil
+ })
+ if err != nil {
+ return "", "", err
+ }
+ claims, ok := parsed.Claims.(*sessionClaims)
+ if !ok || !parsed.Valid || claims.Subject == "" {
+ return "", "", errors.New("invalid session")
+ }
+ return claims.Subject, claims.Email, nil
+}
+
+// WebhookToken matches the httpsms webhook Authorization bearer: HS256, audience
+// is the webhook URL, subject is the user id. The issuer is godesk-sms.
+func (t *Tokens) WebhookToken(userID, audience, signingKey string) (string, error) {
+ now := time.Now().UTC()
+ claims := jwt.RegisteredClaims{
+ Audience: []string{audience},
+ ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
+ IssuedAt: jwt.NewNumericDate(now),
+ Issuer: "godesk-sms",
+ NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)),
+ Subject: userID,
+ }
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(signingKey))
+}
+
+// OIDCStateCookie seals the PKCE verifier for the callback.
+func (t *Tokens) OIDCStateCookie(state, verifier string) (string, error) {
+ now := time.Now().UTC()
+ claims := oidcStateClaims{
+ Verifier: verifier,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: state,
+ Issuer: "godesk-sms",
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
+ },
+ }
+ return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret)
+}
+
+// ParseOIDCState returns the state and PKCE verifier.
+func (t *Tokens) ParseOIDCState(token string) (string, string, error) {
+ parsed, err := jwt.ParseWithClaims(token, &oidcStateClaims{}, func(token *jwt.Token) (any, error) {
+ if token.Method != jwt.SigningMethodHS256 {
+ return nil, fmt.Errorf("unexpected signing method %v", token.Header["alg"])
+ }
+ return t.secret, nil
+ })
+ if err != nil {
+ return "", "", err
+ }
+ claims, ok := parsed.Claims.(*oidcStateClaims)
+ if !ok || !parsed.Valid {
+ return "", "", errors.New("invalid oidc state")
+ }
+ return claims.Subject, claims.Verifier, nil
+}
+
+// RandomToken returns a hex secret.
+func RandomToken(bytes int) (string, error) {
+ buf := make([]byte, bytes)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+// PKCE returns a verifier and S256 challenge.
+func PKCE() (verifier, challenge string, err error) {
+ raw := make([]byte, 32)
+ if _, err = rand.Read(raw); err != nil {
+ return "", "", err
+ }
+ verifier = base64.RawURLEncoding.EncodeToString(raw)
+ sum := sha256.Sum256([]byte(verifier))
+ challenge = base64.RawURLEncoding.EncodeToString(sum[:])
+ return verifier, challenge, nil
+}
diff --git a/selfhosted/internal/gateway/client.go b/selfhosted/internal/gateway/client.go
new file mode 100644
index 000000000..bda0461f7
--- /dev/null
+++ b/selfhosted/internal/gateway/client.go
@@ -0,0 +1,126 @@
+package gateway
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Device is the subset of a handset the gateway client needs.
+type Device struct {
+ Mode string
+ BaseURL string
+ Username string
+ Password string
+}
+
+// Client calls SMS Gateway for Android. Local mode uses the on-phone server.
+// Private mode uses the self-hosted server's 3rdparty API paths.
+type Client struct {
+ HTTP *http.Client
+}
+
+// New returns a client that does not follow redirects, so basic-auth credentials
+// stay on the device URL the operator registered.
+func New() *Client {
+ return &Client{HTTP: &http.Client{
+ Timeout: 15 * time.Second,
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }}
+}
+
+// SendSMS posts one text message. sim is 1 or 2.
+func (c *Client) SendSMS(ctx context.Context, device Device, to, text string, sim int) (string, error) {
+ payload := map[string]any{
+ "textMessage": map[string]string{"text": text},
+ "phoneNumbers": []string{to},
+ "simNumber": sim,
+ }
+ var decoded struct {
+ ID string `json:"id"`
+ }
+ if err := c.post(ctx, device, sendPath(device.Mode), payload, &decoded); err != nil {
+ return "", err
+ }
+ return decoded.ID, nil
+}
+
+// RegisterWebhooks asks the gateway to POST sms events to callback.
+func (c *Client) RegisterWebhooks(ctx context.Context, device Device, callback string) error {
+ for _, event := range []string{"sms:received", "sms:sent", "sms:delivered", "sms:failed"} {
+ body := map[string]string{"url": callback, "event": event}
+ if err := c.post(ctx, device, webhookPath(device.Mode), body, nil); err != nil {
+ return fmt.Errorf("register %s: %w", event, err)
+ }
+ }
+ return nil
+}
+
+func (c *Client) post(ctx context.Context, device Device, path string, payload any, dest any) error {
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ endpoint, err := join(device.BaseURL, path)
+ if err != nil {
+ return err
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.SetBasicAuth(device.Username, device.Password)
+ response, err := c.HTTP.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
+ if response.StatusCode == http.StatusConflict {
+ return nil
+ }
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return fmt.Errorf("gateway %s returned %d", path, response.StatusCode)
+ }
+ if dest == nil || len(bytes.TrimSpace(body)) == 0 {
+ return nil
+ }
+ if err := json.Unmarshal(body, dest); err != nil {
+ return fmt.Errorf("decode gateway response: %w", err)
+ }
+ return nil
+}
+
+func sendPath(mode string) string {
+ if mode == "private" {
+ return "/api/3rdparty/v1/messages"
+ }
+ return "/message"
+}
+
+func webhookPath(mode string) string {
+ if mode == "private" {
+ return "/api/3rdparty/v1/webhooks"
+ }
+ return "/webhooks"
+}
+
+func join(base, path string) (string, error) {
+ parsed, err := url.Parse(strings.TrimRight(base, "/"))
+ if err != nil {
+ return "", err
+ }
+ parsed.Path = path
+ parsed.RawQuery = ""
+ parsed.Fragment = ""
+ return parsed.String(), nil
+}
diff --git a/selfhosted/internal/gateway/client_test.go b/selfhosted/internal/gateway/client_test.go
new file mode 100644
index 000000000..07d979cc4
--- /dev/null
+++ b/selfhosted/internal/gateway/client_test.go
@@ -0,0 +1,69 @@
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestSendAndRegisterLocal(t *testing.T) {
+ var sawSend, sawHook bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, pass, ok := r.BasicAuth()
+ if !ok || user != "sms" || pass != "secret" {
+ t.Errorf("basic auth %q %q ok=%v", user, pass, ok)
+ }
+ body, _ := io.ReadAll(r.Body)
+ switch r.URL.Path {
+ case "/message":
+ sawSend = true
+ var payload struct {
+ PhoneNumbers []string `json:"phoneNumbers"`
+ TextMessage struct {
+ Text string `json:"text"`
+ } `json:"textMessage"`
+ SimNumber int `json:"simNumber"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ t.Fatal(err)
+ }
+ if payload.TextMessage.Text != "hello" || payload.PhoneNumbers[0] != "+18005550100" || payload.SimNumber != 1 {
+ t.Fatalf("payload %+v", payload)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"gw-1","state":"Pending"}`))
+ case "/webhooks":
+ sawHook = true
+ w.WriteHeader(http.StatusCreated)
+ default:
+ t.Errorf("unexpected path %s", r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer server.Close()
+
+ client := New()
+ device := Device{Mode: "local", BaseURL: server.URL, Username: "sms", Password: "secret"}
+ if err := client.RegisterWebhooks(context.Background(), device, "https://godesk.example/v1/gateway/events/token"); err != nil {
+ t.Fatal(err)
+ }
+ id, err := client.SendSMS(context.Background(), device, "+18005550100", "hello", 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id != "gw-1" || !sawSend || !sawHook {
+ t.Fatalf("id %q send %v hook %v", id, sawSend, sawHook)
+ }
+}
+
+func TestPrivatePaths(t *testing.T) {
+ if sendPath("private") != "/api/3rdparty/v1/messages" {
+ t.Fatal(sendPath("private"))
+ }
+ if webhookPath("local") != "/webhooks" {
+ t.Fatal(webhookPath("local"))
+ }
+}
diff --git a/selfhosted/internal/httpapi/dashboard.html b/selfhosted/internal/httpapi/dashboard.html
new file mode 100644
index 000000000..e207b501a
--- /dev/null
+++ b/selfhosted/internal/httpapi/dashboard.html
@@ -0,0 +1,214 @@
+
+
+
+
+
+ GoDesk SMS
+
+
+
+
+
+
+ Account
+ Local email and password. No Firebase. The API key is the x-api-key used by httpsms-compatible clients.
+
+
+
+
+
+
+
+
+
diff --git a/selfhosted/internal/httpapi/server.go b/selfhosted/internal/httpapi/server.go
new file mode 100644
index 000000000..4918283d7
--- /dev/null
+++ b/selfhosted/internal/httpapi/server.go
@@ -0,0 +1,890 @@
+package httpapi
+
+import (
+ "bytes"
+ "context"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/auth"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/gateway"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/phone"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/secretbox"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/urls"
+ "github.com/google/uuid"
+ "golang.org/x/crypto/bcrypt"
+)
+
+//go:embed dashboard.html
+var dashboardHTML []byte
+
+// Server is the self-hosted GoDesk HTTP API and dashboard.
+type Server struct {
+ Store store.Store
+ Tokens *auth.Tokens
+ Box *secretbox.Box
+ Gateway *gateway.Client
+ PublicURL string
+ OIDC *auth.OIDC
+ BcryptCost int
+ Now func() time.Time
+ HTTP *http.Client
+}
+
+// New fills defaults and returns a server.
+func New(s Server) *Server {
+ if s.BcryptCost == 0 {
+ s.BcryptCost = bcrypt.DefaultCost
+ }
+ if s.Now == nil {
+ s.Now = func() time.Time { return time.Now().UTC() }
+ }
+ if s.Gateway == nil {
+ s.Gateway = gateway.New()
+ }
+ if s.HTTP == nil {
+ s.HTTP = &http.Client{Timeout: 5 * time.Second}
+ }
+ s.PublicURL = strings.TrimRight(s.PublicURL, "/")
+ return &s
+}
+
+// Handler is the public HTTP handler.
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", s.dashboard)
+ mux.HandleFunc("GET /health", s.health)
+ mux.HandleFunc("POST /v1/auth/signup", s.signup)
+ mux.HandleFunc("POST /v1/auth/login", s.login)
+ mux.HandleFunc("GET /v1/auth/oidc/start", s.oidcStart)
+ mux.HandleFunc("GET /v1/auth/oidc/callback", s.oidcCallback)
+ mux.HandleFunc("GET /v1/users/me", s.me)
+ mux.HandleFunc("POST /v1/devices", s.createDevice)
+ mux.HandleFunc("GET /v1/devices", s.listDevices)
+ mux.HandleFunc("DELETE /v1/devices/{id}", s.deleteDevice)
+ mux.HandleFunc("POST /v1/devices/{id}/webhooks", s.registerDeviceWebhooks)
+ mux.HandleFunc("POST /v1/messages/send", s.sendMessage)
+ mux.HandleFunc("GET /v1/messages", s.listMessages)
+ mux.HandleFunc("POST /v1/webhooks", s.createWebhook)
+ mux.HandleFunc("GET /v1/webhooks", s.listWebhooks)
+ mux.HandleFunc("DELETE /v1/webhooks/{id}", s.deleteWebhook)
+ mux.HandleFunc("POST /v1/gateway/events/{token}", s.gatewayEvent)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ mux.ServeHTTP(w, r)
+ })
+}
+
+func (s *Server) dashboard(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(dashboardHTML)
+}
+
+func (s *Server) health(w http.ResponseWriter, r *http.Request) {
+ if err := s.Store.Ping(r.Context()); err != nil {
+ writeError(w, http.StatusServiceUnavailable, "database unavailable")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (s *Server) signup(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "request body must be JSON")
+ return
+ }
+ s.writeSession(w, r, body.Email, body.Password, true)
+}
+
+func (s *Server) login(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "request body must be JSON")
+ return
+ }
+ s.writeSession(w, r, body.Email, body.Password, false)
+}
+
+func (s *Server) writeSession(w http.ResponseWriter, r *http.Request, email, password string, create bool) {
+ normalized, err := normalizeEmail(email)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
+ return
+ }
+ if len(password) < 8 || len(password) > 200 {
+ writeError(w, http.StatusUnprocessableEntity, "password must be 8 to 200 characters")
+ return
+ }
+ var user model.User
+ if create {
+ hash, err := auth.HashPassword(password, s.BcryptCost)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not hash password")
+ return
+ }
+ apiKey, err := auth.RandomToken(32)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not create api key")
+ return
+ }
+ user = model.User{
+ ID: uuid.NewString(),
+ Email: normalized,
+ PasswordHash: hash,
+ APIKey: apiKey,
+ CreatedAt: s.Now(),
+ }
+ if err := s.Store.CreateUser(r.Context(), user); err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ writeError(w, http.StatusConflict, "an account with that email already exists")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not create account")
+ return
+ }
+ } else {
+ user, err = s.Store.UserByEmail(r.Context(), normalized)
+ if err != nil || !auth.CheckPassword(user.PasswordHash, password) {
+ writeError(w, http.StatusUnauthorized, "email or password is incorrect")
+ return
+ }
+ }
+ s.writeAuth(w, http.StatusOK, user)
+}
+
+func (s *Server) writeAuth(w http.ResponseWriter, status int, user model.User) {
+ token, err := s.Tokens.SessionToken(user.ID, user.Email)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not create session")
+ return
+ }
+ writeJSON(w, status, map[string]any{
+ "status": "success",
+ "message": "authenticated",
+ "data": map[string]any{
+ "token": token,
+ "api_key": user.APIKey,
+ "user": map[string]string{
+ "id": user.ID,
+ "email": user.Email,
+ },
+ },
+ })
+}
+
+func (s *Server) me(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "success",
+ "message": "user",
+ "data": map[string]any{
+ "api_key": user.APIKey,
+ "user": map[string]string{
+ "id": user.ID,
+ "email": user.Email,
+ },
+ },
+ })
+}
+
+func (s *Server) createDevice(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ PhoneNumber string `json:"phone_number"`
+ BaseURL string `json:"base_url"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Mode string `json:"mode"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "request body must be JSON")
+ return
+ }
+ device, note, err := s.buildDevice(r.Context(), user.ID, body.Name, body.PhoneNumber, body.BaseURL, body.Username, body.Password, body.Mode)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
+ return
+ }
+ if err := s.Store.CreateDevice(r.Context(), device); err != nil {
+ if errors.Is(err, store.ErrConflict) {
+ writeError(w, http.StatusConflict, "a device with that phone number is already registered")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not save device")
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "status": "success",
+ "message": "device registered",
+ "data": s.deviceJSON(device, note),
+ })
+}
+
+func (s *Server) buildDevice(ctx context.Context, userID, name, phoneNumber, baseURL, username, password, mode string) (model.Device, string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" || len(name) > 80 {
+ return model.Device{}, "", errors.New("name is required")
+ }
+ number, err := phone.Normalize(phoneNumber)
+ if err != nil {
+ return model.Device{}, "", err
+ }
+ origin, err := urls.NormalizeBaseURL(baseURL, false)
+ if err != nil {
+ return model.Device{}, "", err
+ }
+ username = strings.TrimSpace(username)
+ if username == "" || password == "" {
+ return model.Device{}, "", errors.New("gateway username and password are required")
+ }
+ if mode == "" {
+ mode = model.ModeLocal
+ }
+ if mode != model.ModeLocal && mode != model.ModePrivate {
+ return model.Device{}, "", errors.New("mode must be local or private")
+ }
+ sealed, err := s.Box.Seal(password)
+ if err != nil {
+ return model.Device{}, "", errors.New("could not protect the gateway password")
+ }
+ token, err := auth.RandomToken(32)
+ if err != nil {
+ return model.Device{}, "", errors.New("could not create an ingest token")
+ }
+ device := model.Device{
+ ID: uuid.NewString(),
+ UserID: userID,
+ Name: name,
+ PhoneNumber: number,
+ BaseURL: origin,
+ Username: username,
+ Password: sealed,
+ Mode: mode,
+ IngestToken: token,
+ CreatedAt: s.Now(),
+ }
+ note := "ok"
+ if err := s.Gateway.RegisterWebhooks(ctx, gateway.Device{
+ Mode: device.Mode, BaseURL: device.BaseURL, Username: username, Password: password,
+ }, s.ingestURL(token)); err != nil {
+ note = err.Error()
+ log.Printf("device webhook registration failed for %s: %s", device.ID, note)
+ }
+ return device, note, nil
+}
+
+func (s *Server) listDevices(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ devices, err := s.Store.ListDevices(r.Context(), user.ID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list devices")
+ return
+ }
+ out := make([]map[string]any, 0, len(devices))
+ for _, device := range devices {
+ out = append(out, s.deviceJSON(device, ""))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "devices", "data": out})
+}
+
+func (s *Server) deleteDevice(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ if err := s.Store.DeleteDevice(r.Context(), user.ID, r.PathValue("id")); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "device not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not delete device")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "device deleted"})
+}
+
+func (s *Server) registerDeviceWebhooks(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ device, err := s.Store.DeviceByID(r.Context(), user.ID, r.PathValue("id"))
+ if err != nil {
+ writeError(w, http.StatusNotFound, "device not found")
+ return
+ }
+ password, err := s.Box.Open(device.Password)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not read the gateway password")
+ return
+ }
+ note := "ok"
+ if err := s.Gateway.RegisterWebhooks(r.Context(), gateway.Device{
+ Mode: device.Mode, BaseURL: device.BaseURL, Username: device.Username, Password: password,
+ }, s.ingestURL(device.IngestToken)); err != nil {
+ note = err.Error()
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "success",
+ "message": "webhook registration attempted",
+ "data": s.deviceJSON(device, note),
+ })
+}
+
+func (s *Server) deviceJSON(device model.Device, registration string) map[string]any {
+ return map[string]any{
+ "id": device.ID,
+ "name": device.Name,
+ "phone_number": device.PhoneNumber,
+ "base_url": device.BaseURL,
+ "username": device.Username,
+ "mode": device.Mode,
+ "ingest_url": s.ingestURL(device.IngestToken),
+ "webhook_registration": registration,
+ "created_at": device.CreatedAt,
+ }
+}
+
+func (s *Server) ingestURL(token string) string {
+ return s.PublicURL + "/v1/gateway/events/" + token
+}
+
+func (s *Server) sendMessage(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ var body struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Content string `json:"content"`
+ RequestID string `json:"request_id"`
+ SIM string `json:"sim"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "request body must be JSON")
+ return
+ }
+ from, err := phone.Normalize(body.From)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "from "+err.Error())
+ return
+ }
+ to, err := phone.Normalize(body.To)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "to "+err.Error())
+ return
+ }
+ content := strings.TrimSpace(body.Content)
+ if content == "" || len(content) > 1600 {
+ writeError(w, http.StatusUnprocessableEntity, "content is required and must be at most 1600 characters")
+ return
+ }
+ device, err := s.Store.DeviceByPhone(r.Context(), user.ID, from)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "no device is registered for "+from)
+ return
+ }
+ password, err := s.Box.Open(device.Password)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not read the gateway password")
+ return
+ }
+ simNumber := 1
+ simLabel := model.SIM1
+ if strings.EqualFold(body.SIM, model.SIM2) {
+ simNumber = 2
+ simLabel = model.SIM2
+ }
+ now := s.Now()
+ message := model.Message{
+ ID: uuid.NewString(),
+ UserID: user.ID,
+ DeviceID: device.ID,
+ RequestID: strings.TrimSpace(body.RequestID),
+ Owner: from,
+ Contact: to,
+ Content: content,
+ Type: model.TypeMobileTerminated,
+ Status: model.StatusPending,
+ SIM: simLabel,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ gatewayID, err := s.Gateway.SendSMS(r.Context(), gateway.Device{
+ Mode: device.Mode, BaseURL: device.BaseURL, Username: device.Username, Password: password,
+ }, to, content, simNumber)
+ if err != nil {
+ message.Status = model.StatusFailed
+ message.FailureReason = "gateway request failed"
+ _ = s.insertIgnoringDuplicate(r.Context(), message)
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "status": "error",
+ "message": "the SMS gateway did not accept the message",
+ "data": messageJSON(message),
+ })
+ return
+ }
+ message.GatewayID = gatewayID
+ if _, err := s.Store.InsertMessage(r.Context(), message); err != nil {
+ writeError(w, http.StatusInternalServerError, "the gateway accepted the message but it could not be stored")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "success",
+ "message": "message added to queue",
+ "data": messageJSON(message),
+ })
+}
+
+func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ messages, err := s.Store.ListMessages(r.Context(), user.ID, 50)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list messages")
+ return
+ }
+ out := make([]map[string]any, 0, len(messages))
+ for _, message := range messages {
+ out = append(out, messageJSON(message))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "messages", "data": out})
+}
+
+func (s *Server) createWebhook(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ var body struct {
+ URL string `json:"url"`
+ SigningKey string `json:"signing_key"`
+ Events []string `json:"events"`
+ }
+ if err := readJSON(r, &body); err != nil {
+ writeError(w, http.StatusBadRequest, "request body must be JSON")
+ return
+ }
+ endpoint, err := urls.NormalizeBaseURL(body.URL, true)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, err.Error())
+ return
+ }
+ if len(body.Events) == 0 {
+ writeError(w, http.StatusUnprocessableEntity, "events must include message.phone.received")
+ return
+ }
+ hook := model.Webhook{
+ ID: uuid.NewString(),
+ UserID: user.ID,
+ URL: endpoint,
+ SigningKey: body.SigningKey,
+ Events: body.Events,
+ CreatedAt: s.Now(),
+ }
+ if err := s.Store.CreateWebhook(r.Context(), hook); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not save webhook")
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "status": "success",
+ "message": "webhook saved",
+ "data": webhookJSON(hook),
+ })
+}
+
+func (s *Server) listWebhooks(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ hooks, err := s.Store.ListWebhooks(r.Context(), user.ID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not list webhooks")
+ return
+ }
+ out := make([]map[string]any, 0, len(hooks))
+ for _, hook := range hooks {
+ out = append(out, webhookJSON(hook))
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "webhooks", "data": out})
+}
+
+func (s *Server) deleteWebhook(w http.ResponseWriter, r *http.Request) {
+ user, ok := s.currentUser(r)
+ if !ok {
+ writeError(w, http.StatusUnauthorized, "authentication required")
+ return
+ }
+ if err := s.Store.DeleteWebhook(r.Context(), user.ID, r.PathValue("id")); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ writeError(w, http.StatusNotFound, "webhook not found")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "could not delete webhook")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "webhook deleted"})
+}
+
+func (s *Server) gatewayEvent(w http.ResponseWriter, r *http.Request) {
+ device, err := s.Store.DeviceByIngestToken(r.Context(), r.PathValue("token"))
+ if err != nil {
+ writeError(w, http.StatusUnauthorized, "unknown device token")
+ return
+ }
+ var event struct {
+ Event string `json:"event"`
+ Payload json.RawMessage `json:"payload"`
+ }
+ if err := readJSON(r, &event); err != nil {
+ writeError(w, http.StatusBadRequest, "request body must be JSON")
+ return
+ }
+ var payload struct {
+ MessageID string `json:"messageId"`
+ Message string `json:"message"`
+ Sender string `json:"sender"`
+ Recipient string `json:"recipient"`
+ SimNumber *int `json:"simNumber"`
+ Reason string `json:"reason"`
+ }
+ if len(event.Payload) > 0 {
+ if err := json.Unmarshal(event.Payload, &payload); err != nil {
+ writeError(w, http.StatusBadRequest, "payload is invalid")
+ return
+ }
+ }
+ switch event.Event {
+ case "sms:received":
+ s.receiveSMS(w, r, device, payload.MessageID, payload.Message, payload.Sender, payload.Recipient, payload.SimNumber)
+ case "sms:sent", "sms:delivered", "sms:failed":
+ status := model.StatusSent
+ if event.Event == "sms:delivered" {
+ status = model.StatusDelivered
+ }
+ if event.Event == "sms:failed" {
+ status = model.StatusFailed
+ }
+ if payload.MessageID != "" {
+ _ = s.Store.UpdateMessageByGatewayID(r.Context(), device.UserID, payload.MessageID, status, payload.Reason)
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "event stored"})
+ default:
+ writeJSON(w, http.StatusOK, map[string]string{"status": "success", "message": "event ignored"})
+ }
+}
+
+func (s *Server) receiveSMS(w http.ResponseWriter, r *http.Request, device model.Device, gatewayID, content, sender, recipient string, simNumber *int) {
+ owner := recipient
+ if owner == "" {
+ owner = device.PhoneNumber
+ }
+ owner, err := phone.Normalize(owner)
+ if err != nil {
+ owner = device.PhoneNumber
+ }
+ contact, err := phone.Normalize(sender)
+ if err != nil {
+ writeError(w, http.StatusUnprocessableEntity, "sender "+err.Error())
+ return
+ }
+ simLabel := model.SIM1
+ if simNumber != nil && *simNumber == 2 {
+ simLabel = model.SIM2
+ }
+ now := s.Now()
+ message := model.Message{
+ ID: uuid.NewString(),
+ UserID: device.UserID,
+ DeviceID: device.ID,
+ Owner: owner,
+ Contact: contact,
+ Content: content,
+ Type: model.TypeMobileOriginated,
+ Status: model.StatusReceived,
+ SIM: simLabel,
+ GatewayID: gatewayID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ created, err := s.Store.InsertMessage(r.Context(), message)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not store message")
+ return
+ }
+ if created {
+ s.fanout(r.Context(), device.UserID, message)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"status": "success", "message": "message received", "data": messageJSON(message)})
+}
+
+func (s *Server) fanout(ctx context.Context, userID string, message model.Message) {
+ hooks, err := s.Store.ListWebhooksByEvent(ctx, userID, model.EventMessagePhoneReceived)
+ if err != nil {
+ log.Printf("list webhooks: %v", err)
+ return
+ }
+ eventID := uuid.NewString()
+ body, err := json.Marshal(map[string]any{
+ "specversion": "1.0",
+ "id": eventID,
+ "source": s.PublicURL,
+ "type": model.EventMessagePhoneReceived,
+ "time": s.Now(),
+ "datacontenttype": "application/json",
+ "data": map[string]any{
+ "message_id": message.ID,
+ "user_id": userID,
+ "owner": message.Owner,
+ "encrypted": false,
+ "contact": message.Contact,
+ "timestamp": message.CreatedAt,
+ "content": message.Content,
+ "sim": message.SIM,
+ "attachments": []string{},
+ },
+ })
+ if err != nil {
+ return
+ }
+ for _, hook := range hooks {
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, hook.URL, bytes.NewReader(body))
+ if err != nil {
+ continue
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("X-Event-Type", model.EventMessagePhoneReceived)
+ if strings.TrimSpace(hook.SigningKey) != "" {
+ token, err := s.Tokens.WebhookToken(userID, hook.URL, hook.SigningKey)
+ if err == nil {
+ request.Header.Set("Authorization", "Bearer "+token)
+ }
+ }
+ response, err := s.HTTP.Do(request)
+ if err != nil {
+ log.Printf("webhook delivery failed: %v", err)
+ continue
+ }
+ response.Body.Close()
+ }
+}
+
+func (s *Server) insertIgnoringDuplicate(ctx context.Context, message model.Message) bool {
+ created, err := s.Store.InsertMessage(ctx, message)
+ if err != nil {
+ log.Printf("store message: %v", err)
+ return false
+ }
+ return created
+}
+
+func (s *Server) oidcStart(w http.ResponseWriter, r *http.Request) {
+ if s.OIDC == nil {
+ writeError(w, http.StatusNotFound, "OIDC is not configured")
+ return
+ }
+ state, err := auth.RandomToken(16)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not start login")
+ return
+ }
+ verifier, challenge, err := auth.PKCE()
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not start login")
+ return
+ }
+ cookie, err := s.Tokens.OIDCStateCookie(state, verifier)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not start login")
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: "godesk_oidc",
+ Value: cookie,
+ Path: "/v1/auth/oidc",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: strings.HasPrefix(s.PublicURL, "https://"),
+ MaxAge: 600,
+ })
+ location, err := s.OIDC.AuthCodeURL(r.Context(), state, challenge)
+ if err != nil {
+ writeError(w, http.StatusBadGateway, "could not reach the OIDC issuer")
+ return
+ }
+ http.Redirect(w, r, location, http.StatusFound)
+}
+
+func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
+ if s.OIDC == nil {
+ writeError(w, http.StatusNotFound, "OIDC is not configured")
+ return
+ }
+ cookie, err := r.Cookie("godesk_oidc")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "OIDC state is missing")
+ return
+ }
+ state, verifier, err := s.Tokens.ParseOIDCState(cookie.Value)
+ if err != nil || state == "" || state != r.URL.Query().Get("state") {
+ writeError(w, http.StatusBadRequest, "OIDC state does not match")
+ return
+ }
+ email, err := s.OIDC.Exchange(r.Context(), r.URL.Query().Get("code"), verifier)
+ if err != nil {
+ writeError(w, http.StatusUnauthorized, "OIDC login failed")
+ return
+ }
+ user, err := s.Store.UserByEmail(r.Context(), email)
+ if errors.Is(err, store.ErrNotFound) {
+ random, randErr := auth.RandomToken(32)
+ if randErr != nil {
+ writeError(w, http.StatusInternalServerError, "could not create account")
+ return
+ }
+ hash, hashErr := auth.HashPassword(random, s.BcryptCost)
+ if hashErr != nil {
+ writeError(w, http.StatusInternalServerError, "could not create account")
+ return
+ }
+ apiKey, keyErr := auth.RandomToken(32)
+ if keyErr != nil {
+ writeError(w, http.StatusInternalServerError, "could not create account")
+ return
+ }
+ user = model.User{ID: uuid.NewString(), Email: email, PasswordHash: hash, APIKey: apiKey, CreatedAt: s.Now()}
+ if err := s.Store.CreateUser(r.Context(), user); err != nil {
+ writeError(w, http.StatusInternalServerError, "could not create account")
+ return
+ }
+ } else if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not load account")
+ return
+ }
+ token, err := s.Tokens.SessionToken(user.ID, user.Email)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "could not create session")
+ return
+ }
+ http.Redirect(w, r, "/#token="+token, http.StatusFound)
+}
+
+func (s *Server) currentUser(r *http.Request) (model.User, bool) {
+ if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
+ userID, _, err := s.Tokens.ParseSession(strings.TrimPrefix(header, "Bearer "))
+ if err == nil {
+ user, err := s.Store.UserByID(r.Context(), userID)
+ if err == nil {
+ return user, true
+ }
+ }
+ }
+ if apiKey := r.Header.Get("x-api-key"); apiKey != "" {
+ user, err := s.Store.UserByAPIKey(r.Context(), apiKey)
+ if err == nil {
+ return user, true
+ }
+ }
+ return model.User{}, false
+}
+
+func messageJSON(message model.Message) map[string]any {
+ var requestID any
+ if message.RequestID != "" {
+ requestID = message.RequestID
+ }
+ var reason any
+ if message.FailureReason != "" {
+ reason = message.FailureReason
+ }
+ return map[string]any{
+ "id": message.ID,
+ "request_id": requestID,
+ "owner": message.Owner,
+ "user_id": message.UserID,
+ "contact": message.Contact,
+ "content": message.Content,
+ "type": message.Type,
+ "status": message.Status,
+ "sim": message.SIM,
+ "failure_reason": reason,
+ "created_at": message.CreatedAt,
+ "updated_at": message.UpdatedAt,
+ }
+}
+
+func webhookJSON(hook model.Webhook) map[string]any {
+ return map[string]any{
+ "id": hook.ID,
+ "url": hook.URL,
+ "events": hook.Events,
+ "created_at": hook.CreatedAt,
+ }
+}
+
+func readJSON(r *http.Request, dest any) error {
+ defer r.Body.Close()
+ return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(dest)
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func writeError(w http.ResponseWriter, status int, message string) {
+ writeJSON(w, status, map[string]string{"status": "error", "message": message})
+}
+
+func normalizeEmail(value string) (string, error) {
+ value = strings.ToLower(strings.TrimSpace(value))
+ if len(value) < 3 || len(value) > 320 || !strings.Contains(value, "@") || strings.HasPrefix(value, "@") || strings.HasSuffix(value, "@") {
+ return "", errors.New("email is invalid")
+ }
+ return value, nil
+}
diff --git a/selfhosted/internal/httpapi/server_test.go b/selfhosted/internal/httpapi/server_test.go
new file mode 100644
index 000000000..43a7cf8c9
--- /dev/null
+++ b/selfhosted/internal/httpapi/server_test.go
@@ -0,0 +1,288 @@
+package httpapi
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/auth"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/secretbox"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store/memory"
+ "github.com/golang-jwt/jwt/v5"
+ "golang.org/x/crypto/bcrypt"
+)
+
+func TestVerticalSlice(t *testing.T) {
+ var mu sync.Mutex
+ var sentUser string
+ var sentTo string
+ var hooks []string
+ gatewaySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ user, _, _ := r.BasicAuth()
+ body, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ defer mu.Unlock()
+ switch r.URL.Path {
+ case "/message":
+ sentUser = user
+ var payload struct {
+ PhoneNumbers []string `json:"phoneNumbers"`
+ }
+ _ = json.Unmarshal(body, &payload)
+ if len(payload.PhoneNumbers) > 0 {
+ sentTo = payload.PhoneNumbers[0]
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"gw-send-1","state":"Pending"}`))
+ case "/webhooks":
+ var payload struct {
+ URL string `json:"url"`
+ Event string `json:"event"`
+ }
+ _ = json.Unmarshal(body, &payload)
+ if payload.Event == "sms:received" {
+ hooks = append(hooks, payload.URL)
+ }
+ w.WriteHeader(http.StatusCreated)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer gatewaySrv.Close()
+
+ var deliveries [][]byte
+ var authHeader string
+ webhookSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, _ := io.ReadAll(r.Body)
+ mu.Lock()
+ deliveries = append(deliveries, body)
+ authHeader = r.Header.Get("Authorization")
+ mu.Unlock()
+ if r.Header.Get("X-Event-Type") != "message.phone.received" {
+ t.Errorf("event header %s", r.Header.Get("X-Event-Type"))
+ }
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer webhookSrv.Close()
+
+ tokens, err := auth.NewTokens("test-jwt-secret-must-be-32-characters")
+ if err != nil {
+ t.Fatal(err)
+ }
+ box, err := secretbox.New("test-jwt-secret-must-be-32-characters")
+ if err != nil {
+ t.Fatal(err)
+ }
+ api := New(Server{
+ Store: memory.New(),
+ Tokens: tokens,
+ Box: box,
+ PublicURL: "http://godesk.test",
+ BcryptCost: bcrypt.MinCost,
+ })
+ server := httptest.NewServer(api.Handler())
+ defer server.Close()
+
+ signup := postJSON(t, server.URL+"/v1/auth/signup", "", map[string]string{
+ "email": "Ada@Example.com", "password": "correct horse",
+ })
+ if signup.StatusCode != http.StatusOK {
+ t.Fatalf("signup %d %s", signup.StatusCode, signup.Body)
+ }
+ var session struct {
+ Data struct {
+ Token string `json:"token"`
+ APIKey string `json:"api_key"`
+ } `json:"data"`
+ }
+ decode(t, signup.Body, &session)
+ if session.Data.Token == "" || session.Data.APIKey == "" {
+ t.Fatalf("session %+v", session.Data)
+ }
+
+ conflict := postJSON(t, server.URL+"/v1/auth/signup", "", map[string]string{
+ "email": "ada@example.com", "password": "correct horse",
+ })
+ if conflict.StatusCode != http.StatusConflict {
+ t.Fatalf("duplicate signup %d", conflict.StatusCode)
+ }
+ badLogin := postJSON(t, server.URL+"/v1/auth/login", "", map[string]string{
+ "email": "ada@example.com", "password": "wrong-password",
+ })
+ if badLogin.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("bad login %d", badLogin.StatusCode)
+ }
+
+ var phoneBIngest string
+ for _, device := range []map[string]string{
+ {"name": "SIM A", "phone_number": "+18005550199", "username": "phone-a"},
+ {"name": "SIM B", "phone_number": "+18005550198", "username": "phone-b"},
+ } {
+ created := postJSON(t, server.URL+"/v1/devices", session.Data.Token, map[string]string{
+ "name": device["name"],
+ "phone_number": device["phone_number"],
+ "base_url": gatewaySrv.URL,
+ "username": device["username"],
+ "password": "gateway-secret",
+ "mode": "local",
+ })
+ if created.StatusCode != http.StatusCreated {
+ t.Fatalf("device %s: %d %s", device["name"], created.StatusCode, created.Body)
+ }
+ var payload struct {
+ Data struct {
+ Registration string `json:"webhook_registration"`
+ IngestURL string `json:"ingest_url"`
+ } `json:"data"`
+ }
+ decode(t, created.Body, &payload)
+ if payload.Data.Registration != "ok" {
+ t.Fatalf("registration %q", payload.Data.Registration)
+ }
+ phoneBIngest = payload.Data.IngestURL
+ }
+
+ sent := postJSON(t, server.URL+"/v1/messages/send", "", map[string]string{
+ "from": "+18005550198", "to": "+18005550100", "content": "hello from b",
+ }, session.Data.APIKey)
+ if sent.StatusCode != http.StatusOK {
+ t.Fatalf("send %d %s", sent.StatusCode, sent.Body)
+ }
+ var sentBody struct {
+ Message string `json:"message"`
+ Data struct {
+ Status string `json:"status"`
+ Owner string `json:"owner"`
+ } `json:"data"`
+ }
+ decode(t, sent.Body, &sentBody)
+ if sentBody.Message != "message added to queue" || sentBody.Data.Status != "pending" || sentBody.Data.Owner != "+18005550198" {
+ t.Fatalf("send body %+v", sentBody)
+ }
+ mu.Lock()
+ if sentUser != "phone-b" || sentTo != "+18005550100" {
+ t.Fatalf("gateway saw user %q to %q", sentUser, sentTo)
+ }
+ if len(hooks) < 2 {
+ t.Fatalf("expected a received webhook per device, got %d", len(hooks))
+ }
+ mu.Unlock()
+ ingestToken := phoneBIngest[strings.LastIndex(phoneBIngest, "/")+1:]
+ ingest := server.URL + "/v1/gateway/events/" + ingestToken
+
+ hook := postJSON(t, server.URL+"/v1/webhooks", session.Data.Token, map[string]any{
+ "url": webhookSrv.URL + "/sms", "signing_key": "hook-secret", "events": []string{"message.phone.received"},
+ })
+ if hook.StatusCode != http.StatusCreated {
+ t.Fatalf("webhook %d %s", hook.StatusCode, hook.Body)
+ }
+
+ event := map[string]any{
+ "event": "sms:received",
+ "payload": map[string]any{
+ "messageId": "inbound-1",
+ "message": "ping",
+ "sender": "+18005550100",
+ "recipient": "+18005550198",
+ "simNumber": 1,
+ },
+ }
+ first := postJSON(t, ingest, "", event)
+ if first.StatusCode != http.StatusOK {
+ t.Fatalf("ingest %d %s", first.StatusCode, first.Body)
+ }
+ second := postJSON(t, ingest, "", event)
+ if second.StatusCode != http.StatusOK {
+ t.Fatalf("duplicate ingest %d", second.StatusCode)
+ }
+ mu.Lock()
+ if len(deliveries) != 1 {
+ t.Fatalf("webhook deliveries %d", len(deliveries))
+ }
+ delivery := deliveries[0]
+ header := authHeader
+ mu.Unlock()
+
+ var cloud struct {
+ Type string `json:"type"`
+ Data struct {
+ Content string `json:"content"`
+ Owner string `json:"owner"`
+ Contact string `json:"contact"`
+ } `json:"data"`
+ }
+ decode(t, string(delivery), &cloud)
+ if cloud.Type != "message.phone.received" || cloud.Data.Content != "ping" || cloud.Data.Owner != "+18005550198" || cloud.Data.Contact != "+18005550100" {
+ t.Fatalf("cloud event %+v", cloud)
+ }
+ if !strings.HasPrefix(header, "Bearer ") {
+ t.Fatalf("signing header %q", header)
+ }
+ parsed, err := jwt.Parse(strings.TrimPrefix(header, "Bearer "), func(token *jwt.Token) (any, error) {
+ return []byte("hook-secret"), nil
+ })
+ if err != nil || !parsed.Valid {
+ t.Fatalf("webhook jwt: %v", err)
+ }
+
+ health, err := http.Get(server.URL + "/health")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer health.Body.Close()
+ if health.StatusCode != http.StatusOK {
+ t.Fatalf("health %d", health.StatusCode)
+ }
+ dashboard, err := http.Get(server.URL + "/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer dashboard.Body.Close()
+ page, _ := io.ReadAll(dashboard.Body)
+ if !bytes.Contains(page, []byte("GoDesk SMS")) {
+ t.Fatal("dashboard missing")
+ }
+}
+
+type recorded struct {
+ StatusCode int
+ Body string
+}
+
+func postJSON(t *testing.T, endpoint, bearer string, payload any, apiKey ...string) recorded {
+ t.Helper()
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ request.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ if len(apiKey) > 0 && apiKey[0] != "" {
+ request.Header.Set("x-api-key", apiKey[0])
+ }
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ body, _ := io.ReadAll(response.Body)
+ return recorded{StatusCode: response.StatusCode, Body: string(body)}
+}
+
+func decode(t *testing.T, raw string, dest any) {
+ t.Helper()
+ if err := json.Unmarshal([]byte(raw), dest); err != nil {
+ t.Fatalf("decode %s: %v", raw, err)
+ }
+}
diff --git a/selfhosted/internal/model/model.go b/selfhosted/internal/model/model.go
new file mode 100644
index 000000000..820d5a30b
--- /dev/null
+++ b/selfhosted/internal/model/model.go
@@ -0,0 +1,75 @@
+package model
+
+import "time"
+
+// User is a GoDesk account. PasswordHash is empty for display and is a bcrypt
+// hash in storage. APIKey is the httpsms-compatible x-api-key credential.
+type User struct {
+ ID string
+ Email string
+ PasswordHash string
+ APIKey string
+ CreatedAt time.Time
+}
+
+// Device is one SMS Gateway for Android handset. Password is sealed before it
+// is stored and opened only when calling the gateway.
+type Device struct {
+ ID string
+ UserID string
+ Name string
+ PhoneNumber string
+ BaseURL string
+ Username string
+ Password string
+ Mode string
+ IngestToken string
+ CreatedAt time.Time
+}
+
+// Message is an outbound or inbound SMS in the httpsms shape.
+type Message struct {
+ ID string
+ UserID string
+ DeviceID string
+ RequestID string
+ Owner string
+ Contact string
+ Content string
+ Type string
+ Status string
+ SIM string
+ GatewayID string
+ FailureReason string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// Webhook is an httpsms-compatible subscription.
+type Webhook struct {
+ ID string
+ UserID string
+ URL string
+ SigningKey string
+ Events []string
+ CreatedAt time.Time
+}
+
+const (
+ ModeLocal = "local"
+ ModePrivate = "private"
+
+ TypeMobileTerminated = "mobile-terminated"
+ TypeMobileOriginated = "mobile-originated"
+
+ StatusPending = "pending"
+ StatusSent = "sent"
+ StatusDelivered = "delivered"
+ StatusFailed = "failed"
+ StatusReceived = "received"
+
+ SIM1 = "SIM1"
+ SIM2 = "SIM2"
+
+ EventMessagePhoneReceived = "message.phone.received"
+)
diff --git a/selfhosted/internal/phone/phone.go b/selfhosted/internal/phone/phone.go
new file mode 100644
index 000000000..d71860255
--- /dev/null
+++ b/selfhosted/internal/phone/phone.go
@@ -0,0 +1,20 @@
+package phone
+
+import (
+ "errors"
+ "strings"
+)
+
+// Normalize checks an E.164 number and returns it unchanged aside from trimming.
+func Normalize(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ if len(value) < 8 || len(value) > 16 || !strings.HasPrefix(value, "+") {
+ return "", errors.New("phone number must be E.164, for example +18005550100")
+ }
+ for _, r := range value[1:] {
+ if r < '0' || r > '9' {
+ return "", errors.New("phone number must be E.164, for example +18005550100")
+ }
+ }
+ return value, nil
+}
diff --git a/selfhosted/internal/secretbox/box.go b/selfhosted/internal/secretbox/box.go
new file mode 100644
index 000000000..759a0d622
--- /dev/null
+++ b/selfhosted/internal/secretbox/box.go
@@ -0,0 +1,70 @@
+package secretbox
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+)
+
+// Box seals short secrets with AES-GCM. The key is derived from a server secret
+// so device passwords are not stored in plaintext.
+type Box struct {
+ key []byte
+}
+
+// New derives a 32-byte key from secret. secret must be non-empty.
+func New(secret string) (*Box, error) {
+ if secret == "" {
+ return nil, errors.New("encryption secret is empty")
+ }
+ sum := sha256.Sum256([]byte(secret))
+ return &Box{key: sum[:]}, nil
+}
+
+// Seal encrypts plaintext and returns a base64 token.
+func (b *Box) Seal(plaintext string) (string, error) {
+ block, err := aes.NewCipher(b.key)
+ if err != nil {
+ return "", err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+ sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+ return base64.RawStdEncoding.EncodeToString(sealed), nil
+}
+
+// Open reverses Seal.
+func (b *Box) Open(token string) (string, error) {
+ raw, err := base64.RawStdEncoding.DecodeString(token)
+ if err != nil {
+ return "", fmt.Errorf("decode secret: %w", err)
+ }
+ block, err := aes.NewCipher(b.key)
+ if err != nil {
+ return "", err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", err
+ }
+ if len(raw) < gcm.NonceSize() {
+ return "", errors.New("secret token is too short")
+ }
+ nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
+ plain, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return "", fmt.Errorf("open secret: %w", err)
+ }
+ return string(plain), nil
+}
diff --git a/selfhosted/internal/secretbox/box_test.go b/selfhosted/internal/secretbox/box_test.go
new file mode 100644
index 000000000..5e9f79da6
--- /dev/null
+++ b/selfhosted/internal/secretbox/box_test.go
@@ -0,0 +1,24 @@
+package secretbox
+
+import "testing"
+
+func TestRoundTrip(t *testing.T) {
+ box, err := New("test-secret-value")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sealed, err := box.Seal("gateway-password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if sealed == "gateway-password" {
+ t.Fatal("password was stored in plaintext")
+ }
+ opened, err := box.Open(sealed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opened != "gateway-password" {
+ t.Fatalf("opened %q", opened)
+ }
+}
diff --git a/selfhosted/internal/store/memory/memory.go b/selfhosted/internal/store/memory/memory.go
new file mode 100644
index 000000000..e86545da9
--- /dev/null
+++ b/selfhosted/internal/store/memory/memory.go
@@ -0,0 +1,245 @@
+package memory
+
+import (
+ "context"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store"
+)
+
+// Store is an in-memory Store used by tests and local experiments.
+type Store struct {
+ mu sync.Mutex
+ users map[string]model.User
+ byEmail map[string]string
+ byAPIKey map[string]string
+ devices map[string]model.Device
+ messages []model.Message
+ webhooks map[string]model.Webhook
+}
+
+// New returns an empty store.
+func New() *Store {
+ return &Store{
+ users: map[string]model.User{},
+ byEmail: map[string]string{},
+ byAPIKey: map[string]string{},
+ devices: map[string]model.Device{},
+ webhooks: map[string]model.Webhook{},
+ }
+}
+
+func (s *Store) Ping(context.Context) error { return nil }
+
+func (s *Store) CreateUser(_ context.Context, user model.User) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if _, ok := s.byEmail[user.Email]; ok {
+ return store.ErrConflict
+ }
+ if _, ok := s.byAPIKey[user.APIKey]; ok {
+ return store.ErrConflict
+ }
+ s.users[user.ID] = user
+ s.byEmail[user.Email] = user.ID
+ s.byAPIKey[user.APIKey] = user.ID
+ return nil
+}
+
+func (s *Store) UserByEmail(_ context.Context, email string) (model.User, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ id, ok := s.byEmail[email]
+ if !ok {
+ return model.User{}, store.ErrNotFound
+ }
+ return s.users[id], nil
+}
+
+func (s *Store) UserByID(_ context.Context, id string) (model.User, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ user, ok := s.users[id]
+ if !ok {
+ return model.User{}, store.ErrNotFound
+ }
+ return user, nil
+}
+
+func (s *Store) UserByAPIKey(_ context.Context, apiKey string) (model.User, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ id, ok := s.byAPIKey[apiKey]
+ if !ok {
+ return model.User{}, store.ErrNotFound
+ }
+ return s.users[id], nil
+}
+
+func (s *Store) CreateDevice(_ context.Context, device model.Device) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, existing := range s.devices {
+ if existing.UserID == device.UserID && existing.PhoneNumber == device.PhoneNumber {
+ return store.ErrConflict
+ }
+ }
+ s.devices[device.ID] = device
+ return nil
+}
+
+func (s *Store) ListDevices(_ context.Context, userID string) ([]model.Device, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var out []model.Device
+ for _, device := range s.devices {
+ if device.UserID == userID {
+ out = append(out, device)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
+ return out, nil
+}
+
+func (s *Store) DeviceByID(_ context.Context, userID, id string) (model.Device, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ device, ok := s.devices[id]
+ if !ok || device.UserID != userID {
+ return model.Device{}, store.ErrNotFound
+ }
+ return device, nil
+}
+
+func (s *Store) DeviceByPhone(_ context.Context, userID, phone string) (model.Device, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, device := range s.devices {
+ if device.UserID == userID && device.PhoneNumber == phone {
+ return device, nil
+ }
+ }
+ return model.Device{}, store.ErrNotFound
+}
+
+func (s *Store) DeviceByIngestToken(_ context.Context, token string) (model.Device, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, device := range s.devices {
+ if device.IngestToken == token {
+ return device, nil
+ }
+ }
+ return model.Device{}, store.ErrNotFound
+}
+
+func (s *Store) DeleteDevice(_ context.Context, userID, id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ device, ok := s.devices[id]
+ if !ok || device.UserID != userID {
+ return store.ErrNotFound
+ }
+ delete(s.devices, id)
+ return nil
+}
+
+func (s *Store) InsertMessage(_ context.Context, message model.Message) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if message.GatewayID != "" {
+ for _, existing := range s.messages {
+ if existing.UserID == message.UserID && existing.GatewayID == message.GatewayID {
+ return false, nil
+ }
+ }
+ }
+ s.messages = append(s.messages, message)
+ return true, nil
+}
+
+func (s *Store) ListMessages(_ context.Context, userID string, limit int) ([]model.Message, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var out []model.Message
+ for i := len(s.messages) - 1; i >= 0; i-- {
+ if s.messages[i].UserID == userID {
+ out = append(out, s.messages[i])
+ }
+ if limit > 0 && len(out) >= limit {
+ break
+ }
+ }
+ return out, nil
+}
+
+func (s *Store) UpdateMessageByGatewayID(_ context.Context, userID, gatewayID, status, reason string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for i := range s.messages {
+ if s.messages[i].UserID == userID && s.messages[i].GatewayID == gatewayID {
+ s.messages[i].Status = status
+ s.messages[i].FailureReason = reason
+ s.messages[i].UpdatedAt = time.Now().UTC()
+ return nil
+ }
+ }
+ return store.ErrNotFound
+}
+
+func (s *Store) CreateWebhook(_ context.Context, hook model.Webhook) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.webhooks[hook.ID] = hook
+ return nil
+}
+
+func (s *Store) ListWebhooks(_ context.Context, userID string) ([]model.Webhook, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.webhooksFor(userID, ""), nil
+}
+
+func (s *Store) ListWebhooksByEvent(_ context.Context, userID, event string) ([]model.Webhook, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.webhooksFor(userID, event), nil
+}
+
+func (s *Store) webhooksFor(userID, event string) []model.Webhook {
+ var out []model.Webhook
+ for _, hook := range s.webhooks {
+ if hook.UserID != userID {
+ continue
+ }
+ if event != "" && !contains(hook.Events, event) {
+ continue
+ }
+ out = append(out, hook)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
+ return out
+}
+
+func (s *Store) DeleteWebhook(_ context.Context, userID, id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ hook, ok := s.webhooks[id]
+ if !ok || hook.UserID != userID {
+ return store.ErrNotFound
+ }
+ delete(s.webhooks, id)
+ return nil
+}
+
+func contains(values []string, want string) bool {
+ for _, value := range values {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/selfhosted/internal/store/pg/migrations/001_init.sql b/selfhosted/internal/store/pg/migrations/001_init.sql
new file mode 100644
index 000000000..463a600b3
--- /dev/null
+++ b/selfhosted/internal/store/pg/migrations/001_init.sql
@@ -0,0 +1,51 @@
+CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ api_key TEXT NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE TABLE devices (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ phone_number TEXT NOT NULL,
+ base_url TEXT NOT NULL,
+ username TEXT NOT NULL,
+ password TEXT NOT NULL,
+ mode TEXT NOT NULL,
+ ingest_token TEXT NOT NULL UNIQUE,
+ created_at TIMESTAMPTZ NOT NULL,
+ UNIQUE (user_id, phone_number)
+);
+
+CREATE TABLE messages (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ device_id TEXT REFERENCES devices (id) ON DELETE SET NULL,
+ request_id TEXT NOT NULL DEFAULT '',
+ owner TEXT NOT NULL,
+ contact TEXT NOT NULL,
+ content TEXT NOT NULL,
+ type TEXT NOT NULL,
+ status TEXT NOT NULL,
+ sim TEXT NOT NULL,
+ gateway_id TEXT NOT NULL DEFAULT '',
+ failure_reason TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE UNIQUE INDEX messages_user_gateway_uidx
+ ON messages (user_id, gateway_id)
+ WHERE gateway_id <> '';
+
+CREATE TABLE webhooks (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ url TEXT NOT NULL,
+ signing_key TEXT NOT NULL DEFAULT '',
+ events TEXT[] NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL
+);
diff --git a/selfhosted/internal/store/pg/pg.go b/selfhosted/internal/store/pg/pg.go
new file mode 100644
index 000000000..f64c8c9f7
--- /dev/null
+++ b/selfhosted/internal/store/pg/pg.go
@@ -0,0 +1,320 @@
+package pg
+
+import (
+ "context"
+ "embed"
+ "errors"
+ "fmt"
+ "sort"
+ "time"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model"
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/store"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+
+// Store is the Postgres implementation used by docker compose.
+type Store struct {
+ pool *pgxpool.Pool
+}
+
+// Open connects, applies migrations, and returns a store.
+func Open(ctx context.Context, databaseURL string) (*Store, error) {
+ pool, err := pgxpool.New(ctx, databaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("connect postgres: %w", err)
+ }
+ store := &Store{pool: pool}
+ if err := store.migrate(ctx); err != nil {
+ pool.Close()
+ return nil, err
+ }
+ return store, nil
+}
+
+// Close releases the pool.
+func (s *Store) Close() {
+ s.pool.Close()
+}
+
+func (s *Store) migrate(ctx context.Context) error {
+ if _, err := s.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY)`); err != nil {
+ return fmt.Errorf("create schema_migrations: %w", err)
+ }
+ entries, err := migrations.ReadDir("migrations")
+ if err != nil {
+ return err
+ }
+ names := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ names = append(names, entry.Name())
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ var applied bool
+ err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, name).Scan(&applied)
+ if err != nil {
+ return err
+ }
+ if applied {
+ continue
+ }
+ body, err := migrations.ReadFile("migrations/" + name)
+ if err != nil {
+ return err
+ }
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, string(body)); err != nil {
+ _ = tx.Rollback(ctx)
+ return fmt.Errorf("apply %s: %w", name, err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil {
+ _ = tx.Rollback(ctx)
+ return err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Store) Ping(ctx context.Context) error {
+ return s.pool.Ping(ctx)
+}
+
+func (s *Store) CreateUser(ctx context.Context, user model.User) error {
+ _, err := s.pool.Exec(ctx, `
+ INSERT INTO users (id, email, password_hash, api_key, created_at)
+ VALUES ($1, $2, $3, $4, $5)`,
+ user.ID, user.Email, user.PasswordHash, user.APIKey, user.CreatedAt)
+ return mapConflict(err)
+}
+
+func (s *Store) UserByEmail(ctx context.Context, email string) (model.User, error) {
+ return s.oneUser(ctx, `SELECT id, email, password_hash, api_key, created_at FROM users WHERE email = $1`, email)
+}
+
+func (s *Store) UserByID(ctx context.Context, id string) (model.User, error) {
+ return s.oneUser(ctx, `SELECT id, email, password_hash, api_key, created_at FROM users WHERE id = $1`, id)
+}
+
+func (s *Store) UserByAPIKey(ctx context.Context, apiKey string) (model.User, error) {
+ return s.oneUser(ctx, `SELECT id, email, password_hash, api_key, created_at FROM users WHERE api_key = $1`, apiKey)
+}
+
+func (s *Store) oneUser(ctx context.Context, query, arg string) (model.User, error) {
+ var user model.User
+ err := s.pool.QueryRow(ctx, query, arg).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.APIKey, &user.CreatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return model.User{}, store.ErrNotFound
+ }
+ return user, err
+}
+
+func (s *Store) CreateDevice(ctx context.Context, device model.Device) error {
+ _, err := s.pool.Exec(ctx, `
+ INSERT INTO devices (id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
+ device.ID, device.UserID, device.Name, device.PhoneNumber, device.BaseURL, device.Username, device.Password, device.Mode, device.IngestToken, device.CreatedAt)
+ return mapConflict(err)
+}
+
+func (s *Store) ListDevices(ctx context.Context, userID string) ([]model.Device, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at
+ FROM devices WHERE user_id = $1 ORDER BY created_at`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []model.Device
+ for rows.Next() {
+ device, err := scanDevice(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, device)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) DeviceByID(ctx context.Context, userID, id string) (model.Device, error) {
+ return s.oneDevice(ctx, `
+ SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at
+ FROM devices WHERE user_id = $1 AND id = $2`, userID, id)
+}
+
+func (s *Store) DeviceByPhone(ctx context.Context, userID, phone string) (model.Device, error) {
+ return s.oneDevice(ctx, `
+ SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at
+ FROM devices WHERE user_id = $1 AND phone_number = $2`, userID, phone)
+}
+
+func (s *Store) DeviceByIngestToken(ctx context.Context, token string) (model.Device, error) {
+ return s.oneDevice(ctx, `
+ SELECT id, user_id, name, phone_number, base_url, username, password, mode, ingest_token, created_at
+ FROM devices WHERE ingest_token = $1`, token)
+}
+
+func (s *Store) oneDevice(ctx context.Context, query string, args ...any) (model.Device, error) {
+ rows, err := s.pool.Query(ctx, query, args...)
+ if err != nil {
+ return model.Device{}, err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return model.Device{}, err
+ }
+ return model.Device{}, store.ErrNotFound
+ }
+ return scanDevice(rows)
+}
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scanDevice(row scanner) (model.Device, error) {
+ var device model.Device
+ err := row.Scan(&device.ID, &device.UserID, &device.Name, &device.PhoneNumber, &device.BaseURL, &device.Username, &device.Password, &device.Mode, &device.IngestToken, &device.CreatedAt)
+ return device, err
+}
+
+func (s *Store) DeleteDevice(ctx context.Context, userID, id string) error {
+ tag, err := s.pool.Exec(ctx, `DELETE FROM devices WHERE user_id = $1 AND id = $2`, userID, id)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return store.ErrNotFound
+ }
+ return nil
+}
+
+func (s *Store) InsertMessage(ctx context.Context, message model.Message) (bool, error) {
+ if message.GatewayID != "" {
+ var existing string
+ err := s.pool.QueryRow(ctx, `SELECT id FROM messages WHERE user_id = $1 AND gateway_id = $2`, message.UserID, message.GatewayID).Scan(&existing)
+ if err == nil {
+ return false, nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return false, err
+ }
+ }
+ _, err := s.pool.Exec(ctx, `
+ INSERT INTO messages (id, user_id, device_id, request_id, owner, contact, content, type, status, sim, gateway_id, failure_reason, created_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
+ message.ID, message.UserID, nullIfEmpty(message.DeviceID), message.RequestID, message.Owner, message.Contact, message.Content,
+ message.Type, message.Status, message.SIM, message.GatewayID, message.FailureReason, message.CreatedAt, message.UpdatedAt)
+ if err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+func (s *Store) ListMessages(ctx context.Context, userID string, limit int) ([]model.Message, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, user_id, COALESCE(device_id, ''), request_id, owner, contact, content, type, status, sim, gateway_id, failure_reason, created_at, updated_at
+ FROM messages WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2`, userID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []model.Message
+ for rows.Next() {
+ var message model.Message
+ if err := rows.Scan(&message.ID, &message.UserID, &message.DeviceID, &message.RequestID, &message.Owner, &message.Contact, &message.Content, &message.Type, &message.Status, &message.SIM, &message.GatewayID, &message.FailureReason, &message.CreatedAt, &message.UpdatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, message)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) UpdateMessageByGatewayID(ctx context.Context, userID, gatewayID, status, reason string) error {
+ tag, err := s.pool.Exec(ctx, `
+ UPDATE messages SET status = $3, failure_reason = $4, updated_at = $5
+ WHERE user_id = $1 AND gateway_id = $2`, userID, gatewayID, status, reason, time.Now().UTC())
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return store.ErrNotFound
+ }
+ return nil
+}
+
+func (s *Store) CreateWebhook(ctx context.Context, hook model.Webhook) error {
+ _, err := s.pool.Exec(ctx, `
+ INSERT INTO webhooks (id, user_id, url, signing_key, events, created_at)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ hook.ID, hook.UserID, hook.URL, hook.SigningKey, hook.Events, hook.CreatedAt)
+ return err
+}
+
+func (s *Store) ListWebhooks(ctx context.Context, userID string) ([]model.Webhook, error) {
+ return s.queryWebhooks(ctx, `SELECT id, user_id, url, signing_key, events, created_at FROM webhooks WHERE user_id = $1 ORDER BY created_at`, userID)
+}
+
+func (s *Store) ListWebhooksByEvent(ctx context.Context, userID, event string) ([]model.Webhook, error) {
+ return s.queryWebhooks(ctx, `
+ SELECT id, user_id, url, signing_key, events, created_at
+ FROM webhooks WHERE user_id = $1 AND $2 = ANY (events) ORDER BY created_at`, userID, event)
+}
+
+func (s *Store) queryWebhooks(ctx context.Context, query string, args ...any) ([]model.Webhook, error) {
+ rows, err := s.pool.Query(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []model.Webhook
+ for rows.Next() {
+ var hook model.Webhook
+ if err := rows.Scan(&hook.ID, &hook.UserID, &hook.URL, &hook.SigningKey, &hook.Events, &hook.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, hook)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) DeleteWebhook(ctx context.Context, userID, id string) error {
+ tag, err := s.pool.Exec(ctx, `DELETE FROM webhooks WHERE user_id = $1 AND id = $2`, userID, id)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return store.ErrNotFound
+ }
+ return nil
+}
+
+func nullIfEmpty(value string) any {
+ if value == "" {
+ return nil
+ }
+ return value
+}
+
+func mapConflict(err error) error {
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return store.ErrConflict
+ }
+ return err
+}
diff --git a/selfhosted/internal/store/pg/pg_test.go b/selfhosted/internal/store/pg/pg_test.go
new file mode 100644
index 000000000..1ae3098f8
--- /dev/null
+++ b/selfhosted/internal/store/pg/pg_test.go
@@ -0,0 +1,41 @@
+package pg
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model"
+)
+
+func TestMigrationsAndUsers(t *testing.T) {
+ databaseURL := os.Getenv("DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ store, err := Open(ctx, databaseURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(store.Close)
+
+ user := model.User{
+ ID: "user-1",
+ Email: "pg@example.com",
+ PasswordHash: "hash",
+ APIKey: "api-key-1",
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := store.CreateUser(ctx, user); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CreateUser(ctx, user); err == nil {
+ t.Fatal("expected a conflict on the second insert")
+ }
+ loaded, err := store.UserByEmail(ctx, user.Email)
+ if err != nil || loaded.APIKey != user.APIKey {
+ t.Fatalf("loaded %+v err %v", loaded, err)
+ }
+}
diff --git a/selfhosted/internal/store/store.go b/selfhosted/internal/store/store.go
new file mode 100644
index 000000000..edf4fa79b
--- /dev/null
+++ b/selfhosted/internal/store/store.go
@@ -0,0 +1,36 @@
+package store
+
+import (
+ "context"
+ "errors"
+
+ "github.com/GoDeskio/GoDesk-SMS/selfhosted/internal/model"
+)
+
+// ErrNotFound is returned when a row does not exist for this caller.
+var ErrNotFound = errors.New("not found")
+
+// ErrConflict is returned when a unique field is already taken.
+var ErrConflict = errors.New("conflict")
+
+// Store is the persistence used by the self-hosted API.
+type Store interface {
+ Ping(ctx context.Context) error
+ CreateUser(ctx context.Context, user model.User) error
+ UserByEmail(ctx context.Context, email string) (model.User, error)
+ UserByID(ctx context.Context, id string) (model.User, error)
+ UserByAPIKey(ctx context.Context, apiKey string) (model.User, error)
+ CreateDevice(ctx context.Context, device model.Device) error
+ ListDevices(ctx context.Context, userID string) ([]model.Device, error)
+ DeviceByID(ctx context.Context, userID, id string) (model.Device, error)
+ DeviceByPhone(ctx context.Context, userID, phone string) (model.Device, error)
+ DeviceByIngestToken(ctx context.Context, token string) (model.Device, error)
+ DeleteDevice(ctx context.Context, userID, id string) error
+ InsertMessage(ctx context.Context, message model.Message) (created bool, err error)
+ ListMessages(ctx context.Context, userID string, limit int) ([]model.Message, error)
+ UpdateMessageByGatewayID(ctx context.Context, userID, gatewayID, status, reason string) error
+ CreateWebhook(ctx context.Context, hook model.Webhook) error
+ ListWebhooks(ctx context.Context, userID string) ([]model.Webhook, error)
+ ListWebhooksByEvent(ctx context.Context, userID, event string) ([]model.Webhook, error)
+ DeleteWebhook(ctx context.Context, userID, id string) error
+}
diff --git a/selfhosted/internal/urls/urls.go b/selfhosted/internal/urls/urls.go
new file mode 100644
index 000000000..fb78511ef
--- /dev/null
+++ b/selfhosted/internal/urls/urls.go
@@ -0,0 +1,48 @@
+package urls
+
+import (
+ "errors"
+ "net"
+ "net/url"
+ "strings"
+)
+
+// NormalizeBaseURL checks a device or webhook URL.
+// Paths are allowed only when allowPath is true.
+// Link-local and unspecified addresses are rejected. LAN addresses are allowed
+// because SMS Gateway local mode is reached on the operator's network.
+func NormalizeBaseURL(raw string, allowPath bool) (string, error) {
+ raw = strings.TrimSpace(raw)
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Host == "" {
+ return "", errors.New("URL is invalid")
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return "", errors.New("URL must be http or https")
+ }
+ if parsed.User != nil {
+ return "", errors.New("URL must not include user info")
+ }
+ host := parsed.Hostname()
+ if host == "" {
+ return "", errors.New("URL is missing a host")
+ }
+ if strings.EqualFold(host, "metadata.google.internal") {
+ return "", errors.New("URL host is not allowed")
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if ip.IsUnspecified() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() {
+ return "", errors.New("URL address is not allowed")
+ }
+ }
+ if !allowPath {
+ if parsed.Path != "" && parsed.Path != "/" {
+ return "", errors.New("device URL must be an origin without a path")
+ }
+ parsed.Path = ""
+ }
+ if parsed.RawQuery != "" || parsed.Fragment != "" {
+ return "", errors.New("URL must not include a query or fragment")
+ }
+ return strings.TrimRight(parsed.String(), "/"), nil
+}
diff --git a/selfhosted/internal/urls/urls_test.go b/selfhosted/internal/urls/urls_test.go
new file mode 100644
index 000000000..169fb7708
--- /dev/null
+++ b/selfhosted/internal/urls/urls_test.go
@@ -0,0 +1,23 @@
+package urls
+
+import "testing"
+
+func TestNormalizeBaseURL(t *testing.T) {
+ got, err := NormalizeBaseURL("https://phone.example:8080/", false)
+ if err != nil || got != "https://phone.example:8080" {
+ t.Fatalf("got %q err %v", got, err)
+ }
+ if _, err := NormalizeBaseURL("http://10.1.2.3:8080", false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NormalizeBaseURL("http://169.254.169.254/", false); err == nil {
+ t.Fatal("expected link-local metadata address to be rejected")
+ }
+ if _, err := NormalizeBaseURL("https://phone.example/api", false); err == nil {
+ t.Fatal("expected a path to be rejected for device origins")
+ }
+ got, err = NormalizeBaseURL("https://hooks.example/sms", true)
+ if err != nil || got != "https://hooks.example/sms" {
+ t.Fatalf("webhook url %q err %v", got, err)
+ }
+}