01a8fecfixed the advice that led operators into this, but advice is not a guard: a stack still running PB_BOOTSTRAP=false gets no app_settings collection on upgrade, and the plugin panel sits at 503 while the retry loop reads a collection that does not exist. The fix is not to soften the reading. A missing collection stays "not ready" rather than "no plugins configured", because the alternative lets the first save write a fresh document over settings the server merely failed to find - the failure this whole line of work exists to prevent. Instead the server now fixes the cause: on a missing collection it creates that collection and reads again. Three pieces: bootstrap.EnsureCollection creates one named collection from the desired schema if absent, and nothing else. Deliberately narrower than Run - no field reconcile elsewhere, no super-admin - so it is safe to call on a deployment that turned the full bootstrap off. It creates the collection the server cannot start without, not the schema the operator declined. The store tells a missing collection apart from an outage. A 404 from a list means the collection itself is gone: an existing but empty one answers 200 with no items. That is tagged errNoCollection, which wraps errNotReady so every write is still refused, and IsMissingCollection narrows it. The distinction matters because the remedies are opposites - creating collections against a flaky database is exactly the wrong reflex, and a test pins that an outage does not trigger it. loadPlugins acts on the tag once, then re-reads. Failing to create is reported as the original read error rather than the repair's, so the log names the real problem. Six tests: the tag and its negative in internal/plugins, and three in internal/api against a fake PocketBase covering the collection being created exactly once, an existing collection not being recreated, and an outage creating nothing. Docs from01a8fecare corrected in the same pass - they said the panel would answer 503 forever, which is no longer true. They now say what still depends on the bootstrap (every other collection and field) and what does not (app_settings alone). go build, go vet and go test ./... pass; compose files still parse. Not verified: no Docker CLI here, so the repair has not been exercised against a real PocketBase, only the fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
648 lines
28 KiB
Go
648 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/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, kept here so the one-time
|
|
// import of a legacy plugins.json can address it directly. legacyPlugins is
|
|
// the path that import reads; pluginsStop ends the background load retry.
|
|
pluginStore plugins.Store
|
|
legacyPlugins string
|
|
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,
|
|
legacyPlugins: cfg.PluginsFile,
|
|
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.readPlugins(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.readPlugins(ctx)
|
|
}
|
|
|
|
// readPlugins imports a pre-PocketBase plugins.json if one is still lying around
|
|
// and the database holds no settings yet, then reads the settings.
|
|
func (s *Server) readPlugins(ctx context.Context) error {
|
|
switch migrated, err := plugins.MigrateLegacyFile(ctx, s.pluginStore, s.legacyPlugins); {
|
|
case err != nil:
|
|
// Not fatal: the read below reports the real problem if there is one.
|
|
log.Printf("plugins: legacy import skipped: %v", err)
|
|
case migrated:
|
|
log.Printf("plugins: imported %s into PocketBase; renamed it to %s.migrated",
|
|
s.legacyPlugins, s.legacyPlugins)
|
|
}
|
|
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()
|
|
}
|