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 "" }