CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
The pocketbase driver no longer embeds PocketBase: Warp keeps its data as PocketBase collections on a server of its own and reaches it only through the REST API, the way the postgres driver reaches PostgreSQL. database.connection_string is the server's URL, and Warp signs in as the superuser in WARP_PB_SUPERUSER_EMAIL / WARP_PB_SUPERUSER_PASSWORD (or a WARP_PB_SUPERUSER_FILE of the two). Every SQL table is a superuser-only collection of the same name and columns, created by warp migrate. Reads go through PocketBase's SQL endpoint with the SQL drivers' queries, via a read-only database/sql driver that inlines arguments, moves long ones into warp_params and pages past 1000 rows. Writes are record operations in batch requests under one write lock in the Warp process. Media is a protected file field in warp_media, local or in the S3 bucket Warp writes into PocketBase's settings. Search matches word prefixes with LIKE. The storage tests run against an in-process PocketBase and pass as on SQLite. Docker: the PocketBase stacks get a pocketbase service running tajniak81/pocketbase-docker-root (WARP_PB_IMAGE), with a pb-data volume and its admin UI on loopback; the AIO images run that PocketBase binary as a process beside the homeserver, making up a superuser when none is set. The .env examples, READMEs, docs and warp.yaml.example follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
9.9 KiB
Go
332 lines
9.9 KiB
Go
package pocketbase
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// client speaks PocketBase's REST API as a superuser. Everything the driver
|
|
// does goes through it: the SQL endpoint for reads, the batch endpoint for
|
|
// writes, and the record and file endpoints for media.
|
|
type client struct {
|
|
base *url.URL
|
|
email string
|
|
password string
|
|
http *http.Client
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
}
|
|
|
|
// newClient parses the server address. Credentials are checked on first use,
|
|
// not here: a PocketBase that is still starting is not a configuration
|
|
// error.
|
|
func newClient(rawURL, email, password string) (*client, error) {
|
|
if rawURL == "" {
|
|
return nil, errors.New("pocketbase: connection_string must be the server's URL, e.g. http://pocketbase:8070")
|
|
}
|
|
base, err := url.Parse(strings.TrimSuffix(rawURL, "/"))
|
|
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
|
|
return nil, fmt.Errorf("pocketbase: connection_string %q is not an http(s) URL", rawURL)
|
|
}
|
|
if email == "" || password == "" {
|
|
return nil, errors.New("pocketbase: a superuser email and password are required")
|
|
}
|
|
return &client{base: base, email: email, password: password, http: &http.Client{}}, nil
|
|
}
|
|
|
|
// apiError is a non-2xx answer from PocketBase.
|
|
type apiError struct {
|
|
Status int
|
|
Message string
|
|
Data json.RawMessage
|
|
}
|
|
|
|
func (e *apiError) Error() string {
|
|
msg := fmt.Sprintf("pocketbase: HTTP %d: %s", e.Status, e.Message)
|
|
if len(e.Data) > 0 && string(e.Data) != "{}" && string(e.Data) != "null" {
|
|
msg += " " + string(e.Data)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// isNotFound reports a 404 from PocketBase.
|
|
func isNotFound(err error) bool {
|
|
var apiErr *apiError
|
|
return errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound
|
|
}
|
|
|
|
// url resolves an API path against the server address.
|
|
func (c *client) url(path string) string { return c.base.String() + path }
|
|
|
|
// authToken returns the current superuser token, signing in if there is
|
|
// none yet.
|
|
func (c *client) authToken(ctx context.Context) (string, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.token != "" {
|
|
return c.token, nil
|
|
}
|
|
body, err := json.Marshal(map[string]string{"identity": c.email, "password": c.password})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.url("/api/collections/_superusers/auth-with-password"), bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := c.send(req, &out); err != nil {
|
|
return "", fmt.Errorf("pocketbase: sign in as superuser %q: %w", c.email, err)
|
|
}
|
|
if out.Token == "" {
|
|
return "", errors.New("pocketbase: sign in returned no token")
|
|
}
|
|
c.token = out.Token
|
|
return c.token, nil
|
|
}
|
|
|
|
// forgetToken drops a token PocketBase refused, so the next call signs in
|
|
// again. A superuser token outlives most deployments' restarts, but not all.
|
|
func (c *client) forgetToken(token string) {
|
|
c.mu.Lock()
|
|
if c.token == token {
|
|
c.token = ""
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// do sends one authenticated request, signing in again once if the token has
|
|
// expired. body is JSON-encoded unless it is an io.Reader, which is sent as
|
|
// is with contentType.
|
|
func (c *client) do(ctx context.Context, method, path string, body any, contentType string, out any) error {
|
|
var payload []byte
|
|
if body != nil {
|
|
if r, ok := body.(io.Reader); ok {
|
|
// A streamed body cannot be replayed, so it is sent once and a
|
|
// stale token is the caller's error to retry.
|
|
return c.doOnce(ctx, method, path, r, contentType, out)
|
|
}
|
|
var err error
|
|
if payload, err = json.Marshal(body); err != nil {
|
|
return err
|
|
}
|
|
contentType = "application/json"
|
|
}
|
|
for attempt := 0; ; attempt++ {
|
|
var r io.Reader
|
|
if payload != nil {
|
|
r = bytes.NewReader(payload)
|
|
}
|
|
err := c.doOnce(ctx, method, path, r, contentType, out)
|
|
var apiErr *apiError
|
|
if attempt == 0 && errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized {
|
|
continue
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (c *client) doOnce(ctx context.Context, method, path string, body io.Reader, contentType string, out any) error {
|
|
token, err := c.authToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.url(path), body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", token)
|
|
if contentType != "" {
|
|
req.Header.Set("Content-Type", contentType)
|
|
}
|
|
err = c.send(req, out)
|
|
var apiErr *apiError
|
|
if errors.As(err, &apiErr) && apiErr.Status == http.StatusUnauthorized {
|
|
c.forgetToken(token)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// send runs a request and decodes a JSON answer into out, if out is set.
|
|
func (c *client) send(req *http.Request, out any) error {
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("pocketbase: %s %s: %w", req.Method, req.URL.Path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode >= 300 {
|
|
return readAPIError(resp)
|
|
}
|
|
if out == nil || resp.StatusCode == http.StatusNoContent {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
|
return fmt.Errorf("pocketbase: decode the answer to %s %s: %w", req.Method, req.URL.Path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readAPIError(resp *http.Response) error {
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
apiErr := &apiError{Status: resp.StatusCode}
|
|
var body struct {
|
|
Message string `json:"message"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if json.Unmarshal(raw, &body) == nil && body.Message != "" {
|
|
apiErr.Message = body.Message
|
|
apiErr.Data = body.Data
|
|
} else {
|
|
apiErr.Message = strings.TrimSpace(string(raw))
|
|
}
|
|
return apiErr
|
|
}
|
|
|
|
// health checks the server answers at all; it needs no token.
|
|
func (c *client) health(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url("/api/health"), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.send(req, nil)
|
|
}
|
|
|
|
// sqlResult is what PocketBase's SQL endpoint answers with. Every value comes
|
|
// back as text, or null.
|
|
type sqlResult struct {
|
|
Columns []struct {
|
|
Name string `json:"name"`
|
|
} `json:"columns"`
|
|
Rows [][]*string `json:"rows"`
|
|
}
|
|
|
|
// query runs one SELECT through the SQL endpoint.
|
|
func (c *client) query(ctx context.Context, query string) (*sqlResult, error) {
|
|
var out sqlResult
|
|
if err := c.do(ctx, http.MethodPost, "/api/sql", map[string]string{"query": query}, "", &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// batchRequest is one record operation inside a batch.
|
|
type batchRequest struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Body map[string]any `json:"body,omitempty"`
|
|
}
|
|
|
|
// batch runs record operations in one PocketBase transaction: all of them
|
|
// happen, or none do.
|
|
func (c *client) batch(ctx context.Context, reqs []batchRequest) error {
|
|
if len(reqs) == 0 {
|
|
return nil
|
|
}
|
|
return c.do(ctx, http.MethodPost, "/api/batch", map[string]any{"requests": reqs}, "", nil)
|
|
}
|
|
|
|
// patchSettings changes part of PocketBase's application settings.
|
|
func (c *client) patchSettings(ctx context.Context, settings map[string]any) error {
|
|
return c.do(ctx, http.MethodPatch, "/api/settings", settings, "", nil)
|
|
}
|
|
|
|
// importCollections creates or updates collections, leaving any not named
|
|
// alone.
|
|
func (c *client) importCollections(ctx context.Context, collections []map[string]any) error {
|
|
return c.do(ctx, http.MethodPut, "/api/collections/import",
|
|
map[string]any{"collections": collections, "deleteMissing": false}, "", nil)
|
|
}
|
|
|
|
// getRecord reads one record as JSON fields.
|
|
func (c *client) getRecord(ctx context.Context, collection, id string) (map[string]any, error) {
|
|
var out map[string]any
|
|
if err := c.do(ctx, http.MethodGet,
|
|
"/api/collections/"+collection+"/records/"+url.PathEscape(id), nil, "", &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// deleteRecord removes one record.
|
|
func (c *client) deleteRecord(ctx context.Context, collection, id string) error {
|
|
return c.do(ctx, http.MethodDelete,
|
|
"/api/collections/"+collection+"/records/"+url.PathEscape(id), nil, "", nil)
|
|
}
|
|
|
|
// createRecordWithFile creates a record with one file field, streaming the
|
|
// file as multipart so that it is never held in memory whole.
|
|
func (c *client) createRecordWithFile(ctx context.Context, collection string, fields map[string]string,
|
|
fileField, fileName string, file io.Reader) error {
|
|
pr, pw := io.Pipe()
|
|
mw := multipart.NewWriter(pw)
|
|
go func() {
|
|
err := func() error {
|
|
for k, v := range fields {
|
|
if err := mw.WriteField(k, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
part, err := mw.CreateFormFile(fileField, fileName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(part, file); err != nil {
|
|
return err
|
|
}
|
|
return mw.Close()
|
|
}()
|
|
_ = pw.CloseWithError(err)
|
|
}()
|
|
err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/records", pr, mw.FormDataContentType(), nil)
|
|
_ = pr.Close()
|
|
return err
|
|
}
|
|
|
|
// fileToken mints the short-lived token protected files are read with.
|
|
func (c *client) fileToken(ctx context.Context) (string, error) {
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, "/api/files/token", nil, "", &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Token, nil
|
|
}
|
|
|
|
// openFile starts a download of a stored file from offset on.
|
|
func (c *client) openFile(ctx context.Context, collection, id, name, token string, offset int64) (*http.Response, error) {
|
|
path := "/api/files/" + collection + "/" + url.PathEscape(id) + "/" + url.PathEscape(name) +
|
|
"?token=" + url.QueryEscape(token)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url(path), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if offset > 0 {
|
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pocketbase: download %s: %w", name, err)
|
|
}
|
|
if resp.StatusCode >= 300 {
|
|
defer func() { _ = resp.Body.Close() }()
|
|
return nil, readAPIError(resp)
|
|
}
|
|
return resp, nil
|
|
}
|