Files
PilotVault/Web App/server/bff.go
T
tajniak81andClaude Opus 4.8 52f84ad6cf Add compliance & operational document store (PocketBase)
Introduces a `documents` collection and full-stack UI for tracking pilot
certificates, aircraft registrations, insurance, airspace authorisations,
contracts, and other paperwork.

- Migration 1720300800_add_documents.js (also provisioned live on remote PB):
  doc_type/owner/expiry/status/access_tier + file blob, a self-referential
  `replaces` version chain, audit fields, and a partial index on expiry_date.
- API Server (documents.go): role-scoped CRUD, server-computed expiry
  assessment, ?expiring=N query, versioning (replaces -> version+1, old row
  auto-archived), and streamed blob download. admin.go gains multipart upload,
  file tokens, and protected-file streaming.
- Web App: BFF passthrough (multipart create + streamed download), api.js
  client fns, and Documents.vue wired into the Documents nav slot.

Blobs live in PocketBase file storage for now; only that backend swaps when
S3-compatible object storage lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:46:18 +02:00

612 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) {
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/opensky/health", 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 download, preserving the upstream Content-Type + Content-Disposition.
func (a *App) handleDownloadDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id)+"/file", 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,
})
}