d6956395c8
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>
266 lines
7.0 KiB
Go
266 lines
7.0 KiB
Go
// 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
|
|
}
|