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>
162 lines
6.0 KiB
Go
162 lines
6.0 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Single-file attachments. A document has a scan, a service or a refill has a
|
|
// receipt, a workshop visit has an invoice, a catalog part has a photo of the
|
|
// box — all the same thing: one optional file hanging off a record that already
|
|
// exists.
|
|
//
|
|
// The terms are identical everywhere, so the endpoints are registered from one
|
|
// place (attachmentRoutes) rather than copied per collection. The bytes live in
|
|
// PocketBase's file storage and are reached only through this server's superuser
|
|
// service account, so an attachment is never a public URL: clients fetch it from
|
|
// GET /api/{records}/{id}/file, which re-checks car access on every request.
|
|
|
|
// maxAttachmentUpload caps an attachment at 10 MB — comfortably above a scanned
|
|
// certificate or a photographed receipt, well below anything that would tie up
|
|
// the server. Mirrored by the collections' own maxSize in setup-pocketbase.mjs.
|
|
const maxAttachmentUpload = 10 << 20
|
|
|
|
// attachmentFileTypes are the extensions an attachment may carry. The list is
|
|
// restrictive on purpose: these are scans and photos, and anything executable
|
|
// has no business being stored and handed back out.
|
|
var attachmentFileTypes = map[string]bool{
|
|
".pdf": true, ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".heic": true,
|
|
}
|
|
|
|
// attachedRecord is the slice of an attachable record these handlers need: the
|
|
// parent car to authorize against, and the stored file name. Every attachable
|
|
// collection has both, which is what lets one handler serve all of them.
|
|
type attachedRecord struct {
|
|
ID string `json:"id"`
|
|
Car string `json:"car"`
|
|
File string `json:"file"`
|
|
}
|
|
|
|
// attachmentRoutes registers the upload/download/detach endpoints for one
|
|
// collection under prefix (e.g. "/api/service-records").
|
|
//
|
|
// respond is the collection's own GET handler. An upload finishes by delegating
|
|
// to it, so the response carries the full record in its own model shape —
|
|
// derived fields and all — exactly as a re-read would.
|
|
func (s *Server) attachmentRoutes(mux *http.ServeMux, prefix, collection string, respond http.HandlerFunc) {
|
|
mux.HandleFunc("POST "+prefix+"/{id}/file", s.handleUploadAttachment(collection, respond))
|
|
mux.HandleFunc("GET "+prefix+"/{id}/file", s.handleGetAttachment(collection))
|
|
mux.HandleFunc("DELETE "+prefix+"/{id}/file", s.handleDeleteAttachment(collection))
|
|
}
|
|
|
|
// handleUploadAttachment serves POST /api/{records}/{id}/file, replacing
|
|
// whatever was attached before.
|
|
func (s *Server) handleUploadAttachment(collection string, respond http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
existing, ok := s.attachmentTarget(w, r, collection, accessWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseMultipartForm(maxAttachmentUpload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "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, maxAttachmentUpload+1))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "could not read upload")
|
|
return
|
|
}
|
|
if len(data) > maxAttachmentUpload {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "upload must be under 10MB")
|
|
return
|
|
}
|
|
if msg := validateAttachmentFile(header.Filename); msg != "" {
|
|
writeError(w, http.StatusBadRequest, msg)
|
|
return
|
|
}
|
|
|
|
if err := s.pb.UpdateMultipart(r.Context(), collection, existing.ID, nil, "file", header.Filename, data); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
respond(w, r)
|
|
}
|
|
}
|
|
|
|
// handleGetAttachment serves GET /api/{records}/{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) handleGetAttachment(collection string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
rec, ok := s.attachmentTarget(w, r, collection, accessRead)
|
|
if !ok {
|
|
return
|
|
}
|
|
if rec.File == "" {
|
|
writeError(w, http.StatusNotFound, "no file attached")
|
|
return
|
|
}
|
|
data, contentType, err := s.pb.GetFile(r.Context(), collection, 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)
|
|
}
|
|
}
|
|
|
|
// handleDeleteAttachment serves DELETE /api/{records}/{id}/file, detaching the
|
|
// file but keeping the record itself.
|
|
func (s *Server) handleDeleteAttachment(collection string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
existing, ok := s.attachmentTarget(w, r, collection, accessWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.pb.Update(r.Context(), collection, existing.ID, map[string]any{"file": nil}, nil); err != nil {
|
|
writePBError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// attachmentTarget loads the record named by the {id} path value and authorizes
|
|
// the caller against its car. It writes the error response itself; ok=false
|
|
// means the caller must return without writing anything further.
|
|
func (s *Server) attachmentTarget(w http.ResponseWriter, r *http.Request, collection, need string) (attachedRecord, bool) {
|
|
var rec attachedRecord
|
|
if err := s.pb.GetOne(r.Context(), collection, r.PathValue("id"), &rec); err != nil {
|
|
writePBError(w, err)
|
|
return rec, false
|
|
}
|
|
if !s.requireCarAccess(w, r, rec.Car, need) {
|
|
return rec, false
|
|
}
|
|
return rec, true
|
|
}
|
|
|
|
func validateAttachmentFile(filename string) string {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
if !attachmentFileTypes[ext] {
|
|
return "file must be a PDF or an image (pdf, jpg, png, webp, heic)"
|
|
}
|
|
return ""
|
|
}
|