Let the OpenSky health probe run over a chosen bounding box without touching the saved default, so its credit cost (which scales with area) can be checked cheaply. - Backend: the plugin health endpoint (panel Check) and the OpenSky health endpoint (Web App Test connection) accept an optional ?bbox=, validated with validBBox and applied to a transient probe instance; both BFF and API forward it. Saved config is untouched. - API panel: a "Probe area" picker beside each bbox-capable plugin's Check button — saved default, all countries (grouped by continent), or Custom. Adds a compact pv-input-sm style. - Web App: a "Test area" picker beside Test connection, using the full country list (new countryGroups() helper) plus Custom. This is where the probe is authenticated and the credit cost is shown. Builds pass across both Go modules and both frontends. Panel picker verified live (186 options, defaults to saved default). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
635 lines
22 KiB
Go
635 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
cookieToken = "dji_token"
|
|
cookieEmail = "dji_email"
|
|
cookieApi = "dji_api"
|
|
)
|
|
|
|
var client = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
// apiBaseFor returns the API Server base for this request: the per-session value
|
|
// chosen at login (cookie), falling back to the server's configured default.
|
|
func (a *App) apiBaseFor(r *http.Request) string {
|
|
if c, err := r.Cookie(cookieApi); err == nil && c.Value != "" {
|
|
return c.Value
|
|
}
|
|
return a.apiBase
|
|
}
|
|
|
|
func normalizeURL(u string) string {
|
|
u = strings.TrimSpace(u)
|
|
if u == "" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
|
|
u = "http://" + u
|
|
}
|
|
return strings.TrimRight(u, "/")
|
|
}
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// requireAuth gates a handler on the presence of a session cookie.
|
|
func (a *App) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := r.Cookie(cookieToken); err != nil {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "not signed in"})
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
// POST /bff/login {"email","password"} → proxies to API Server /api/auth/login.
|
|
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
ApiBase string `json:"apiBase"` // optional API Server override (per session)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
|
|
return
|
|
}
|
|
|
|
base := a.apiBase
|
|
if b := normalizeURL(body.ApiBase); b != "" {
|
|
base = b
|
|
}
|
|
|
|
payload, _ := json.Marshal(map[string]string{"email": body.Email, "password": body.Password})
|
|
resp, err := client.Post(base+"/api/auth/login", "application/json", bytes.NewReader(payload))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Relay PocketBase's error (e.g. invalid credentials).
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = w.Write(data)
|
|
return
|
|
}
|
|
|
|
var auth struct {
|
|
Token string `json:"token"`
|
|
Record struct {
|
|
Email string `json:"email"`
|
|
} `json:"record"`
|
|
}
|
|
if err := json.Unmarshal(data, &auth); err != nil || auth.Token == "" {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected auth response"})
|
|
return
|
|
}
|
|
|
|
setCookie(w, cookieToken, auth.Token, true)
|
|
setCookie(w, cookieEmail, auth.Record.Email, false)
|
|
setCookie(w, cookieApi, base, true) // remember the chosen API Server for this session
|
|
writeJSON(w, http.StatusOK, map[string]any{"email": auth.Record.Email})
|
|
}
|
|
|
|
// GET /bff/config → the server's default API Server address (to prefill the field).
|
|
func (a *App) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{"apiBase": a.apiBase})
|
|
}
|
|
|
|
// POST /bff/logout
|
|
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
clearCookie(w, cookieToken)
|
|
clearCookie(w, cookieEmail)
|
|
clearCookie(w, cookieApi)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// GET /bff/me → current session info incl. role (or 401). Proxies to the API
|
|
// Server /api/me so the role is always fresh (reflects admin promotions).
|
|
func (a *App) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
token := tokenOf(r)
|
|
if token == "" {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "not signed in"})
|
|
return
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/me", nil)
|
|
req.Header.Set("Authorization", token)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
// Fall back to the cookie email so the session survives a brief API blip.
|
|
email := ""
|
|
if c, err := r.Cookie(cookieEmail); err == nil {
|
|
email = c.Value
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"email": email, "role": "user"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
relay(w, resp)
|
|
}
|
|
|
|
// GET /bff/devices → API Server /api/devices
|
|
func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) {
|
|
a.proxyGET(w, a.apiBaseFor(r)+"/api/devices")
|
|
}
|
|
|
|
// GET /bff/devices/{id}/track → API Server /api/devices/{id}/track
|
|
func (a *App) handleTrack(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
a.proxyGET(w, a.apiBaseFor(r)+"/api/devices/"+id+"/track")
|
|
}
|
|
|
|
// POST /bff/devices/{id}/command → API Server /api/devices/{id}/command
|
|
func (a *App) handleCommand(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
body, _ := io.ReadAll(r.Body)
|
|
resp, err := client.Post(a.apiBaseFor(r)+"/api/devices/"+id+"/command", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
relay(w, resp)
|
|
}
|
|
|
|
// tokenOf returns the PocketBase auth token stored in the session cookie.
|
|
func tokenOf(r *http.Request) string {
|
|
if c, err := r.Cookie(cookieToken); err == nil {
|
|
return c.Value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GET /bff/preferences → API Server /api/preferences (Authorization: session token)
|
|
func (a *App) handleGetPrefs(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/preferences", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
relay(w, resp)
|
|
}
|
|
|
|
// PUT /bff/preferences → API Server /api/preferences (Authorization: session token)
|
|
func (a *App) handlePutPrefs(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/preferences", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
relay(w, resp)
|
|
}
|
|
|
|
// GET /bff/integrations/opensky → API Server /api/integrations/opensky
|
|
func (a *App) handleGetOpenSky(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/opensky", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PUT /bff/integrations/opensky → API Server /api/integrations/opensky
|
|
func (a *App) handlePutOpenSky(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/opensky", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/integrations/opensky/health → API Server /api/integrations/opensky/health
|
|
func (a *App) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
|
target := a.apiBaseFor(r) + "/api/integrations/opensky/health"
|
|
// Forward the optional ?bbox= probe-area override from Test connection.
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
req, _ := http.NewRequest(http.MethodPost, target, nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/integrations/opensky/states → API Server /api/integrations/opensky/states.
|
|
// Live aircraft positions for the Live map.
|
|
func (a *App) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
|
|
target := a.apiBaseFor(r) + "/api/integrations/opensky/states"
|
|
// Forward the optional ?bbox= override the Live map sends in auto mode.
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, target, nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/integrations/filetransfer → API Server /api/integrations/filetransfer
|
|
func (a *App) handleGetFileTransfer(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/filetransfer", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PUT /bff/integrations/filetransfer → API Server /api/integrations/filetransfer
|
|
func (a *App) handlePutFileTransfer(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/filetransfer", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/integrations/filetransfer/health → API Server /api/integrations/filetransfer/health
|
|
func (a *App) handleFileTransferHealth(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/filetransfer/health", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/integrations/localstorage → API Server /api/integrations/localstorage
|
|
func (a *App) handleGetLocalStorage(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/localstorage", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PUT /bff/integrations/localstorage → API Server /api/integrations/localstorage
|
|
func (a *App) handlePutLocalStorage(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/localstorage", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/integrations/localstorage/health → API Server /api/integrations/localstorage/health
|
|
func (a *App) handleLocalStorageHealth(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/localstorage/health", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/integrations/webdav → API Server /api/integrations/webdav
|
|
func (a *App) handleGetWebDav(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/webdav", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PUT /bff/integrations/webdav → API Server /api/integrations/webdav
|
|
func (a *App) handlePutWebDav(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/webdav", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/integrations/webdav/health → API Server /api/integrations/webdav/health
|
|
func (a *App) handleWebDavHealth(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/webdav/health", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/users → API Server /api/users (admin only, enforced upstream)
|
|
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/users", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/users → API Server /api/users
|
|
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/users", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PATCH /bff/users/{id} → API Server /api/users/{id}
|
|
func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/users/"+url.PathEscape(id), bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// DELETE /bff/users/{id} → API Server /api/users/{id}
|
|
func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/users/"+url.PathEscape(id), nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/orgs → API Server /api/orgs (manager only, enforced upstream)
|
|
func (a *App) handleListOrgs(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/orgs", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/orgs → API Server /api/orgs
|
|
func (a *App) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/orgs", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PATCH /bff/orgs/{id} → API Server /api/orgs/{id}
|
|
func (a *App) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/orgs/"+url.PathEscape(id), bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// DELETE /bff/orgs/{id} → API Server /api/orgs/{id}
|
|
func (a *App) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/orgs/"+url.PathEscape(id), nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
/* ---------- Logbook: drones ---------- */
|
|
|
|
// GET /bff/drones → API Server /api/drones
|
|
func (a *App) handleListDrones(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/drones", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/drones → API Server /api/drones
|
|
func (a *App) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/drones", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PATCH /bff/drones/{id} → API Server /api/drones/{id}
|
|
func (a *App) handleUpdateDrone(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/drones/"+url.PathEscape(id), bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// DELETE /bff/drones/{id} → API Server /api/drones/{id}
|
|
func (a *App) handleDeleteDrone(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/drones/"+url.PathEscape(id), nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
/* ---------- Logbook: flights ---------- */
|
|
|
|
// GET /bff/flights → API Server /api/flights
|
|
func (a *App) handleListFlights(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/flights", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/flights → API Server /api/flights
|
|
func (a *App) handleCreateFlight(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/flights", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PATCH /bff/flights/{id} → API Server /api/flights/{id}
|
|
func (a *App) handleUpdateFlight(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/flights/"+url.PathEscape(id), bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// DELETE /bff/flights/{id} → API Server /api/flights/{id}
|
|
func (a *App) handleDeleteFlight(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/flights/"+url.PathEscape(id), nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/logbook/export → API Server /api/logbook/export. Unlike the JSON
|
|
// endpoints this streams a CSV download, so it preserves the upstream
|
|
// Content-Type + Content-Disposition instead of forcing application/json.
|
|
func (a *App) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/logbook/export", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
|
w.Header().Set("Content-Type", ct)
|
|
}
|
|
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
|
w.Header().Set("Content-Disposition", cd)
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}
|
|
|
|
/* ---------- Documents ---------- */
|
|
|
|
// GET /bff/documents → API Server /api/documents (preserves ?expiring=N).
|
|
func (a *App) handleListDocuments(w http.ResponseWriter, r *http.Request) {
|
|
target := a.apiBaseFor(r) + "/api/documents"
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, target, nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/documents → API Server /api/documents. Streams the multipart body
|
|
// through unchanged (metadata fields + the optional file blob).
|
|
func (a *App) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/documents", r.Body)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
if ct := r.Header.Get("Content-Type"); ct != "" {
|
|
req.Header.Set("Content-Type", ct)
|
|
}
|
|
req.ContentLength = r.ContentLength
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PATCH /bff/documents/{id} → API Server /api/documents/{id} (JSON metadata).
|
|
func (a *App) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id), bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// DELETE /bff/documents/{id} → API Server /api/documents/{id}
|
|
func (a *App) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id), nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/documents/{id}/file → API Server /api/documents/{id}/file. Streams
|
|
// the blob, preserving the upstream Content-Type + Content-Disposition. The
|
|
// `?inline=1` flag (preview vs. download) is forwarded unchanged.
|
|
func (a *App) handleDownloadDocument(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
target := a.apiBaseFor(r) + "/api/documents/" + url.PathEscape(id) + "/file"
|
|
if r.URL.RawQuery != "" {
|
|
target += "?" + r.URL.RawQuery
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, target, nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
for _, h := range []string{"Content-Type", "Content-Disposition", "Content-Length"} {
|
|
if v := resp.Header.Get(h); v != "" {
|
|
w.Header().Set(h, v)
|
|
}
|
|
}
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(w, resp.Body)
|
|
}
|
|
|
|
// doRelay executes an outbound request and relays the response verbatim.
|
|
func (a *App) doRelay(w http.ResponseWriter, req *http.Request) {
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
relay(w, resp)
|
|
}
|
|
|
|
func (a *App) proxyGET(w http.ResponseWriter, url string) {
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
relay(w, resp)
|
|
}
|
|
|
|
func relay(w http.ResponseWriter, resp *http.Response) {
|
|
data, _ := io.ReadAll(resp.Body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
// GET /bff/ws → relays the API Server's /ws/ui websocket to the browser.
|
|
func (a *App) handleWS(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := r.Cookie(cookieToken); err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
wsBase := strings.Replace(a.apiBaseFor(r), "http", "ws", 1) // http→ws, https→wss
|
|
upstream, _, err := websocket.DefaultDialer.Dial(wsBase+"/ws/ui", nil)
|
|
if err != nil {
|
|
http.Error(w, "upstream websocket unavailable", http.StatusBadGateway)
|
|
return
|
|
}
|
|
downstream, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
upstream.Close()
|
|
return
|
|
}
|
|
go pipe(upstream, downstream)
|
|
pipe(downstream, upstream)
|
|
}
|
|
|
|
func pipe(src, dst *websocket.Conn) {
|
|
defer src.Close()
|
|
defer dst.Close()
|
|
for {
|
|
mt, msg, err := src.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := dst.WriteMessage(mt, msg); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func setCookie(w http.ResponseWriter, name, value string, httpOnly bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: value,
|
|
Path: "/",
|
|
HttpOnly: httpOnly,
|
|
SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 7 * 24 * 3600,
|
|
})
|
|
}
|
|
|
|
func clearCookie(w http.ResponseWriter, name string) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name, Value: "", Path: "/", HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode, MaxAge: -1,
|
|
})
|
|
}
|