Files
PilotVault/API Server/internal/api/server.go
T
tajniak81andClaude Opus 4.8 33595c99e8 Add a Drones fleet section that fills itself in on connect
The fleet lived as a tab inside the Logbook, which buried it, and every
drone had to be typed in by hand — model, serial and firmware copied off
an airframe the app was already talking to.

Promote it to its own nav section above Logbook, and let a connecting
drone register itself. The Fly App already forwarded model, serial and
firmware upstream; the hub was keeping only the model. It now carries the
identity through to DeviceState, and the Web App offers it to a new
POST /api/drones/auto, which upserts keyed by serial. The auto path only
writes what the aircraft is authoritative about (model, both firmware
versions) and never touches what the pilot curates.

Serial and the firmware versions resolve on their own schedules after
connect — the serial in seconds, the aircraft firmware sometimes a minute
later — so nothing along the path treats an absent value as a cleared one,
and a later event filling firmware in still reaches the server. The auto
call rides every telemetry frame, so the client remembers the identity
tuple it last sent and only a change goes out; a 4xx is the server's
settled answer and is not retried, or one drone connected for an hour
would mean one request per frame for an hour.

New fields on drones: firmware, controller_firmware, and registration for
the FAA/CAA aircraft number — distinct from operator_number, which stays
the EU operator ID. Controller firmware is the remote controller's own
version, read from its component; the flight controller's version is a
different quantity and stays off this field (see 002e484). name becomes
optional and is now the pilot's custom name: auto-added drones arrive
unnamed, so the API serves a computed displayName (name, else model +
serial) for the fleet table, the flight picker and the CSV export. A
unique index on serial is what keeps the find-then-create path from
forking a drone's history across two records.

The schema is applied to the remote PocketBase; the migration is here for
fresh deployments, which the remote does not read.

Verified against a simulated device over the real socket with identity
resolving late: one record from four events, both firmware versions
filled, curated fields intact across re-registration, and a drone deleted
while connected coming back on the next frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:04:12 +02:00

269 lines
11 KiB
Go

package api
import (
"bufio"
"context"
"log"
"net"
"net/http"
"sync"
"time"
"pilotvault/apiserver/internal/config"
"pilotvault/apiserver/internal/hub"
"pilotvault/apiserver/internal/plugins"
_ "pilotvault/apiserver/internal/plugins/builtin" // register built-in plugins
)
// 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
hub *hub.Hub
auth *authProxy
admin *adminClient
plugins *plugins.Manager
}
// pbURL returns the current PocketBase base URL.
func (s *Server) pbURL() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.PocketBaseURL
}
// 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 both the auth proxy and the admin service account.
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.auth.setBaseURL(url)
s.admin.reconfigure(url, adminEmail, adminPassword)
}
// New constructs a Server.
func New(cfg config.Config, h *hub.Hub) *Server {
return &Server{
cfg: cfg,
hub: h,
auth: newAuthProxy(cfg.PocketBaseURL),
admin: newAdminClient(cfg.PocketBaseURL, cfg.PocketBaseAdminEmail, cfg.PocketBaseAdminPassword),
plugins: plugins.NewManager(cfg.PluginsFile),
}
}
// StartPlugins loads persisted plugin state and initialises enabled plugins.
func (s *Server) StartPlugins() error { return s.plugins.Load() }
// Stop releases server-held resources (currently: plugin instances).
func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) }
// 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)
// Current user (id, email, role) resolved from the caller's token.
mux.HandleFunc("GET /api/me", s.handleMe)
// User preferences — persisted on the caller's own PocketBase user record.
mux.HandleFunc("GET /api/preferences", s.handleGetPreferences)
mux.HandleFunc("PUT /api/preferences", s.handlePutPreferences)
// Plugin integrations for end users — per-user/per-org settings resolved
// through the superadmin→org→user cascade. Role logic lives inside the
// handlers (org users must reach them too), so no requireManager wrapper.
mux.HandleFunc("GET /api/integrations/opensky", s.handleGetOpenSky)
mux.HandleFunc("PUT /api/integrations/opensky", s.handlePutOpenSky)
mux.HandleFunc("POST /api/integrations/opensky/health", s.handleOpenSkyHealth)
mux.HandleFunc("GET /api/integrations/opensky/states", s.handleOpenSkyStates)
mux.HandleFunc("GET /api/integrations/filetransfer", s.handleGetFileTransfer)
mux.HandleFunc("PUT /api/integrations/filetransfer", s.handlePutFileTransfer)
mux.HandleFunc("POST /api/integrations/filetransfer/health", s.handleFileTransferHealth)
mux.HandleFunc("GET /api/integrations/localstorage", s.handleGetLocalStorage)
mux.HandleFunc("PUT /api/integrations/localstorage", s.handlePutLocalStorage)
mux.HandleFunc("POST /api/integrations/localstorage/health", s.handleLocalStorageHealth)
mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav)
mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav)
mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth)
mux.HandleFunc("GET /api/integrations/openweather", s.handleGetOpenWeather)
mux.HandleFunc("PUT /api/integrations/openweather", s.handlePutOpenWeather)
mux.HandleFunc("POST /api/integrations/openweather/health", s.handleOpenWeatherHealth)
mux.HandleFunc("GET /api/integrations/openweather/current", s.handleOpenWeatherCurrent)
// 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 — listing is manager-scoped; create/edit/delete are
// superadmin-only (a superadmin spans all organizations).
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg))
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg))
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(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))
// 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))
// Logbook — drones + flights (BEK 1649 §5). Available to any authenticated
// user; per-role scoping (user→own, admin→org, superadmin→all) is enforced
// inside the handlers, so the shared requireUser gate suffices.
mux.HandleFunc("GET /api/drones", s.requireUser(s.handleListDrones))
mux.HandleFunc("POST /api/drones", s.requireUser(s.handleCreateDrone))
mux.HandleFunc("POST /api/drones/auto", s.requireUser(s.handleAutoDrone))
mux.HandleFunc("PATCH /api/drones/{id}", s.requireUser(s.handleUpdateDrone))
mux.HandleFunc("DELETE /api/drones/{id}", s.requireUser(s.handleDeleteDrone))
mux.HandleFunc("GET /api/flights", s.requireUser(s.handleListFlights))
mux.HandleFunc("POST /api/flights", s.requireUser(s.handleCreateFlight))
mux.HandleFunc("PATCH /api/flights/{id}", s.requireUser(s.handleUpdateFlight))
mux.HandleFunc("DELETE /api/flights/{id}", s.requireUser(s.handleDeleteFlight))
mux.HandleFunc("GET /api/logbook/export", s.requireUser(s.handleExportLogbook))
// Documents — the compliance + operational document store (metadata in
// PocketBase, blob in PocketBase file storage for now). Available to any
// authenticated user; per-role scoping is enforced inside the handlers.
mux.HandleFunc("GET /api/documents", s.requireUser(s.handleListDocuments))
mux.HandleFunc("POST /api/documents", s.requireUser(s.handleCreateDocument))
mux.HandleFunc("PATCH /api/documents/{id}", s.requireUser(s.handleUpdateDocument))
mux.HandleFunc("DELETE /api/documents/{id}", s.requireUser(s.handleDeleteDocument))
mux.HandleFunc("GET /api/documents/{id}/file", s.requireUser(s.handleDownloadDocument))
// Device / dashboard API.
mux.HandleFunc("GET /api/devices", s.handleListDevices)
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
mux.HandleFunc("POST /api/devices/{id}/command", s.handleCommand)
mux.HandleFunc("DELETE /api/devices/{id}", s.handleForget)
mux.HandleFunc("POST /api/telemetry", s.handleTelemetryPost)
// Websockets: device uplink (Fly App) and dashboard stream (Web App/panel).
mux.HandleFunc("GET /ws/device", s.handleDeviceWS)
mux.HandleFunc("GET /ws/ui", s.handleUIWS)
return s.withMiddleware(mux)
}
// withMiddleware applies panic recovery, CORS, and request logging globally.
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverer(s.cors(s.logger(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)
})
}
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.cfg.AllowOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
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 websocket upgrader take over the underlying connection even
// though the logger has wrapped the ResponseWriter.
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
h, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return h.Hijack()
}