Add GET /api/integrations/opensky/states: resolves the caller's OpenSky cascade and returns trimmed aircraft state vectors (icao24, callsign, country, lat/lng, heading, velocity, altitude, onGround) for their bbox. Reuses the settings gates (global master / org / personal opt-in) and is backed by a new Manager.InvokeWith that runs the plugin's states.bbox action on a transient instance. Proxied through the Web App BFF. DeviceMap now overlays these as rotatable plane markers (accent when airborne, grey on ground) with tooltips; Dashboard polls every 30s while Overview is visible and shows an aircraft count badge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
7.1 KiB
Go
158 lines
7.1 KiB
Go
// Command webapp is the PilotVault control-panel BFF. It serves the embedded
|
|
// Vue single-page app and proxies /bff/* to the API Server, so the browser only
|
|
// ever talks to this server (same-origin). The API Server is the gateway to
|
|
// PocketBase and to the live Fly App data; the browser never contacts either
|
|
// directly.
|
|
package main
|
|
|
|
import (
|
|
"embed"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
//go:embed all:dist
|
|
var distFS embed.FS
|
|
|
|
// App is the control-panel BFF. It talks ONLY to the API Server; the API Server
|
|
// is the gateway to PocketBase and to the live Fly App data. The browser never
|
|
// contacts PocketBase or the API Server directly.
|
|
type App struct {
|
|
apiBase string // e.g. http://localhost:8080
|
|
}
|
|
|
|
func main() {
|
|
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
|
log.SetPrefix("[web] ")
|
|
|
|
loadDotEnv(".env")
|
|
addr := envOr("ADDR", ":8090")
|
|
app := &App{apiBase: strings.TrimRight(envOr("API_BASE", "http://localhost:8080"), "/")}
|
|
|
|
mux := http.NewServeMux()
|
|
// Auth (proxied to API Server → PocketBase)
|
|
mux.HandleFunc("POST /bff/login", app.handleLogin)
|
|
mux.HandleFunc("POST /bff/logout", app.handleLogout)
|
|
mux.HandleFunc("GET /bff/me", app.handleMe)
|
|
mux.HandleFunc("GET /bff/config", app.handleConfig)
|
|
// Data (proxied to the API Server; gated by session cookie)
|
|
mux.HandleFunc("GET /bff/devices", app.requireAuth(app.handleDevices))
|
|
mux.HandleFunc("GET /bff/devices/{id}/track", app.requireAuth(app.handleTrack))
|
|
mux.HandleFunc("POST /bff/devices/{id}/command", app.requireAuth(app.handleCommand))
|
|
// User preferences (persisted in PocketBase via the API Server; needs the token)
|
|
mux.HandleFunc("GET /bff/preferences", app.requireAuth(app.handleGetPrefs))
|
|
mux.HandleFunc("PUT /bff/preferences", app.requireAuth(app.handlePutPrefs))
|
|
// Plugin integrations (OpenSky) — per-user/per-org settings via the API Server
|
|
mux.HandleFunc("GET /bff/integrations/opensky", app.requireAuth(app.handleGetOpenSky))
|
|
mux.HandleFunc("PUT /bff/integrations/opensky", app.requireAuth(app.handlePutOpenSky))
|
|
mux.HandleFunc("POST /bff/integrations/opensky/health", app.requireAuth(app.handleOpenSkyHealth))
|
|
mux.HandleFunc("GET /bff/integrations/opensky/states", app.requireAuth(app.handleOpenSkyStates))
|
|
// Plugin integrations (File transfer: FTP/SFTP) — per-user/per-org settings
|
|
mux.HandleFunc("GET /bff/integrations/filetransfer", app.requireAuth(app.handleGetFileTransfer))
|
|
mux.HandleFunc("PUT /bff/integrations/filetransfer", app.requireAuth(app.handlePutFileTransfer))
|
|
mux.HandleFunc("POST /bff/integrations/filetransfer/health", app.requireAuth(app.handleFileTransferHealth))
|
|
// Plugin integrations (Local storage: host filesystem) — per-user/per-org isolated folders
|
|
mux.HandleFunc("GET /bff/integrations/localstorage", app.requireAuth(app.handleGetLocalStorage))
|
|
mux.HandleFunc("PUT /bff/integrations/localstorage", app.requireAuth(app.handlePutLocalStorage))
|
|
mux.HandleFunc("POST /bff/integrations/localstorage/health", app.requireAuth(app.handleLocalStorageHealth))
|
|
// Plugin integrations (WebDAV) — per-user/per-org settings
|
|
mux.HandleFunc("GET /bff/integrations/webdav", app.requireAuth(app.handleGetWebDav))
|
|
mux.HandleFunc("PUT /bff/integrations/webdav", app.requireAuth(app.handlePutWebDav))
|
|
mux.HandleFunc("POST /bff/integrations/webdav/health", app.requireAuth(app.handleWebDavHealth))
|
|
// User-management (role + org scoping enforced by the API Server)
|
|
mux.HandleFunc("GET /bff/users", app.requireAuth(app.handleListUsers))
|
|
mux.HandleFunc("POST /bff/users", app.requireAuth(app.handleCreateUser))
|
|
mux.HandleFunc("PATCH /bff/users/{id}", app.requireAuth(app.handleUpdateUser))
|
|
mux.HandleFunc("DELETE /bff/users/{id}", app.requireAuth(app.handleDeleteUser))
|
|
// Organizations (create/edit/delete are superadmin-only upstream)
|
|
mux.HandleFunc("GET /bff/orgs", app.requireAuth(app.handleListOrgs))
|
|
mux.HandleFunc("POST /bff/orgs", app.requireAuth(app.handleCreateOrg))
|
|
mux.HandleFunc("PATCH /bff/orgs/{id}", app.requireAuth(app.handleUpdateOrg))
|
|
mux.HandleFunc("DELETE /bff/orgs/{id}", app.requireAuth(app.handleDeleteOrg))
|
|
// Logbook — drones, flights, and the compliance CSV export (scoping upstream)
|
|
mux.HandleFunc("GET /bff/drones", app.requireAuth(app.handleListDrones))
|
|
mux.HandleFunc("POST /bff/drones", app.requireAuth(app.handleCreateDrone))
|
|
mux.HandleFunc("PATCH /bff/drones/{id}", app.requireAuth(app.handleUpdateDrone))
|
|
mux.HandleFunc("DELETE /bff/drones/{id}", app.requireAuth(app.handleDeleteDrone))
|
|
mux.HandleFunc("GET /bff/flights", app.requireAuth(app.handleListFlights))
|
|
mux.HandleFunc("POST /bff/flights", app.requireAuth(app.handleCreateFlight))
|
|
mux.HandleFunc("PATCH /bff/flights/{id}", app.requireAuth(app.handleUpdateFlight))
|
|
mux.HandleFunc("DELETE /bff/flights/{id}", app.requireAuth(app.handleDeleteFlight))
|
|
mux.HandleFunc("GET /bff/logbook/export", app.requireAuth(app.handleExportLogbook))
|
|
// Documents — the compliance + operational document store (scoping upstream)
|
|
mux.HandleFunc("GET /bff/documents", app.requireAuth(app.handleListDocuments))
|
|
mux.HandleFunc("POST /bff/documents", app.requireAuth(app.handleCreateDocument))
|
|
mux.HandleFunc("PATCH /bff/documents/{id}", app.requireAuth(app.handleUpdateDocument))
|
|
mux.HandleFunc("DELETE /bff/documents/{id}", app.requireAuth(app.handleDeleteDocument))
|
|
mux.HandleFunc("GET /bff/documents/{id}/file", app.requireAuth(app.handleDownloadDocument))
|
|
mux.HandleFunc("GET /bff/ws", app.handleWS)
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase})
|
|
})
|
|
|
|
dist, err := fs.Sub(distFS, "dist")
|
|
if err != nil {
|
|
log.Fatalf("embed dist: %v", err)
|
|
}
|
|
mux.Handle("/", noCache(http.FileServer(http.FS(dist))))
|
|
|
|
srv := &http.Server{
|
|
Addr: addr,
|
|
Handler: logRequests(mux),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
log.Printf("Web App (control panel) on http://localhost%s → API Server %s", addr, app.apiBase)
|
|
log.Fatal(srv.ListenAndServe())
|
|
}
|
|
|
|
func envOr(k, d string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
|
|
// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they
|
|
// are not already set. It is intentionally minimal (no quoting rules beyond
|
|
// trimming surrounding quotes).
|
|
func loadDotEnv(path string) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, val, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
|
if _, exists := os.LookupEnv(key); !exists {
|
|
_ = os.Setenv(key, val)
|
|
}
|
|
}
|
|
}
|
|
|
|
func noCache(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", "no-store, must-revalidate")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func logRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
|
})
|
|
}
|