// Package api exposes the HTTP REST surface of the car-control API Server. // // Clients (web app, phone app, ...) talk only to this server; this server is // the only thing that talks to PocketBase. Endpoints: // // GET /api/health // GET /api/cars // POST /api/cars // GET /api/cars/{id} // PATCH /api/cars/{id} // DELETE /api/cars/{id} // GET /api/cars/{id}/service-records // GET /api/cars/{id}/parts // GET /api/cars/{id}/shares // POST /api/cars/{id}/shares // DELETE /api/cars/{id}/shares/{userId} // GET /api/service-records // POST /api/service-records // GET /api/service-records/{id} // PATCH /api/service-records/{id} // DELETE /api/service-records/{id} // GET /api/parts // POST /api/parts // GET /api/parts/{id} // PATCH /api/parts/{id} // DELETE /api/parts/{id} // GET /api/me // PATCH /api/me // DELETE /api/me // POST /api/me/password // POST /api/me/avatar // GET /api/me/avatar // DELETE /api/me/avatar // POST /api/me/verify/request // GET /api/me/export // POST /api/me/import // POST /api/me/delete // POST /api/me/delete/cancel // GET /api/sessions // DELETE /api/sessions/{id} // DELETE /api/sessions // GET /api/admin/users // POST /api/admin/users // PATCH /api/admin/users/{id} // POST /api/admin/users/{id}/password // DELETE /api/admin/users/{id} package api import ( "encoding/json" "log" "net/http" "time" "carcontrol/api/internal/pb" ) // PocketBase collection names. const ( colCars = "cars" colServices = "service_records" colParts = "parts" colSessions = "sessions" colShares = "car_shares" ) type Server struct { pb *pb.Client corsOrigins map[string]bool authSecret string usersCollection string } type Options struct { CORSOrigins []string AuthSecret string UsersCollection string } func NewServer(client *pb.Client, opts Options) *Server { set := make(map[string]bool, len(opts.CORSOrigins)) for _, o := range opts.CORSOrigins { set[o] = true } return &Server{ pb: client, corsOrigins: set, authSecret: opts.AuthSecret, usersCollection: opts.UsersCollection, } } // Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux). func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // DriverVault web panel (public) — embedded Vue + Tailwind app served at the // root. Only explicit panel paths are routed to it, so unknown /api/* paths // still 404 as JSON rather than serving the SPA shell. panel := panelHandler() mux.Handle("GET /{$}", panel) mux.Handle("GET /assets/", panel) mux.Handle("GET /favicon.svg", panel) mux.HandleFunc("GET /api/health", s.handleHealth) mux.HandleFunc("POST /api/auth/login", s.handleLogin) mux.HandleFunc("GET /api/auth/me", s.handleMe) mux.HandleFunc("GET /api/me", s.handleGetMe) mux.HandleFunc("PATCH /api/me", s.handleUpdateMe) mux.HandleFunc("POST /api/me/password", s.handleChangePassword) mux.HandleFunc("POST /api/me/avatar", s.handleUploadAvatar) mux.HandleFunc("GET /api/me/avatar", s.handleGetAvatar) mux.HandleFunc("DELETE /api/me/avatar", s.handleDeleteAvatar) mux.HandleFunc("POST /api/me/verify/request", s.handleRequestVerification) mux.HandleFunc("GET /api/me/export", s.handleExportData) mux.HandleFunc("POST /api/me/import", s.handleImportData) mux.HandleFunc("POST /api/me/delete", s.handleRequestDeletion) mux.HandleFunc("POST /api/me/delete/cancel", s.handleCancelDeletion) mux.HandleFunc("DELETE /api/me", s.handleFinalizeDeletion) mux.HandleFunc("GET /api/sessions", s.handleListSessions) mux.HandleFunc("DELETE /api/sessions/{id}", s.handleRevokeSession) mux.HandleFunc("DELETE /api/sessions", s.handleRevokeOtherSessions) mux.HandleFunc("GET /api/admin/users", s.handleListUsers) mux.HandleFunc("POST /api/admin/users", s.handleCreateUser) mux.HandleFunc("PATCH /api/admin/users/{id}", s.handleUpdateUser) mux.HandleFunc("POST /api/admin/users/{id}/password", s.handleSetUserPassword) mux.HandleFunc("DELETE /api/admin/users/{id}", s.handleDeleteUser) mux.HandleFunc("GET /api/cars", s.listCars) mux.HandleFunc("POST /api/cars", s.createCar) mux.HandleFunc("GET /api/cars/{id}", s.getCar) mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar) mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar) mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords) mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts) mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares) mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare) mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare) mux.HandleFunc("GET /api/service-records", s.listServiceRecords) mux.HandleFunc("POST /api/service-records", s.createServiceRecord) mux.HandleFunc("GET /api/service-records/{id}", s.getServiceRecord) mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord) mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord) mux.HandleFunc("GET /api/parts", s.listParts) mux.HandleFunc("POST /api/parts", s.createPart) mux.HandleFunc("GET /api/parts/{id}", s.getPart) mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart) mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart) return s.withCORS(s.withLogging(s.withAuth(mux))) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), }) } // --- middleware --- func (s *Server) withCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" && (s.corsOrigins[origin] || s.corsOrigins["*"]) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } func (s *Server) withLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) }) } // --- helpers --- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if v != nil { _ = json.NewEncoder(w).Encode(v) } } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // writePBError maps a PocketBase error to an appropriate HTTP status. func writePBError(w http.ResponseWriter, err error) { if apiErr, ok := err.(*pb.APIError); ok { status := apiErr.Status if status < 400 { status = http.StatusBadGateway } writeError(w, status, apiErr.Body) return } writeError(w, http.StatusBadGateway, err.Error()) } func decodeJSON(r *http.Request, dest any) error { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() return dec.Decode(dest) }