Files
PilotVault/API Server/internal/api/server.go
T
tajniak81andClaude Opus 4.8 52f84ad6cf Add compliance & operational document store (PocketBase)
Introduces a `documents` collection and full-stack UI for tracking pilot
certificates, aircraft registrations, insurance, airspace authorisations,
contracts, and other paperwork.

- Migration 1720300800_add_documents.js (also provisioned live on remote PB):
  doc_type/owner/expiry/status/access_tier + file blob, a self-referential
  `replaces` version chain, audit fields, and a partial index on expiry_date.
- API Server (documents.go): role-scoped CRUD, server-computed expiry
  assessment, ?expiring=N query, versioning (replaces -> version+1, old row
  auto-archived), and streamed blob download. admin.go gains multipart upload,
  file tokens, and protected-file streaming.
- Web App: BFF passthrough (multipart create + streamed download), api.js
  client fns, and Documents.vue wired into the Documents nav slot.

Blobs live in PocketBase file storage for now; only that backend swaps when
S3-compatible object storage lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:46:18 +02:00

263 lines
10 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/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)
// 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("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()
}