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>
268 lines
11 KiB
Go
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()
|
|
}
|