// 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)) }) }