Files
PilotVault/Web App/server/main.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

162 lines
7.5 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))
mux.HandleFunc("GET /bff/integrations/openweather", app.requireAuth(app.handleGetOpenWeather))
mux.HandleFunc("PUT /bff/integrations/openweather", app.requireAuth(app.handlePutOpenWeather))
mux.HandleFunc("POST /bff/integrations/openweather/health", app.requireAuth(app.handleOpenWeatherHealth))
mux.HandleFunc("GET /bff/integrations/openweather/current", app.requireAuth(app.handleOpenWeatherCurrent))
// 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))
})
}