Files
PilotVault/Web App/server/bff.go
T
tajniak81andClaude Opus 4.8 15bff7f517 Add adjustable probe area for OpenSky test connection
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>
2026-07-14 10:25:59 +02:00

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