Add attachments to service, maintenance, fuel and parts records
Documents already carried a single optional file: upload, download, detach,
access-checked on every request and proxied through this server, so PocketBase's
files are never public URLs. Service records, workshop visits, refills and
catalog parts all want the same thing — a receipt, an invoice, a photo of the
box — so extend it to them.
Rather than copy the document handlers four more times, lift them into one
shared layer. Every attachable collection has a car relation and a file field,
which is what lets a single set of handlers authorize and serve all of them. An
upload finishes by delegating to the collection's own GET handler, so the
response carries the full record — derived fields and all — exactly as a re-read
would. The web side gets the same treatment: one picker component and one
upload-after-save helper behind all five forms. Net effect is five features for
about the cost of the one that was already there.
Alongside:
- parts gain a notes field
- Reminders moves behind Parts catalog in the car detail tabs
- the changed-part labels spell out in full ("Oil & Oil filter" rather than
"Oil & filter"), and the form and table now agree
The PocketBase schema must be migrated before the new attachments work:
scripts/setup-pocketbase.mjs adds the file fields and parts.notes. It is
additive and safe to re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e64c89a564
commit
07192f1238
@@ -3,10 +3,8 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -22,14 +20,8 @@ import (
|
||||
// (models.CarDocument.ComputeExpiry) rather than stored and left to go stale.
|
||||
// Documents also feed the auto-derived reminders in reminders.go.
|
||||
//
|
||||
// The scan/PDF lives in PocketBase's file storage and is reached only through
|
||||
// this server's superuser service account, so an attachment is never a public
|
||||
// URL: clients fetch it from GET /api/car-documents/{id}/file, which re-checks
|
||||
// car access on every request.
|
||||
|
||||
// maxDocumentUpload caps an attachment at 10 MB — comfortably above a scanned
|
||||
// certificate, well below anything that would tie up the server.
|
||||
const maxDocumentUpload = 10 << 20
|
||||
// The scan/PDF attached to a document is handled by attachments.go, on the same
|
||||
// terms as every other record's attachment.
|
||||
|
||||
var documentTypes = map[string]bool{
|
||||
"insurance": true,
|
||||
@@ -196,104 +188,6 @@ func (s *Server) deleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleUploadDocumentFile serves POST /api/car-documents/{id}/file — the
|
||||
// scan/PDF for an existing document. Replaces whatever was attached before.
|
||||
func (s *Server) handleUploadDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxDocumentUpload); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "document upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "missing file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxDocumentUpload+1))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read upload")
|
||||
return
|
||||
}
|
||||
if len(data) > maxDocumentUpload {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "document upload must be under 10MB")
|
||||
return
|
||||
}
|
||||
if msg := validateDocumentFile(header.Filename); msg != "" {
|
||||
writeError(w, http.StatusBadRequest, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.pb.UpdateMultipart(r.Context(), colDocuments, existing.ID, nil, "file", header.Filename, data); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, existing.ID, &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
d := rec.toModel()
|
||||
d.ComputeExpiry(time.Now())
|
||||
writeJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
// handleGetDocumentFile serves GET /api/car-documents/{id}/file. The bytes are
|
||||
// proxied through this server because PocketBase's collections have no public
|
||||
// access rules — only the service account can read them.
|
||||
func (s *Server) handleGetDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var rec documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &rec); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
|
||||
return
|
||||
}
|
||||
if rec.File == "" {
|
||||
writeError(w, http.StatusNotFound, "no file attached")
|
||||
return
|
||||
}
|
||||
data, contentType, err := s.pb.GetFile(r.Context(), colDocuments, rec.ID, rec.File)
|
||||
if err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// The stored name is PocketBase's slugified one; quotes are stripped so a
|
||||
// crafted filename can't break out of the header.
|
||||
name := strings.ReplaceAll(rec.File, `"`, "")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleDeleteDocumentFile serves DELETE /api/car-documents/{id}/file, detaching
|
||||
// the attachment but keeping the document's metadata.
|
||||
func (s *Server) handleDeleteDocumentFile(w http.ResponseWriter, r *http.Request) {
|
||||
var existing documentRecord
|
||||
if err := s.pb.GetOne(r.Context(), colDocuments, r.PathValue("id"), &existing); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
|
||||
return
|
||||
}
|
||||
if err := s.pb.Update(r.Context(), colDocuments, existing.ID, map[string]any{"file": nil}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateDocument(d models.CarDocument) string {
|
||||
switch {
|
||||
case d.Car == "":
|
||||
@@ -310,18 +204,3 @@ func validateDocument(d models.CarDocument) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// documentFileTypes are the extensions an attachment may carry. The list is
|
||||
// restrictive on purpose: these documents are scans, and anything executable has
|
||||
// no business being stored and handed back out.
|
||||
var documentFileTypes = map[string]bool{
|
||||
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
|
||||
}
|
||||
|
||||
func validateDocumentFile(filename string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if !documentFileTypes[ext] {
|
||||
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user