Add compliance & operational document store (PocketBase)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e3106d7b60
commit
52f84ad6cf
@@ -6,7 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -153,3 +155,105 @@ func (a *adminClient) do(ctx context.Context, method, path string, payload any)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user