Introduces a `documents` collection and full-stack UI for tracking pilot certificates, aircraft registrations, insurance, airspace authorisations, contracts, and other paperwork. - Migration 1720300800_add_documents.js (also provisioned live on remote PB): doc_type/owner/expiry/status/access_tier + file blob, a self-referential `replaces` version chain, audit fields, and a partial index on expiry_date. - API Server (documents.go): role-scoped CRUD, server-computed expiry assessment, ?expiring=N query, versioning (replaces -> version+1, old row auto-archived), and streamed blob download. admin.go gains multipart upload, file tokens, and protected-file streaming. - Web App: BFF passthrough (multipart create + streamed download), api.js client fns, and Documents.vue wired into the Documents nav slot. Blobs live in PocketBase file storage for now; only that backend swaps when S3-compatible object storage lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
260 lines
7.4 KiB
Go
260 lines
7.4 KiB
Go
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)
|
|
}
|