// 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 }