Files
PilotVault/API Server/internal/api/server.go
T
tajniak81andClaude Opus 4.8 5960fb4806 Show current weather on the Overview
Add a weather card to the Web App Overview, wired like the OpenSky live-map
overlay and backed by the OpenWeather plugin.

- API Server: GET /api/integrations/openweather/current resolves the
  caller's cascade, gates it (master/org/personal opt-in/key), and returns
  trimmed current conditions for the configured or a supplied ?lat=&lon=
  point; off/keyless returns {unavailable, detail} so the card degrades.
- BFF relay (forwards the location override) + route; api.js client.
- Dashboard: a Weather card stacked above Schedule showing an emoji
  condition, temperature in the resolved unit, description, location, and a
  feels-like/wind/humidity/cloud grid. Location follows the same cascade as
  the map (drone -> phone -> browser -> default) without prompting for geo;
  refreshes every 10 min while on Overview.
- validLatLon test; rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:34:01 +02:00

268 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("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()
}