Signing in worked and everything after it did not: the login screen took the password, PocketBase handed back the record and the token, and then /api/me answered "The requested resource wasn't found." The three symptoms underneath looked unrelated — a 404 on the profile, an organization whose name came back empty, a user list with nobody in it — and they are one thing. The API Server's superuser token had expired. PocketBase does not say so. A record call carrying a token it no longer accepts is not refused; the header is ignored and the request is served as a guest, and the collection rules answer in the transport's place. The organizations collection is superuser-only, so it 403s. A user record is guarded by a view rule, so it 404s — hidden rather than denied. The users list is rule-filtered, so it comes back 200 with an empty array. Not one of those is a 401, and a 401 was the only thing that made this client sign in again. So the token was acquired once at startup and then kept for the life of the container, and the retry meant to renew it could never fire. Uptime longer than the token's lifetime was all it took. Nothing had to change for it to break, which is why it broke on a stack nobody had touched. The client now reads the exp claim PocketBase stamps into the token and renews before spending it, a minute early so a call cannot land just after it lapses. A token with no readable expiry is still taken at face value, and the 401 retry stays where it is as the backstop for the case this does not cover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
589 lines
19 KiB
Go
589 lines
19 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/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"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
|
|
tokenExp time.Time
|
|
}
|
|
|
|
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.tokenExp = time.Time{}
|
|
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.tokenExp = jwtExpiry(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
|
|
}
|
|
|
|
// tokenSkew is how long before its stated expiry a cached token is treated as
|
|
// spent. It covers clock drift between this server and PocketBase, and the
|
|
// flight time of a request that passes the check and then arrives just late.
|
|
const tokenSkew = 60 * time.Second
|
|
|
|
// tokenLive reports whether the cached token can still be used. A token with no
|
|
// readable expiry is taken at face value — the 401 retry remains the backstop.
|
|
func (c *Client) tokenLive() bool {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.token == "" {
|
|
return false
|
|
}
|
|
return c.tokenExp.IsZero() || time.Now().Add(tokenSkew).Before(c.tokenExp)
|
|
}
|
|
|
|
// jwtExpiry reads the exp claim out of a PocketBase auth token. The signature is
|
|
// PocketBase's business — this only needs the expiry the server itself stamped,
|
|
// so the payload is decoded without verification. Anything unreadable comes back
|
|
// as the zero time, which tokenLive treats as "no expiry known".
|
|
func jwtExpiry(token string) time.Time {
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return time.Time{}
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
var claims struct {
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
if err := json.Unmarshal(raw, &claims); err != nil || claims.Exp == 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(claims.Exp, 0)
|
|
}
|
|
|
|
// ensureToken acquires a superuser token when the cached one is missing or
|
|
// spent, 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.
|
|
//
|
|
// Expiry is checked here rather than left to the 401 retry below, because that
|
|
// retry never fires for this client: PocketBase does not reject a stale token on
|
|
// a record call, it ignores the header and serves the request as a guest. The
|
|
// collection rules then answer instead of the transport — a superuser-only
|
|
// collection 403s, a rule-guarded record 404s, and a rule-filtered list comes
|
|
// back 200 with nothing in it. None of those are a 401, so a server whose token
|
|
// has lapsed keeps sending it and keeps being treated as a stranger to its own
|
|
// database until it is restarted. Superuser tokens are long-lived, which only
|
|
// means the failure waits weeks and then arrives as "the app forgot my account".
|
|
func (c *Client) ensureToken(ctx context.Context) error {
|
|
if c.tokenLive() || !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
|
|
}
|