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>
670 lines
24 KiB
Go
670 lines
24 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/integrations/openweather → API Server /api/integrations/openweather
|
|
func (a *App) handleGetOpenWeather(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/integrations/openweather", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// PUT /bff/integrations/openweather → API Server /api/integrations/openweather
|
|
func (a *App) handlePutOpenWeather(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req, _ := http.NewRequest(http.MethodPut, a.apiBaseFor(r)+"/api/integrations/openweather", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// POST /bff/integrations/openweather/health → API Server /api/integrations/openweather/health
|
|
func (a *App) handleOpenWeatherHealth(w http.ResponseWriter, r *http.Request) {
|
|
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/openweather/health", nil)
|
|
req.Header.Set("Authorization", tokenOf(r))
|
|
a.doRelay(w, req)
|
|
}
|
|
|
|
// GET /bff/integrations/openweather/current → API Server current conditions for the
|
|
// Overview weather card. Forwards the optional ?lat=&lon= location override.
|
|
func (a *App) handleOpenWeatherCurrent(w http.ResponseWriter, r *http.Request) {
|
|
target := a.apiBaseFor(r) + "/api/integrations/openweather/current"
|
|
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/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,
|
|
})
|
|
}
|