Adds an inline preview for stored document files alongside download.
- API Server: GET /api/documents/{id}/file honours ?inline=1, serving an
inline Content-Disposition so the browser renders the file instead of
forcing a download (default stays attachment).
- Web App: BFF forwards the inline flag; documentPreviewUrl() helper; an eye
icon; and a preview modal (Teleported to body) that picks a viewer from the
file extension — images -> <img>, PDFs/text -> <iframe>, else a
download-instead fallback. Closes on backdrop click or Escape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
579 lines
19 KiB
Go
579 lines
19 KiB
Go
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=<days>` 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.
|
|
// By default the response is an attachment (download); `?inline=1` serves it
|
|
// with an inline disposition so the browser renders it in-place (preview).
|
|
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)
|
|
}
|
|
disposition := "attachment"
|
|
if r.URL.Query().Get("inline") == "1" {
|
|
disposition = "inline"
|
|
}
|
|
w.Header().Set("Content-Disposition", disposition+"; 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)
|
|
}
|