// Package api exposes the HTTP REST surface of the DriverVault API Server. // // Clients (web app, phone app, Home Assistant plugin, ESP32 device) talk only to // this server; this server is the only thing that talks to PocketBase. It also // serves the superadmin web panel at the root. // // Authentication is PocketBase's own: /api/auth/login is proxied to the // PocketBase users collection and the client keeps the token PocketBase minted. // Every protected request re-resolves that token against PocketBase, so a role // change or a deletion takes effect immediately. // // # public // GET /healthz // GET /api/health // GET /api/status // POST /api/auth/login // GET /api/auth/validate // // # identity // GET /api/auth/me // GET /api/identity // // # current user // 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 // // # users + organizations (manager; writes to orgs are superadmin-only) // GET /api/users POST /api/users // PATCH /api/users/{id} DELETE /api/users/{id} // GET /api/orgs POST /api/orgs // PATCH /api/orgs/{id} DELETE /api/orgs/{id} // // # superadmin // GET /api/admin/pb-config PUT /api/admin/pb-config // POST /api/admin/pb-config/test // GET /api/admin/plugins POST /api/admin/plugins // GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name} // DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health // // # cars, service records, parts, shares // 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} package api import ( "context" "log" "net/http" "sync" "time" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" "drivervault/apiserver/internal/plugins" _ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins ) // PocketBase collection names. const ( colCars = "cars" colServices = "service_records" colParts = "parts" colShares = "car_shares" colOrgs = "organizations" ) // Server wires together the HTTP handlers and their dependencies. type Server struct { mu sync.RWMutex // guards the mutable PocketBase connection in cfg cfg config.Config pb *pb.Client plugins *plugins.Manager } // New constructs a Server around an already-built PocketBase client. func New(cfg config.Config, client *pb.Client) *Server { return &Server{ cfg: cfg, pb: client, plugins: plugins.NewManager(cfg.PluginsFile), } } // StartPlugins loads persisted plugin state and initialises enabled plugins. func (s *Server) StartPlugins() error { return s.plugins.Load() } // Stop releases server-held resources (currently: plugin instances). func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) } // usersCollection returns the PocketBase auth collection holding app users. func (s *Server) usersCollection() string { s.mu.RLock() defer s.mu.RUnlock() return s.cfg.UsersCollection } // webAppURL returns the Web App address probed by /api/status. func (s *Server) webAppURL() string { s.mu.RLock() defer s.mu.RUnlock() return s.cfg.WebAppURL } // pbSettings snapshots the PocketBase connection for the settings endpoints. func (s *Server) pbSettings() (url, adminEmail, adminPassword string) { s.mu.RLock() defer s.mu.RUnlock() return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword } // setPBConfig retargets the PocketBase connection at runtime: it updates the // cached config and repoints the client (which drops its cached superuser token, // so the next call re-authenticates against the new target). func (s *Server) setPBConfig(url, adminEmail, adminPassword string) { s.mu.Lock() s.cfg.PocketBaseURL = url s.cfg.PocketBaseAdminEmail = adminEmail s.cfg.PocketBaseAdminPassword = adminPassword s.mu.Unlock() s.pb.Reconfigure(url, adminEmail, adminPassword) } // Handler returns the root HTTP handler with all routes registered. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() // Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel // paths are routed to it so unknown /api/* paths still 404 as JSON. panel := panelHandler() mux.Handle("GET /{$}", panel) mux.Handle("GET /assets/", panel) mux.Handle("GET /favicon.svg", panel) // Health (public). mux.HandleFunc("GET /healthz", s.handleHealth) mux.HandleFunc("GET /api/health", s.handleHealth) mux.HandleFunc("GET /api/status", s.handleStatus) // Auth — proxied to the PocketBase kept behind this server. mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin) mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate) mux.HandleFunc("GET /api/auth/me", s.handleAuthMe) mux.HandleFunc("GET /api/identity", s.handleIdentity) // Current user (profile / appearance / avatar / data / account lifecycle). 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) // User management — gated on the caller being a manager (admin or // superadmin). Admins are scoped to their own organization inside each // handler. mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers)) mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser)) mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser)) mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser)) // Organizations — listing is manager-scoped; create/edit/delete are // superadmin-only (a superadmin spans all organizations). mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs)) mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg)) mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg)) mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg)) // PocketBase connection settings — superadmin only. These do NOT require the // service account to already be configured (they exist to configure it). mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig)) mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig)) mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig)) // Plugins — external-service integrations, managed by a superadmin. mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins)) mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin)) mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin)) mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin)) mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin)) mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth)) // Cars + sharing. 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) // Service records. 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) // Parts. 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.withMiddleware(mux) } // withMiddleware applies panic recovery, CORS, request logging, and // authentication globally. func (s *Server) withMiddleware(next http.Handler) http.Handler { return s.recoverer(s.cors(s.logger(s.withAuth(next)))) } func (s *Server) logger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(sw, r) log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond)) }) } func (s *Server) recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { log.Printf("panic: %v", rec) writeError(w, http.StatusInternalServerError, "internal error") } }() next.ServeHTTP(w, r) }) } func (s *Server) cors(next http.Handler) http.Handler { allowed := map[string]bool{} wildcard := false for _, o := range s.cfg.AllowOrigins { if o == "*" { wildcard = true } allowed[o] = true } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" && (wildcard || allowed[origin]) { if wildcard { w.Header().Set("Access-Control-Allow-Origin", "*") } else { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Add("Vary", "Origin") } w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } // statusWriter captures the response status code for logging. type statusWriter struct { http.ResponseWriter status int wrote bool } func (w *statusWriter) WriteHeader(code int) { if !w.wrote { w.status = code w.wrote = true } w.ResponseWriter.WriteHeader(code) } func (w *statusWriter) Write(b []byte) (int, error) { w.wrote = true return w.ResponseWriter.Write(b) }