package api import ( "context" "encoding/json" "io" "net/http" "net/url" "strconv" "strings" "time" ) // The document store holds PilotVault's compliance + operational paperwork — // pilot certificates, aircraft registrations, insurance, airspace // authorisations, contracts, and so on. Metadata is stored in the `documents` // PocketBase collection; the blob itself is kept in PocketBase's file storage // *for now* (the temporary stand-in for the S3-compatible object store this // grows into). Like the logbook, every access flows through the superuser // service account and per-role scoping is enforced here in Go. // // Scoping: // - user → documents they uploaded, or that are about them (owner_pilot). // - admin → all documents in their organization. // - superadmin → everything. // // The highest-value behaviour is expiry alerting: each document carries an // `expiry_date`, and the store computes a live expiry assessment (and a // dedicated "expiring soon" query) so certs and registrations get flagged before // they lapse. // soonDays is the default window (days) within which an upcoming expiry is // surfaced as "expiring soon". const soonDays = 30 // --------------------------------------------------------------------------- // Record shapes (snake_case, as stored) + client-facing views. // --------------------------------------------------------------------------- type documentRecord struct { ID string `json:"id"` Title string `json:"title"` DocType string `json:"doc_type"` OwnerType string `json:"owner_type"` OwnerPilot string `json:"owner_pilot"` OwnerDrone string `json:"owner_drone"` OwnerRef string `json:"owner_ref"` Reference string `json:"reference"` Jurisdiction string `json:"jurisdiction"` IssueDate string `json:"issue_date"` ExpiryDate string `json:"expiry_date"` Status string `json:"status"` AccessTier string `json:"access_tier"` File string `json:"file"` Metadata json.RawMessage `json:"metadata"` Notes string `json:"notes"` Replaces string `json:"replaces"` Version float64 `json:"version"` UploadedBy string `json:"uploaded_by"` Organization string `json:"organization"` Created string `json:"created"` Updated string `json:"updated"` } type documentView struct { ID string `json:"id"` Title string `json:"title"` DocType string `json:"docType"` OwnerType string `json:"ownerType"` OwnerPilot string `json:"ownerPilot"` OwnerDrone string `json:"ownerDrone"` OwnerDroneName string `json:"ownerDroneName"` OwnerRef string `json:"ownerRef"` Reference string `json:"reference"` Jurisdiction string `json:"jurisdiction"` IssueDate string `json:"issueDate"` ExpiryDate string `json:"expiryDate"` Status string `json:"status"` AccessTier string `json:"accessTier"` FileName string `json:"fileName"` HasFile bool `json:"hasFile"` Metadata json.RawMessage `json:"metadata,omitempty"` Notes string `json:"notes"` Replaces string `json:"replaces"` Version int `json:"version"` UploadedBy string `json:"uploadedBy"` Organization string `json:"organization"` Created string `json:"created"` Updated string `json:"updated"` Expiry expiryAssessment `json:"expiry"` } // expiryAssessment is the server-computed lifecycle assessment for a document. type expiryAssessment struct { State string `json:"state"` // no_expiry | valid | expiring_soon | expired Days *int `json:"daysUntilExpiry"` // nil when no expiry date Flags []string `json:"flags"` // things worth surfacing } func (d documentRecord) view(drones map[string]droneRecord, now time.Time) documentView { droneName := "" if d.OwnerDrone != "" { if dr, ok := drones[d.OwnerDrone]; ok { droneName = dr.Name } } return documentView{ ID: d.ID, Title: d.Title, DocType: d.DocType, OwnerType: d.OwnerType, OwnerPilot: d.OwnerPilot, OwnerDrone: d.OwnerDrone, OwnerDroneName: droneName, OwnerRef: d.OwnerRef, Reference: d.Reference, Jurisdiction: d.Jurisdiction, IssueDate: day(d.IssueDate), ExpiryDate: day(d.ExpiryDate), Status: d.Status, AccessTier: d.AccessTier, FileName: d.File, HasFile: d.File != "", Metadata: d.Metadata, Notes: d.Notes, Replaces: d.Replaces, Version: int(d.Version), UploadedBy: d.UploadedBy, Organization: d.Organization, Created: d.Created, Updated: d.Updated, Expiry: computeExpiry(d, now), } } // computeExpiry classifies a document by its expiry date relative to `now`. func computeExpiry(d documentRecord, now time.Time) expiryAssessment { a := expiryAssessment{State: "no_expiry", Flags: []string{}} exp := parseDay(d.ExpiryDate) if exp.IsZero() { if d.Status == "pending_review" { a.Flags = append(a.Flags, "Pending review — not yet confirmed valid") } return a } today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) days := int(exp.Sub(today).Hours() / 24) a.Days = &days switch { case days < 0: a.State = "expired" a.Flags = append(a.Flags, "Expired "+plural(-days, "day")+" ago — renew before the linked pilot/aircraft operates") case days <= soonDays: a.State = "expiring_soon" a.Flags = append(a.Flags, "Expires in "+plural(days, "day")+" — schedule a renewal") default: a.State = "valid" } if d.Status == "pending_review" { a.Flags = append(a.Flags, "Pending review — not yet confirmed valid") } return a } func plural(n int, unit string) string { s := strconv.Itoa(n) + " " + unit if n != 1 { s += "s" } return s } // --------------------------------------------------------------------------- // Scope + authorisation. // --------------------------------------------------------------------------- func documentScopeFilter(who *callerIdentity) string { if who.isSuperadmin() { return "" } if who.isManager() && who.OrgID != "" { return "organization = \"" + who.OrgID + "\"" } // Plain user: their own uploads, or documents about them. return "uploaded_by = \"" + who.ID + "\" || owner_pilot = \"" + who.ID + "\"" } func canManageDocument(who *callerIdentity, d documentRecord) bool { if who.isSuperadmin() { return true } if who.isManager() && who.OrgID != "" && d.Organization == who.OrgID { return true } return d.UploadedBy == who.ID } func canViewDocument(who *callerIdentity, d documentRecord) bool { if canManageDocument(who, d) { return true } return d.OwnerPilot == who.ID } // getDocument fetches one document record by id. func (s *Server) getDocument(ctx context.Context, id string) (documentRecord, int, error) { var d documentRecord data, status, err := s.admin.do(ctx, http.MethodGet, "/api/collections/documents/records/"+url.PathEscape(id), nil) if err != nil { return d, 0, err } if status == http.StatusOK { _ = json.Unmarshal(data, &d) } return d, status, nil } // --------------------------------------------------------------------------- // List. // --------------------------------------------------------------------------- // GET /api/documents — list the caller's in-scope documents, each with its // computed expiry assessment. Optional `?expiring=` narrows the result to // live documents that expire within that many days (already-expired included). func (s *Server) handleListDocuments(w http.ResponseWriter, r *http.Request) { who := caller(r) drones, err := s.dronesInScope(r.Context(), who) if err != nil { gatewayError(w, err) return } var list struct { Items []documentRecord `json:"items"` } if _, err := s.listRecords(r.Context(), "documents", documentScopeFilter(who), "-created", &list); err != nil { gatewayError(w, err) return } within, expiringOnly := 0, false if q := strings.TrimSpace(r.URL.Query().Get("expiring")); q != "" { if n, err := strconv.Atoi(q); err == nil { within, expiringOnly = n, true } } now := time.Now().UTC() out := make([]documentView, 0, len(list.Items)) for _, d := range list.Items { v := d.view(drones, now) if expiringOnly { if d.Status == "archived" || v.Expiry.Days == nil || *v.Expiry.Days > within { continue } } out = append(out, v) } writeJSON(w, http.StatusOK, map[string]any{"documents": out}) } // --------------------------------------------------------------------------- // Create (multipart: metadata fields + an optional file blob). // --------------------------------------------------------------------------- const maxUploadBytes = 52 << 20 // 52 MiB, matching the collection's file cap. // POST /api/documents — create a document. Accepts multipart/form-data so the // blob can ride along with the metadata. When `replaces` names an existing // document, this becomes the next version and the superseded row is archived // (the version chain is preserved, never overwritten). func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { who := caller(r) if err := r.ParseMultipartForm(maxUploadBytes); err != nil { writeError(w, http.StatusBadRequest, "invalid multipart form") return } title := strings.TrimSpace(r.FormValue("title")) if title == "" { writeError(w, http.StatusBadRequest, "document title is required") return } // Resolve + authorise an owning drone, if one was named. ownerDrone := strings.TrimSpace(r.FormValue("ownerDrone")) if ownerDrone != "" { drone, status, err := s.getDrone(r.Context(), ownerDrone) if err != nil { gatewayError(w, err) return } if status != http.StatusOK || !droneVisibleTo(who, drone) { writeError(w, http.StatusBadRequest, "selected aircraft is not in your scope") return } } // Versioning: if replacing, inherit version+1 from the superseded document. version := 1 replaces := strings.TrimSpace(r.FormValue("replaces")) var superseded *documentRecord if replaces != "" { old, status, err := s.getDocument(r.Context(), replaces) if err != nil { gatewayError(w, err) return } if status != http.StatusOK || !canManageDocument(who, old) { writeError(w, http.StatusBadRequest, "the document being replaced is not in your scope") return } version = int(old.Version) + 1 if version < 2 { version = 2 } superseded = &old } fields := map[string]string{ "title": title, "doc_type": strings.TrimSpace(r.FormValue("docType")), "owner_type": strings.TrimSpace(r.FormValue("ownerType")), "owner_ref": strings.TrimSpace(r.FormValue("ownerRef")), "reference": strings.TrimSpace(r.FormValue("reference")), "jurisdiction": strings.TrimSpace(r.FormValue("jurisdiction")), "issue_date": strings.TrimSpace(r.FormValue("issueDate")), "expiry_date": strings.TrimSpace(r.FormValue("expiryDate")), "status": statusOr(r.FormValue("status")), "access_tier": strings.TrimSpace(r.FormValue("accessTier")), "notes": strings.TrimSpace(r.FormValue("notes")), "version": strconv.Itoa(version), "uploaded_by": who.ID, } // Relations + selects are omitted when blank so PocketBase doesn't reject an // empty relation id. setIfNotEmpty(fields, "owner_pilot", r.FormValue("ownerPilot")) setIfNotEmpty(fields, "owner_drone", ownerDrone) setIfNotEmpty(fields, "replaces", replaces) setIfNotEmpty(fields, "organization", who.OrgID) if md := strings.TrimSpace(r.FormValue("metadata")); md != "" && json.Valid([]byte(md)) { fields["metadata"] = md } var files []multipartFile if f, hdr, err := r.FormFile("file"); err == nil { defer f.Close() data, err := io.ReadAll(io.LimitReader(f, maxUploadBytes+1)) if err != nil { writeError(w, http.StatusBadRequest, "could not read the uploaded file") return } if len(data) > maxUploadBytes { writeError(w, http.StatusRequestEntityTooLarge, "file exceeds the 50 MB limit") return } files = append(files, multipartFile{field: "file", filename: hdr.Filename, data: data}) } data, status, err := s.admin.doMultipart(r.Context(), http.MethodPost, "/api/collections/documents/records", fields, files) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { relayRaw(w, status, data) return } var d documentRecord _ = json.Unmarshal(data, &d) // Archive the superseded version now that the successor exists. if superseded != nil { _, _, _ = s.admin.do(r.Context(), http.MethodPatch, "/api/collections/documents/records/"+url.PathEscape(superseded.ID), map[string]any{"status": "archived"}) } drones, _ := s.dronesInScope(r.Context(), who) writeJSON(w, http.StatusCreated, map[string]any{"document": d.view(drones, time.Now().UTC())}) } // statusOr defaults a blank/invalid status to "active". func statusOr(s string) string { switch strings.TrimSpace(s) { case "pending_review", "archived", "active": return strings.TrimSpace(s) default: return "active" } } func setIfNotEmpty(m map[string]string, key, val string) { if v := strings.TrimSpace(val); v != "" { m[key] = v } } // --------------------------------------------------------------------------- // Update (metadata only; a new blob is a new version via create+replaces). // --------------------------------------------------------------------------- type documentPatch struct { Title *string `json:"title"` DocType *string `json:"docType"` OwnerType *string `json:"ownerType"` OwnerPilot *string `json:"ownerPilot"` OwnerDrone *string `json:"ownerDrone"` OwnerRef *string `json:"ownerRef"` Reference *string `json:"reference"` Jurisdiction *string `json:"jurisdiction"` IssueDate *string `json:"issueDate"` ExpiryDate *string `json:"expiryDate"` Status *string `json:"status"` AccessTier *string `json:"accessTier"` Notes *string `json:"notes"` Metadata json.RawMessage `json:"metadata"` } // PATCH /api/documents/{id} — update a document's metadata. func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") existing, status, err := s.getDocument(r.Context(), id) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { writeError(w, http.StatusNotFound, "document not found") return } if !canManageDocument(who, existing) { writeError(w, http.StatusForbidden, "you cannot modify this document") return } var in documentPatch if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } payload := map[string]any{} putStr := func(key string, p *string) { if p != nil { payload[key] = strings.TrimSpace(*p) } } if in.Title != nil { t := strings.TrimSpace(*in.Title) if t == "" { writeError(w, http.StatusBadRequest, "document title cannot be empty") return } payload["title"] = t } putStr("doc_type", in.DocType) putStr("owner_type", in.OwnerType) putStr("owner_ref", in.OwnerRef) putStr("reference", in.Reference) putStr("jurisdiction", in.Jurisdiction) putStr("issue_date", in.IssueDate) putStr("expiry_date", in.ExpiryDate) putStr("access_tier", in.AccessTier) putStr("notes", in.Notes) // Relations may be cleared (empty string tells PocketBase to unset them). if in.OwnerPilot != nil { payload["owner_pilot"] = strings.TrimSpace(*in.OwnerPilot) } if in.OwnerDrone != nil { payload["owner_drone"] = strings.TrimSpace(*in.OwnerDrone) } if in.Status != nil { payload["status"] = statusOr(*in.Status) } if len(in.Metadata) > 0 && json.Valid(in.Metadata) { payload["metadata"] = in.Metadata } data, status, err := s.admin.do(r.Context(), http.MethodPatch, "/api/collections/documents/records/"+url.PathEscape(id), payload) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { relayRaw(w, status, data) return } var d documentRecord _ = json.Unmarshal(data, &d) drones, _ := s.dronesInScope(r.Context(), who) writeJSON(w, http.StatusOK, map[string]any{"document": d.view(drones, time.Now().UTC())}) } // --------------------------------------------------------------------------- // Delete. // --------------------------------------------------------------------------- // DELETE /api/documents/{id} — delete a document (and its stored blob). func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") existing, status, err := s.getDocument(r.Context(), id) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { writeError(w, http.StatusNotFound, "document not found") return } if !canManageDocument(who, existing) { writeError(w, http.StatusForbidden, "you cannot delete this document") return } _, st, err := s.admin.do(r.Context(), http.MethodDelete, "/api/collections/documents/records/"+url.PathEscape(id), nil) if err != nil { gatewayError(w, err) return } if st != http.StatusOK && st != http.StatusNoContent { writeError(w, http.StatusBadGateway, "could not delete document") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // --------------------------------------------------------------------------- // File download (streamed through the API Server so the browser never touches // PocketBase directly). // --------------------------------------------------------------------------- // GET /api/documents/{id}/file — stream the stored blob for a document. func (s *Server) handleDownloadDocument(w http.ResponseWriter, r *http.Request) { who := caller(r) id := r.PathValue("id") rec, status, err := s.getDocument(r.Context(), id) if err != nil { gatewayError(w, err) return } if status != http.StatusOK { writeError(w, http.StatusNotFound, "document not found") return } if !canViewDocument(who, rec) { writeError(w, http.StatusForbidden, "you cannot access this document") return } if rec.File == "" { writeError(w, http.StatusNotFound, "this document has no file attached") return } resp, err := s.admin.streamFile(r.Context(), "documents", rec.ID, rec.File) if err != nil { gatewayError(w, err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { writeError(w, http.StatusBadGateway, "could not retrieve the stored file") return } if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } if cl := resp.Header.Get("Content-Length"); cl != "" { w.Header().Set("Content-Length", cl) } w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(rec.File)+"\"") w.WriteHeader(http.StatusOK) _, _ = io.Copy(w, resp.Body) } // sanitizeFilename strips characters that would break a Content-Disposition // header (quotes / control chars); PocketBase filenames are already safe, this // is belt-and-braces. func sanitizeFilename(name string) string { return strings.Map(func(r rune) rune { if r == '"' || r < 0x20 { return '_' } return r }, name) }