Files
DriverVault/API Server/internal/api/server.go
T
tajniak81andClaude Opus 5 245870a96a The add and remove buttons, and the read that checks them
Anker documents neither rfid write, so the bodies are inferred from the
field names get_device_cards answers with, and every write re-reads the
list: what the card shows is what the account holds, never what an
undocumented endpoint claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:50:00 +02:00

681 lines
30 KiB
Go

// 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/server-config PUT /api/admin/server-config
// 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
// GET /api/integrations/anker-solix/chargers/{sn}/details
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
// GET /api/integrations/greencell PUT /api/integrations/greencell
// POST /api/integrations/greencell/health
// GET /api/integrations/greencell/chargers
// GET /api/integrations/greencell/chargers/{sn}/state
//
// # 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"
colHomeChargers = "home_chargers"
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...)
}
// serverName snapshots this server's display name. /api/health reads it per
// request so a rename from the panel needs no restart.
func (s *Server) serverName() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.ServerName
}
// setServerName renames this server at runtime.
func (s *Server) setServerName(name string) {
s.mu.Lock()
defer s.mu.Unlock()
s.cfg.ServerName = name
}
// 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))
// API Server settings — superadmin only. This server's own display name,
// which clients pointed at several DriverVaults use to tell them apart.
mux.HandleFunc("GET /api/admin/server-config", s.requireSuperadminAuth(s.handleGetServerConfig))
mux.HandleFunc("PUT /api/admin/server-config", s.requireSuperadminAuth(s.handleUpdateServerConfig))
// 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)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete)
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
mux.HandleFunc("GET /api/integrations/greencell/chargers", s.handleGreencellChargers)
mux.HandleFunc("GET /api/integrations/greencell/chargers/{sn}/state", s.handleGreencellChargerState)
// 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("PUT /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress)
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress)
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)
// Charger providers — the same move for the wallbox: a charger on a connected
// service becomes one of the caller's home chargers. See chargerproviders.go.
mux.HandleFunc("GET /api/charger-providers", s.handleListChargerProviders)
mux.HandleFunc("GET /api/charger-providers/{provider}/chargers", s.handleProviderChargers)
mux.HandleFunc("POST /api/charger-providers/{provider}/import", s.handleChargerImport)
// The caller's own chargers (homechargers.go).
mux.HandleFunc("GET /api/home-chargers", s.listHomeChargers)
mux.HandleFunc("PATCH /api/home-chargers/{id}", s.updateHomeCharger)
mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger)
// 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()
}