Logging in failed on both the Web App and the Phone App with PocketBase's
{"data":{},"message":"The requested resource wasn't found.","status":404}.
The login itself succeeded; the profile fetch right after it — GET /api/me
— was what 404ed, and the web app renders the relayed body on the login
form, so it read as a rejected sign-in.
PocketBase answers a record read it will not allow with 404 rather than
401: it hides the record instead of refusing the credentials. The pb
client only re-authenticated on a 401, so with no cached token the first
request went out carrying no Authorization header at all, came back 404,
and the retry never fired. Nothing ever tried again — the server kept
404ing long after PocketBase was healthy.
The cache is empty in exactly the two cases that matter: a startup where
the up-front Authenticate failed because PocketBase wasn't up yet, which
main.go treats as non-fatal on purpose so a superadmin can still log in
and fix the connection; and a Reconfigure from the panel, which clears
the token so the new credentials get used.
So acquire the token before the first attempt rather than hoping for a
401 to prompt it, across all four superuser paths. Bad credentials now
surface as the authentication failure they are instead of masquerading
as a missing record. With no service account configured there is nothing
to acquire and the call proceeds as before, since the endpoints that need
superuser access already answer 503 on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
536 lines
17 KiB
Go
536 lines
17 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
|
|
}
|
|
|
|
// ensureToken acquires a superuser token when none is cached yet, so that a
|
|
// superuser call never goes out unauthenticated.
|
|
//
|
|
// It matters because PocketBase answers a record read it will not allow with
|
|
// 404, not 401 — it hides the record rather than refusing the credentials. So a
|
|
// request sent with no Authorization header comes back "The requested resource
|
|
// wasn't found", the 401 retries below never fire, and the caller is told the
|
|
// record is missing when the truth is that this client never signed in. The
|
|
// cache is empty in exactly the cases that matter: a startup where Authenticate
|
|
// failed because PocketBase wasn't up yet (deliberately non-fatal), and a
|
|
// Reconfigure, which clears the token so the new credentials are used.
|
|
//
|
|
// With no service account configured there is nothing to acquire, so the call
|
|
// proceeds as before — the endpoints that need superuser access answer 503 on
|
|
// their own.
|
|
func (c *Client) ensureToken(ctx context.Context) error {
|
|
if c.currentToken() != "" || !c.Configured() {
|
|
return nil
|
|
}
|
|
return c.Authenticate(ctx)
|
|
}
|
|
|
|
// do executes a request, attaching the auth token — signing in first if there
|
|
// isn't one yet. On a 401 it re-authenticates once and retries.
|
|
func (c *Client) do(ctx context.Context, method, path string, payload any) ([]byte, error) {
|
|
if err := c.ensureToken(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
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) {
|
|
if err := c.ensureToken(ctx); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
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
|
|
if err := c.ensureToken(ctx); err != nil {
|
|
return nil, "", err
|
|
}
|
|
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 {
|
|
if err := c.ensureToken(ctx); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|