The integration cascade stored its top layer differently from the two below it: org (L2) and user (L3) plugin config lived in PocketBase, in a pluginSettings field, while the global (L1) layer sat in a plugins.json next to the binary. That split was accretion rather than design - the file was the whole store in the v1 MVP, and the per-tenant layers were later built on PocketBase and layered on top of it instead of replacing it. It also cost something real. plugins.json was a second state store with different durability from pb_data: its own volume, its own ownership, its own backup. Losing pb_data is unmissable; losing api_data was silent, which is how "every plugin comes back disabled after a redeploy" happened. L1 now lives in the app_settings collection - one record keyed "global", holding its settings in a pluginSettings field, the same mechanism and the same field name the layers below use. The documents still differ in shape, because only L1 carries enable state and the registration of external plugins, but the storage is no longer a special case. The Manager grows a Store seam (PocketBase in production, file for the import, memory for tests) and, more importantly, a loaded gate. Settings in a database mean the store can be unreachable at boot - a cold stack, or a service account still to be set from the panel. That must not read as "no plugins configured", or the first save would write emptiness over real settings. So until a read succeeds the Manager stays unloaded, every mutation is refused, /api/admin/plugins* answers 503, and a background retry backs off to two minutes. The same gate covers a document that will not parse: it is never replaced by one built from an empty map, which is a stronger guarantee than the .corrupt backup it replaces. Writing to a store also revealed a hole in the previous fix. Classifying a save failure as errPersist was left to each Store, and a store that returned a plain error would fall through to the "saved, but the plugin failed to start" branch and be reported as a 200 - the same silent-success bug through a different door. The Manager now classifies, whatever the Store returns; a test pins it. Upgrades are automatic: on the first boot that finds no settings in the database, an existing plugins.json is imported and renamed to plugins.json.migrated. The import is refused if the store is merely unreachable, or if the file does not parse, so a stale or broken file can never overwrite live settings. /data is still needed - the panel rewrites .env there when it retargets PocketBase - but plugin settings no longer depend on it. 21 tests in internal/plugins cover both stores, including the production path against a fake PocketBase: create-then-update of the singleton, round-trip across a restart, an outage that leaves settings intact, a missing collection reading as not-ready rather than empty, and the import running exactly once. go build, go vet and go test ./... pass. Schema changes are mirrored into scripts/setup-pocketbase.mjs as that file requires. Not verified: no Docker CLI here, so no image was built and the bootstrap of app_settings against a real PocketBase is untested outside the fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
622 lines
27 KiB
Go
622 lines
27 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/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 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) loadPlugins(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()
|
|
}
|