The OpenSky "Default bounding box" now follows where flying happens. A new "Automatic" picker mode (the default) resolves the live-map area from a location cascade — drone telemetry → phone GPS → browser geolocation → the user's Region country → Europe — instead of a fixed box. Manual presets and Custom coordinates still work. - Web App: new shared countries.js dataset (all countries + bbox, offline point→country); the bbox picker gains all European countries and an Automatic option (client pref prefs.autoBbox); the Region setting expands from 6 locale entries to all countries; the live map resolves the cascade each poll and sends it as ?bbox=. - API Server: the states endpoint accepts and validates a ?bbox= override (validBBox); the Web App BFF forwards the query; the hub relays new phoneLatitude/phoneLongitude telemetry to the Web App. - Fly App: reports the phone's own GPS (geolocator) alongside telemetry, used as the "your location" fallback. - API panel: the OpenSky bbox picker lists all European countries. Builds verified across web, panel, both Go modules and the Fly App APK. Region list, Automatic default and the cascade ?bbox= override verified in the browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
738 lines
26 KiB
Go
738 lines
26 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// validBBox reports whether s is a well-formed "lamin,lomin,lamax,lomax" bounding
|
|
// box: four numbers, latitudes in [-90,90], longitudes in [-180,180], and min < max
|
|
// on each axis. Used to vet the Live map's client-supplied ?bbox= override before
|
|
// it reaches OpenSky.
|
|
func validBBox(s string) bool {
|
|
parts := strings.Split(s, ",")
|
|
if len(parts) != 4 {
|
|
return false
|
|
}
|
|
n := make([]float64, 4)
|
|
for i, p := range parts {
|
|
v, err := strconv.ParseFloat(strings.TrimSpace(p), 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
n[i] = v
|
|
}
|
|
laMin, loMin, laMax, loMax := n[0], n[1], n[2], n[3]
|
|
if laMin < -90 || laMax > 90 || loMin < -180 || loMax > 180 {
|
|
return false
|
|
}
|
|
return laMin < laMax && loMin < loMax
|
|
}
|
|
|
|
// Integrations exposes the OpenSky plugin's settings to end users under a
|
|
// three-layer cascade (superadmin/global → organization → user). Each of the
|
|
// four settings resolves independently, top wins, and a blank field falls
|
|
// through to the layer below:
|
|
//
|
|
// - global (L1): the plugin's config in plugins.json, set in the API Server panel.
|
|
// - org (L2): pluginSettings on the caller's organization record (org admins).
|
|
// - user (L3): pluginSettings on the caller's own user record.
|
|
//
|
|
// The OAuth2 client id + secret resolve as a *pair* from the highest layer that
|
|
// supplies a client id, so credential halves are never mixed across layers.
|
|
// Enablement is strictly per-user (L3) and gated by the global master switch.
|
|
//
|
|
// Secrets (and inherited client ids) are never returned to a lower-privileged
|
|
// client: the effective config is resolved server-side and only masked values
|
|
// leave the API. Live probes run server-side against the resolved config.
|
|
|
|
const (
|
|
openSkyPlugin = "opensky"
|
|
openSkySecretMask = "••••••••"
|
|
)
|
|
|
|
// osConfig is one layer's OpenSky settings.
|
|
type osConfig struct {
|
|
ClientID string `json:"clientId"`
|
|
ClientSecret string `json:"clientSecret"`
|
|
Plan string `json:"plan"`
|
|
Bbox string `json:"bbox"`
|
|
}
|
|
|
|
// osStored is what we persist per user/org under pluginSettings.opensky.
|
|
type osStored struct {
|
|
Config osConfig `json:"config"`
|
|
// Enabled is the personal per-user opt-in (user layer). Default false.
|
|
Enabled bool `json:"enabled"`
|
|
// Disabled is the organization layer's off switch, stored inverted so that
|
|
// absent == enabled: existing org records (which carry a legacy enabled:false
|
|
// from earlier config saves) therefore read as enabled, avoiding a regression.
|
|
// Only meaningful on the org record; ignored on user records.
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
// osSettingsDoc is the pluginSettings JSON shape (only opensky today).
|
|
type osSettingsDoc struct {
|
|
OpenSky osStored `json:"opensky"`
|
|
}
|
|
|
|
// osFieldView is one field's resolved state for the UI.
|
|
type osFieldView struct {
|
|
Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked)
|
|
Own string `json:"own"` // the caller's own editable-layer value (secret masked)
|
|
Source string `json:"source"` // global | org | user | unset
|
|
Locked bool `json:"locked"` // set above the caller's editable layer
|
|
}
|
|
|
|
// osResolution is the fully-resolved OpenSky state for one caller. It captures the
|
|
// effective cascade plus both editable layers (personal + organization), so an org
|
|
// admin can manage each independently — their own settings as a user, and the
|
|
// organization-wide settings that override every user's.
|
|
type osResolution struct {
|
|
eff osConfig // effective (unmasked) — used only server-side (probes)
|
|
userOwn osConfig // caller's personal (L3) values (unmasked)
|
|
orgOwn osConfig // organization (L2) values (unmasked)
|
|
source map[string]string // field -> layer name (global|org|user|unset)
|
|
isSuper bool // superadmin: manages the global layer in the panel
|
|
canOrg bool // caller may edit the organization layer (org admin)
|
|
available bool // global master switch
|
|
orgEnabled bool // org master switch (default true; gates the org's users)
|
|
allowAnon bool // global anonymous policy
|
|
enabled bool // caller's personal enable flag
|
|
}
|
|
|
|
// resolveOpenSky computes the cascade for a caller. userRaw is the caller's
|
|
// pluginSettings blob (from their auth-refresh record).
|
|
func (s *Server) resolveOpenSky(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) osResolution {
|
|
g, masterEnabled, _ := s.plugins.RawConfig(openSkyPlugin)
|
|
gc := osConfig{ClientID: g["clientId"], ClientSecret: g["clientSecret"], Plan: g["plan"], Bbox: g["bbox"]}
|
|
allowAnon := !strings.EqualFold(strings.TrimSpace(g["allowAnonymous"]), "false")
|
|
|
|
var oStored osStored
|
|
if who.OrgID != "" {
|
|
oStored, _ = s.orgOpenSky(ctx, who.OrgID)
|
|
}
|
|
oc := oStored.Config
|
|
|
|
var uStored osStored
|
|
if len(userRaw) > 0 {
|
|
var d osSettingsDoc
|
|
_ = json.Unmarshal(userRaw, &d)
|
|
uStored = d.OpenSky
|
|
}
|
|
uc := uStored.Config
|
|
|
|
res := osResolution{
|
|
source: map[string]string{},
|
|
userOwn: uc,
|
|
orgOwn: oc,
|
|
isSuper: who.isSuperadmin(),
|
|
// An org admin may edit the organization layer in addition to their own
|
|
// personal layer. Requires the service account (org writes go through it);
|
|
// without it the org layer is invisible to the cascade anyway.
|
|
canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.admin.configured(),
|
|
available: masterEnabled,
|
|
// Org gate: enabled by default, off only when the org explicitly disabled it.
|
|
orgEnabled: !oStored.Disabled,
|
|
allowAnon: allowAnon,
|
|
enabled: uStored.Enabled,
|
|
}
|
|
|
|
// Ordered layers, top (highest priority) first.
|
|
type layer struct {
|
|
name string
|
|
c osConfig
|
|
}
|
|
layers := []layer{{"global", gc}}
|
|
if who.OrgID != "" {
|
|
layers = append(layers, layer{"org", oc})
|
|
}
|
|
layers = append(layers, layer{"user", uc})
|
|
|
|
pick := func(get func(osConfig) string) (val, src string) {
|
|
for _, l := range layers {
|
|
if v := strings.TrimSpace(get(l.c)); v != "" {
|
|
return v, l.name
|
|
}
|
|
}
|
|
return "", "unset"
|
|
}
|
|
res.eff.Plan, res.source["plan"] = pick(func(c osConfig) string { return c.Plan })
|
|
res.eff.Bbox, res.source["bbox"] = pick(func(c osConfig) string { return c.Bbox })
|
|
|
|
// Credentials resolve as a pair from the highest layer with a client id.
|
|
credSrc := "unset"
|
|
for _, l := range layers {
|
|
if id := strings.TrimSpace(l.c.ClientID); id != "" {
|
|
res.eff.ClientID, res.eff.ClientSecret, credSrc = id, l.c.ClientSecret, l.name
|
|
break
|
|
}
|
|
}
|
|
res.source["clientId"] = credSrc
|
|
res.source["clientSecret"] = credSrc
|
|
return res
|
|
}
|
|
|
|
// layerRank orders the cascade layers; a higher number is lower priority.
|
|
var layerRank = map[string]int{"global": 1, "org": 2, "user": 3}
|
|
|
|
// lockedFor reports whether a field whose value comes from source is locked for a
|
|
// caller whose editable layer is editable (i.e. the value is set above them).
|
|
func lockedFor(source, editable string) bool {
|
|
if editable == "none" {
|
|
return true // superadmin edits the global layer in the panel, not here
|
|
}
|
|
sr, ok := layerRank[source]
|
|
if !ok {
|
|
return false // unset — the caller may be the first to set it
|
|
}
|
|
return sr < layerRank[editable]
|
|
}
|
|
|
|
// maskPresent returns the secret mask when v is non-empty, else "".
|
|
func maskPresent(v string) string {
|
|
if strings.TrimSpace(v) != "" {
|
|
return openSkySecretMask
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// orgOpenSky reads an organization's stored OpenSky settings (config + the org
|
|
// gate) and its raw pluginSettings blob via the service account. Best effort: zero
|
|
// values on any miss so callers can proceed as if the org layer were empty.
|
|
func (s *Server) orgOpenSky(ctx context.Context, orgID string) (osStored, json.RawMessage) {
|
|
if orgID == "" || !s.admin.configured() {
|
|
return osStored{}, nil
|
|
}
|
|
data, status, err := s.admin.do(ctx, http.MethodGet,
|
|
"/api/collections/organizations/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil)
|
|
if err != nil || status != http.StatusOK {
|
|
return osStored{}, nil
|
|
}
|
|
var rec struct {
|
|
PluginSettings json.RawMessage `json:"pluginSettings"`
|
|
}
|
|
_ = json.Unmarshal(data, &rec)
|
|
var doc osSettingsDoc
|
|
if len(rec.PluginSettings) > 0 {
|
|
_ = json.Unmarshal(rec.PluginSettings, &doc)
|
|
}
|
|
return doc.OpenSky, rec.PluginSettings
|
|
}
|
|
|
|
// mergeOpenSky applies a mutation to the opensky entry of a pluginSettings blob,
|
|
// preserving any other plugin keys, and returns the new blob.
|
|
func mergeOpenSky(existing json.RawMessage, apply func(*osStored)) json.RawMessage {
|
|
doc := map[string]json.RawMessage{}
|
|
if len(existing) > 0 {
|
|
_ = json.Unmarshal(existing, &doc)
|
|
}
|
|
if doc == nil {
|
|
doc = map[string]json.RawMessage{} // existing was JSON null
|
|
}
|
|
var os osStored
|
|
if raw, ok := doc["opensky"]; ok {
|
|
_ = json.Unmarshal(raw, &os)
|
|
}
|
|
apply(&os)
|
|
b, _ := json.Marshal(os)
|
|
doc["opensky"] = b
|
|
out, _ := json.Marshal(doc)
|
|
return out
|
|
}
|
|
|
|
// callerFromRecord builds an identity from an auth-refresh record.
|
|
func callerFromRecord(rec *pbAuthResp) *callerIdentity {
|
|
role := unquote(rec.Record["role"])
|
|
if role == "" {
|
|
role = roleUser
|
|
}
|
|
return &callerIdentity{
|
|
ID: unquote(rec.Record["id"]),
|
|
Email: unquote(rec.Record["email"]),
|
|
Role: role,
|
|
OrgID: unquote(rec.Record["organization"]),
|
|
}
|
|
}
|
|
|
|
// GET /api/integrations/opensky — resolved OpenSky view for the caller.
|
|
func (s *Server) handleGetOpenSky(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || rec == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
who := callerFromRecord(rec)
|
|
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
|
|
writeJSON(w, http.StatusOK, s.openSkyView(who, res))
|
|
}
|
|
|
|
// osScopeView builds the masked field set for one editable scope. editable is the
|
|
// layer the caller edits in this scope ("user" | "org" | "none"); a field is locked
|
|
// when its effective value is set above that layer.
|
|
func (s *Server) osScopeView(res osResolution, editable string) map[string]any {
|
|
own := res.userOwn
|
|
if editable == "org" {
|
|
own = res.orgOwn
|
|
}
|
|
field := func(key, eff, ownv string, secret bool) osFieldView {
|
|
src := res.source[key]
|
|
locked := lockedFor(src, editable)
|
|
fv := osFieldView{Source: src, Locked: locked}
|
|
switch {
|
|
case secret:
|
|
// Never expose a secret; show only presence.
|
|
fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv)
|
|
case key == "clientId" && locked:
|
|
// Inherited client id — hide the concrete value from a lower layer.
|
|
fv.Effective, fv.Own = maskPresent(eff), maskPresent(ownv)
|
|
default:
|
|
fv.Effective, fv.Own = eff, ownv
|
|
}
|
|
return fv
|
|
}
|
|
return map[string]any{
|
|
"editableLayer": editable,
|
|
"fields": map[string]osFieldView{
|
|
"clientId": field("clientId", res.eff.ClientID, own.ClientID, false),
|
|
"clientSecret": field("clientSecret", res.eff.ClientSecret, own.ClientSecret, true),
|
|
"plan": field("plan", res.eff.Plan, own.Plan, false),
|
|
"bbox": field("bbox", res.eff.Bbox, own.Bbox, false),
|
|
},
|
|
}
|
|
}
|
|
|
|
// openSkyView builds the masked, client-safe response body from a resolution. It
|
|
// exposes a "user" scope for everyone plus, for org admins, an "org" scope — each
|
|
// with its own locked-field state — so the UI can present the two independently.
|
|
func (s *Server) openSkyView(who *callerIdentity, res osResolution) map[string]any {
|
|
out := map[string]any{
|
|
"available": res.available,
|
|
"orgEnabled": res.orgEnabled,
|
|
"allowAnonymous": res.allowAnon,
|
|
"enabled": res.enabled,
|
|
"role": who.Role,
|
|
"orgId": who.OrgID,
|
|
"canEditOrg": res.canOrg,
|
|
"isSuperadmin": res.isSuper,
|
|
}
|
|
if res.isSuper {
|
|
// Superadmin manages the global layer in the panel; here it is read-only.
|
|
out["editableLayer"] = "none"
|
|
out["scopes"] = map[string]any{"user": s.osScopeView(res, "none")}
|
|
return out
|
|
}
|
|
scopes := map[string]any{"user": s.osScopeView(res, "user")}
|
|
if res.canOrg {
|
|
scopes["org"] = s.osScopeView(res, "org")
|
|
}
|
|
out["scopes"] = scopes
|
|
return out
|
|
}
|
|
|
|
// PUT /api/integrations/opensky — save the caller's editable layer. Body:
|
|
// {enabled?: bool, config?: {clientId, clientSecret, plan, bbox}}. Fields locked
|
|
// above the caller are ignored; a client secret left at the mask is preserved.
|
|
func (s *Server) handlePutOpenSky(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
var body struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Scope string `json:"scope"` // "user" (default) | "org" (admins only)
|
|
Config map[string]string `json:"config"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
|
|
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || rec == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
who := callerFromRecord(rec)
|
|
userRaw := rec.Record["pluginSettings"]
|
|
res := s.resolveOpenSky(r.Context(), who, userRaw)
|
|
|
|
// Resolve which layer this write targets. Everyone edits their own personal
|
|
// (user) layer by default; an org admin may target the organization layer by
|
|
// asking for scope "org". Superadmins are read-only here (they manage global
|
|
// in the panel) and may only toggle their personal enable flag.
|
|
editable := "user"
|
|
switch {
|
|
case res.isSuper:
|
|
editable = "none"
|
|
case strings.EqualFold(strings.TrimSpace(body.Scope), "org"):
|
|
if !res.canOrg {
|
|
writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings")
|
|
return
|
|
}
|
|
editable = "org"
|
|
}
|
|
|
|
// Build the new target-layer config from its current own values, overlaying
|
|
// only fields the caller is allowed to change in this scope.
|
|
newOwn := res.userOwn
|
|
if editable == "org" {
|
|
newOwn = res.orgOwn
|
|
}
|
|
applyField := func(key string, set func(*osConfig, string)) {
|
|
v, ok := body.Config[key]
|
|
if !ok || lockedFor(res.source[key], editable) {
|
|
return
|
|
}
|
|
if key == "clientSecret" && v == openSkySecretMask {
|
|
return // keep current secret
|
|
}
|
|
set(&newOwn, strings.TrimSpace(v))
|
|
}
|
|
applyField("plan", func(c *osConfig, v string) { c.Plan = v })
|
|
applyField("bbox", func(c *osConfig, v string) { c.Bbox = v })
|
|
applyField("clientId", func(c *osConfig, v string) { c.ClientID = v })
|
|
// Secret is not trimmed (may legitimately contain edge whitespace? no — trim
|
|
// for consistency with the panel's Upsert, which TrimSpaces all values).
|
|
applyField("clientSecret", func(c *osConfig, v string) { c.ClientSecret = v })
|
|
|
|
// Persist the organization layer (admins) via the service account.
|
|
if editable == "org" {
|
|
if who.OrgID == "" {
|
|
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
|
|
return
|
|
}
|
|
if !s.admin.configured() {
|
|
writeError(w, http.StatusServiceUnavailable, "organization settings not configured on the server")
|
|
return
|
|
}
|
|
_, orgRaw := s.orgOpenSky(r.Context(), who.OrgID)
|
|
newDoc := mergeOpenSky(orgRaw, func(os *osStored) {
|
|
os.Config = newOwn
|
|
// In the org scope the enable flag is the org master switch, stored
|
|
// inverted (disabled) so absent means enabled.
|
|
if body.Enabled != nil {
|
|
os.Disabled = !*body.Enabled
|
|
}
|
|
})
|
|
_, st, err := s.admin.do(r.Context(), http.MethodPatch,
|
|
"/api/collections/organizations/records/"+url.PathEscape(who.OrgID),
|
|
map[string]json.RawMessage{"pluginSettings": newDoc})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if st != http.StatusOK {
|
|
writeError(w, http.StatusBadGateway, "could not save organization settings")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Persist the user record: the personal enable flag lives here (user/superadmin
|
|
// scope — in the org scope it targets the org gate instead), and so does the
|
|
// personal config layer when this write targets the user scope.
|
|
personalEnable := body.Enabled != nil && editable != "org"
|
|
if personalEnable || editable == "user" {
|
|
newDoc := mergeOpenSky(userRaw, func(os *osStored) {
|
|
if personalEnable {
|
|
os.Enabled = *body.Enabled
|
|
}
|
|
if editable == "user" {
|
|
os.Config = newOwn
|
|
}
|
|
})
|
|
if code, err := s.patchUserPluginSettings(r.Context(), token, who.ID, newDoc); err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
} else if code != http.StatusOK {
|
|
writeError(w, http.StatusBadGateway, "could not save user settings")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Re-resolve and return the fresh view.
|
|
fresh, st, err := s.pbAuthRefresh(r.Context(), token)
|
|
if err != nil || st != http.StatusOK || fresh == nil {
|
|
// The writes succeeded; just report success minimally.
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
res2 := s.resolveOpenSky(r.Context(), who, fresh.Record["pluginSettings"])
|
|
writeJSON(w, http.StatusOK, s.openSkyView(who, res2))
|
|
}
|
|
|
|
// patchUserPluginSettings writes the pluginSettings blob onto the caller's own
|
|
// user record using their token (PocketBase authorises self-writes).
|
|
func (s *Server) patchUserPluginSettings(ctx context.Context, token, id string, doc json.RawMessage) (int, error) {
|
|
if id == "" {
|
|
return 0, io.EOF // treated as a transport-ish failure by the caller
|
|
}
|
|
patch, _ := json.Marshal(map[string]json.RawMessage{"pluginSettings": doc})
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPatch,
|
|
s.auth.url()+"/api/collections/users/records/"+url.PathEscape(id), bytes.NewReader(patch))
|
|
req.Header.Set("Authorization", token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := s.auth.client.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// POST /api/integrations/opensky/health — live probe using the caller's resolved
|
|
// config. Never returns secrets.
|
|
func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || rec == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
who := callerFromRecord(rec)
|
|
if !who.isSuperadmin() {
|
|
if _, _, ok := s.plugins.RawConfig(openSkyPlugin); !ok {
|
|
writeError(w, http.StatusNotFound, "unknown plugin")
|
|
return
|
|
}
|
|
}
|
|
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
|
|
if !res.available {
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
|
"status": "down", "detail": "OpenSky is disabled by the administrator"}})
|
|
return
|
|
}
|
|
if !res.orgEnabled {
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{
|
|
"status": "down", "detail": "OpenSky is disabled for your organization"}})
|
|
return
|
|
}
|
|
cfg := map[string]string{
|
|
"clientId": res.eff.ClientID,
|
|
"clientSecret": res.eff.ClientSecret,
|
|
"plan": res.eff.Plan,
|
|
"bbox": res.eff.Bbox,
|
|
"allowAnonymous": boolStr(res.allowAnon),
|
|
}
|
|
h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
|
}
|
|
|
|
// osAircraft is one trimmed aircraft state vector for the Live map. It flattens the
|
|
// positional fields the UI actually plots out of OpenSky's raw index-addressed array.
|
|
type osAircraft struct {
|
|
Icao24 string `json:"icao24"`
|
|
Callsign string `json:"callsign"`
|
|
Country string `json:"country"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Heading *float64 `json:"heading,omitempty"` // true track, degrees
|
|
Velocity *float64 `json:"velocity,omitempty"` // m/s over ground
|
|
Altitude *float64 `json:"altitude,omitempty"` // barometric, metres
|
|
OnGround bool `json:"onGround"`
|
|
}
|
|
|
|
// GET /api/integrations/opensky/states — live aircraft positions for the caller's
|
|
// resolved bounding box, for plotting on the Web App Live map. Runs server-side
|
|
// against the resolved cascade config (never returns credentials). Gated by the
|
|
// same switches as the settings view: global master, org gate, and the caller's
|
|
// personal opt-in. When any gate is off it returns 200 with an empty list plus a
|
|
// reason, so the map can degrade quietly rather than error.
|
|
func (s *Server) handleOpenSkyStates(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
|
return
|
|
}
|
|
rec, status, err := s.pbAuthRefresh(r.Context(), token)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
|
return
|
|
}
|
|
if status != http.StatusOK || rec == nil {
|
|
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
|
return
|
|
}
|
|
who := callerFromRecord(rec)
|
|
res := s.resolveOpenSky(r.Context(), who, rec.Record["pluginSettings"])
|
|
|
|
// The plan drives the recommended refresh cadence (its daily credit budget and
|
|
// upstream update rate). Surfaced on every response so the "Auto" interval in the
|
|
// Map settings popover resolves even before the overlay is enabled.
|
|
plan := res.eff.Plan
|
|
recInterval := recommendedOpenSkyInterval(plan)
|
|
disabled := func(detail string) {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"states": []osAircraft{}, "unavailable": true, "detail": detail,
|
|
"plan": plan, "recommendedInterval": recInterval})
|
|
}
|
|
switch {
|
|
case !res.available:
|
|
disabled("OpenSky is disabled by the administrator")
|
|
return
|
|
case !res.orgEnabled:
|
|
disabled("OpenSky is disabled for your organization")
|
|
return
|
|
case !res.enabled:
|
|
disabled("Enable OpenSky in Settings → Integrations to show live air traffic")
|
|
return
|
|
}
|
|
|
|
// The Live map may request a specific area (auto cascade: drone → device →
|
|
// region). Honour a well-formed ?bbox= override; otherwise use the resolved
|
|
// config bbox. Malformed input is ignored rather than erroring.
|
|
bbox := res.eff.Bbox
|
|
if q := strings.TrimSpace(r.URL.Query().Get("bbox")); q != "" && validBBox(q) {
|
|
bbox = q
|
|
}
|
|
|
|
cfg := map[string]string{
|
|
"clientId": res.eff.ClientID,
|
|
"clientSecret": res.eff.ClientSecret,
|
|
"plan": res.eff.Plan,
|
|
"bbox": bbox,
|
|
"allowAnonymous": boolStr(res.allowAnon),
|
|
}
|
|
raw, err := s.plugins.InvokeWith(r.Context(), openSkyPlugin, cfg, "states.bbox", nil)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// OpenSky /states/all shape: {time, states: [[icao24, callsign, country,
|
|
// time_position, last_contact, lon, lat, baro_altitude, on_ground, velocity,
|
|
// true_track, ...], ...]}. states may be null when nothing is in the box.
|
|
var osResp struct {
|
|
Time int64 `json:"time"`
|
|
States [][]json.RawMessage `json:"states"`
|
|
}
|
|
if err := json.Unmarshal(raw, &osResp); err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "unexpected OpenSky response"})
|
|
return
|
|
}
|
|
|
|
aircraft := make([]osAircraft, 0, len(osResp.States))
|
|
for _, st := range osResp.States {
|
|
lng, okLng := rawFloat(st, 5)
|
|
lat, okLat := rawFloat(st, 6)
|
|
if !okLat || !okLng {
|
|
continue // no position fix — nothing to plot
|
|
}
|
|
a := osAircraft{
|
|
Icao24: strings.TrimSpace(rawString(st, 0)),
|
|
Callsign: strings.TrimSpace(rawString(st, 1)),
|
|
Country: strings.TrimSpace(rawString(st, 2)),
|
|
Lat: lat,
|
|
Lng: lng,
|
|
OnGround: rawBool(st, 8),
|
|
}
|
|
if v, ok := rawFloat(st, 7); ok {
|
|
a.Altitude = &v
|
|
}
|
|
if v, ok := rawFloat(st, 9); ok {
|
|
a.Velocity = &v
|
|
}
|
|
if v, ok := rawFloat(st, 10); ok {
|
|
a.Heading = &v
|
|
}
|
|
aircraft = append(aircraft, a)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"time": osResp.Time, "states": aircraft,
|
|
"plan": plan, "recommendedInterval": recInterval})
|
|
}
|
|
|
|
// recommendedOpenSkyInterval maps a resolved plan to a sensible default poll
|
|
// interval (seconds) for the Live map. It balances the plan's daily credit budget
|
|
// (anonymous 400 · standard 4000 · contributor 8000) against how often OpenSky
|
|
// actually refreshes state vectors (~10s anonymous, ~5s authenticated), so the map
|
|
// stays fresh without draining credits. An unset plan resolves as standard (the
|
|
// runtime default). See planDailyCredits in the opensky plugin.
|
|
func recommendedOpenSkyInterval(plan string) int {
|
|
switch plan {
|
|
case "anonymous":
|
|
return 60
|
|
case "contributor":
|
|
return 15
|
|
default: // standard or unset
|
|
return 30
|
|
}
|
|
}
|
|
|
|
// rawFloat reads element i of an OpenSky state array as a float, reporting ok=false
|
|
// for a missing index or a JSON null (OpenSky uses null for unknown fields).
|
|
func rawFloat(st []json.RawMessage, i int) (float64, bool) {
|
|
if i >= len(st) {
|
|
return 0, false
|
|
}
|
|
var f float64
|
|
if err := json.Unmarshal(st[i], &f); err != nil {
|
|
return 0, false
|
|
}
|
|
return f, true
|
|
}
|
|
|
|
// rawString reads element i as a string ("" for missing/null/non-string).
|
|
func rawString(st []json.RawMessage, i int) string {
|
|
if i >= len(st) {
|
|
return ""
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(st[i], &s); err != nil {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// rawBool reads element i as a bool (false for missing/null/non-bool).
|
|
func rawBool(st []json.RawMessage, i int) bool {
|
|
if i >= len(st) {
|
|
return false
|
|
}
|
|
var b bool
|
|
if err := json.Unmarshal(st[i], &b); err != nil {
|
|
return false
|
|
}
|
|
return b
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|