Rebrand CommGate to gsmnode and adopt new design system

Rename the whole project from CommGate to gsmnode and re-skin every
surface onto the new signal-green + ink design system (Space Grotesk /
IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark,
lowercase gsm+node wordmark).

- Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme,
  new logo/favicon assets; both builds verified.
- Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive
  launcher icons; rename Android applicationId/package to app.gsmnode.phone;
  bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified.
- API Server (Go): panel routes, User-Agent, health service id, branding.
- Home Assistant Plugin: rename integration domain sms_gateway to gsmnode
  (folder, DOMAIN, services, entity ids, classes); py_compile verified.
- Update all READMEs; add .gitignore (excludes Design/, build artifacts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# API Server configuration
# Copy to .env and adjust. The server also reads plain environment variables.
# Address the API Server listens on
API_ADDR=:8080
# PocketBase base URL (no trailing slash)
POCKETBASE_URL=http://10.2.1.10:8028
# PocketBase superuser credentials. The API Server is the ONLY thing that talks
# to PocketBase, so it authenticates as a superuser and enforces ownership in
# application logic. Lock all collection API rules to superuser-only.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me
# Secret used to sign client JWTs (use a long random string in production)
JWT_SECRET=dev-insecure-change-me-please
# Access token lifetime (Go duration: 30m, 24h, 168h ...)
JWT_ACCESS_TTL=24h
# How long a message/call may stay unprocessed before it's marked Failed.
# A device must pull it (Pending) and report a result (Processed) within this
# window, else the background sweeper fails it.
MESSAGE_TTL=5m
# CORS allowed origins for the Web App (comma separated, or * for any)
CORS_ALLOW_ORIGINS=*
+5
View File
@@ -0,0 +1,5 @@
.env
/server
/server.exe
/tmp/
*.log
+154
View File
@@ -0,0 +1,154 @@
# gsmnode — API Server
Go service that is the **single trusted entry point** in front of PocketBase.
The Web App and Phone App talk only to this server; it performs all PocketBase
access as a superuser and enforces ownership in application logic.
```
Web App ─┐
├─► API Server (:8080) ─► PocketBase (10.2.1.10:8028)
Phone App ─┘
```
The server root (`GET /`) serves a gsmnode-branded **web panel**: a live health
readout plus a quick reference of both API audiences. Open
http://localhost:8080/ in a browser to check the server at a glance.
The panel is a **Vue 3 + Tailwind v4** app in [`panel/`](panel/), built into
`internal/api/dist` and embedded into the Go binary at compile time:
```powershell
cd panel
npm install
npm run build # outputs to ../internal/api/dist
cd ..; go build -o api-server.exe ./cmd/server # embeds the fresh dist
```
For panel development with hot reload (proxies `/api` to a running server on
`:8080`): `cd panel; npm run dev` → http://localhost:5174.
## Requirements
- Go 1.26+ (`go version`)
- A reachable PocketBase v0.23+ instance and its **superuser** credentials
- Node 18+ (only for the setup scripts)
## 1. Configure
```powershell
Copy-Item .env.example .env
# edit .env: set PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, and a strong JWT_SECRET
```
| Variable | Purpose | Default |
|---|---|---|
| `API_ADDR` | Listen address | `:8080` |
| `POCKETBASE_URL` | PocketBase base URL | `http://10.2.1.10:8028` |
| `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` | PocketBase superuser login | — (required) |
| `JWT_SECRET` | Signs client JWTs | dev placeholder |
| `JWT_ACCESS_TTL` | Access-token lifetime | `24h` |
| `CORS_ALLOW_ORIGINS` | Comma list, or `*` | `*` |
## 2. Set up PocketBase collections
Creates/updates `devices`, `messages`, `inbox`, `webhooks` (the default `users`
auth collection is reused). Idempotent.
```powershell
$env:POCKETBASE_URL="http://10.2.1.10:8028"
$env:PB_ADMIN_EMAIL="admin@example.com"
$env:PB_ADMIN_PASSWORD="your-password"
node scripts/setup-pocketbase.mjs
```
Create a login user:
```powershell
node scripts/create-user.mjs user@example.com "user-password" "Display Name"
```
## 3. Run
```powershell
./scripts/Run-ApiServer.ps1
# or: go run ./cmd/server
```
Health check: `GET http://localhost:8080/api/health`.
## API
### Authentication scheme
- **Client API** (`/api/...`): `Authorization: Bearer <JWT>` from `/api/auth/login`.
Used by the Web App and integrators.
- **Mobile API** (`/api/mobile/...`): `Authorization: Bearer <device_token>`
returned by device registration. Used by the Phone App.
### Client / 3rd-party endpoints
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/health` | Readiness probe (public) |
| `POST` | `/api/auth/login` | `{email, password}` → JWT + user |
| `POST` | `/api/auth/refresh` | New JWT (auth) |
| `GET` | `/api/auth/me` | Current user |
| `GET` | `/api/devices` | List your devices |
| `DELETE` | `/api/devices/{id}` | Remove a device |
| `POST` | `/api/messages` | Enqueue SMS `{phone_numbers[], text_message, device_id?, sim_number?, schedule_at?}` |
| `POST` | `/api/calls` | Enqueue a phone call `{phone_number, device_id?}` |
| `GET` | `/api/messages` | List messages (`?status=&device_id=&type=&page=&per_page=`) |
| `GET` | `/api/messages/{id}` | Message state |
| `GET` | `/api/inbox` | Received SMS |
| `GET` | `/api/webhooks` | List webhooks |
| `POST` | `/api/webhooks` | Register `{event, url, device_id?}` |
| `DELETE` | `/api/webhooks/{id}` | Delete webhook |
### Mobile / device endpoints
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/mobile/v1/device` | Register device (auth: **user JWT**) → returns `auth_token` |
| `POST` | `/api/mobile/v1/ping` | Heartbeat (auth: device token) |
| `GET` | `/api/mobile/v1/messages` | Pull pending messages; marks them `Processed` |
| `PATCH` | `/api/mobile/v1/messages/{id}` | Report `{status, error?}` (`Sent`/`Delivered`/`Failed`) |
| `POST` | `/api/mobile/v1/inbox` | Report received SMS `{phone_number, message, received_at?}` |
### Message lifecycle
`Pending` → (device pulls) `Processed``Sent``Delivered`, or `Failed`.
Calls are stored as messages with `type: "call"` (SMS is `type: "sms"`) and flow
through the same pull/report pipeline. The device dials the number natively and
reports `Sent` (a call has no delivery report) or `Failed`.
### Webhook events
`sms:received`, `sms:sent`, `sms:delivered`, `sms:failed`. Delivered as
`POST {event, device_id, payload, created_at}` to the registered URL.
## Quick smoke test
```powershell
# log in
$login = curl -s -X POST http://localhost:8080/api/auth/login `
-H "Content-Type: application/json" `
-d '{"email":"user@example.com","password":"user-password"}' | ConvertFrom-Json
$token = $login.access_token
# register a device (as the phone app would)
curl -s -X POST http://localhost:8080/api/mobile/v1/device `
-H "Authorization: Bearer $token" -H "Content-Type: application/json" `
-d '{"device_id":"test-1","name":"Test Phone","platform":"android"}'
```
## Project layout
```
cmd/server/main.go entry point, wiring, graceful shutdown
internal/config env/.env configuration
internal/pb PocketBase REST client (superuser)
internal/auth JWT issue/verify, device token generation
internal/api router, middleware, handlers
scripts/ PocketBase setup + run helpers
```
+64
View File
@@ -0,0 +1,64 @@
// Command server runs the gsmnode API Server. It is the single trusted
// entry point in front of PocketBase: the Web App and Phone App talk only to
// this service, which in turn performs all PocketBase access.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"smsgateway/apiserver/internal/api"
"smsgateway/apiserver/internal/auth"
"smsgateway/apiserver/internal/config"
"smsgateway/apiserver/internal/pb"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[api] ")
cfg := config.Load()
client := pb.New(cfg.PocketBaseURL, cfg.PBAdminEmail, cfg.PBAdminPass)
jwtMgr := auth.NewManager(cfg.JWTSecret, cfg.JWTAccessTTL)
srv := api.New(cfg, client, jwtMgr)
// Background worker that fails messages no device processed in time.
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
srv.StartExpiryWorker(workerCtx)
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("listening on %s (PocketBase: %s)", cfg.Addr, cfg.PocketBaseURL)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("stopped")
}
+5
View File
@@ -0,0 +1,5 @@
module smsgateway/apiserver
go 1.26
require github.com/golang-jwt/jwt/v5 v5.2.1
+2
View File
@@ -0,0 +1,2 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"net/http"
"strings"
)
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type userDTO struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresAt string `json:"expires_at"`
User userDTO `json:"user"`
}
// handleLogin authenticates a user against PocketBase and issues a client JWT.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.Email = strings.TrimSpace(req.Email)
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
res, err := s.pb.AuthWithPassword(r.Context(), colUsers, req.Email, req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
user := recordToUser(res.Record)
token, exp, err := s.jwt.Issue(user.ID, user.Email, user.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
writeJSON(w, http.StatusOK, tokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresAt: exp.UTC().Format("2006-01-02T15:04:05Z07:00"),
User: user,
})
}
// handleRefresh issues a fresh token for an already-authenticated user.
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
id, email := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colUsers, id)
if err != nil {
writeUpstreamError(w, err)
return
}
user := recordToUser(rec)
if user.Email == "" {
user.Email = email
}
token, exp, err := s.jwt.Issue(user.ID, user.Email, user.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
writeJSON(w, http.StatusOK, tokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresAt: exp.UTC().Format("2006-01-02T15:04:05Z07:00"),
User: user,
})
}
// handleMe returns the current user's profile.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
id, _ := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colUsers, id)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusOK, recordToUser(rec))
}
func recordToUser(rec map[string]any) userDTO {
return userDTO{
ID: asString(rec["id"]),
Email: asString(rec["email"]),
Name: asString(rec["name"]),
}
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"strings"
"smsgateway/apiserver/internal/pb"
)
type enqueueCallRequest struct {
PhoneNumber string `json:"phone_number"`
DeviceID string `json:"device_id"`
}
// handleEnqueueCall queues an outbound phone call for one of the user's devices.
// A call is stored as a message with type "call"; the device pulls it through
// the same mobile pipeline as SMS and places the call natively.
func (s *Server) handleEnqueueCall(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req enqueueCallRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
phone := strings.TrimSpace(req.PhoneNumber)
if phone == "" {
writeError(w, http.StatusBadRequest, "phone_number is required")
return
}
deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID)
if err != nil {
writeUpstreamError(w, err)
return
}
if deviceRecID == "" {
writeError(w, http.StatusBadRequest, "no device available; register a device first")
return
}
rec, err := s.pb.Create(r.Context(), colMessages, pb.Record{
"phone_numbers": []string{phone},
"type": msgTypeCall,
"device": deviceRecID,
"owner": uid,
"status": statusPending,
})
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusAccepted, recordToMessage(rec))
}
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/auth"
"smsgateway/apiserver/internal/pb"
)
type deviceDTO struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Name string `json:"name"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Status string `json:"status"`
LastSeenAt string `json:"last_seen_at"`
}
// onlineWindow is how recently a device must have pinged to count as online.
// The phone pings every ~60s, so this tolerates a couple of missed pings.
const onlineWindow = 3 * time.Minute
func recordToDevice(rec pb.Record) deviceDTO {
lastSeen := asString(rec["last_seen_at"])
status := "offline"
if deviceOnline(lastSeen) {
status = "online"
}
return deviceDTO{
ID: asString(rec["id"]),
DeviceID: asString(rec["device_id"]),
Name: asString(rec["name"]),
Platform: asString(rec["platform"]),
AppVersion: asString(rec["app_version"]),
Status: status,
LastSeenAt: lastSeen,
}
}
// deviceOnline reports whether last_seen_at is within the online window. It
// accepts the PocketBase datetime format as well as RFC3339.
func deviceOnline(lastSeenAt string) bool {
if lastSeenAt == "" {
return false
}
for _, layout := range []string{
"2006-01-02 15:04:05.999Z07:00",
"2006-01-02 15:04:05.999Z",
time.RFC3339,
} {
if t, err := time.Parse(layout, lastSeenAt); err == nil {
return time.Since(t) < onlineWindow
}
}
return false
}
// handleListDevices returns the authenticated user's devices.
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
res, err := s.pb.List(r.Context(), colDevices, pb.ListOptions{
Filter: "owner = " + pbQuote(uid),
Sort: "-created",
PerPage: 200,
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]deviceDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToDevice(rec))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems})
}
// handleDeleteDevice removes a device owned by the user.
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
id := r.PathValue("id")
rec, err := s.pb.GetOne(r.Context(), colDevices, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your device")
return
}
if err := s.pb.Delete(r.Context(), colDevices, id); err != nil {
writeUpstreamError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type registerDeviceRequest struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
PushToken string `json:"push_token"`
}
type registerDeviceResponse struct {
deviceDTO
AuthToken string `json:"auth_token"`
}
// handleRegisterDevice registers (or re-registers) a mobile device for the
// authenticated user and returns an opaque device token. Re-registering with
// the same device_id rotates the token and updates metadata.
func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req registerDeviceRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.DeviceID = strings.TrimSpace(req.DeviceID)
if req.DeviceID == "" {
writeError(w, http.StatusBadRequest, "device_id is required")
return
}
if req.Platform == "" {
req.Platform = "android"
}
token, err := auth.NewDeviceToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not generate token")
return
}
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
fields := pb.Record{
"device_id": req.DeviceID,
"name": req.Name,
"platform": req.Platform,
"app_version": req.AppVersion,
"push_token": req.PushToken,
"status": "online",
"last_seen_at": now,
"auth_token": token,
"owner": uid,
}
// Upsert by (owner, device_id).
existing, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid)+" && device_id = "+pbQuote(req.DeviceID), "")
if err != nil {
writeUpstreamError(w, err)
return
}
var rec pb.Record
if existing != nil {
rec, err = s.pb.Update(r.Context(), colDevices, asString(existing["id"]), fields)
} else {
rec, err = s.pb.Create(r.Context(), colDevices, fields)
}
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusOK, registerDeviceResponse{
deviceDTO: recordToDevice(rec),
AuthToken: token,
})
}
// handlePing updates a device heartbeat (last_seen_at + online status).
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
_, err := s.pb.Update(r.Context(), colDevices, asString(device["id"]), pb.Record{
"status": "online",
"last_seen_at": now,
})
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#2E9E6B"></rect>
<g transform="translate(6.5 6.5) scale(0.475)">
<line x1="9" y1="15" x2="28" y2="15" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M25 10.5 L31 15 L25 19.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="12" y1="25" x2="31" y2="25" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M15 20.5 L9 25 L15 29.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2E9E6B" />
<title>gsmnode · API Server</title>
<script type="module" crossorigin src="/assets/index-Hipq9mYq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FjfYnLdU.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
package api
import "net/http"
// handleHealth reports server readiness.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "gsmnode-api",
})
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/pb"
)
type inboxDTO struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
PhoneNumber string `json:"phone_number"`
Message string `json:"message"`
ReceivedAt string `json:"received_at"`
}
func recordToInbox(rec pb.Record) inboxDTO {
return inboxDTO{
ID: asString(rec["id"]),
DeviceID: asString(rec["device"]),
PhoneNumber: asString(rec["phone_number"]),
Message: asString(rec["message"]),
ReceivedAt: asString(rec["received_at"]),
}
}
// handleListInbox lists received SMS for the authenticated user.
func (s *Server) handleListInbox(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
res, err := s.pb.List(r.Context(), colInbox, pb.ListOptions{
Filter: "owner = " + pbQuote(uid),
Sort: "-received_at",
Page: queryInt(r, "page", 1),
PerPage: clampPerPage(queryInt(r, "per_page", 50)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]inboxDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToInbox(rec))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "total": res.TotalItems, "page": res.Page,
})
}
type receiveSMSRequest struct {
PhoneNumber string `json:"phone_number"`
Message string `json:"message"`
ReceivedAt string `json:"received_at"`
}
// handleReceiveSMS records an incoming SMS reported by a device and fires
// sms:received webhooks.
func (s *Server) handleReceiveSMS(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
var req receiveSMSRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.PhoneNumber) == "" {
writeError(w, http.StatusBadRequest, "phone_number is required")
return
}
receivedAt := req.ReceivedAt
if receivedAt == "" {
receivedAt = time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
}
rec, err := s.pb.Create(r.Context(), colInbox, pb.Record{
"device": asString(device["id"]),
"owner": asString(device["owner"]),
"phone_number": req.PhoneNumber,
"message": req.Message,
"received_at": receivedAt,
})
if err != nil {
writeUpstreamError(w, err)
return
}
s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), "sms:received", map[string]any{
"inbox_id": asString(rec["id"]),
"phone_number": req.PhoneNumber,
"message": req.Message,
"received_at": receivedAt,
})
writeJSON(w, http.StatusCreated, recordToInbox(rec))
}
+314
View File
@@ -0,0 +1,314 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/pb"
)
// Message lifecycle states.
const (
statusPending = "Pending" // enqueued, not yet picked up by a device
statusProcessed = "Processed" // delivered to a device for sending
statusSent = "Sent" // device handed it to the SIM/radio
statusDelivered = "Delivered" // delivery report received
statusFailed = "Failed" // sending failed
)
var validReportStates = map[string]bool{
statusSent: true, statusDelivered: true, statusFailed: true, statusProcessed: true,
}
// Message kinds.
const (
msgTypeSMS = "sms"
msgTypeCall = "call"
)
type messageDTO struct {
ID string `json:"id"`
Type string `json:"type"`
PhoneNumbers []string `json:"phone_numbers"`
TextMessage string `json:"text_message"`
DeviceID string `json:"device_id,omitempty"`
SimNumber int `json:"sim_number,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
ScheduleAt string `json:"schedule_at,omitempty"`
CreatedAt string `json:"created_at"`
}
func recordToMessage(rec pb.Record) messageDTO {
msgType := asString(rec["type"])
if msgType == "" {
msgType = msgTypeSMS
}
return messageDTO{
ID: asString(rec["id"]),
Type: msgType,
PhoneNumbers: asStringSlice(rec["phone_numbers"]),
TextMessage: asString(rec["text_message"]),
DeviceID: asString(rec["device"]),
SimNumber: asInt(rec["sim_number"]),
Status: asString(rec["status"]),
Error: asString(rec["error"]),
ScheduleAt: asString(rec["schedule_at"]),
CreatedAt: asString(rec["created"]),
}
}
type enqueueRequest struct {
PhoneNumbers []string `json:"phone_numbers"`
TextMessage string `json:"text_message"`
DeviceID string `json:"device_id"`
SimNumber int `json:"sim_number"`
ScheduleAt string `json:"schedule_at"`
}
// handleEnqueueMessage queues an outbound SMS for one of the user's devices.
func (s *Server) handleEnqueueMessage(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req enqueueRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
phones := cleanPhones(req.PhoneNumbers)
if len(phones) == 0 {
writeError(w, http.StatusBadRequest, "at least one phone number is required")
return
}
if strings.TrimSpace(req.TextMessage) == "" {
writeError(w, http.StatusBadRequest, "text_message is required")
return
}
// Resolve the target device: explicit device_id, else the user's first
// online device.
deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID)
if err != nil {
writeUpstreamError(w, err)
return
}
if deviceRecID == "" {
writeError(w, http.StatusBadRequest, "no device available; register a device first")
return
}
fields := pb.Record{
"phone_numbers": phones,
"text_message": req.TextMessage,
"type": msgTypeSMS,
"device": deviceRecID,
"owner": uid,
"status": statusPending,
}
if req.SimNumber > 0 {
fields["sim_number"] = req.SimNumber
}
if req.ScheduleAt != "" {
fields["schedule_at"] = req.ScheduleAt
}
rec, err := s.pb.Create(r.Context(), colMessages, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusAccepted, recordToMessage(rec))
}
// resolveDevice returns the PocketBase device record id to target. requested may
// be either the internal record id or the client-facing device_id.
func (s *Server) resolveDevice(r *http.Request, uid, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" {
dev, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid)+" && (id = "+pbQuote(requested)+" || device_id = "+pbQuote(requested)+")", "")
if err != nil {
return "", err
}
if dev == nil {
return "", nil
}
return asString(dev["id"]), nil
}
dev, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid), "-last_seen_at")
if err != nil || dev == nil {
return "", err
}
return asString(dev["id"]), nil
}
// handleListMessages lists the user's messages with optional filters.
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
filters := []string{"owner = " + pbQuote(uid)}
if st := r.URL.Query().Get("status"); st != "" {
filters = append(filters, "status = "+pbQuote(st))
}
if dev := r.URL.Query().Get("device_id"); dev != "" {
filters = append(filters, "device = "+pbQuote(dev))
}
if t := r.URL.Query().Get("type"); t != "" {
filters = append(filters, "type = "+pbQuote(t))
}
res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{
Filter: strings.Join(filters, " && "),
Sort: "-created",
Page: queryInt(r, "page", 1),
PerPage: clampPerPage(queryInt(r, "per_page", 50)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]messageDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToMessage(rec))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "total": res.TotalItems, "page": res.Page,
})
}
// handleGetMessage returns a single message's state.
func (s *Server) handleGetMessage(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colMessages, r.PathValue("id"))
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your message")
return
}
writeJSON(w, http.StatusOK, recordToMessage(rec))
}
// handlePullMessages returns pending messages for the calling device and marks
// them Processed so they are not handed out twice.
func (s *Server) handlePullMessages(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
deviceID := asString(device["id"])
res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{
Filter: "device = " + pbQuote(deviceID) + " && status = " + pbQuote(statusPending),
Sort: "created",
PerPage: clampPerPage(queryInt(r, "limit", 20)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]messageDTO, 0, len(res.Items))
for _, rec := range res.Items {
id := asString(rec["id"])
updated, uerr := s.pb.Update(r.Context(), colMessages, id, pb.Record{"status": statusProcessed})
if uerr != nil {
// Skip a message we couldn't claim rather than failing the pull.
continue
}
out = append(out, recordToMessage(updated))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
type reportRequest struct {
Status string `json:"status"`
Error string `json:"error"`
}
// handleReportMessage records a delivery state reported by the device and fires
// the matching webhooks.
func (s *Server) handleReportMessage(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
id := r.PathValue("id")
var req reportRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validReportStates[req.Status] {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
rec, err := s.pb.GetOne(r.Context(), colMessages, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["device"]) != asString(device["id"]) {
writeError(w, http.StatusForbidden, "message not assigned to this device")
return
}
fields := pb.Record{"status": req.Status, "error": req.Error}
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
switch req.Status {
case statusSent:
fields["sent_at"] = now
case statusDelivered:
fields["delivered_at"] = now
}
updated, err := s.pb.Update(r.Context(), colMessages, id, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
// Dispatch webhooks for terminal/notable states.
if event := eventForStatus(req.Status); event != "" {
s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), event, map[string]any{
"message_id": id,
"phone_numbers": asStringSlice(updated["phone_numbers"]),
"status": req.Status,
"error": req.Error,
})
}
writeJSON(w, http.StatusOK, recordToMessage(updated))
}
func eventForStatus(status string) string {
switch status {
case statusSent:
return "sms:sent"
case statusDelivered:
return "sms:delivered"
case statusFailed:
return "sms:failed"
default:
return ""
}
}
func cleanPhones(in []string) []string {
out := make([]string, 0, len(in))
for _, p := range in {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func clampPerPage(n int) int {
if n <= 0 {
return 50
}
if n > 200 {
return 200
}
return n
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"embed"
"io/fs"
"net/http"
)
// The gsmnode web panel: a Vue 3 + Tailwind app (source in panel/, built
// with `npm run build` into dist/) embedded at compile time and served at
// the server root.
//
//go:embed all:dist
var panelFS embed.FS
// panelHandler serves the built panel assets.
func panelHandler() http.Handler {
sub, err := fs.Sub(panelFS, "dist")
if err != nil {
panic(err) // embedded dist is malformed; unreachable in a valid build
}
return http.FileServerFS(sub)
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"smsgateway/apiserver/internal/pb"
)
// writeJSON writes v as a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v == nil {
return
}
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON: %v", err)
}
}
// errorBody is the standard error envelope.
type errorBody struct {
Error string `json:"error"`
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
// writeUpstreamError maps a PocketBase error onto an appropriate HTTP status.
func writeUpstreamError(w http.ResponseWriter, err error) {
var apiErr *pb.APIError
if errors.As(err, &apiErr) {
switch apiErr.Status {
case http.StatusNotFound:
writeError(w, http.StatusNotFound, "not found")
case http.StatusBadRequest:
writeError(w, http.StatusBadRequest, apiErr.Message)
case http.StatusForbidden:
writeError(w, http.StatusForbidden, "forbidden")
default:
log.Printf("upstream error: %v (%s)", apiErr, apiErr.Body)
writeError(w, http.StatusBadGateway, "upstream error")
}
return
}
log.Printf("internal error: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
// decodeJSON reads and decodes a JSON request body into v.
func decodeJSON(r *http.Request, v any) error {
defer io.Copy(io.Discard, r.Body)
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package api
import (
"context"
"log"
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/auth"
"smsgateway/apiserver/internal/config"
"smsgateway/apiserver/internal/pb"
)
// Collection names in PocketBase.
const (
colUsers = "users"
colDevices = "devices"
colMessages = "messages"
colInbox = "inbox"
colWebhooks = "webhooks"
)
// Server wires together the HTTP handlers and their dependencies.
type Server struct {
cfg config.Config
pb *pb.Client
jwt *auth.Manager
}
// New constructs a Server.
func New(cfg config.Config, client *pb.Client, jwt *auth.Manager) *Server {
return &Server{cfg: cfg, pb: client, jwt: jwt}
}
// Handler returns the root HTTP handler with all routes registered.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit
// panel paths are routed to it so unknown /api/* paths still 404 as JSON.
panel := panelHandler()
mux.Handle("GET /{$}", panel)
mux.Handle("GET /assets/", panel)
mux.Handle("GET /favicon.svg", panel)
mux.Handle("GET /favicon-32.png", panel)
mux.Handle("GET /gsmnode-horizontal.png", panel)
mux.Handle("GET /gsmnode-horizontal-white.png", panel)
// Health (public)
mux.HandleFunc("GET /api/health", s.handleHealth)
// Client / 3rd-party API (JWT auth) — used by the Web App and integrators.
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/refresh", s.requireUser(s.handleRefresh))
mux.HandleFunc("GET /api/auth/me", s.requireUser(s.handleMe))
mux.HandleFunc("GET /api/devices", s.requireUser(s.handleListDevices))
mux.HandleFunc("DELETE /api/devices/{id}", s.requireUser(s.handleDeleteDevice))
mux.HandleFunc("GET /api/messages", s.requireUser(s.handleListMessages))
mux.HandleFunc("POST /api/messages", s.requireUser(s.handleEnqueueMessage))
mux.HandleFunc("GET /api/messages/{id}", s.requireUser(s.handleGetMessage))
mux.HandleFunc("POST /api/calls", s.requireUser(s.handleEnqueueCall))
mux.HandleFunc("GET /api/inbox", s.requireUser(s.handleListInbox))
mux.HandleFunc("GET /api/webhooks", s.requireUser(s.handleListWebhooks))
mux.HandleFunc("POST /api/webhooks", s.requireUser(s.handleCreateWebhook))
mux.HandleFunc("DELETE /api/webhooks/{id}", s.requireUser(s.handleDeleteWebhook))
// Mobile / device API — the phone app registers, pulls work, and reports.
// Registration is authenticated with the user's JWT; everything else with
// the opaque device token returned at registration.
mux.HandleFunc("POST /api/mobile/v1/device", s.requireUser(s.handleRegisterDevice))
mux.HandleFunc("POST /api/mobile/v1/ping", s.requireDevice(s.handlePing))
mux.HandleFunc("GET /api/mobile/v1/messages", s.requireDevice(s.handlePullMessages))
mux.HandleFunc("PATCH /api/mobile/v1/messages/{id}", s.requireDevice(s.handleReportMessage))
mux.HandleFunc("POST /api/mobile/v1/inbox", s.requireDevice(s.handleReceiveSMS))
return s.withMiddleware(mux)
}
// withMiddleware applies CORS, request logging, and panic recovery globally.
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverer(s.cors(s.logger(next)))
}
func (s *Server) logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.cfg.AllowOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
if wildcard {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// statusWriter captures the response status code for logging.
type statusWriter struct {
http.ResponseWriter
status int
wrote bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.wrote {
w.status = code
w.wrote = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
w.wrote = true
return w.ResponseWriter.Write(b)
}
// --- auth context ---
type ctxKey int
const (
ctxUser ctxKey = iota
ctxDevice
)
// userFromCtx returns the authenticated user id/email from the request context.
func userFromCtx(ctx context.Context) (id, email string) {
if c, ok := ctx.Value(ctxUser).(*auth.Claims); ok {
return c.Subject, c.Email
}
return "", ""
}
// deviceFromCtx returns the authenticated device record from the request context.
func deviceFromCtx(ctx context.Context) pb.Record {
if d, ok := ctx.Value(ctxDevice).(pb.Record); ok {
return d
}
return nil
}
// requireUser wraps a handler to require a valid client JWT.
func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
claims, err := s.jwt.Verify(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), ctxUser, claims)
next(w, r.WithContext(ctx))
}
}
// requireDevice wraps a handler to require a valid device token. The matching
// device record is loaded and attached to the request context.
func (s *Server) requireDevice(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing device token")
return
}
device, err := s.pb.FindFirst(r.Context(), colDevices,
"auth_token = "+pbQuote(token), "")
if err != nil {
writeUpstreamError(w, err)
return
}
if device == nil {
writeError(w, http.StatusUnauthorized, "unknown device token")
return
}
ctx := context.WithValue(r.Context(), ctxDevice, device)
next(w, r.WithContext(ctx))
}
}
// bearerToken extracts a token from the Authorization header (with or without
// the "Bearer " prefix).
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after)
}
return strings.TrimSpace(h)
}
// pbQuote safely quotes a string value for a PocketBase filter expression.
func pbQuote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"context"
"log"
"time"
"smsgateway/apiserver/internal/pb"
)
// sweepInterval is how often stale messages are checked.
const sweepInterval = 30 * time.Second
// StartExpiryWorker launches a background loop that fails messages/calls which
// no device picked up or reported on within the configured MessageTTL. Without
// it, a message targeting an offline device would stay Pending forever.
func (s *Server) StartExpiryWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
// Run once at startup so stale items clear promptly.
s.expireStaleMessages(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.expireStaleMessages(ctx)
}
}
}()
}
// expireStaleMessages marks Pending/Processed messages older than MessageTTL as
// Failed. "Pending" means no online device pulled it; "Processed" means a device
// pulled it but never reported a terminal state (e.g. it crashed).
func (s *Server) expireStaleMessages(ctx context.Context) {
cutoff := time.Now().UTC().Add(-s.cfg.MessageTTL).Format("2006-01-02 15:04:05.000Z")
filter := `(status = "` + statusPending + `" || status = "` + statusProcessed +
`") && updated < "` + cutoff + `"`
res, err := s.pb.List(ctx, colMessages, pb.ListOptions{Filter: filter, PerPage: 200})
if err != nil {
log.Printf("expiry sweep failed: %v", err)
return
}
expired := 0
for _, rec := range res.Items {
// Don't expire messages scheduled for the future.
if asString(rec["schedule_at"]) != "" {
continue
}
id := asString(rec["id"])
const reason = "expired: no device processed the message within the timeout"
if _, err := s.pb.Update(ctx, colMessages, id, pb.Record{
"status": statusFailed,
"error": reason,
}); err != nil {
log.Printf("expire message %s: %v", id, err)
continue
}
expired++
s.dispatchWebhooks(asString(rec["owner"]), asString(rec["device"]), "sms:failed", map[string]any{
"message_id": id,
"type": asString(rec["type"]),
"status": statusFailed,
"error": reason,
})
}
if expired > 0 {
log.Printf("expired %d stale message(s) older than %s", expired, s.cfg.MessageTTL)
}
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"net/http"
"strconv"
)
// asString coerces an arbitrary JSON value to a string.
func asString(v any) string {
switch t := v.(type) {
case string:
return t
case nil:
return ""
default:
return ""
}
}
// asInt coerces a JSON number/string to an int.
func asInt(v any) int {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case string:
if n, err := strconv.Atoi(t); err == nil {
return n
}
}
return 0
}
// asStringSlice coerces a JSON array (or single string) to []string.
func asStringSlice(v any) []string {
switch t := v.(type) {
case []any:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
case []string:
return t
case string:
if t == "" {
return nil
}
return []string{t}
}
return nil
}
// queryInt parses a query parameter as an int, falling back to def.
func queryInt(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
+170
View File
@@ -0,0 +1,170 @@
package api
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"time"
"smsgateway/apiserver/internal/pb"
)
var validWebhookEvents = map[string]bool{
"sms:received": true,
"sms:sent": true,
"sms:delivered": true,
"sms:failed": true,
}
type webhookDTO struct {
ID string `json:"id"`
Event string `json:"event"`
URL string `json:"url"`
DeviceID string `json:"device_id,omitempty"`
}
func recordToWebhook(rec pb.Record) webhookDTO {
return webhookDTO{
ID: asString(rec["id"]),
Event: asString(rec["event"]),
URL: asString(rec["url"]),
DeviceID: asString(rec["device"]),
}
}
// handleListWebhooks lists the user's registered webhooks.
func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
res, err := s.pb.List(r.Context(), colWebhooks, pb.ListOptions{
Filter: "owner = " + pbQuote(uid),
Sort: "-created",
PerPage: 200,
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]webhookDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToWebhook(rec))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems})
}
type createWebhookRequest struct {
Event string `json:"event"`
URL string `json:"url"`
DeviceID string `json:"device_id"`
}
// handleCreateWebhook registers a webhook for the user.
func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req createWebhookRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validWebhookEvents[req.Event] {
writeError(w, http.StatusBadRequest, "invalid event")
return
}
if req.URL == "" {
writeError(w, http.StatusBadRequest, "url is required")
return
}
fields := pb.Record{"owner": uid, "event": req.Event, "url": req.URL}
if req.DeviceID != "" {
fields["device"] = req.DeviceID
}
rec, err := s.pb.Create(r.Context(), colWebhooks, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusCreated, recordToWebhook(rec))
}
// handleDeleteWebhook removes a webhook owned by the user.
func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
id := r.PathValue("id")
rec, err := s.pb.GetOne(r.Context(), colWebhooks, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your webhook")
return
}
if err := s.pb.Delete(r.Context(), colWebhooks, id); err != nil {
writeUpstreamError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// dispatchWebhooks delivers an event to every matching webhook for the owner.
// Delivery is best-effort and runs in the background so it never blocks the
// device/client request.
func (s *Server) dispatchWebhooks(owner, deviceID, event string, payload map[string]any) {
if owner == "" {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := s.pb.List(ctx, colWebhooks, pb.ListOptions{
Filter: "owner = " + pbQuote(owner) + " && event = " + pbQuote(event),
PerPage: 200,
})
if err != nil {
log.Printf("webhook lookup failed for %s/%s: %v", owner, event, err)
return
}
body := map[string]any{
"event": event,
"device_id": deviceID,
"payload": payload,
"created_at": time.Now().UTC().Format(time.RFC3339),
}
raw, _ := json.Marshal(body)
for _, hook := range res.Items {
// A webhook scoped to a specific device only fires for that device.
if dev := asString(hook["device"]); dev != "" && dev != deviceID {
continue
}
deliverWebhook(ctx, asString(hook["url"]), raw)
}
}()
}
func deliverWebhook(ctx context.Context, url string, body []byte) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
log.Printf("webhook build request %s: %v", url, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "gsmnode-api/1.0")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("webhook delivery to %s failed: %v", url, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log.Printf("webhook %s returned %d", url, resp.StatusCode)
}
}
+64
View File
@@ -0,0 +1,64 @@
// Package auth issues and verifies the client-facing JWTs used by the Web App
// and other integrators.
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims is the JWT payload for an authenticated user session.
type Claims struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
jwt.RegisteredClaims
}
// Manager issues and verifies JWTs.
type Manager struct {
secret []byte
ttl time.Duration
}
// NewManager creates a JWT manager.
func NewManager(secret []byte, ttl time.Duration) *Manager {
return &Manager{secret: secret, ttl: ttl}
}
// Issue creates a signed access token for the given user.
func (m *Manager) Issue(userID, email, name string) (token string, expiresAt time.Time, err error) {
now := time.Now()
expiresAt = now.Add(m.ttl)
claims := Claims{
Email: email,
Name: name,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := t.SignedString(m.secret)
return signed, expiresAt, err
}
// Verify parses and validates a token, returning its claims.
func (m *Manager) Verify(token string) (*Claims, error) {
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
if !parsed.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"encoding/hex"
)
// NewDeviceToken returns a cryptographically random opaque token used to
// authenticate a registered mobile device.
func NewDeviceToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+95
View File
@@ -0,0 +1,95 @@
package config
import (
"log"
"os"
"strings"
"time"
)
// Config holds all runtime configuration for the API Server.
type Config struct {
Addr string
PocketBaseURL string
PBAdminEmail string
PBAdminPass string
JWTSecret []byte
JWTAccessTTL time.Duration
AllowOrigins []string
MessageTTL time.Duration
}
// Load reads configuration from environment variables, applying sensible
// defaults. A .env file, if present in the working directory, is loaded first.
func Load() Config {
loadDotEnv(".env")
cfg := Config{
Addr: getenv("API_ADDR", ":8080"),
PocketBaseURL: strings.TrimRight(getenv("POCKETBASE_URL", "http://10.2.1.10:8028"), "/"),
PBAdminEmail: getenv("PB_ADMIN_EMAIL", ""),
PBAdminPass: getenv("PB_ADMIN_PASSWORD", ""),
JWTSecret: []byte(getenv("JWT_SECRET", "dev-insecure-change-me-please")),
JWTAccessTTL: getdur("JWT_ACCESS_TTL", 24*time.Hour),
AllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")),
MessageTTL: getdur("MESSAGE_TTL", 5*time.Minute),
}
if cfg.PBAdminEmail == "" || cfg.PBAdminPass == "" {
log.Println("WARNING: PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are not set; PocketBase calls will fail")
}
return cfg
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getdur(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
log.Printf("invalid duration for %s=%q, using default %s", key, v, def)
}
return def
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they
// are not already set. It is intentionally minimal (no quoting rules beyond
// trimming surrounding quotes).
func loadDotEnv(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
}
}
+265
View File
@@ -0,0 +1,265 @@
// Package pb is a thin REST client for PocketBase (v0.23+). The API Server is
// the only component that talks to PocketBase: it authenticates as a superuser
// and performs all CRUD on behalf of users, enforcing ownership in app logic.
package pb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"sync"
"time"
)
// Record is a generic PocketBase record (a JSON object).
type Record map[string]any
// ListResult is the envelope returned by PocketBase list endpoints.
type ListResult struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items []Record `json:"items"`
}
// APIError is returned for non-2xx PocketBase responses.
type APIError struct {
Status int
Message string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("pocketbase: %d %s", e.Status, e.Message)
}
// NotFound reports whether err is a 404 from PocketBase.
func NotFound(err error) bool {
var ae *APIError
if e, ok := err.(*APIError); ok {
ae = e
}
return ae != nil && ae.Status == http.StatusNotFound
}
// Client is a PocketBase REST client with automatic superuser token refresh.
type Client struct {
baseURL string
adminEmail string
adminPass string
http *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// New creates a PocketBase client.
func New(baseURL, adminEmail, adminPass string) *Client {
return &Client{
baseURL: baseURL,
adminEmail: adminEmail,
adminPass: adminPass,
http: &http.Client{Timeout: 15 * time.Second},
}
}
// AuthResult is returned by AuthWithPassword.
type AuthResult struct {
Token string `json:"token"`
Record Record `json:"record"`
}
// authenticate logs in as a superuser and caches the token.
func (c *Client) authenticate(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.tokenExp) {
return c.token, nil
}
body := map[string]string{"identity": c.adminEmail, "password": c.adminPass}
var res AuthResult
if err := c.do(ctx, http.MethodPost,
"/api/collections/_superusers/auth-with-password", "", body, &res); err != nil {
return "", fmt.Errorf("superuser auth: %w", err)
}
c.token = res.Token
// PocketBase superuser tokens are long-lived; refresh conservatively.
c.tokenExp = time.Now().Add(10 * time.Minute)
return c.token, nil
}
// AuthWithPassword verifies credentials against an auth collection (e.g. "users")
// and returns the user record. Used to validate logins; the API Server then
// issues its own JWT.
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthResult, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
body := map[string]string{"identity": identity, "password": password}
var res AuthResult
path := "/api/collections/" + url.PathEscape(collection) + "/auth-with-password"
if err := c.do(ctx, http.MethodPost, path, token, body, &res); err != nil {
return nil, err
}
return &res, nil
}
// Create inserts a record into a collection.
func (c *Client) Create(ctx context.Context, collection string, data any) (Record, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
var out Record
path := "/api/collections/" + url.PathEscape(collection) + "/records"
if err := c.do(ctx, http.MethodPost, path, token, data, &out); err != nil {
return nil, err
}
return out, nil
}
// Update patches a record.
func (c *Client) Update(ctx context.Context, collection, id string, data any) (Record, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
var out Record
path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id)
if err := c.do(ctx, http.MethodPatch, path, token, data, &out); err != nil {
return nil, err
}
return out, nil
}
// Delete removes a record.
func (c *Client) Delete(ctx context.Context, collection, id string) error {
token, err := c.authenticate(ctx)
if err != nil {
return err
}
path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id)
return c.do(ctx, http.MethodDelete, path, token, nil, nil)
}
// GetOne fetches a single record by id.
func (c *Client) GetOne(ctx context.Context, collection, id string) (Record, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
var out Record
path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id)
if err := c.do(ctx, http.MethodGet, path, token, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// ListOptions controls a list query.
type ListOptions struct {
Filter string
Sort string
Expand string
Page int
PerPage int
}
// List queries records with optional filter/sort/pagination.
func (c *Client) List(ctx context.Context, collection string, opt ListOptions) (*ListResult, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
q := url.Values{}
if opt.Filter != "" {
q.Set("filter", opt.Filter)
}
if opt.Sort != "" {
q.Set("sort", opt.Sort)
}
if opt.Expand != "" {
q.Set("expand", opt.Expand)
}
if opt.Page > 0 {
q.Set("page", strconv.Itoa(opt.Page))
}
if opt.PerPage > 0 {
q.Set("perPage", strconv.Itoa(opt.PerPage))
}
path := "/api/collections/" + url.PathEscape(collection) + "/records?" + q.Encode()
var out ListResult
if err := c.do(ctx, http.MethodGet, path, token, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// FindFirst returns the first record matching filter, or (nil, nil) if none.
func (c *Client) FindFirst(ctx context.Context, collection, filter, sort string) (Record, error) {
res, err := c.List(ctx, collection, ListOptions{Filter: filter, Sort: sort, PerPage: 1})
if err != nil {
return nil, err
}
if len(res.Items) == 0 {
return nil, nil
}
return res.Items[0], nil
}
// do performs an HTTP request against PocketBase and decodes the JSON response.
func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := http.StatusText(resp.StatusCode)
var pbErr struct {
Message string `json:"message"`
}
if json.Unmarshal(raw, &pbErr) == nil && pbErr.Message != "" {
msg = pbErr.Message
}
return &APIError{Status: resp.StatusCode, Message: msg, Body: string(raw)}
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2E9E6B" />
<title>gsmnode · API Server</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1974
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "gsmnode-api-panel",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#2E9E6B"></rect>
<g transform="translate(6.5 6.5) scale(0.475)">
<line x1="9" y1="15" x2="28" y2="15" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M25 10.5 L31 15 L25 19.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="12" y1="25" x2="31" y2="25" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M15 20.5 L9 25 L15 29.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+111
View File
@@ -0,0 +1,111 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { Moon, Sun } from "@lucide/vue";
import { theme, toggleTheme } from "./theme";
import EndpointTable from "./components/EndpointTable.vue";
// Live health poll against this server.
const status = ref("checking"); // checking | ok | error | unreachable
const httpStatus = ref(null);
const latency = ref(null);
const checkedAt = ref(null);
let timer = null;
async function check() {
const started = performance.now();
try {
const r = await fetch("/api/health");
latency.value = Math.round(performance.now() - started);
httpStatus.value = r.status;
status.value = r.ok ? "ok" : "error";
} catch {
latency.value = null;
httpStatus.value = null;
status.value = "unreachable";
}
checkedAt.value = new Date();
}
onMounted(() => {
check();
timer = setInterval(check, 10000);
});
onUnmounted(() => clearInterval(timer));
const badge = {
checking: { label: "checking", cls: "bg-sunken text-secondary" },
ok: { label: "operational", cls: "bg-success-tint text-success" },
error: { label: "error", cls: "bg-danger-tint text-danger" },
unreachable: { label: "unreachable", cls: "bg-danger-tint text-danger" },
};
const clientApi = [
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
{ method: "GET", path: "/api/devices", desc: "List registered gateway devices" },
{ method: "DELETE", path: "/api/devices/{id}", desc: "Remove a device" },
{ method: "POST", path: "/api/messages", desc: "Queue an outbound SMS" },
{ method: "GET", path: "/api/messages", desc: "Outbound message history" },
{ method: "POST", path: "/api/calls", desc: "Queue an outbound phone call" },
{ method: "GET", path: "/api/inbox", desc: "Messages received by your devices" },
{ method: "GET", path: "/api/webhooks", desc: "List webhook subscriptions" },
{ method: "POST", path: "/api/webhooks", desc: "Register a webhook" },
];
const mobileApi = [
{ method: "POST", path: "/api/mobile/v1/device", desc: "Register this phone as a gateway" },
{ method: "GET", path: "/api/mobile/v1/messages", desc: "Pull pending messages to send" },
{ method: "PATCH", path: "/api/mobile/v1/messages/{id}", desc: "Report sent / delivered / failed" },
{ method: "POST", path: "/api/mobile/v1/inbox", desc: "Push a received SMS" },
{ method: "POST", path: "/api/mobile/v1/ping", desc: "Device heartbeat" },
];
</script>
<template>
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-4">
<img
:src="theme === 'dark' ? '/gsmnode-horizontal-white.png' : '/gsmnode-horizontal.png'"
alt="gsmnode"
class="h-8"
/>
<span class="gn-eyebrow mt-1.5">API server</span>
<div class="flex-1"></div>
<button
class="gn-btn-sec gn-btn-sm"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
Theme
</button>
</div>
<!-- Status -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Status</div>
<span
class="inline-flex items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
:class="badge[status].cls"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 font-mono text-xs text-secondary">
<span>GET /api/health</span>
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
</div>
</div>
<EndpointTable title="Client API" auth="Bearer JWT" :endpoints="clientApi" />
<EndpointTable title="Mobile API" auth="Device token" :endpoints="mobileApi" />
<p class="text-center font-mono text-[11px] text-muted">
gsmnode turn any Android phone into an SMS gateway.
</p>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
defineProps({
title: String,
auth: String,
endpoints: Array, // [{ method, path, desc }]
});
const methodClass = {
GET: "text-success",
POST: "text-brand-text",
PATCH: "text-warning",
DELETE: "text-danger",
};
</script>
<template>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">{{ title }}</div>
<span class="gn-eyebrow">{{ auth }}</span>
</div>
<table class="w-full text-left text-sm">
<thead>
<tr class="gn-eyebrow">
<th class="px-5 py-2.5 font-medium">Endpoint</th>
<th class="px-5 py-2.5 font-medium">Description</th>
</tr>
</thead>
<tbody>
<tr v-for="e in endpoints" :key="e.method + e.path" class="transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap">
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
<span class="text-primary"> {{ e.path }}</span>
</td>
<td class="border-t border-subtle px-5 py-2.5 text-secondary">{{ e.desc }}</td>
</tr>
</tbody>
</table>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+336
View File
@@ -0,0 +1,336 @@
/* gsmnode design system — tokens from Design/SMS Gateway logo design/tokens/*,
mapped into Tailwind v4. Signal-green + ink, developer-tool aesthetic. */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
@import "tailwindcss";
/* ============================================================
RAW RAMPS + SEMANTIC ALIASES (light)
============================================================ */
:root {
/* Signal green (brand) — shared chroma, stepped lightness */
--green-100: oklch(0.95 0.04 152);
--green-200: oklch(0.88 0.07 152);
--green-300: oklch(0.78 0.11 152);
--green-500: oklch(0.60 0.14 152); /* primary */
--green-600: oklch(0.52 0.13 152);
--green-700: oklch(0.44 0.11 152);
--green-on-dark: oklch(0.72 0.14 152);
/* Ink & paper */
--ink: #12161C;
--ink-900: #0A0D11;
--ink-800: #1A1F27;
--paper: #FAFAF9;
--white: #FFFFFF;
/* Cool gray ramp */
--gray-50: #F4F5F4;
--gray-100: #E9EBEA;
--gray-200: #DCDFDE;
--gray-300: #C2C7C6;
--gray-400: #9AA0A2;
--gray-500: #6B7278;
--gray-600: #4A5157;
--gray-700: #333A40;
/* Semantic status */
--success: oklch(0.60 0.14 152); /* green — also "delivered" */
--warning: oklch(0.72 0.15 75);
--danger: oklch(0.60 0.17 25);
--info: oklch(0.62 0.13 245);
--success-x: oklch(0.95 0.04 152);
--warning-x: oklch(0.95 0.05 80);
--danger-x: oklch(0.95 0.05 25);
--info-x: oklch(0.95 0.04 245);
/* Semantic aliases — reference these in components */
--bg-page: var(--paper);
--bg-sunken: var(--gray-50);
--surface-card: var(--white);
--surface-raised: var(--white);
--surface-inverse:var(--ink);
--surface-code: var(--ink-900);
--border-subtle: rgba(18, 22, 28, 0.08);
--border-strong: rgba(18, 22, 28, 0.20);
--border-focus: var(--green-500);
--text-primary: var(--ink);
--text-secondary: var(--gray-700);
--text-muted: var(--gray-500);
--text-inverse: #F5F6F4;
--text-brand: var(--green-600);
--text-link: var(--green-600);
--brand: var(--green-500);
--brand-hover: var(--green-600);
--brand-active: var(--green-700);
--brand-tint: var(--green-100);
--brand-contrast: var(--white);
--brand-on-dark: var(--green-on-dark);
--success-tint: var(--success-x);
--warning-tint: var(--warning-x);
--danger-tint: var(--danger-x);
--info-tint: var(--info-x);
--ring-focus: 0 0 0 3px oklch(0.60 0.14 152 / 0.45);
--ring-danger: 0 0 0 3px oklch(0.60 0.17 25 / 0.35);
/* Flat, low-contrast elevation */
--sh-xs: 0 1px 2px rgba(18, 22, 28, 0.04);
--sh-sm: 0 1px 2px rgba(18, 22, 28, 0.06), 0 1px 1px rgba(18, 22, 28, 0.04);
--sh-md: 0 4px 12px rgba(18, 22, 28, 0.08);
--sh-lg: 0 8px 24px rgba(18, 22, 28, 0.14);
--sh-xl: 0 16px 40px rgba(18, 22, 28, 0.18);
--dur-instant: 100ms;
--dur-fast: 120ms;
--dur-base: 180ms;
--dur-slow: 260ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ============================================================
DARK THEME — only the semantic layer remaps.
Namespaced data-gsm-theme so it matches the design system.
============================================================ */
[data-gsm-theme="dark"] {
--bg-page: var(--ink-900);
--bg-sunken: #0E1216;
--surface-card: #14191F;
--surface-raised: #1A2027;
--surface-inverse:var(--white);
--surface-code: #05070A;
--border-subtle: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--green-on-dark);
--text-primary: #F5F6F4;
--text-secondary: var(--gray-200);
--text-muted: var(--gray-400);
--text-inverse: var(--ink);
--text-brand: var(--green-on-dark);
--text-link: var(--green-on-dark);
--brand: var(--green-500);
--brand-hover: var(--green-on-dark);
--brand-active: var(--green-600);
--brand-tint: oklch(0.60 0.14 152 / 0.16);
--brand-contrast: var(--white);
--success: var(--green-300);
--warning: oklch(0.80 0.14 80);
--danger: oklch(0.72 0.16 25);
--info: oklch(0.72 0.12 245);
--success-tint: oklch(0.60 0.14 152 / 0.16);
--warning-tint: oklch(0.72 0.15 75 / 0.16);
--danger-tint: oklch(0.60 0.17 25 / 0.18);
--info-tint: oklch(0.62 0.13 245 / 0.16);
--ring-focus: 0 0 0 3px oklch(0.72 0.14 152 / 0.45);
--sh-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4);
--sh-lg: 0 8px 24px rgba(0, 0, 0, 0.6), 0 4px 8px rgba(0, 0, 0, 0.4);
--sh-xl: 0 16px 40px rgba(0, 0, 0, 0.7);
color-scheme: dark;
}
/* ============================================================
TAILWIND THEME — utilities resolve to the semantic vars,
so everything flips automatically under data-gsm-theme="dark".
============================================================ */
@theme inline {
--color-*: initial;
--color-page: var(--bg-page);
--color-sunken: var(--bg-sunken);
--color-card: var(--surface-card);
--color-raised: var(--surface-raised);
--color-inverse: var(--surface-inverse);
--color-code: var(--surface-code);
--color-subtle: var(--border-subtle);
--color-strong: var(--border-strong);
--color-primary: var(--text-primary);
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-on-brand: var(--brand-contrast);
--color-link: var(--text-link);
--color-brand: var(--brand);
--color-brand-hover: var(--brand-hover);
--color-brand-active: var(--brand-active);
--color-brand-tint: var(--brand-tint);
--color-brand-text: var(--text-brand);
--color-success: var(--success);
--color-success-tint: var(--success-tint);
--color-warning: var(--warning);
--color-warning-tint: var(--warning-tint);
--color-danger: var(--danger);
--color-danger-tint: var(--danger-tint);
--color-info: var(--info);
--color-info-tint: var(--info-tint);
--color-white: #FFFFFF;
--color-ink: var(--ink);
--color-green: var(--green-500);
--font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--radius-*: initial;
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 15px;
--radius-2xl: 18px;
--radius-full: 9999px;
--shadow-*: initial;
--shadow-xs: var(--sh-xs);
--shadow-sm: var(--sh-sm);
--shadow-md: var(--sh-md);
--shadow-lg: var(--sh-lg);
--shadow-xl: var(--sh-xl);
--shadow-ring: var(--ring-focus);
}
/* ============================================================
BASE
============================================================ */
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* Display face for titles & metrics */
h1, h2, h3 {
font-family: var(--font-display);
letter-spacing: -0.02em;
}
/* Shared control styles (inputs, selects, textareas) */
@utility gn-input {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-card);
color: var(--text-primary);
padding: 0 12px;
height: 40px;
font-size: 0.875rem;
font-family: var(--font-sans);
transition: border-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
@utility gn-textarea {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-card);
color: var(--text-primary);
padding: 10px 12px;
font-size: 0.875rem;
font-family: var(--font-sans);
resize: vertical;
transition: border-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.gn-input:focus,
.gn-textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: var(--ring-focus);
}
.gn-input::placeholder,
.gn-textarea::placeholder {
color: var(--text-muted);
}
/* Primary button — flat signal-green, no glow (infrastructure) */
@utility gn-btn-pri {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: var(--brand);
color: var(--brand-contrast);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.gn-btn-pri:hover:not(:disabled) { background: var(--brand-hover); }
.gn-btn-pri:active:not(:disabled) { background: var(--brand-active); transform: translateY(1px); }
.gn-btn-pri:focus-visible { outline: none; box-shadow: var(--ring-focus); }
.gn-btn-pri:disabled { opacity: 0.45; cursor: not-allowed; }
/* Secondary button */
@utility gn-btn-sec {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.gn-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); }
.gn-btn-sec:active:not(:disabled) { transform: translateY(1px); }
.gn-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); }
.gn-btn-sec:disabled { opacity: 0.45; cursor: not-allowed; }
/* Small button size modifier */
@utility gn-btn-sm {
height: 32px;
padding: 0 12px;
font-size: 0.75rem;
border-radius: var(--radius-sm);
}
/* Mono eyebrow — uppercase, tracked out */
@utility gn-eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
}
+30
View File
@@ -0,0 +1,30 @@
import { ref } from "vue";
// Persisted light/dark theme, shared key with the design-system kits.
const KEY = "gsmnode-theme";
function initial() {
try {
return localStorage.getItem(KEY) === "dark" ? "dark" : "light";
} catch {
return "light";
}
}
export const theme = ref(initial());
export function applyTheme(t) {
theme.value = t;
document.documentElement.setAttribute("data-gsm-theme", t);
try {
localStorage.setItem(KEY, t);
} catch {
/* private mode — theme just won't persist */
}
}
export function toggleTheme() {
applyTheme(theme.value === "dark" ? "light" : "dark");
}
applyTheme(theme.value);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Builds into internal/api/dist, which the Go server embeds via go:embed
// and serves at the server root.
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../internal/api/dist",
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
// Dev-mode proxy to a locally running API Server.
"/api": "http://localhost:8080",
},
},
});
+25
View File
@@ -0,0 +1,25 @@
# Runs the SMS Gateway API Server.
# Loads .env (if present), then starts the Go server.
#
# ./scripts/Run-ApiServer.ps1
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent $here # the "API Server" folder
Push-Location $root
try {
# Ensure Go is on PATH for this session.
$goBin = "C:\Program Files\Go\bin"
if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" }
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "Go is not installed or not on PATH."
}
Write-Host "Starting API Server (Ctrl+C to stop)..." -ForegroundColor Cyan
go run ./cmd/server
}
finally {
Pop-Location
}
+50
View File
@@ -0,0 +1,50 @@
// Creates a user in the PocketBase "users" collection to log in with.
//
// Usage (PowerShell):
// $env:POCKETBASE_URL="http://10.2.1.10:8028"
// $env:PB_ADMIN_EMAIL="admin@example.com"
// $env:PB_ADMIN_PASSWORD="admin-password"
// node scripts/create-user.mjs user@example.com "user-password" "Display Name"
const BASE = (process.env.POCKETBASE_URL || "http://10.2.1.10:8028").replace(/\/$/, "");
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
const [, , userEmail, userPassword, userName] = process.argv;
if (!EMAIL || !PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
process.exit(1);
}
if (!userEmail || !userPassword) {
console.error('Usage: node scripts/create-user.mjs <email> <password> ["name"]');
process.exit(1);
}
async function api(method, path, body, token) {
const res = await fetch(BASE + path, {
method,
headers: { "Content-Type": "application/json", ...(token ? { Authorization: token } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${json?.message || text}`);
return json;
}
const auth = await api("POST", "/api/collections/_superusers/auth-with-password", {
identity: EMAIL,
password: PASSWORD,
});
const user = await api("POST", "/api/collections/users/records", {
email: userEmail,
password: userPassword,
passwordConfirm: userPassword,
name: userName || "",
emailVisibility: true,
verified: true,
}, auth.token);
console.log(`Created user ${user.email} (id: ${user.id}).`);
+194
View File
@@ -0,0 +1,194 @@
// Sets up the PocketBase collections the API Server expects.
//
// Usage (PowerShell):
// $env:POCKETBASE_URL="http://10.2.1.10:8028"
// $env:PB_ADMIN_EMAIL="you@example.com"
// $env:PB_ADMIN_PASSWORD="your-password"
// node scripts/setup-pocketbase.mjs
//
// It is idempotent: existing collections are updated, missing ones created.
// Requires Node 18+ (uses global fetch). No npm install needed.
const BASE = (process.env.POCKETBASE_URL || "http://10.2.1.10:8028").replace(/\/$/, "");
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!EMAIL || !PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
process.exit(1);
}
let token = "";
async function api(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON */ }
if (!res.ok) {
const msg = json?.message || text || res.statusText;
throw new Error(`${method} ${path} -> ${res.status}: ${msg}`);
}
return json;
}
async function authenticate() {
const res = await api("POST", "/api/collections/_superusers/auth-with-password", {
identity: EMAIL,
password: PASSWORD,
});
token = res.token;
console.log("Authenticated as superuser.");
}
async function getCollections() {
const res = await api("GET", "/api/collections?perPage=200");
const byName = {};
for (const c of res.items) byName[c.name] = c;
return byName;
}
// Field builders for PocketBase v0.23+ (collections use a `fields` array).
const f = {
text: (name, opts = {}) => ({ name, type: "text", required: false, ...opts }),
number: (name, opts = {}) => ({ name, type: "number", required: false, ...opts }),
date: (name, opts = {}) => ({ name, type: "date", required: false, ...opts }),
json: (name, opts = {}) => ({ name, type: "json", required: false, maxSize: 2000000, ...opts }),
url: (name, opts = {}) => ({ name, type: "url", required: false, ...opts }),
select: (name, values, opts = {}) => ({
name, type: "select", required: false, maxSelect: 1, values, ...opts,
}),
relation: (name, collectionId, opts = {}) => ({
name, type: "relation", required: false, collectionId,
cascadeDelete: false, maxSelect: 1, minSelect: 0, ...opts,
}),
autodate: (name, opts) => ({ name, type: "autodate", onCreate: true, onUpdate: false, ...opts }),
};
// Superuser-only API rules: only the API Server (acting as superuser) reads or
// writes these collections. Clients never touch PocketBase directly.
const LOCKED = { listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null };
function definitions(ids) {
return [
{
name: "devices",
type: "base",
...LOCKED,
fields: [
f.text("device_id", { required: true }),
f.text("name"),
f.text("platform"),
f.text("app_version"),
f.text("push_token"),
f.text("auth_token", { required: true }),
f.select("status", ["online", "offline"]),
f.date("last_seen_at"),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
f.autodate("updated", { onCreate: true, onUpdate: true }),
],
indexes: [
"CREATE UNIQUE INDEX idx_devices_auth_token ON devices (auth_token)",
"CREATE UNIQUE INDEX idx_devices_owner_device ON devices (owner, device_id)",
],
},
{
name: "messages",
type: "base",
...LOCKED,
fields: [
f.json("phone_numbers", { required: true }),
f.text("text_message"),
f.select("type", ["sms", "call"]),
f.number("sim_number"),
f.select("status", ["Pending", "Processed", "Sent", "Delivered", "Failed"]),
f.text("error"),
f.date("schedule_at"),
f.date("sent_at"),
f.date("delivered_at"),
f.relation("device", ids.devices, { cascadeDelete: false }),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
f.autodate("updated", { onCreate: true, onUpdate: true }),
],
indexes: [
"CREATE INDEX idx_messages_device_status ON messages (device, status)",
"CREATE INDEX idx_messages_owner ON messages (owner)",
],
},
{
name: "inbox",
type: "base",
...LOCKED,
fields: [
f.text("phone_number", { required: true }),
f.text("message"),
f.date("received_at"),
f.relation("device", ids.devices, { cascadeDelete: false }),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
],
indexes: ["CREATE INDEX idx_inbox_owner ON inbox (owner)"],
},
{
name: "webhooks",
type: "base",
...LOCKED,
fields: [
f.select("event", ["sms:received", "sms:sent", "sms:delivered", "sms:failed"]),
f.url("url", { required: true }),
f.relation("device", ids.devices, { cascadeDelete: false }),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
],
indexes: ["CREATE INDEX idx_webhooks_owner_event ON webhooks (owner, event)"],
},
];
}
async function main() {
await authenticate();
let collections = await getCollections();
if (!collections.users) {
throw new Error('The default "users" auth collection was not found in PocketBase.');
}
// Resolve collection ids needed for relation fields. devices must exist before
// messages/inbox/webhooks reference it, so create in order, refreshing ids.
const order = ["devices", "messages", "inbox", "webhooks"];
for (const name of order) {
const ids = {
users: collections.users.id,
devices: collections.devices?.id,
};
const def = definitions(ids).find((d) => d.name === name);
if (collections[name]) {
await api("PATCH", `/api/collections/${collections[name].id}`, def);
console.log(`Updated collection: ${name}`);
} else {
await api("POST", "/api/collections", def);
console.log(`Created collection: ${name}`);
}
collections = await getCollections(); // refresh so later relations resolve
}
console.log("\nPocketBase setup complete.");
console.log("Collections: users (existing), devices, messages, inbox, webhooks.");
console.log("\nNext: create a user to log in with, e.g. via the PocketBase admin UI,");
console.log("or run scripts/create-user.mjs.");
}
main().catch((err) => {
console.error("\nSetup failed:", err.message);
process.exit(1);
});