package api import ( "encoding/json" "fmt" "io" "net/http" "net/url" "path/filepath" "sort" "strings" "time" "drivervault/apiserver/internal/models" ) // Document tracking: insurance policies, pollution/emissions certificates, // registration papers and the like, each with a renewal date. // // The expiry date is the reason the feature exists — a lapsed policy is a car // that cannot legally be driven — so it is assessed live on every read // (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 var documentTypes = map[string]bool{ "insurance": true, "pollution": true, "registration": true, "inspection": true, "roadTax": true, "warranty": true, "other": true, } // listCarDocuments serves GET /api/cars/{id}/documents. func (s *Server) listCarDocuments(w http.ResponseWriter, r *http.Request) { carID := r.PathValue("id") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondDocumentList(w, r, carID) } // listDocuments serves GET /api/car-documents?car={id}. func (s *Server) listDocuments(w http.ResponseWriter, r *http.Request) { carID := r.URL.Query().Get("car") if !s.requireCarAccess(w, r, carID, accessRead) { return } s.respondDocumentList(w, r, carID) } func (s *Server) respondDocumentList(w http.ResponseWriter, r *http.Request, carID string) { docs, err := s.fetchDocuments(r, carID) if err != nil { writePBError(w, err) return } writeJSON(w, http.StatusOK, docs) } // fetchDocuments loads a car's documents with the soonest renewal first — the // order in which they need attention. func (s *Server) fetchDocuments(r *http.Request, carID string) ([]models.CarDocument, error) { res, err := s.pb.List(r.Context(), colDocuments, url.Values{ "filter": {fmt.Sprintf("car='%s'", carID)}, "perPage": {"500"}, }) if err != nil { return nil, err } var recs []documentRecord if err := json.Unmarshal(res.Items, &recs); err != nil { return nil, err } now := time.Now() out := make([]models.CarDocument, 0, len(recs)) for _, rec := range recs { d := rec.toModel() d.ComputeExpiry(now) out = append(out, d) } // Sorted here rather than by PocketBase: it orders a blank expiry_date ahead // of every real date, which would file the documents that never expire above // the ones that have already lapsed — the exact inverse of what this list is // for. Everything with a renewal date comes first, soonest at the top. sort.SliceStable(out, func(i, j int) bool { a, b := out[i], out[j] ae, be := a.ExpiryDate != nil, b.ExpiryDate != nil if ae != be { return ae } if ae && be && !a.ExpiryDate.Equal(*b.ExpiryDate) { return a.ExpiryDate.Before(*b.ExpiryDate) } return a.Title < b.Title }) return out, nil } func (s *Server) getDocument(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 } d := rec.toModel() d.ComputeExpiry(time.Now()) writeJSON(w, http.StatusOK, d) } func (s *Server) createDocument(w http.ResponseWriter, r *http.Request) { var in models.CarDocument if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if in.Type == "" { in.Type = "other" } if msg := validateDocument(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } if !s.requireCarAccess(w, r, in.Car, accessWrite) { return } var rec documentRecord if err := s.pb.Create(r.Context(), colDocuments, documentPayload(in), &rec); err != nil { writePBError(w, err) return } d := rec.toModel() d.ComputeExpiry(time.Now()) writeJSON(w, http.StatusCreated, d) } func (s *Server) updateDocument(w http.ResponseWriter, r *http.Request) { var in models.CarDocument if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } 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 } in.Car = existing.Car // the document's car is not reassignable via PATCH if in.Type == "" { in.Type = "other" } if msg := validateDocument(in); msg != "" { writeError(w, http.StatusBadRequest, msg) return } var rec documentRecord if err := s.pb.Update(r.Context(), colDocuments, r.PathValue("id"), documentPayload(in), &rec); err != nil { writePBError(w, err) return } d := rec.toModel() d.ComputeExpiry(time.Now()) writeJSON(w, http.StatusOK, d) } func (s *Server) deleteDocument(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.Delete(r.Context(), colDocuments, r.PathValue("id")); err != nil { writePBError(w, err) return } 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 == "": return "car is required" case strings.TrimSpace(d.Title) == "": return "title is required" case !documentTypes[d.Type]: return "type must be one of: insurance, pollution, registration, inspection, roadTax, warranty, other" case d.Cost < 0: return "cost cannot be negative" } if d.IssueDate != nil && d.ExpiryDate != nil && d.ExpiryDate.Before(*d.IssueDate) { return "expiry date cannot be before the issue date" } 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 "" }