// 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; POST /api/orgs is open to any user) // 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/webapp-config PUT /api/admin/webapp-config // POST /api/admin/webapp-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 // // # integrations (per-user plugin settings; superadmin → org admin → user cascade) // GET /api/integrations/toyota PUT /api/integrations/toyota // POST /api/integrations/toyota/health // GET /api/integrations/toyota/vehicles // GET /api/integrations/anker-solix PUT /api/integrations/anker-solix // POST /api/integrations/anker-solix/health // GET /api/integrations/anker-solix/chargers // // # vehicle providers (create a car from a manufacturer service; per-car tab) // GET /api/vehicle-providers // GET /api/vehicle-providers/{provider}/vehicles // POST /api/vehicle-providers/{provider}/import // // # cars, service records, parts, shares // GET /api/cars POST /api/cars // GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id} // PUT /api/cars/{id}/view // GET /api/cars/{id}/provider POST /api/cars/{id}/provider // POST /api/cars/{id}/provider/sync // 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} // // # technical checks (roadworthiness inspections; time-only, next date derived) // GET /api/cars/{id}/technical-checks // GET /api/technical-checks POST /api/technical-checks // GET /api/technical-checks/{id} PATCH /api/technical-checks/{id} // DELETE /api/technical-checks/{id} // // # fuel tracking (efficiency is derived on read, never stored) // GET /api/cars/{id}/fuel-entries // GET /api/cars/{id}/fuel-stats // GET /api/fuel-entries POST /api/fuel-entries // GET /api/fuel-entries/{id} PATCH /api/fuel-entries/{id} // DELETE /api/fuel-entries/{id} // // # charging tracking (EV counterpart of fuel; efficiency derived on read) // GET /api/cars/{id}/charging-sessions // GET /api/cars/{id}/charging-stats // GET /api/charging-sessions POST /api/charging-sessions // GET /api/charging-sessions/{id} PATCH /api/charging-sessions/{id} // DELETE /api/charging-sessions/{id} // // # maintenance log (workshop visits + repairs; distinct from service records) // GET /api/cars/{id}/maintenance // GET /api/maintenance POST /api/maintenance // GET /api/maintenance/{id} PATCH /api/maintenance/{id} // DELETE /api/maintenance/{id} // // # document tracking (insurance, pollution certs, … + renewal dates) // GET /api/cars/{id}/documents // GET /api/car-documents POST /api/car-documents // GET /api/car-documents/{id} PATCH /api/car-documents/{id} // DELETE /api/car-documents/{id} // // # attachments — one optional file per record, same three verbs everywhere. // # {records} is car-documents | service-records | technical-checks | maintenance // # | fuel-entries | charging-sessions | parts // POST /api/{records}/{id}/file // GET /api/{records}/{id}/file // DELETE /api/{records}/{id}/file // // # reminders (stored + auto-derived from documents and service records) // GET /api/cars/{id}/reminders // GET /api/reminders POST /api/reminders // GET /api/reminders/{id} PATCH /api/reminders/{id} // DELETE /api/reminders/{id} POST /api/reminders/{id}/complete package api import ( "bufio" "context" "log" "net" "net/http" "sync" "time" "drivervault/apiserver/internal/bootstrap" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/ocpp" "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" colTechnicalChecks = "technical_checks" colParts = "parts" colShares = "car_shares" colOrgs = "organizations" colFuel = "fuel_entries" colCharging = "charging_sessions" colMaintenance = "maintenance_entries" colDocuments = "car_documents" colReminders = "reminders" colControlAudit = "control_audit" colAppSettings = "app_settings" ) // 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 // pluginStore is the Manager's backing store; pluginsStop ends the // background load retry started by StartPlugins. pluginStore plugins.Store pluginsStop context.CancelFunc // ocpp is the OCPP 1.6J Central System that Anker Solix chargers connect to // when their owner picks a control mode of own/proxy (see internal/ocpp and // integrations_ankersolix_control.go). Nil-safe: control endpoints report a // clear error when a charger is not connected. ocpp *ocpp.CSMS control *controlIndex // token -> owning user/charger for the /ocpp endpoint ctlRL *rateLimiter // per user+charger control-command rate limit } // New constructs a Server around an already-built PocketBase client. func New(cfg config.Config, client *pb.Client) *Server { // The global (L1) plugin layer lives in PocketBase alongside the org (L2) // and user (L3) layers, rather than in a file beside the binary. store := plugins.NewPocketBaseStore(client, colAppSettings) return &Server{ cfg: cfg, pb: client, pluginStore: store, plugins: plugins.NewManager(store), ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }), control: newControlIndex(), ctlRL: newRateLimiter(30, time.Minute), // 30 control commands / min / charger } } // StartPlugins reads the plugin settings from PocketBase and initialises every // enabled plugin. It also warms the OCPP control-token index from PocketBase (its // source of truth), so the first charger to reconnect after a restart resolves // immediately instead of triggering a lazy rebuild mid-handshake. The warm-up is // best-effort and non-blocking; if PocketBase is not yet configured it no-ops and // the lazy path rebuilds on first connect. // // The settings now live in the database, so at boot the database may not be // reachable yet — a cold stack, or a service account still to be configured from // the panel. That is not fatal and, crucially, not treated as "no plugins // configured": the Manager stays unloaded, the admin endpoints answer 503, and a // background retry keeps trying until the read succeeds. Nothing is written // until something has been read, so an outage cannot erase the settings. func (s *Server) StartPlugins() error { ctx, cancel := context.WithCancel(context.Background()) s.pluginsStop = cancel go func() { warmCtx, warmCancel := context.WithTimeout(context.Background(), 15*time.Second) defer warmCancel() s.ensureControlIndex(warmCtx) }() err := s.loadPlugins(ctx) if err != nil { log.Printf("plugins: settings unavailable, retrying in the background (%v)", err) go s.retryLoadPlugins(ctx) } return err } // loadPlugins reads the settings, creating the collection they live in if it // turns out not to exist. // // That happens on a stack upgraded with PB_BOOTSTRAP off: the on-boot schema // pass never ran, so app_settings was never created, and because a missing // collection is read as "not ready" (never as "no plugins configured", which // would let the first save overwrite settings the server merely failed to find) // the retry below would spin forever with the plugin panel stuck at 503. So // create just that one collection — not a full schema reconcile, which an // operator who turned the bootstrap off has not asked for — and read again. func (s *Server) loadPlugins(ctx context.Context) error { err := s.plugins.Load(ctx) if err == nil || !plugins.IsMissingCollection(err) { return err } log.Printf("plugins: the %s collection does not exist; creating it", colAppSettings) if repairErr := bootstrap.EnsureCollection(ctx, s.pb, colAppSettings); repairErr != nil { log.Printf("plugins: could not create %s: %v", colAppSettings, repairErr) return err // report the original problem, not the repair's } log.Printf("plugins: created %s", colAppSettings) return s.plugins.Load(ctx) } // retryLoadPlugins keeps reading until the settings load or the server stops. // The backoff caps at two minutes, so a long outage costs at most one log line // every two minutes rather than a tight spin. func (s *Server) retryLoadPlugins(ctx context.Context) { const maxBackoff = 2 * time.Minute backoff := 5 * time.Second for { select { case <-ctx.Done(): return case <-time.After(backoff): } if err := s.loadPlugins(ctx); err == nil { log.Println("plugins: settings loaded") return } else { log.Printf("plugins: still unavailable, retrying in %s (%v)", backoff, err) } if backoff < maxBackoff { backoff *= 2 } } } // Stop releases server-held resources (plugin instances and OCPP sessions). func (s *Server) Stop(ctx context.Context) { if s.pluginsStop != nil { s.pluginsStop() } s.ocpp.Shutdown(ctx) 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 } // webAppSettings snapshots the Web App settings for the settings endpoints. func (s *Server) webAppSettings() (url string, allowOrigins []string) { s.mu.RLock() defer s.mu.RUnlock() return s.cfg.WebAppURL, append([]string(nil), s.cfg.AllowOrigins...) } // setWebAppConfig applies new Web App settings at runtime. The CORS middleware // reads the origin list per request, so the new list is live immediately. func (s *Server) setWebAppConfig(url string, allowOrigins []string) { s.mu.Lock() defer s.mu.Unlock() s.cfg.WebAppURL = url s.cfg.AllowOrigins = append([]string(nil), allowOrigins...) } // 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 — the tenants users belong to. Listing is manager-scoped (an // admin sees only their own org). Any org-less user may create an org and // becomes its admin; an admin may rename or delete their own org; a superadmin // spans every organization. Create carries no role gate, so it checks the // service account itself. mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs)) mux.HandleFunc("POST /api/orgs", s.handleCreateOrg) mux.HandleFunc("PATCH /api/orgs/{id}", s.requireManager(s.handleUpdateOrg)) mux.HandleFunc("DELETE /api/orgs/{id}", s.requireManager(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)) // Web App settings — superadmin only. Where the Web App lives (probed by // /api/status) and which browser origins CORS admits. mux.HandleFunc("GET /api/admin/webapp-config", s.requireSuperadminAuth(s.handleGetWebAppConfig)) mux.HandleFunc("POST /api/admin/webapp-config/test", s.requireSuperadminAuth(s.handleTestWebAppConfig)) mux.HandleFunc("PUT /api/admin/webapp-config", s.requireSuperadminAuth(s.handleUpdateWebAppConfig)) // 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)) // Integrations — per-user plugin settings under the superadmin → org admin → // user cascade (see integrations.go). Any authenticated user manages their // own layer; an org admin may also target their organization's layer. mux.HandleFunc("GET /api/integrations/toyota", s.handleGetToyota) mux.HandleFunc("PUT /api/integrations/toyota", s.handlePutToyota) mux.HandleFunc("POST /api/integrations/toyota/health", s.handleToyotaHealth) mux.HandleFunc("GET /api/integrations/toyota/vehicles", s.handleToyotaVehicles) mux.HandleFunc("GET /api/integrations/anker-solix", s.handleGetAnker) mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker) mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth) mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers) // Anker Solix OCPP control (per-charger; gated by the same cascade plus a // control mode of own/proxy and a live CSMS session). See // integrations_ankersolix_control.go. mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus) mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken) mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlRevoke) mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction) // OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits // outside /api/ so it bypasses bearer auth; it authenticates the charger with // OCPP Basic auth (serial + per-charger control token) instead. mux.HandleFunc("GET /ocpp/{serial}", s.handleOCPPConnect) // Vehicle providers — manufacturer services a car can be created from, and // the per-car provider tab. Generic over the registered providers; see // vehicleproviders.go. mux.HandleFunc("GET /api/vehicle-providers", s.handleListVehicleProviders) mux.HandleFunc("GET /api/vehicle-providers/{provider}/vehicles", s.handleProviderVehicles) mux.HandleFunc("POST /api/vehicle-providers/{provider}/import", s.handleProviderImport) // 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("PUT /api/cars/{id}/view", s.updateCarView) mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar) mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords) mux.HandleFunc("GET /api/cars/{id}/technical-checks", s.listCarTechnicalChecks) mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts) mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries) mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats) mux.HandleFunc("GET /api/cars/{id}/charging-sessions", s.listCarChargingSessions) mux.HandleFunc("GET /api/cars/{id}/charging-stats", s.listCarChargingStats) mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance) mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments) mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders) mux.HandleFunc("GET /api/cars/{id}/provider", s.handleCarProvider) mux.HandleFunc("POST /api/cars/{id}/provider", s.handleLinkCarProvider) mux.HandleFunc("POST /api/cars/{id}/provider/sync", s.handleSyncCarProvider) 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) // Technical checks (roadworthiness inspections). mux.HandleFunc("GET /api/technical-checks", s.listTechnicalChecks) mux.HandleFunc("POST /api/technical-checks", s.createTechnicalCheck) mux.HandleFunc("GET /api/technical-checks/{id}", s.getTechnicalCheck) mux.HandleFunc("PATCH /api/technical-checks/{id}", s.updateTechnicalCheck) mux.HandleFunc("DELETE /api/technical-checks/{id}", s.deleteTechnicalCheck) // 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) // Fuel entries. mux.HandleFunc("GET /api/fuel-entries", s.listFuelEntries) mux.HandleFunc("POST /api/fuel-entries", s.createFuelEntry) mux.HandleFunc("GET /api/fuel-entries/{id}", s.getFuelEntry) mux.HandleFunc("PATCH /api/fuel-entries/{id}", s.updateFuelEntry) mux.HandleFunc("DELETE /api/fuel-entries/{id}", s.deleteFuelEntry) // Charging sessions. mux.HandleFunc("GET /api/charging-sessions", s.listChargingSessions) mux.HandleFunc("POST /api/charging-sessions", s.createChargingSession) mux.HandleFunc("GET /api/charging-sessions/{id}", s.getChargingSession) mux.HandleFunc("PATCH /api/charging-sessions/{id}", s.updateChargingSession) mux.HandleFunc("DELETE /api/charging-sessions/{id}", s.deleteChargingSession) // Maintenance log. mux.HandleFunc("GET /api/maintenance", s.listMaintenance) mux.HandleFunc("POST /api/maintenance", s.createMaintenance) mux.HandleFunc("GET /api/maintenance/{id}", s.getMaintenance) mux.HandleFunc("PATCH /api/maintenance/{id}", s.updateMaintenance) mux.HandleFunc("DELETE /api/maintenance/{id}", s.deleteMaintenance) // Documents. Named /api/car-documents so the path can't be mistaken for the // user-facing account documents some other Vault services expose. mux.HandleFunc("GET /api/car-documents", s.listDocuments) mux.HandleFunc("POST /api/car-documents", s.createDocument) mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument) mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument) mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument) // Reminders. mux.HandleFunc("GET /api/reminders", s.listReminders) mux.HandleFunc("POST /api/reminders", s.createReminder) mux.HandleFunc("GET /api/reminders/{id}", s.getReminder) mux.HandleFunc("PATCH /api/reminders/{id}", s.updateReminder) mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder) mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder) // Attachments — one optional file per record, on identical terms for every // collection that takes one (see attachments.go). The GET handler passed // alongside is what renders the record after an upload. s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument) s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord) s.attachmentRoutes(mux, "/api/technical-checks", colTechnicalChecks, s.getTechnicalCheck) s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance) s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry) s.attachmentRoutes(mux, "/api/charging-sessions", colCharging, s.getChargingSession) s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart) 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) }) } // originAllowed reports whether origin may call this server, and whether it was // the wildcard that allowed it. The allow-list is consulted per request rather // than captured once, so editing it from the panel takes effect without a // restart. func (s *Server) originAllowed(origin string) (allowed, wildcard bool) { s.mu.RLock() defer s.mu.RUnlock() for _, o := range s.cfg.AllowOrigins { if o == "*" { return true, true } if o == origin { allowed = true } } return allowed, false } func (s *Server) cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if origin := r.Header.Get("Origin"); origin != "" { if allowed, wildcard := s.originAllowed(origin); allowed { 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) } // Hijack lets the wrapped ResponseWriter be taken over for a protocol switch // (the OCPP WebSocket upgrade at /ocpp/{serial}). Without this pass-through the // logging middleware would hide the underlying http.Hijacker. func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { hj, ok := w.ResponseWriter.(http.Hijacker) if !ok { return nil, nil, http.ErrNotSupported } w.wrote = true // a hijacked connection writes its own response return hj.Hijack() }