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