Files
DriverVault/API Server/internal/api/server.go
T
tajniak81andClaude Opus 5 215c027ada Panel: give the API Server a name, and a tab to set it in
The Web App can be pointed at more than one DriverVault, but a server it
adds is only ever identified by the URL that was typed into the connect
dialog. Nothing on the other end says what it is called, so the switcher
has no name to show that the operator did not invent locally.

So the server now carries one. SERVER_NAME joins the config, defaulting to
"DriverVault API Server" so /api/health always has something a client can
display rather than an empty string every caller has to special-case.

GET/PUT /api/admin/server-config follow the pb-config and webapp-config
shape exactly: superadmin only, applied at runtime and then persisted to
.env, with the same "applied but could not be saved" warning when the write
fails. There is no /test sibling, because a name is a label and not an
address - there is nothing to probe. The length cap counts runes rather
than bytes, so a 64-character Polish or Danish name is not cut off at the
halfway mark.

/api/health reports it, unauthenticated, which is the point of the whole
change: a client adding this server by URL can label it from the probe it
already makes, instead of needing a second and authenticated call before it
can draw the entry.

In the panel it is a new API Server tab, first in the superadmin group
since it is this server itself, ahead of the PocketBase and Web App tabs
that describe what it talks to. Strings in all three languages, and the
route table in the README and the API reference tab both grow the two new
endpoints.

Known gap, deliberately not closed here: the compose files do not pass
SERVER_NAME, so under Docker a rename from the panel writes the container's
.env and no volume keeps it - it reverts to the default the next time the
container is recreated. Wiring it as ${SERVER_NAME:-} would make the host
.env authoritative, at the cost of the other trap the previous commit
documented, where the environment silently overrides the panel on every
restart. That is a call about the deployment, not about this endpoint.

Verified by new tests over the handler: the rename applies at runtime,
lands in .env, reaches /api/health, is rejected without touching .env when
blank or over-long, and accepts a name of exactly the limit in multi-byte
runes. go build, go vet and go test ./... pass. Drove the built panel in a
browser against a stub backend - the tab renders, loads the current name,
saves, and reads correctly in Polish - and ran the rebuilt api-server.exe
and webapp.exe end to end, confirming the embedded bundle really contains
the new tab and that SERVER_NAME reaches /api/health through both the
server itself and the Web App's proxy.

Not verified: no Docker build, so the images still serve the old panel
until they are rebuilt and pushed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:59:00 +02:00

652 lines
28 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
//
// # 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...)
}
// 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)
// 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()
}