The project has no public installs, so there is nothing to migrate from. MigrateLegacyFile, the file-backed Store it read through, PLUGINS_FILE and the legacy path threaded through the Server all go. What is left is one store, PocketBase, and a plugins package that touches no filesystem at all. That was the last thing keeping api_data alive, so the volume goes too. All four compose files now declare exactly one volume, pb_data, and the standalone API Server compose declares none - it talks to an external PocketBase and has nothing of its own to keep. Backing up the stack is backing up one path again. Both images get simpler for it. The API Server image loses VOLUME /data and the su-exec entrypoint that existed only to fix a mounted volume's ownership, so it goes back to a plain USER app; its working directory is now /app and holds nothing. The AIO image loses its second volume and chowns only /pb/pb_data. One consequence worth stating plainly, because it is a small regression rather than a no-op. The panel's Settings -> PocketBase and Settings -> Web App screens write .env in the working directory, which is now ephemeral. In the multi-container stack that changes nothing: compose sets all five of those keys as container environment, and loadDotEnv only applies a key that is not already set, so the file could never win a restart there anyway. In the AIO image it did win for POCKETBASE_ADMIN_EMAIL/_PASSWORD, which are not in that container's environment - so a service account fixed from the panel now lasts only until the container is recreated. Both READMEs say so. Moving those two screens into the app_settings singleton would close it properly; the PocketBase URL and credentials cannot follow, since they are how the database is reached in the first place. go build, go vet and go test ./... pass; the compose files parse and each resolves to a single pb_data volume. Not verified: no Docker CLI here, so neither image was built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
631 lines
27 KiB
Go
631 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/bootstrap"
|
|
"drivervault/apiserver/internal/config"
|
|
"drivervault/apiserver/internal/ocpp"
|
|
"drivervault/apiserver/internal/pb"
|
|
"drivervault/apiserver/internal/plugins"
|
|
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
|
|
)
|
|
|
|
// PocketBase collection names.
|
|
const (
|
|
colCars = "cars"
|
|
colServices = "service_records"
|
|
colTechnicalChecks = "technical_checks"
|
|
colParts = "parts"
|
|
colShares = "car_shares"
|
|
colOrgs = "organizations"
|
|
colFuel = "fuel_entries"
|
|
colCharging = "charging_sessions"
|
|
colMaintenance = "maintenance_entries"
|
|
colDocuments = "car_documents"
|
|
colReminders = "reminders"
|
|
colControlAudit = "control_audit"
|
|
colAppSettings = "app_settings"
|
|
)
|
|
|
|
// Server wires together the HTTP handlers and their dependencies.
|
|
type Server struct {
|
|
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
|
cfg config.Config
|
|
pb *pb.Client
|
|
plugins *plugins.Manager
|
|
|
|
// pluginStore is the Manager's backing store; pluginsStop ends the
|
|
// background load retry started by StartPlugins.
|
|
pluginStore plugins.Store
|
|
pluginsStop context.CancelFunc
|
|
|
|
// ocpp is the OCPP 1.6J Central System that Anker Solix chargers connect to
|
|
// when their owner picks a control mode of own/proxy (see internal/ocpp and
|
|
// integrations_ankersolix_control.go). Nil-safe: control endpoints report a
|
|
// clear error when a charger is not connected.
|
|
ocpp *ocpp.CSMS
|
|
control *controlIndex // token -> owning user/charger for the /ocpp endpoint
|
|
ctlRL *rateLimiter // per user+charger control-command rate limit
|
|
}
|
|
|
|
// New constructs a Server around an already-built PocketBase client.
|
|
func New(cfg config.Config, client *pb.Client) *Server {
|
|
// The global (L1) plugin layer lives in PocketBase alongside the org (L2)
|
|
// and user (L3) layers, rather than in a file beside the binary.
|
|
store := plugins.NewPocketBaseStore(client, colAppSettings)
|
|
return &Server{
|
|
cfg: cfg,
|
|
pb: client,
|
|
pluginStore: store,
|
|
plugins: plugins.NewManager(store),
|
|
ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }),
|
|
control: newControlIndex(),
|
|
ctlRL: newRateLimiter(30, time.Minute), // 30 control commands / min / charger
|
|
}
|
|
}
|
|
|
|
// StartPlugins reads the plugin settings from PocketBase and initialises every
|
|
// enabled plugin. It also warms the OCPP control-token index from PocketBase (its
|
|
// source of truth), so the first charger to reconnect after a restart resolves
|
|
// immediately instead of triggering a lazy rebuild mid-handshake. The warm-up is
|
|
// best-effort and non-blocking; if PocketBase is not yet configured it no-ops and
|
|
// the lazy path rebuilds on first connect.
|
|
//
|
|
// The settings now live in the database, so at boot the database may not be
|
|
// reachable yet — a cold stack, or a service account still to be configured from
|
|
// the panel. That is not fatal and, crucially, not treated as "no plugins
|
|
// configured": the Manager stays unloaded, the admin endpoints answer 503, and a
|
|
// background retry keeps trying until the read succeeds. Nothing is written
|
|
// until something has been read, so an outage cannot erase the settings.
|
|
func (s *Server) StartPlugins() error {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.pluginsStop = cancel
|
|
|
|
go func() {
|
|
warmCtx, warmCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer warmCancel()
|
|
s.ensureControlIndex(warmCtx)
|
|
}()
|
|
|
|
err := s.loadPlugins(ctx)
|
|
if err != nil {
|
|
log.Printf("plugins: settings unavailable, retrying in the background (%v)", err)
|
|
go s.retryLoadPlugins(ctx)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// loadPlugins reads the settings, creating the collection they live in if it
|
|
// turns out not to exist.
|
|
//
|
|
// That happens on a stack upgraded with PB_BOOTSTRAP off: the on-boot schema
|
|
// pass never ran, so app_settings was never created, and because a missing
|
|
// collection is read as "not ready" (never as "no plugins configured", which
|
|
// would let the first save overwrite settings the server merely failed to find)
|
|
// the retry below would spin forever with the plugin panel stuck at 503. So
|
|
// create just that one collection — not a full schema reconcile, which an
|
|
// operator who turned the bootstrap off has not asked for — and read again.
|
|
func (s *Server) loadPlugins(ctx context.Context) error {
|
|
err := s.plugins.Load(ctx)
|
|
if err == nil || !plugins.IsMissingCollection(err) {
|
|
return err
|
|
}
|
|
|
|
log.Printf("plugins: the %s collection does not exist; creating it", colAppSettings)
|
|
if repairErr := bootstrap.EnsureCollection(ctx, s.pb, colAppSettings); repairErr != nil {
|
|
log.Printf("plugins: could not create %s: %v", colAppSettings, repairErr)
|
|
return err // report the original problem, not the repair's
|
|
}
|
|
log.Printf("plugins: created %s", colAppSettings)
|
|
return s.plugins.Load(ctx)
|
|
}
|
|
|
|
// retryLoadPlugins keeps reading until the settings load or the server stops.
|
|
// The backoff caps at two minutes, so a long outage costs at most one log line
|
|
// every two minutes rather than a tight spin.
|
|
func (s *Server) retryLoadPlugins(ctx context.Context) {
|
|
const maxBackoff = 2 * time.Minute
|
|
backoff := 5 * time.Second
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(backoff):
|
|
}
|
|
if err := s.loadPlugins(ctx); err == nil {
|
|
log.Println("plugins: settings loaded")
|
|
return
|
|
} else {
|
|
log.Printf("plugins: still unavailable, retrying in %s (%v)", backoff, err)
|
|
}
|
|
if backoff < maxBackoff {
|
|
backoff *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop releases server-held resources (plugin instances and OCPP sessions).
|
|
func (s *Server) Stop(ctx context.Context) {
|
|
if s.pluginsStop != nil {
|
|
s.pluginsStop()
|
|
}
|
|
s.ocpp.Shutdown(ctx)
|
|
s.plugins.Shutdown(ctx)
|
|
}
|
|
|
|
// usersCollection returns the PocketBase auth collection holding app users.
|
|
func (s *Server) usersCollection() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.UsersCollection
|
|
}
|
|
|
|
// webAppURL returns the Web App address probed by /api/status.
|
|
func (s *Server) webAppURL() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.WebAppURL
|
|
}
|
|
|
|
// webAppSettings snapshots the Web App settings for the settings endpoints.
|
|
func (s *Server) webAppSettings() (url string, allowOrigins []string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.WebAppURL, append([]string(nil), s.cfg.AllowOrigins...)
|
|
}
|
|
|
|
// setWebAppConfig applies new Web App settings at runtime. The CORS middleware
|
|
// reads the origin list per request, so the new list is live immediately.
|
|
func (s *Server) setWebAppConfig(url string, allowOrigins []string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.WebAppURL = url
|
|
s.cfg.AllowOrigins = append([]string(nil), allowOrigins...)
|
|
}
|
|
|
|
// pbSettings snapshots the PocketBase connection for the settings endpoints.
|
|
func (s *Server) pbSettings() (url, adminEmail, adminPassword string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword
|
|
}
|
|
|
|
// setPBConfig retargets the PocketBase connection at runtime: it updates the
|
|
// cached config and repoints the client (which drops its cached superuser token,
|
|
// so the next call re-authenticates against the new target).
|
|
func (s *Server) setPBConfig(url, adminEmail, adminPassword string) {
|
|
s.mu.Lock()
|
|
s.cfg.PocketBaseURL = url
|
|
s.cfg.PocketBaseAdminEmail = adminEmail
|
|
s.cfg.PocketBaseAdminPassword = adminPassword
|
|
s.mu.Unlock()
|
|
|
|
s.pb.Reconfigure(url, adminEmail, adminPassword)
|
|
}
|
|
|
|
// Handler returns the root HTTP handler with all routes registered.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel
|
|
// paths are routed to it so unknown /api/* paths still 404 as JSON.
|
|
panel := panelHandler()
|
|
mux.Handle("GET /{$}", panel)
|
|
mux.Handle("GET /assets/", panel)
|
|
mux.Handle("GET /favicon.svg", panel)
|
|
|
|
// Health (public).
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
|
mux.HandleFunc("GET /api/health", s.handleHealth)
|
|
mux.HandleFunc("GET /api/status", s.handleStatus)
|
|
|
|
// Auth — proxied to the PocketBase kept behind this server.
|
|
mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin)
|
|
mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate)
|
|
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
|
|
mux.HandleFunc("GET /api/identity", s.handleIdentity)
|
|
|
|
// Current user (profile / appearance / avatar / data / account lifecycle).
|
|
mux.HandleFunc("GET /api/me", s.handleGetMe)
|
|
mux.HandleFunc("PATCH /api/me", s.handleUpdateMe)
|
|
mux.HandleFunc("POST /api/me/password", s.handleChangePassword)
|
|
mux.HandleFunc("POST /api/me/avatar", s.handleUploadAvatar)
|
|
mux.HandleFunc("GET /api/me/avatar", s.handleGetAvatar)
|
|
mux.HandleFunc("DELETE /api/me/avatar", s.handleDeleteAvatar)
|
|
mux.HandleFunc("POST /api/me/verify/request", s.handleRequestVerification)
|
|
mux.HandleFunc("GET /api/me/export", s.handleExportData)
|
|
mux.HandleFunc("POST /api/me/import", s.handleImportData)
|
|
mux.HandleFunc("POST /api/me/delete", s.handleRequestDeletion)
|
|
mux.HandleFunc("POST /api/me/delete/cancel", s.handleCancelDeletion)
|
|
mux.HandleFunc("DELETE /api/me", s.handleFinalizeDeletion)
|
|
|
|
// User management — gated on the caller being a manager (admin or
|
|
// superadmin). Admins are scoped to their own organization inside each
|
|
// handler.
|
|
mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers))
|
|
mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser))
|
|
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
|
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
|
|
|
// Organizations — the tenants users belong to. Listing is manager-scoped (an
|
|
// admin sees only their own org). Any org-less user may create an org and
|
|
// becomes its admin; an admin may rename or delete their own org; a superadmin
|
|
// spans every organization. Create carries no role gate, so it checks the
|
|
// service account itself.
|
|
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
|
mux.HandleFunc("POST /api/orgs", s.handleCreateOrg)
|
|
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireManager(s.handleUpdateOrg))
|
|
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireManager(s.handleDeleteOrg))
|
|
|
|
// PocketBase connection settings — superadmin only. These do NOT require the
|
|
// service account to already be configured (they exist to configure it).
|
|
mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig))
|
|
mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig))
|
|
mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig))
|
|
|
|
// Web App settings — superadmin only. Where the Web App lives (probed by
|
|
// /api/status) and which browser origins CORS admits.
|
|
mux.HandleFunc("GET /api/admin/webapp-config", s.requireSuperadminAuth(s.handleGetWebAppConfig))
|
|
mux.HandleFunc("POST /api/admin/webapp-config/test", s.requireSuperadminAuth(s.handleTestWebAppConfig))
|
|
mux.HandleFunc("PUT /api/admin/webapp-config", s.requireSuperadminAuth(s.handleUpdateWebAppConfig))
|
|
|
|
// Plugins — external-service integrations, managed by a superadmin.
|
|
mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins))
|
|
mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin))
|
|
mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin))
|
|
mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin))
|
|
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
|
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
|
|
|
// Integrations — per-user plugin settings under the superadmin → org admin →
|
|
// user cascade (see integrations.go). Any authenticated user manages their
|
|
// own layer; an org admin may also target their organization's layer.
|
|
mux.HandleFunc("GET /api/integrations/toyota", s.handleGetToyota)
|
|
mux.HandleFunc("PUT /api/integrations/toyota", s.handlePutToyota)
|
|
mux.HandleFunc("POST /api/integrations/toyota/health", s.handleToyotaHealth)
|
|
mux.HandleFunc("GET /api/integrations/toyota/vehicles", s.handleToyotaVehicles)
|
|
mux.HandleFunc("GET /api/integrations/anker-solix", s.handleGetAnker)
|
|
mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
|
|
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
|
|
|
|
// Anker Solix OCPP control (per-charger; gated by the same cascade plus a
|
|
// control mode of own/proxy and a live CSMS session). See
|
|
// integrations_ankersolix_control.go.
|
|
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken)
|
|
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlRevoke)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction)
|
|
|
|
// OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits
|
|
// outside /api/ so it bypasses bearer auth; it authenticates the charger with
|
|
// OCPP Basic auth (serial + per-charger control token) instead.
|
|
mux.HandleFunc("GET /ocpp/{serial}", s.handleOCPPConnect)
|
|
|
|
// Vehicle providers — manufacturer services a car can be created from, and
|
|
// the per-car provider tab. Generic over the registered providers; see
|
|
// vehicleproviders.go.
|
|
mux.HandleFunc("GET /api/vehicle-providers", s.handleListVehicleProviders)
|
|
mux.HandleFunc("GET /api/vehicle-providers/{provider}/vehicles", s.handleProviderVehicles)
|
|
mux.HandleFunc("POST /api/vehicle-providers/{provider}/import", s.handleProviderImport)
|
|
|
|
// Cars + sharing.
|
|
mux.HandleFunc("GET /api/cars", s.listCars)
|
|
mux.HandleFunc("POST /api/cars", s.createCar)
|
|
mux.HandleFunc("GET /api/cars/{id}", s.getCar)
|
|
mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar)
|
|
mux.HandleFunc("PUT /api/cars/{id}/view", s.updateCarView)
|
|
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
|
|
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
|
|
mux.HandleFunc("GET /api/cars/{id}/technical-checks", s.listCarTechnicalChecks)
|
|
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
|
mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries)
|
|
mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats)
|
|
mux.HandleFunc("GET /api/cars/{id}/charging-sessions", s.listCarChargingSessions)
|
|
mux.HandleFunc("GET /api/cars/{id}/charging-stats", s.listCarChargingStats)
|
|
mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance)
|
|
mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments)
|
|
mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders)
|
|
mux.HandleFunc("GET /api/cars/{id}/provider", s.handleCarProvider)
|
|
mux.HandleFunc("POST /api/cars/{id}/provider", s.handleLinkCarProvider)
|
|
mux.HandleFunc("POST /api/cars/{id}/provider/sync", s.handleSyncCarProvider)
|
|
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
|
|
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
|
|
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
|
|
|
|
// Service records.
|
|
mux.HandleFunc("GET /api/service-records", s.listServiceRecords)
|
|
mux.HandleFunc("POST /api/service-records", s.createServiceRecord)
|
|
mux.HandleFunc("GET /api/service-records/{id}", s.getServiceRecord)
|
|
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
|
|
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
|
|
|
|
// Technical checks (roadworthiness inspections).
|
|
mux.HandleFunc("GET /api/technical-checks", s.listTechnicalChecks)
|
|
mux.HandleFunc("POST /api/technical-checks", s.createTechnicalCheck)
|
|
mux.HandleFunc("GET /api/technical-checks/{id}", s.getTechnicalCheck)
|
|
mux.HandleFunc("PATCH /api/technical-checks/{id}", s.updateTechnicalCheck)
|
|
mux.HandleFunc("DELETE /api/technical-checks/{id}", s.deleteTechnicalCheck)
|
|
|
|
// Parts.
|
|
mux.HandleFunc("GET /api/parts", s.listParts)
|
|
mux.HandleFunc("POST /api/parts", s.createPart)
|
|
mux.HandleFunc("GET /api/parts/{id}", s.getPart)
|
|
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
|
|
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
|
|
|
|
// Fuel entries.
|
|
mux.HandleFunc("GET /api/fuel-entries", s.listFuelEntries)
|
|
mux.HandleFunc("POST /api/fuel-entries", s.createFuelEntry)
|
|
mux.HandleFunc("GET /api/fuel-entries/{id}", s.getFuelEntry)
|
|
mux.HandleFunc("PATCH /api/fuel-entries/{id}", s.updateFuelEntry)
|
|
mux.HandleFunc("DELETE /api/fuel-entries/{id}", s.deleteFuelEntry)
|
|
|
|
// Charging sessions.
|
|
mux.HandleFunc("GET /api/charging-sessions", s.listChargingSessions)
|
|
mux.HandleFunc("POST /api/charging-sessions", s.createChargingSession)
|
|
mux.HandleFunc("GET /api/charging-sessions/{id}", s.getChargingSession)
|
|
mux.HandleFunc("PATCH /api/charging-sessions/{id}", s.updateChargingSession)
|
|
mux.HandleFunc("DELETE /api/charging-sessions/{id}", s.deleteChargingSession)
|
|
|
|
// Maintenance log.
|
|
mux.HandleFunc("GET /api/maintenance", s.listMaintenance)
|
|
mux.HandleFunc("POST /api/maintenance", s.createMaintenance)
|
|
mux.HandleFunc("GET /api/maintenance/{id}", s.getMaintenance)
|
|
mux.HandleFunc("PATCH /api/maintenance/{id}", s.updateMaintenance)
|
|
mux.HandleFunc("DELETE /api/maintenance/{id}", s.deleteMaintenance)
|
|
|
|
// Documents. Named /api/car-documents so the path can't be mistaken for the
|
|
// user-facing account documents some other Vault services expose.
|
|
mux.HandleFunc("GET /api/car-documents", s.listDocuments)
|
|
mux.HandleFunc("POST /api/car-documents", s.createDocument)
|
|
mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
|
|
mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
|
|
mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
|
|
|
|
// Reminders.
|
|
mux.HandleFunc("GET /api/reminders", s.listReminders)
|
|
mux.HandleFunc("POST /api/reminders", s.createReminder)
|
|
mux.HandleFunc("GET /api/reminders/{id}", s.getReminder)
|
|
mux.HandleFunc("PATCH /api/reminders/{id}", s.updateReminder)
|
|
mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
|
|
mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
|
|
|
|
// Attachments — one optional file per record, on identical terms for every
|
|
// collection that takes one (see attachments.go). The GET handler passed
|
|
// alongside is what renders the record after an upload.
|
|
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
|
|
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
|
|
s.attachmentRoutes(mux, "/api/technical-checks", colTechnicalChecks, s.getTechnicalCheck)
|
|
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
|
|
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
|
|
s.attachmentRoutes(mux, "/api/charging-sessions", colCharging, s.getChargingSession)
|
|
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
|
|
|
|
return s.withMiddleware(mux)
|
|
}
|
|
|
|
// withMiddleware applies panic recovery, CORS, request logging, and
|
|
// authentication globally.
|
|
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
|
return s.recoverer(s.cors(s.logger(s.withAuth(next))))
|
|
}
|
|
|
|
func (s *Server) logger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(sw, r)
|
|
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
|
})
|
|
}
|
|
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
log.Printf("panic: %v", rec)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// originAllowed reports whether origin may call this server, and whether it was
|
|
// the wildcard that allowed it. The allow-list is consulted per request rather
|
|
// than captured once, so editing it from the panel takes effect without a
|
|
// restart.
|
|
func (s *Server) originAllowed(origin string) (allowed, wildcard bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, o := range s.cfg.AllowOrigins {
|
|
if o == "*" {
|
|
return true, true
|
|
}
|
|
if o == origin {
|
|
allowed = true
|
|
}
|
|
}
|
|
return allowed, false
|
|
}
|
|
|
|
func (s *Server) cors(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
if allowed, wildcard := s.originAllowed(origin); allowed {
|
|
if wildcard {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
}
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// statusWriter captures the response status code for logging.
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
wrote bool
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
if !w.wrote {
|
|
w.status = code
|
|
w.wrote = true
|
|
}
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
w.wrote = true
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// Hijack lets the wrapped ResponseWriter be taken over for a protocol switch
|
|
// (the OCPP WebSocket upgrade at /ocpp/{serial}). Without this pass-through the
|
|
// logging middleware would hide the underlying http.Hijacker.
|
|
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
hj, ok := w.ResponseWriter.(http.Hijacker)
|
|
if !ok {
|
|
return nil, nil, http.ErrNotSupported
|
|
}
|
|
w.wrote = true // a hijacked connection writes its own response
|
|
return hj.Hijack()
|
|
}
|