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:
@@ -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"]),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
BIN
Binary file not shown.
|
After Width: | Height: | Size: 759 B |
+9
@@ -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
@@ -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>
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user