Initial commit: PilotVault multi-service project
Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -0,0 +1,339 @@
|
||||
// Package opensky is a built-in plugin connecting the OpenSky Network REST API
|
||||
// (live ADS-B aircraft state vectors). It demonstrates a real third-party
|
||||
// integration behind the plugin contract, including an OAuth2 client-credentials
|
||||
// AuthProvider with an anonymous fallback.
|
||||
//
|
||||
// Docs: https://openskynetwork.github.io/opensky-api/rest.html
|
||||
package opensky
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://opensky-network.org/api"
|
||||
tokenURL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"
|
||||
// Small default bounding box (Netherlands) keeps the health probe cheap.
|
||||
defaultBBox = "50.5,3.2,53.7,7.3" // lamin,lomin,lamax,lomax
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugins.Register("opensky", func() plugins.Plugin { return &Plugin{} })
|
||||
}
|
||||
|
||||
// Plugin is the OpenSky connector.
|
||||
type Plugin struct {
|
||||
mu sync.Mutex
|
||||
clientID string
|
||||
clientSecret string
|
||||
bbox string
|
||||
plan string
|
||||
allowAnonymous bool
|
||||
client *http.Client
|
||||
|
||||
token string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// errAnonDisabled is returned when a probe/call has no resolved credentials and
|
||||
// the operator has disabled anonymous access.
|
||||
var errAnonDisabled = errors.New("OpenSky credentials required — anonymous access is disabled")
|
||||
|
||||
func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
return plugins.Descriptor{
|
||||
Name: "opensky",
|
||||
Provider: "OpenSky Network",
|
||||
Version: "1.0.0",
|
||||
Kind: plugins.KindBuiltin,
|
||||
Category: plugins.CategoryAPIsExternal,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "states.all", Method: "GET", Endpoint: "/states/all",
|
||||
Description: "All current aircraft state vectors, world-wide (costs 4 credits/call)."},
|
||||
{ID: "states.bbox", Method: "GET", Endpoint: "/states/all?lamin&lomin&lamax&lomax",
|
||||
Description: "State vectors within the configured bounding box (1–4 credits by area)."},
|
||||
},
|
||||
AuthType: plugins.AuthOAuth2,
|
||||
ConfigFields: []plugins.ConfigField{
|
||||
{Key: "plan", Label: "OpenSky plan", Type: "select",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "", Label: "Not set — let organizations and users choose"},
|
||||
{Value: "anonymous", Label: "Anonymous — 400 credits/day"},
|
||||
{Value: "standard", Label: "Standard (registered) — 4000 credits/day"},
|
||||
{Value: "contributor", Label: "Contributor — 8000 credits/day"},
|
||||
},
|
||||
Help: "Global account tier. Leave it unset to let each organization or user pick their own plan; set a value only to force one plan for everyone. Determines the daily credit allowance shown next to remaining credits."},
|
||||
{Key: "clientId", Label: "OAuth2 client ID", Type: "text", Help: "Optional — leave blank for anonymous access (lower rate limits)."},
|
||||
{Key: "clientSecret", Label: "OAuth2 client secret", Type: "password", Secret: true, Help: "Paired with the client ID for authenticated access."},
|
||||
{Key: "bbox", Label: "Default bounding box", Type: "text", Default: defaultBBox, Help: "lamin,lomin,lamax,lomax — used by the health probe and states.bbox."},
|
||||
{Key: "allowAnonymous", Label: "Anonymous access", Type: "select", Default: "true",
|
||||
Options: []plugins.SelectOption{
|
||||
{Value: "true", Label: "Enabled — allow use without credentials"},
|
||||
{Value: "false", Label: "Disabled — require OAuth2 credentials"},
|
||||
},
|
||||
Help: "Global policy: when disabled, the plugin can only be used once OAuth2 credentials resolve from some layer (superadmin, organization, or user)."},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// planDailyCredits maps an OpenSky plan to its daily credit allowance.
|
||||
// See https://openskynetwork.github.io/opensky-api/rest.html#api-credits
|
||||
func planDailyCredits(plan string) int {
|
||||
switch plan {
|
||||
case "anonymous":
|
||||
return 400
|
||||
case "contributor":
|
||||
return 8000
|
||||
default: // "standard"
|
||||
return 4000
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.clientID = strings.TrimSpace(config["clientId"])
|
||||
p.clientSecret = config["clientSecret"]
|
||||
p.bbox = strings.TrimSpace(config["bbox"])
|
||||
if p.bbox == "" {
|
||||
p.bbox = defaultBBox
|
||||
}
|
||||
p.plan = strings.TrimSpace(config["plan"])
|
||||
if p.plan == "" {
|
||||
p.plan = "standard" // OpenSky registered-user default
|
||||
}
|
||||
// Anonymous access defaults to enabled; only an explicit "false" turns it off.
|
||||
p.allowAnonymous = !strings.EqualFold(strings.TrimSpace(config["allowAnonymous"]), "false")
|
||||
p.client = &http.Client{Timeout: 10 * time.Second}
|
||||
p.token, p.tokenExp = "", time.Time{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bearer returns a valid OAuth2 token, fetching/refreshing via client-credentials
|
||||
// when configured. Returns "" (no error) when running anonymously.
|
||||
func (p *Plugin) bearer(ctx context.Context) (string, error) {
|
||||
p.mu.Lock()
|
||||
id, secret, allowAnon := p.clientID, p.clientSecret, p.allowAnonymous
|
||||
if p.token != "" && time.Now().Before(p.tokenExp) {
|
||||
tok := p.token
|
||||
p.mu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if id == "" || secret == "" {
|
||||
if !allowAnon {
|
||||
return "", errAnonDisabled
|
||||
}
|
||||
return "", nil // anonymous
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {id},
|
||||
"client_secret": {secret},
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errors.New("token endpoint returned HTTP " + resp.Status)
|
||||
}
|
||||
var out struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil || out.AccessToken == "" {
|
||||
return "", errors.New("no access_token in token response")
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.token = out.AccessToken
|
||||
ttl := out.ExpiresIn
|
||||
if ttl <= 0 {
|
||||
ttl = 1800
|
||||
}
|
||||
p.tokenExp = time.Now().Add(time.Duration(ttl-30) * time.Second)
|
||||
p.mu.Unlock()
|
||||
return out.AccessToken, nil
|
||||
}
|
||||
|
||||
// statesURLBBox builds the /states/all request URL constrained to the configured
|
||||
// bounding box. Falls back to the whole world if the bbox is malformed.
|
||||
func (p *Plugin) statesURLBBox() string {
|
||||
p.mu.Lock()
|
||||
bbox := p.bbox
|
||||
p.mu.Unlock()
|
||||
parts := strings.Split(bbox, ",")
|
||||
if len(parts) != 4 {
|
||||
return apiBase + "/states/all"
|
||||
}
|
||||
q := url.Values{
|
||||
"lamin": {strings.TrimSpace(parts[0])},
|
||||
"lomin": {strings.TrimSpace(parts[1])},
|
||||
"lamax": {strings.TrimSpace(parts[2])},
|
||||
"lomax": {strings.TrimSpace(parts[3])},
|
||||
}
|
||||
return apiBase + "/states/all?" + q.Encode()
|
||||
}
|
||||
|
||||
// statesURLAll returns the world-wide /states/all URL (no bounding box).
|
||||
func (p *Plugin) statesURLAll() string { return apiBase + "/states/all" }
|
||||
|
||||
// creditCost returns the OpenSky credit cost of a /states/all call over the given
|
||||
// bounding box, per https://openskynetwork.github.io/opensky-api/rest.html#api-credits:
|
||||
// 1 credit ≤ 25 sq°, 2 ≤ 100, 3 ≤ 400, 4 for larger or the whole world.
|
||||
func creditCost(bbox string) int {
|
||||
parts := strings.Split(bbox, ",")
|
||||
if len(parts) != 4 {
|
||||
return 4 // no/invalid box → whole world
|
||||
}
|
||||
lamin, e1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
|
||||
lomin, e2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||
lamax, e3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
|
||||
lomax, e4 := strconv.ParseFloat(strings.TrimSpace(parts[3]), 64)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil {
|
||||
return 4
|
||||
}
|
||||
area := math.Abs(lamax-lamin) * math.Abs(lomax-lomin)
|
||||
switch {
|
||||
case area <= 25:
|
||||
return 1
|
||||
case area <= 100:
|
||||
return 2
|
||||
case area <= 400:
|
||||
return 3
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
// creditWord renders a credit count with correct pluralisation.
|
||||
func creditWord(n int) string {
|
||||
if n == 1 {
|
||||
return "1 credit"
|
||||
}
|
||||
return strconv.Itoa(n) + " credits"
|
||||
}
|
||||
|
||||
// HealthCheck performs a live states query (authenticated when configured, else
|
||||
// anonymous) and classifies the outcome.
|
||||
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
|
||||
start := time.Now()
|
||||
token, err := p.bearer(ctx)
|
||||
if errors.Is(err, errAnonDisabled) {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
|
||||
Detail: err.Error()}
|
||||
}
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: time.Since(start).Milliseconds(),
|
||||
Detail: "auth failed: " + err.Error()}
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, p.statesURLBBox(), nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
lat := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
|
||||
|
||||
mode := "anonymous"
|
||||
if token != "" {
|
||||
mode = "authenticated"
|
||||
}
|
||||
|
||||
h := plugins.Health{LatencyMs: lat}
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
h.Status, h.Detail = plugins.StatusOK, "OpenSky reachable ("+mode+")"
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
h.Status, h.Detail = plugins.StatusDegraded, "rate limited (HTTP 429)"
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
h.Status, h.Detail = plugins.StatusDegraded, "auth rejected (HTTP "+resp.Status+")"
|
||||
default:
|
||||
h.Status, h.Detail = plugins.StatusDown, "HTTP "+resp.Status
|
||||
}
|
||||
// Surface live credit usage from the rate-limit header, the plan's daily
|
||||
// allowance, and this probe's cost (e.g. "3996/4000 credits left today · 1 credit/probe").
|
||||
// The same figures are also exposed structurally (h.Credits) so the UI can
|
||||
// render a dedicated usage meter without parsing this string.
|
||||
p.mu.Lock()
|
||||
bbox, plan := p.bbox, p.plan
|
||||
p.mu.Unlock()
|
||||
|
||||
cost := creditCost(bbox)
|
||||
credits := &plugins.HealthCredits{Daily: planDailyCredits(plan), ProbeCost: cost, Mode: mode}
|
||||
if rem := strings.TrimSpace(resp.Header.Get("X-Rate-Limit-Remaining")); rem != "" {
|
||||
if n, err := strconv.Atoi(rem); err == nil {
|
||||
credits.Remaining = &n
|
||||
}
|
||||
h.Detail += " · " + p.creditsText(rem)
|
||||
}
|
||||
h.Detail += " · " + creditWord(cost) + "/probe"
|
||||
h.Credits = credits
|
||||
return h
|
||||
}
|
||||
|
||||
// creditsText formats the remaining-credit header against the plan's daily
|
||||
// allowance. Empty when the header is absent.
|
||||
func (p *Plugin) creditsText(remaining string) string {
|
||||
remaining = strings.TrimSpace(remaining)
|
||||
if remaining == "" {
|
||||
return ""
|
||||
}
|
||||
p.mu.Lock()
|
||||
daily := planDailyCredits(p.plan)
|
||||
p.mu.Unlock()
|
||||
return remaining + "/" + strconv.Itoa(daily) + " credits left today"
|
||||
}
|
||||
|
||||
// Invoke exposes states.all / states.bbox. Part of the contract; no HTTP endpoint
|
||||
// surfaces it in v1, but it keeps the connector functional for future use.
|
||||
func (p *Plugin) Invoke(ctx context.Context, action string, _ json.RawMessage) (json.RawMessage, error) {
|
||||
switch action {
|
||||
case "states.all", "states.bbox":
|
||||
token, err := p.bearer(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := p.statesURLBBox()
|
||||
if action == "states.all" {
|
||||
target = p.statesURLAll() // world-wide (4 credits)
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
return data, nil
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) Shutdown(context.Context) error { return nil }
|
||||
Reference in new issue
Block a user