package api import ( "bytes" "context" "encoding/json" "errors" "io" "mime/multipart" "net/http" "net/url" "sync" "time" ) // adminClient authenticates to PocketBase as a superuser service account and is // used only for admin user-management (list/create/delete users). It caches the // superuser token and transparently re-authenticates when PocketBase rejects it. // // This is the one place the server holds elevated PocketBase credentials; every // admin endpoint that uses it first verifies the *caller* is an app admin. type adminClient struct { baseURL string email string password string client *http.Client mu sync.Mutex token string } func newAdminClient(baseURL, email, password string) *adminClient { return &adminClient{ baseURL: baseURL, email: email, password: password, client: &http.Client{Timeout: 15 * time.Second}, } } func (a *adminClient) configured() bool { if a == nil { return false } _, email, password := a.creds() return email != "" && password != "" } // creds snapshots the current base URL + service-account credentials under lock, // so a concurrent reconfigure() can't tear them mid-request. func (a *adminClient) creds() (baseURL, email, password string) { a.mu.Lock() defer a.mu.Unlock() return a.baseURL, a.email, a.password } // reconfigure retargets the service account at a new PocketBase and/or new // credentials, invalidating any cached superuser token. func (a *adminClient) reconfigure(baseURL, email, password string) { a.mu.Lock() a.baseURL = baseURL a.email = email a.password = password a.token = "" // force re-auth against the new target a.mu.Unlock() } func (a *adminClient) authenticate(ctx context.Context) (string, error) { baseURL, email, password := a.creds() tok, _, err := superuserAuth(ctx, a.client, baseURL, email, password) if err != nil { return "", err } a.mu.Lock() a.token = tok a.mu.Unlock() return tok, nil } // superuserAuth performs a PocketBase superuser auth-with-password and returns // the token and HTTP status. Shared by the live client and the settings // connection-test so both classify failures identically. func superuserAuth(ctx context.Context, client *http.Client, baseURL, email, password string) (string, int, error) { body, _ := json.Marshal(map[string]string{"identity": email, "password": password}) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/collections/_superusers/auth-with-password", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return "", 0, err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", resp.StatusCode, errors.New("superuser auth failed: " + string(data)) } var out struct { Token string `json:"token"` } if err := json.Unmarshal(data, &out); err != nil || out.Token == "" { return "", resp.StatusCode, errors.New("superuser auth: no token") } return out.Token, resp.StatusCode, nil } func (a *adminClient) cachedToken() string { a.mu.Lock() defer a.mu.Unlock() return a.token } // do performs an admin request, (re)authenticating as needed. It returns the // upstream response body and status. On a 401 it re-authenticates once and // retries, so an expired cached token is self-healing. func (a *adminClient) do(ctx context.Context, method, path string, payload any) ([]byte, int, error) { token := a.cachedToken() if token == "" { var err error if token, err = a.authenticate(ctx); err != nil { return nil, 0, err } } baseURL, _, _ := a.creds() send := func(tok string) ([]byte, int, error) { var body io.Reader if payload != nil { b, _ := json.Marshal(payload) body = bytes.NewReader(b) } req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, body) req.Header.Set("Authorization", tok) if payload != nil { req.Header.Set("Content-Type", "application/json") } resp, err := a.client.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) return data, resp.StatusCode, nil } data, status, err := send(token) if err != nil { return nil, 0, err } if status == http.StatusUnauthorized { if token, err = a.authenticate(ctx); err != nil { return nil, 0, err } return send(token) } return data, status, nil } // multipartFile is one file part for a multipart upload. type multipartFile struct { field string filename string data []byte } // doMultipart performs an admin request with a multipart/form-data body (used to // upload PocketBase file-field records). It mirrors do()'s self-healing re-auth: // on a 401 it re-authenticates once and retries. The body is buffered so the // retry can resend it. func (a *adminClient) doMultipart(ctx context.Context, method, path string, fields map[string]string, files []multipartFile) ([]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 nil, 0, err } } for _, f := range files { fw, err := mw.CreateFormFile(f.field, f.filename) if err != nil { return nil, 0, err } if _, err := fw.Write(f.data); err != nil { return nil, 0, err } } if err := mw.Close(); err != nil { return nil, 0, err } contentType := mw.FormDataContentType() body := buf.Bytes() token := a.cachedToken() if token == "" { var err error if token, err = a.authenticate(ctx); err != nil { return nil, 0, err } } baseURL, _, _ := a.creds() send := func(tok string) ([]byte, int, error) { req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body)) req.Header.Set("Authorization", tok) req.Header.Set("Content-Type", contentType) resp, err := a.client.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() data, _ := io.ReadAll(resp.Body) return data, resp.StatusCode, nil } data, status, err := send(token) if err != nil { return nil, 0, err } if status == http.StatusUnauthorized { if token, err = a.authenticate(ctx); err != nil { return nil, 0, err } return send(token) } return data, status, nil } // fileToken mints a short-lived PocketBase file-access token so protected files // (the documents collection's rules are locked) can be fetched by URL. func (a *adminClient) fileToken(ctx context.Context) (string, error) { data, status, err := a.do(ctx, http.MethodPost, "/api/files/token", nil) if err != nil { return "", err } if status != http.StatusOK { return "", errors.New("file token request failed: " + string(data)) } var out struct { Token string `json:"token"` } if err := json.Unmarshal(data, &out); err != nil || out.Token == "" { return "", errors.New("file token: no token") } return out.Token, nil } // streamFile fetches a stored file for a record and returns the raw upstream // response so the caller can copy its headers + body to the client. The caller // must Close the returned Body. func (a *adminClient) streamFile(ctx context.Context, collection, recordID, filename string) (*http.Response, error) { tok, err := a.fileToken(ctx) if err != nil { return nil, err } baseURL, _, _ := a.creds() fileURL := baseURL + "/api/files/" + url.PathEscape(collection) + "/" + url.PathEscape(recordID) + "/" + url.PathEscape(filename) + "?token=" + url.QueryEscape(tok) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) return a.client.Do(req) }