Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
502 lines
15 KiB
Go
502 lines
15 KiB
Go
// Package pb is a small PocketBase REST client. The API Server is the only
|
|
// component that talks to PocketBase, so all database access funnels through
|
|
// here. It authenticates as a superuser and performs CRUD on collection records.
|
|
package pb
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Client is a concurrency-safe PocketBase REST client with auto re-auth.
|
|
//
|
|
// The target address and service-account credentials are guarded by mu because
|
|
// a superadmin can retarget them at runtime (Settings → PocketBase in the panel)
|
|
// while requests are in flight.
|
|
type Client struct {
|
|
http *http.Client
|
|
|
|
mu sync.RWMutex
|
|
baseURL string
|
|
email string
|
|
password string
|
|
token string
|
|
}
|
|
|
|
func New(baseURL, email, password string) *Client {
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
email: email,
|
|
password: password,
|
|
http: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
// creds snapshots the connection under lock so a concurrent Reconfigure can't
|
|
// tear it mid-request.
|
|
func (c *Client) creds() (baseURL, email, password string) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.baseURL, c.email, c.password
|
|
}
|
|
|
|
// BaseURL returns the PocketBase address currently in use.
|
|
func (c *Client) BaseURL() string {
|
|
baseURL, _, _ := c.creds()
|
|
return baseURL
|
|
}
|
|
|
|
// Configured reports whether a service account has been supplied. Endpoints that
|
|
// need superuser access check this and return 503 when it is false.
|
|
func (c *Client) Configured() bool {
|
|
_, email, password := c.creds()
|
|
return email != "" && password != ""
|
|
}
|
|
|
|
// Reconfigure retargets the client at a new PocketBase and/or new credentials,
|
|
// invalidating any cached superuser token so the next call re-authenticates.
|
|
func (c *Client) Reconfigure(baseURL, email, password string) {
|
|
c.mu.Lock()
|
|
c.baseURL = baseURL
|
|
c.email = email
|
|
c.password = password
|
|
c.token = ""
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// APIError carries the HTTP status and body from a failed PocketBase call.
|
|
type APIError struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("pocketbase: status %d: %s", e.Status, e.Body)
|
|
}
|
|
|
|
// Authenticate obtains a superuser token. It tries the PocketBase v0.23+
|
|
// (_superusers collection) endpoint first, then the legacy admins endpoint.
|
|
func (c *Client) Authenticate(ctx context.Context) error {
|
|
baseURL, email, password := c.creds()
|
|
body, _ := json.Marshal(map[string]string{
|
|
"identity": email,
|
|
"password": password,
|
|
})
|
|
|
|
endpoints := []string{
|
|
"/api/collections/_superusers/auth-with-password",
|
|
"/api/admins/auth-with-password",
|
|
}
|
|
|
|
var lastErr error
|
|
for _, ep := range endpoints {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
c.token = out.Token
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
|
|
}
|
|
return fmt.Errorf("authentication failed: %w", lastErr)
|
|
}
|
|
|
|
func (c *Client) currentToken() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.token
|
|
}
|
|
|
|
// do executes a request, attaching the auth token. On a 401 it re-authenticates
|
|
// once and retries.
|
|
func (c *Client) do(ctx context.Context, method, path string, payload any) ([]byte, error) {
|
|
raw, status, err := c.attempt(ctx, method, path, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status == http.StatusUnauthorized {
|
|
if err := c.Authenticate(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
raw, status, err = c.attempt(ctx, method, path, payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return nil, &APIError{Status: status, Body: string(raw)}
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
func (c *Client) attempt(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
|
|
var reader io.Reader
|
|
if payload != nil {
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL()+path, reader)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if tok := c.currentToken(); tok != "" {
|
|
req.Header.Set("Authorization", tok)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
return raw, resp.StatusCode, err
|
|
}
|
|
|
|
// Raw performs a superuser request and returns the upstream body and status
|
|
// WITHOUT translating a non-2xx into an error. Handlers that want to relay
|
|
// PocketBase's own validation errors to the client verbatim (user/organization
|
|
// management) use this; handlers that want Go errors use the typed CRUD helpers.
|
|
func (c *Client) Raw(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
|
|
raw, status, err := c.attempt(ctx, method, path, payload)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if status == http.StatusUnauthorized {
|
|
if err := c.Authenticate(ctx); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return c.attempt(ctx, method, path, payload)
|
|
}
|
|
return raw, status, nil
|
|
}
|
|
|
|
// AuthRefresh validates an end user's auth token against PocketBase and returns
|
|
// the refreshed auth response body and status. Unlike the superuser calls this
|
|
// carries the *caller's* token, not the service account's — it is how the server
|
|
// resolves who a request belongs to.
|
|
func (c *Client) AuthRefresh(ctx context.Context, collection, token string) ([]byte, int, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.BaseURL()+"/api/collections/"+collection+"/auth-refresh", nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Authorization", token)
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
return raw, resp.StatusCode, err
|
|
}
|
|
|
|
// LoginWithPassword forwards a login to PocketBase's auth-with-password and
|
|
// returns its response body and status untouched, so the caller can relay both
|
|
// (token + record) straight back to the client.
|
|
func (c *Client) LoginWithPassword(ctx context.Context, collection, identity, password string) ([]byte, int, error) {
|
|
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.BaseURL()+"/api/collections/"+collection+"/auth-with-password", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
return raw, resp.StatusCode, err
|
|
}
|
|
|
|
// SuperuserAuth performs a one-off superuser auth-with-password against an
|
|
// arbitrary PocketBase and returns the HTTP status. It shares no state with any
|
|
// Client, so the settings endpoints can test a *candidate* connection before
|
|
// applying it. Both the v0.23+ (_superusers) and legacy (admins) endpoints are
|
|
// tried, matching Client.Authenticate.
|
|
func SuperuserAuth(ctx context.Context, httpClient *http.Client, baseURL, email, password string) (int, error) {
|
|
body, _ := json.Marshal(map[string]string{"identity": email, "password": password})
|
|
|
|
var lastStatus int
|
|
var lastErr error
|
|
for _, ep := range []string{
|
|
"/api/collections/_superusers/auth-with-password",
|
|
"/api/admins/auth-with-password",
|
|
} {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
|
|
return resp.StatusCode, fmt.Errorf("superuser auth: no token in response")
|
|
}
|
|
return resp.StatusCode, nil
|
|
}
|
|
lastStatus = resp.StatusCode
|
|
lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
|
|
}
|
|
return lastStatus, lastErr
|
|
}
|
|
|
|
// AuthRecord is the user record returned by a successful password auth.
|
|
type AuthRecord struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// AuthWithPassword verifies a user's credentials against an auth collection
|
|
// (e.g. "users"). This is an unauthenticated PocketBase call — it does not use
|
|
// the superuser token. Returns the matched user record on success.
|
|
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthRecord, error) {
|
|
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
|
|
url := c.BaseURL() + "/api/collections/" + collection + "/auth-with-password"
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &APIError{Status: resp.StatusCode, Body: string(raw)}
|
|
}
|
|
var out struct {
|
|
Record AuthRecord `json:"record"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out.Record, nil
|
|
}
|
|
|
|
// ListResult is the envelope PocketBase returns from list endpoints.
|
|
type ListResult struct {
|
|
Page int `json:"page"`
|
|
PerPage int `json:"perPage"`
|
|
TotalItems int `json:"totalItems"`
|
|
TotalPages int `json:"totalPages"`
|
|
Items json.RawMessage `json:"items"`
|
|
}
|
|
|
|
// List fetches records from a collection. The query (filter, sort, perPage,
|
|
// expand, ...) is passed through as URL query parameters.
|
|
func (c *Client) List(ctx context.Context, collection string, query url.Values) (*ListResult, error) {
|
|
path := "/api/collections/" + collection + "/records"
|
|
if len(query) > 0 {
|
|
path += "?" + query.Encode()
|
|
}
|
|
raw, err := c.do(ctx, http.MethodGet, path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var lr ListResult
|
|
if err := json.Unmarshal(raw, &lr); err != nil {
|
|
return nil, err
|
|
}
|
|
return &lr, nil
|
|
}
|
|
|
|
// GetOne fetches a single record by id and unmarshals it into dest.
|
|
func (c *Client) GetOne(ctx context.Context, collection, id string, dest any) error {
|
|
raw, err := c.do(ctx, http.MethodGet, "/api/collections/"+collection+"/records/"+id, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(raw, dest)
|
|
}
|
|
|
|
// Create inserts a record and unmarshals the created record into dest.
|
|
func (c *Client) Create(ctx context.Context, collection string, payload any, dest any) error {
|
|
raw, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/records", payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dest != nil {
|
|
return json.Unmarshal(raw, dest)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Update patches a record and unmarshals the updated record into dest.
|
|
func (c *Client) Update(ctx context.Context, collection, id string, payload any, dest any) error {
|
|
raw, err := c.do(ctx, http.MethodPatch, "/api/collections/"+collection+"/records/"+id, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dest != nil {
|
|
return json.Unmarshal(raw, dest)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a record by id.
|
|
func (c *Client) Delete(ctx context.Context, collection, id string) error {
|
|
_, err := c.do(ctx, http.MethodDelete, "/api/collections/"+collection+"/records/"+id, nil)
|
|
return err
|
|
}
|
|
|
|
// GetFile downloads a file field's stored content, authenticating as the
|
|
// superuser (this project's collections have no public access rules).
|
|
func (c *Client) GetFile(ctx context.Context, collection, recordID, filename string) ([]byte, string, error) {
|
|
path := "/api/files/" + collection + "/" + recordID + "/" + filename
|
|
raw, ctype, status, err := c.attemptGetFile(ctx, path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if status == http.StatusUnauthorized {
|
|
if aerr := c.Authenticate(ctx); aerr != nil {
|
|
return nil, "", aerr
|
|
}
|
|
raw, ctype, status, err = c.attemptGetFile(ctx, path)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return nil, "", &APIError{Status: status, Body: string(raw)}
|
|
}
|
|
return raw, ctype, nil
|
|
}
|
|
|
|
func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL()+path, nil)
|
|
if err != nil {
|
|
return nil, "", 0, err
|
|
}
|
|
if tok := c.currentToken(); tok != "" {
|
|
req.Header.Set("Authorization", tok)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, "", 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", 0, err
|
|
}
|
|
return raw, resp.Header.Get("Content-Type"), resp.StatusCode, nil
|
|
}
|
|
|
|
// RequestVerification asks PocketBase to send a verification email for the
|
|
// given address (a public PocketBase endpoint; it always "succeeds" whether or
|
|
// not the email exists, to avoid leaking account existence — and it only
|
|
// actually sends mail if the PocketBase instance has SMTP configured).
|
|
func (c *Client) RequestVerification(ctx context.Context, collection, email string) error {
|
|
_, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/request-verification", map[string]string{"email": email})
|
|
return err
|
|
}
|
|
|
|
// UpdateMultipart patches a record via multipart/form-data — required for file
|
|
// fields (e.g. the users.avatar upload), which PocketBase doesn't accept as
|
|
// plain JSON. Pass fileField == "" to send only the plain fields.
|
|
func (c *Client) UpdateMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) error {
|
|
status, err := c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent)
|
|
if status == http.StatusUnauthorized {
|
|
if aerr := c.Authenticate(ctx); aerr != nil {
|
|
return aerr
|
|
}
|
|
_, err = c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) (int, error) {
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
for k, v := range fields {
|
|
if err := mw.WriteField(k, v); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
if fileField != "" {
|
|
fw, err := mw.CreateFormFile(fileField, filename)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := fw.Write(fileContent); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL()+"/api/collections/"+collection+"/records/"+id, &buf)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
if tok := c.currentToken(); tok != "" {
|
|
req.Header.Set("Authorization", tok)
|
|
}
|
|
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return resp.StatusCode, &APIError{Status: resp.StatusCode, Body: string(raw)}
|
|
}
|
|
return resp.StatusCode, nil
|
|
}
|