Files
DriverVault/API Server/internal/pb/client.go
T
2026-07-06 08:50:52 +02:00

363 lines
10 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.
type Client struct {
baseURL string
email string
password string
http *http.Client
mu sync.RWMutex
token string
}
func New(baseURL, email, password string) *Client {
return &Client{
baseURL: baseURL,
email: email,
password: password,
http: &http.Client{Timeout: 15 * time.Second},
}
}
// 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 {
body, _ := json.Marshal(map[string]string{
"identity": c.email,
"password": c.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, c.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
}
// 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
}