Anker documents neither rfid write, so the bodies are inferred from the field names get_device_cards answers with, and every write re-reads the list: what the card shows is what the account holds, never what an undocumented endpoint claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
681 lines
30 KiB
Go
681 lines
30 KiB
Go
// Package api exposes the HTTP REST surface of the DriverVault API Server.
|
|
//
|
|
// Clients (web app, phone app, Home Assistant plugin, ESP32 device) talk only to
|
|
// this server; this server is the only thing that talks to PocketBase. It also
|
|
// serves the superadmin web panel at the root.
|
|
//
|
|
// Authentication is PocketBase's own: /api/auth/login is proxied to the
|
|
// PocketBase users collection and the client keeps the token PocketBase minted.
|
|
// Every protected request re-resolves that token against PocketBase, so a role
|
|
// change or a deletion takes effect immediately.
|
|
//
|
|
// # public
|
|
// GET /healthz
|
|
// GET /api/health
|
|
// GET /api/status
|
|
// POST /api/auth/login
|
|
// GET /api/auth/validate
|
|
//
|
|
// # identity
|
|
// GET /api/auth/me
|
|
// GET /api/identity
|
|
//
|
|
// # current user
|
|
// GET /api/me PATCH /api/me DELETE /api/me
|
|
// POST /api/me/password
|
|
// POST /api/me/avatar GET /api/me/avatar DELETE /api/me/avatar
|
|
// POST /api/me/verify/request
|
|
// GET /api/me/export POST /api/me/import
|
|
// POST /api/me/delete POST /api/me/delete/cancel
|
|
//
|
|
// # users + organizations (manager; POST /api/orgs is open to any user)
|
|
// GET /api/users POST /api/users
|
|
// PATCH /api/users/{id} DELETE /api/users/{id}
|
|
// GET /api/orgs POST /api/orgs
|
|
// PATCH /api/orgs/{id} DELETE /api/orgs/{id}
|
|
//
|
|
// # superadmin
|
|
// GET /api/admin/pb-config PUT /api/admin/pb-config
|
|
// POST /api/admin/pb-config/test
|
|
// GET /api/admin/webapp-config PUT /api/admin/webapp-config
|
|
// POST /api/admin/webapp-config/test
|
|
// GET /api/admin/server-config PUT /api/admin/server-config
|
|
// GET /api/admin/plugins POST /api/admin/plugins
|
|
// GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
|
|
// DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
|
|
//
|
|
// # integrations (per-user plugin settings; superadmin → org admin → user cascade)
|
|
// GET /api/integrations/toyota PUT /api/integrations/toyota
|
|
// POST /api/integrations/toyota/health
|
|
// GET /api/integrations/toyota/vehicles
|
|
// GET /api/integrations/anker-solix PUT /api/integrations/anker-solix
|
|
// POST /api/integrations/anker-solix/health
|
|
// GET /api/integrations/anker-solix/chargers
|
|
// GET /api/integrations/anker-solix/chargers/{sn}/details
|
|
// POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards
|
|
// DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}
|
|
// GET /api/integrations/greencell PUT /api/integrations/greencell
|
|
// POST /api/integrations/greencell/health
|
|
// GET /api/integrations/greencell/chargers
|
|
// GET /api/integrations/greencell/chargers/{sn}/state
|
|
//
|
|
// # vehicle providers (create a car from a manufacturer service; per-car tab)
|
|
// GET /api/vehicle-providers
|
|
// GET /api/vehicle-providers/{provider}/vehicles
|
|
// POST /api/vehicle-providers/{provider}/import
|
|
//
|
|
// # cars, service records, parts, shares
|
|
// GET /api/cars POST /api/cars
|
|
// GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
|
|
// PUT /api/cars/{id}/view
|
|
// GET /api/cars/{id}/provider POST /api/cars/{id}/provider
|
|
// POST /api/cars/{id}/provider/sync
|
|
// GET /api/cars/{id}/service-records
|
|
// GET /api/cars/{id}/parts
|
|
// GET /api/cars/{id}/shares POST /api/cars/{id}/shares
|
|
// DELETE /api/cars/{id}/shares/{userId}
|
|
// GET /api/service-records POST /api/service-records
|
|
// GET /api/service-records/{id} PATCH /api/service-records/{id}
|
|
// DELETE /api/service-records/{id}
|
|
// GET /api/parts POST /api/parts
|
|
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
|
|
//
|
|
// # technical checks (roadworthiness inspections; time-only, next date derived)
|
|
// GET /api/cars/{id}/technical-checks
|
|
// GET /api/technical-checks POST /api/technical-checks
|
|
// GET /api/technical-checks/{id} PATCH /api/technical-checks/{id}
|
|
// DELETE /api/technical-checks/{id}
|
|
//
|
|
// # fuel tracking (efficiency is derived on read, never stored)
|
|
// GET /api/cars/{id}/fuel-entries
|
|
// GET /api/cars/{id}/fuel-stats
|
|
// GET /api/fuel-entries POST /api/fuel-entries
|
|
// GET /api/fuel-entries/{id} PATCH /api/fuel-entries/{id}
|
|
// DELETE /api/fuel-entries/{id}
|
|
//
|
|
// # charging tracking (EV counterpart of fuel; efficiency derived on read)
|
|
// GET /api/cars/{id}/charging-sessions
|
|
// GET /api/cars/{id}/charging-stats
|
|
// GET /api/charging-sessions POST /api/charging-sessions
|
|
// GET /api/charging-sessions/{id} PATCH /api/charging-sessions/{id}
|
|
// DELETE /api/charging-sessions/{id}
|
|
//
|
|
// # maintenance log (workshop visits + repairs; distinct from service records)
|
|
// GET /api/cars/{id}/maintenance
|
|
// GET /api/maintenance POST /api/maintenance
|
|
// GET /api/maintenance/{id} PATCH /api/maintenance/{id}
|
|
// DELETE /api/maintenance/{id}
|
|
//
|
|
// # document tracking (insurance, pollution certs, … + renewal dates)
|
|
// GET /api/cars/{id}/documents
|
|
// GET /api/car-documents POST /api/car-documents
|
|
// GET /api/car-documents/{id} PATCH /api/car-documents/{id}
|
|
// DELETE /api/car-documents/{id}
|
|
//
|
|
// # attachments — one optional file per record, same three verbs everywhere.
|
|
// # {records} is car-documents | service-records | technical-checks | maintenance
|
|
// # | fuel-entries | charging-sessions | parts
|
|
// POST /api/{records}/{id}/file
|
|
// GET /api/{records}/{id}/file
|
|
// DELETE /api/{records}/{id}/file
|
|
//
|
|
// # reminders (stored + auto-derived from documents and service records)
|
|
// GET /api/cars/{id}/reminders
|
|
// GET /api/reminders POST /api/reminders
|
|
// GET /api/reminders/{id} PATCH /api/reminders/{id}
|
|
// DELETE /api/reminders/{id} POST /api/reminders/{id}/complete
|
|
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/bootstrap"
|
|
"drivervault/apiserver/internal/config"
|
|
"drivervault/apiserver/internal/ocpp"
|
|
"drivervault/apiserver/internal/pb"
|
|
"drivervault/apiserver/internal/plugins"
|
|
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
|
|
)
|
|
|
|
// PocketBase collection names.
|
|
const (
|
|
colCars = "cars"
|
|
colServices = "service_records"
|
|
colTechnicalChecks = "technical_checks"
|
|
colParts = "parts"
|
|
colShares = "car_shares"
|
|
colOrgs = "organizations"
|
|
colFuel = "fuel_entries"
|
|
colCharging = "charging_sessions"
|
|
colMaintenance = "maintenance_entries"
|
|
colDocuments = "car_documents"
|
|
colReminders = "reminders"
|
|
colHomeChargers = "home_chargers"
|
|
colControlAudit = "control_audit"
|
|
colAppSettings = "app_settings"
|
|
)
|
|
|
|
// Server wires together the HTTP handlers and their dependencies.
|
|
type Server struct {
|
|
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
|
cfg config.Config
|
|
pb *pb.Client
|
|
plugins *plugins.Manager
|
|
|
|
// pluginStore is the Manager's backing store; pluginsStop ends the
|
|
// background load retry started by StartPlugins.
|
|
pluginStore plugins.Store
|
|
pluginsStop context.CancelFunc
|
|
|
|
// ocpp is the OCPP 1.6J Central System that Anker Solix chargers connect to
|
|
// when their owner picks a control mode of own/proxy (see internal/ocpp and
|
|
// integrations_ankersolix_control.go). Nil-safe: control endpoints report a
|
|
// clear error when a charger is not connected.
|
|
ocpp *ocpp.CSMS
|
|
control *controlIndex // token -> owning user/charger for the /ocpp endpoint
|
|
ctlRL *rateLimiter // per user+charger control-command rate limit
|
|
}
|
|
|
|
// New constructs a Server around an already-built PocketBase client.
|
|
func New(cfg config.Config, client *pb.Client) *Server {
|
|
// The global (L1) plugin layer lives in PocketBase alongside the org (L2)
|
|
// and user (L3) layers, rather than in a file beside the binary.
|
|
store := plugins.NewPocketBaseStore(client, colAppSettings)
|
|
return &Server{
|
|
cfg: cfg,
|
|
pb: client,
|
|
pluginStore: store,
|
|
plugins: plugins.NewManager(store),
|
|
ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }),
|
|
control: newControlIndex(),
|
|
ctlRL: newRateLimiter(30, time.Minute), // 30 control commands / min / charger
|
|
}
|
|
}
|
|
|
|
// StartPlugins reads the plugin settings from PocketBase and initialises every
|
|
// enabled plugin. It also warms the OCPP control-token index from PocketBase (its
|
|
// source of truth), so the first charger to reconnect after a restart resolves
|
|
// immediately instead of triggering a lazy rebuild mid-handshake. The warm-up is
|
|
// best-effort and non-blocking; if PocketBase is not yet configured it no-ops and
|
|
// the lazy path rebuilds on first connect.
|
|
//
|
|
// The settings now live in the database, so at boot the database may not be
|
|
// reachable yet — a cold stack, or a service account still to be configured from
|
|
// the panel. That is not fatal and, crucially, not treated as "no plugins
|
|
// configured": the Manager stays unloaded, the admin endpoints answer 503, and a
|
|
// background retry keeps trying until the read succeeds. Nothing is written
|
|
// until something has been read, so an outage cannot erase the settings.
|
|
func (s *Server) StartPlugins() error {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.pluginsStop = cancel
|
|
|
|
go func() {
|
|
warmCtx, warmCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer warmCancel()
|
|
s.ensureControlIndex(warmCtx)
|
|
}()
|
|
|
|
err := s.loadPlugins(ctx)
|
|
if err != nil {
|
|
log.Printf("plugins: settings unavailable, retrying in the background (%v)", err)
|
|
go s.retryLoadPlugins(ctx)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// loadPlugins reads the settings, creating the collection they live in if it
|
|
// turns out not to exist.
|
|
//
|
|
// That happens on a stack upgraded with PB_BOOTSTRAP off: the on-boot schema
|
|
// pass never ran, so app_settings was never created, and because a missing
|
|
// collection is read as "not ready" (never as "no plugins configured", which
|
|
// would let the first save overwrite settings the server merely failed to find)
|
|
// the retry below would spin forever with the plugin panel stuck at 503. So
|
|
// create just that one collection — not a full schema reconcile, which an
|
|
// operator who turned the bootstrap off has not asked for — and read again.
|
|
func (s *Server) loadPlugins(ctx context.Context) error {
|
|
err := s.plugins.Load(ctx)
|
|
if err == nil || !plugins.IsMissingCollection(err) {
|
|
return err
|
|
}
|
|
|
|
log.Printf("plugins: the %s collection does not exist; creating it", colAppSettings)
|
|
if repairErr := bootstrap.EnsureCollection(ctx, s.pb, colAppSettings); repairErr != nil {
|
|
log.Printf("plugins: could not create %s: %v", colAppSettings, repairErr)
|
|
return err // report the original problem, not the repair's
|
|
}
|
|
log.Printf("plugins: created %s", colAppSettings)
|
|
return s.plugins.Load(ctx)
|
|
}
|
|
|
|
// retryLoadPlugins keeps reading until the settings load or the server stops.
|
|
// The backoff caps at two minutes, so a long outage costs at most one log line
|
|
// every two minutes rather than a tight spin.
|
|
func (s *Server) retryLoadPlugins(ctx context.Context) {
|
|
const maxBackoff = 2 * time.Minute
|
|
backoff := 5 * time.Second
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(backoff):
|
|
}
|
|
if err := s.loadPlugins(ctx); err == nil {
|
|
log.Println("plugins: settings loaded")
|
|
return
|
|
} else {
|
|
log.Printf("plugins: still unavailable, retrying in %s (%v)", backoff, err)
|
|
}
|
|
if backoff < maxBackoff {
|
|
backoff *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stop releases server-held resources (plugin instances and OCPP sessions).
|
|
func (s *Server) Stop(ctx context.Context) {
|
|
if s.pluginsStop != nil {
|
|
s.pluginsStop()
|
|
}
|
|
s.ocpp.Shutdown(ctx)
|
|
s.plugins.Shutdown(ctx)
|
|
}
|
|
|
|
// usersCollection returns the PocketBase auth collection holding app users.
|
|
func (s *Server) usersCollection() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.UsersCollection
|
|
}
|
|
|
|
// webAppURL returns the Web App address probed by /api/status.
|
|
func (s *Server) webAppURL() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.WebAppURL
|
|
}
|
|
|
|
// webAppSettings snapshots the Web App settings for the settings endpoints.
|
|
func (s *Server) webAppSettings() (url string, allowOrigins []string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.WebAppURL, append([]string(nil), s.cfg.AllowOrigins...)
|
|
}
|
|
|
|
// setWebAppConfig applies new Web App settings at runtime. The CORS middleware
|
|
// reads the origin list per request, so the new list is live immediately.
|
|
func (s *Server) setWebAppConfig(url string, allowOrigins []string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.WebAppURL = url
|
|
s.cfg.AllowOrigins = append([]string(nil), allowOrigins...)
|
|
}
|
|
|
|
// serverName snapshots this server's display name. /api/health reads it per
|
|
// request so a rename from the panel needs no restart.
|
|
func (s *Server) serverName() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.ServerName
|
|
}
|
|
|
|
// setServerName renames this server at runtime.
|
|
func (s *Server) setServerName(name string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.cfg.ServerName = name
|
|
}
|
|
|
|
// pbSettings snapshots the PocketBase connection for the settings endpoints.
|
|
func (s *Server) pbSettings() (url, adminEmail, adminPassword string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword
|
|
}
|
|
|
|
// setPBConfig retargets the PocketBase connection at runtime: it updates the
|
|
// cached config and repoints the client (which drops its cached superuser token,
|
|
// so the next call re-authenticates against the new target).
|
|
func (s *Server) setPBConfig(url, adminEmail, adminPassword string) {
|
|
s.mu.Lock()
|
|
s.cfg.PocketBaseURL = url
|
|
s.cfg.PocketBaseAdminEmail = adminEmail
|
|
s.cfg.PocketBaseAdminPassword = adminPassword
|
|
s.mu.Unlock()
|
|
|
|
s.pb.Reconfigure(url, adminEmail, adminPassword)
|
|
}
|
|
|
|
// Handler returns the root HTTP handler with all routes registered.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel
|
|
// paths are routed to it so unknown /api/* paths still 404 as JSON.
|
|
panel := panelHandler()
|
|
mux.Handle("GET /{$}", panel)
|
|
mux.Handle("GET /assets/", panel)
|
|
mux.Handle("GET /favicon.svg", panel)
|
|
|
|
// Health (public).
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
|
mux.HandleFunc("GET /api/health", s.handleHealth)
|
|
mux.HandleFunc("GET /api/status", s.handleStatus)
|
|
|
|
// Auth — proxied to the PocketBase kept behind this server.
|
|
mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin)
|
|
mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate)
|
|
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
|
|
mux.HandleFunc("GET /api/identity", s.handleIdentity)
|
|
|
|
// Current user (profile / appearance / avatar / data / account lifecycle).
|
|
mux.HandleFunc("GET /api/me", s.handleGetMe)
|
|
mux.HandleFunc("PATCH /api/me", s.handleUpdateMe)
|
|
mux.HandleFunc("POST /api/me/password", s.handleChangePassword)
|
|
mux.HandleFunc("POST /api/me/avatar", s.handleUploadAvatar)
|
|
mux.HandleFunc("GET /api/me/avatar", s.handleGetAvatar)
|
|
mux.HandleFunc("DELETE /api/me/avatar", s.handleDeleteAvatar)
|
|
mux.HandleFunc("POST /api/me/verify/request", s.handleRequestVerification)
|
|
mux.HandleFunc("GET /api/me/export", s.handleExportData)
|
|
mux.HandleFunc("POST /api/me/import", s.handleImportData)
|
|
mux.HandleFunc("POST /api/me/delete", s.handleRequestDeletion)
|
|
mux.HandleFunc("POST /api/me/delete/cancel", s.handleCancelDeletion)
|
|
mux.HandleFunc("DELETE /api/me", s.handleFinalizeDeletion)
|
|
|
|
// User management — gated on the caller being a manager (admin or
|
|
// superadmin). Admins are scoped to their own organization inside each
|
|
// handler.
|
|
mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers))
|
|
mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser))
|
|
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
|
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
|
|
|
// Organizations — the tenants users belong to. Listing is manager-scoped (an
|
|
// admin sees only their own org). Any org-less user may create an org and
|
|
// becomes its admin; an admin may rename or delete their own org; a superadmin
|
|
// spans every organization. Create carries no role gate, so it checks the
|
|
// service account itself.
|
|
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
|
mux.HandleFunc("POST /api/orgs", s.handleCreateOrg)
|
|
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireManager(s.handleUpdateOrg))
|
|
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireManager(s.handleDeleteOrg))
|
|
|
|
// PocketBase connection settings — superadmin only. These do NOT require the
|
|
// service account to already be configured (they exist to configure it).
|
|
mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig))
|
|
mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig))
|
|
mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig))
|
|
|
|
// Web App settings — superadmin only. Where the Web App lives (probed by
|
|
// /api/status) and which browser origins CORS admits.
|
|
mux.HandleFunc("GET /api/admin/webapp-config", s.requireSuperadminAuth(s.handleGetWebAppConfig))
|
|
mux.HandleFunc("POST /api/admin/webapp-config/test", s.requireSuperadminAuth(s.handleTestWebAppConfig))
|
|
mux.HandleFunc("PUT /api/admin/webapp-config", s.requireSuperadminAuth(s.handleUpdateWebAppConfig))
|
|
|
|
// API Server settings — superadmin only. This server's own display name,
|
|
// which clients pointed at several DriverVaults use to tell them apart.
|
|
mux.HandleFunc("GET /api/admin/server-config", s.requireSuperadminAuth(s.handleGetServerConfig))
|
|
mux.HandleFunc("PUT /api/admin/server-config", s.requireSuperadminAuth(s.handleUpdateServerConfig))
|
|
|
|
// Plugins — external-service integrations, managed by a superadmin.
|
|
mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins))
|
|
mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin))
|
|
mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin))
|
|
mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin))
|
|
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
|
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
|
|
|
// Integrations — per-user plugin settings under the superadmin → org admin →
|
|
// user cascade (see integrations.go). Any authenticated user manages their
|
|
// own layer; an org admin may also target their organization's layer.
|
|
mux.HandleFunc("GET /api/integrations/toyota", s.handleGetToyota)
|
|
mux.HandleFunc("PUT /api/integrations/toyota", s.handlePutToyota)
|
|
mux.HandleFunc("POST /api/integrations/toyota/health", s.handleToyotaHealth)
|
|
mux.HandleFunc("GET /api/integrations/toyota/vehicles", s.handleToyotaVehicles)
|
|
mux.HandleFunc("GET /api/integrations/anker-solix", s.handleGetAnker)
|
|
mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
|
|
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
|
|
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/details", s.handleAnkerChargerDetails)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards", s.handleAnkerCardSave)
|
|
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number}", s.handleAnkerCardDelete)
|
|
mux.HandleFunc("GET /api/integrations/greencell", s.handleGetGreencell)
|
|
mux.HandleFunc("PUT /api/integrations/greencell", s.handlePutGreencell)
|
|
mux.HandleFunc("POST /api/integrations/greencell/health", s.handleGreencellHealth)
|
|
mux.HandleFunc("GET /api/integrations/greencell/chargers", s.handleGreencellChargers)
|
|
mux.HandleFunc("GET /api/integrations/greencell/chargers/{sn}/state", s.handleGreencellChargerState)
|
|
|
|
// Anker Solix OCPP control (per-charger; gated by the same cascade plus a
|
|
// control mode of own/proxy and a live CSMS session). See
|
|
// integrations_ankersolix_control.go.
|
|
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken)
|
|
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlRevoke)
|
|
mux.HandleFunc("PUT /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress)
|
|
mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress)
|
|
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction)
|
|
|
|
// OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits
|
|
// outside /api/ so it bypasses bearer auth; it authenticates the charger with
|
|
// OCPP Basic auth (serial + per-charger control token) instead.
|
|
mux.HandleFunc("GET /ocpp/{serial}", s.handleOCPPConnect)
|
|
|
|
// Vehicle providers — manufacturer services a car can be created from, and
|
|
// the per-car provider tab. Generic over the registered providers; see
|
|
// vehicleproviders.go.
|
|
mux.HandleFunc("GET /api/vehicle-providers", s.handleListVehicleProviders)
|
|
mux.HandleFunc("GET /api/vehicle-providers/{provider}/vehicles", s.handleProviderVehicles)
|
|
mux.HandleFunc("POST /api/vehicle-providers/{provider}/import", s.handleProviderImport)
|
|
|
|
// Charger providers — the same move for the wallbox: a charger on a connected
|
|
// service becomes one of the caller's home chargers. See chargerproviders.go.
|
|
mux.HandleFunc("GET /api/charger-providers", s.handleListChargerProviders)
|
|
mux.HandleFunc("GET /api/charger-providers/{provider}/chargers", s.handleProviderChargers)
|
|
mux.HandleFunc("POST /api/charger-providers/{provider}/import", s.handleChargerImport)
|
|
|
|
// The caller's own chargers (homechargers.go).
|
|
mux.HandleFunc("GET /api/home-chargers", s.listHomeChargers)
|
|
mux.HandleFunc("PATCH /api/home-chargers/{id}", s.updateHomeCharger)
|
|
mux.HandleFunc("DELETE /api/home-chargers/{id}", s.deleteHomeCharger)
|
|
|
|
// Cars + sharing.
|
|
mux.HandleFunc("GET /api/cars", s.listCars)
|
|
mux.HandleFunc("POST /api/cars", s.createCar)
|
|
mux.HandleFunc("GET /api/cars/{id}", s.getCar)
|
|
mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar)
|
|
mux.HandleFunc("PUT /api/cars/{id}/view", s.updateCarView)
|
|
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
|
|
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
|
|
mux.HandleFunc("GET /api/cars/{id}/technical-checks", s.listCarTechnicalChecks)
|
|
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
|
|
mux.HandleFunc("GET /api/cars/{id}/fuel-entries", s.listCarFuelEntries)
|
|
mux.HandleFunc("GET /api/cars/{id}/fuel-stats", s.listCarFuelStats)
|
|
mux.HandleFunc("GET /api/cars/{id}/charging-sessions", s.listCarChargingSessions)
|
|
mux.HandleFunc("GET /api/cars/{id}/charging-stats", s.listCarChargingStats)
|
|
mux.HandleFunc("GET /api/cars/{id}/maintenance", s.listCarMaintenance)
|
|
mux.HandleFunc("GET /api/cars/{id}/documents", s.listCarDocuments)
|
|
mux.HandleFunc("GET /api/cars/{id}/reminders", s.listCarReminders)
|
|
mux.HandleFunc("GET /api/cars/{id}/provider", s.handleCarProvider)
|
|
mux.HandleFunc("POST /api/cars/{id}/provider", s.handleLinkCarProvider)
|
|
mux.HandleFunc("POST /api/cars/{id}/provider/sync", s.handleSyncCarProvider)
|
|
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
|
|
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
|
|
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
|
|
|
|
// Service records.
|
|
mux.HandleFunc("GET /api/service-records", s.listServiceRecords)
|
|
mux.HandleFunc("POST /api/service-records", s.createServiceRecord)
|
|
mux.HandleFunc("GET /api/service-records/{id}", s.getServiceRecord)
|
|
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
|
|
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
|
|
|
|
// Technical checks (roadworthiness inspections).
|
|
mux.HandleFunc("GET /api/technical-checks", s.listTechnicalChecks)
|
|
mux.HandleFunc("POST /api/technical-checks", s.createTechnicalCheck)
|
|
mux.HandleFunc("GET /api/technical-checks/{id}", s.getTechnicalCheck)
|
|
mux.HandleFunc("PATCH /api/technical-checks/{id}", s.updateTechnicalCheck)
|
|
mux.HandleFunc("DELETE /api/technical-checks/{id}", s.deleteTechnicalCheck)
|
|
|
|
// Parts.
|
|
mux.HandleFunc("GET /api/parts", s.listParts)
|
|
mux.HandleFunc("POST /api/parts", s.createPart)
|
|
mux.HandleFunc("GET /api/parts/{id}", s.getPart)
|
|
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
|
|
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
|
|
|
|
// Fuel entries.
|
|
mux.HandleFunc("GET /api/fuel-entries", s.listFuelEntries)
|
|
mux.HandleFunc("POST /api/fuel-entries", s.createFuelEntry)
|
|
mux.HandleFunc("GET /api/fuel-entries/{id}", s.getFuelEntry)
|
|
mux.HandleFunc("PATCH /api/fuel-entries/{id}", s.updateFuelEntry)
|
|
mux.HandleFunc("DELETE /api/fuel-entries/{id}", s.deleteFuelEntry)
|
|
|
|
// Charging sessions.
|
|
mux.HandleFunc("GET /api/charging-sessions", s.listChargingSessions)
|
|
mux.HandleFunc("POST /api/charging-sessions", s.createChargingSession)
|
|
mux.HandleFunc("GET /api/charging-sessions/{id}", s.getChargingSession)
|
|
mux.HandleFunc("PATCH /api/charging-sessions/{id}", s.updateChargingSession)
|
|
mux.HandleFunc("DELETE /api/charging-sessions/{id}", s.deleteChargingSession)
|
|
|
|
// Maintenance log.
|
|
mux.HandleFunc("GET /api/maintenance", s.listMaintenance)
|
|
mux.HandleFunc("POST /api/maintenance", s.createMaintenance)
|
|
mux.HandleFunc("GET /api/maintenance/{id}", s.getMaintenance)
|
|
mux.HandleFunc("PATCH /api/maintenance/{id}", s.updateMaintenance)
|
|
mux.HandleFunc("DELETE /api/maintenance/{id}", s.deleteMaintenance)
|
|
|
|
// Documents. Named /api/car-documents so the path can't be mistaken for the
|
|
// user-facing account documents some other Vault services expose.
|
|
mux.HandleFunc("GET /api/car-documents", s.listDocuments)
|
|
mux.HandleFunc("POST /api/car-documents", s.createDocument)
|
|
mux.HandleFunc("GET /api/car-documents/{id}", s.getDocument)
|
|
mux.HandleFunc("PATCH /api/car-documents/{id}", s.updateDocument)
|
|
mux.HandleFunc("DELETE /api/car-documents/{id}", s.deleteDocument)
|
|
|
|
// Reminders.
|
|
mux.HandleFunc("GET /api/reminders", s.listReminders)
|
|
mux.HandleFunc("POST /api/reminders", s.createReminder)
|
|
mux.HandleFunc("GET /api/reminders/{id}", s.getReminder)
|
|
mux.HandleFunc("PATCH /api/reminders/{id}", s.updateReminder)
|
|
mux.HandleFunc("DELETE /api/reminders/{id}", s.deleteReminder)
|
|
mux.HandleFunc("POST /api/reminders/{id}/complete", s.handleCompleteReminder)
|
|
|
|
// Attachments — one optional file per record, on identical terms for every
|
|
// collection that takes one (see attachments.go). The GET handler passed
|
|
// alongside is what renders the record after an upload.
|
|
s.attachmentRoutes(mux, "/api/car-documents", colDocuments, s.getDocument)
|
|
s.attachmentRoutes(mux, "/api/service-records", colServices, s.getServiceRecord)
|
|
s.attachmentRoutes(mux, "/api/technical-checks", colTechnicalChecks, s.getTechnicalCheck)
|
|
s.attachmentRoutes(mux, "/api/maintenance", colMaintenance, s.getMaintenance)
|
|
s.attachmentRoutes(mux, "/api/fuel-entries", colFuel, s.getFuelEntry)
|
|
s.attachmentRoutes(mux, "/api/charging-sessions", colCharging, s.getChargingSession)
|
|
s.attachmentRoutes(mux, "/api/parts", colParts, s.getPart)
|
|
|
|
return s.withMiddleware(mux)
|
|
}
|
|
|
|
// withMiddleware applies panic recovery, CORS, request logging, and
|
|
// authentication globally.
|
|
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
|
return s.recoverer(s.cors(s.logger(s.withAuth(next))))
|
|
}
|
|
|
|
func (s *Server) logger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(sw, r)
|
|
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
|
})
|
|
}
|
|
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
log.Printf("panic: %v", rec)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// originAllowed reports whether origin may call this server, and whether it was
|
|
// the wildcard that allowed it. The allow-list is consulted per request rather
|
|
// than captured once, so editing it from the panel takes effect without a
|
|
// restart.
|
|
func (s *Server) originAllowed(origin string) (allowed, wildcard bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, o := range s.cfg.AllowOrigins {
|
|
if o == "*" {
|
|
return true, true
|
|
}
|
|
if o == origin {
|
|
allowed = true
|
|
}
|
|
}
|
|
return allowed, false
|
|
}
|
|
|
|
func (s *Server) cors(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
if allowed, wildcard := s.originAllowed(origin); allowed {
|
|
if wildcard {
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Add("Vary", "Origin")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
}
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// statusWriter captures the response status code for logging.
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
wrote bool
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
if !w.wrote {
|
|
w.status = code
|
|
w.wrote = true
|
|
}
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
w.wrote = true
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// Hijack lets the wrapped ResponseWriter be taken over for a protocol switch
|
|
// (the OCPP WebSocket upgrade at /ocpp/{serial}). Without this pass-through the
|
|
// logging middleware would hide the underlying http.Hijacker.
|
|
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
hj, ok := w.ResponseWriter.(http.Hijacker)
|
|
if !ok {
|
|
return nil, nil, http.ErrNotSupported
|
|
}
|
|
w.wrote = true // a hijacked connection writes its own response
|
|
return hj.Hijack()
|
|
}
|