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:
@@ -0,0 +1,240 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"pilotvault/apiserver/internal/config"
|
||||
"pilotvault/apiserver/internal/hub"
|
||||
"pilotvault/apiserver/internal/plugins"
|
||||
_ "pilotvault/apiserver/internal/plugins/builtin" // register built-in plugins
|
||||
)
|
||||
|
||||
// Server wires together the HTTP handlers and their dependencies.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
|
||||
cfg config.Config
|
||||
hub *hub.Hub
|
||||
auth *authProxy
|
||||
admin *adminClient
|
||||
plugins *plugins.Manager
|
||||
}
|
||||
|
||||
// pbURL returns the current PocketBase base URL.
|
||||
func (s *Server) pbURL() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.PocketBaseURL
|
||||
}
|
||||
|
||||
// pbSettings snapshots the PocketBase connection for the settings endpoints.
|
||||
func (s *Server) pbSettings() (url, adminEmail, adminPassword string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg.PocketBaseURL, s.cfg.PocketBaseAdminEmail, s.cfg.PocketBaseAdminPassword
|
||||
}
|
||||
|
||||
// setPBConfig retargets the PocketBase connection at runtime: it updates the
|
||||
// cached config and repoints both the auth proxy and the admin service account.
|
||||
func (s *Server) setPBConfig(url, adminEmail, adminPassword string) {
|
||||
s.mu.Lock()
|
||||
s.cfg.PocketBaseURL = url
|
||||
s.cfg.PocketBaseAdminEmail = adminEmail
|
||||
s.cfg.PocketBaseAdminPassword = adminPassword
|
||||
s.mu.Unlock()
|
||||
|
||||
s.auth.setBaseURL(url)
|
||||
s.admin.reconfigure(url, adminEmail, adminPassword)
|
||||
}
|
||||
|
||||
// New constructs a Server.
|
||||
func New(cfg config.Config, h *hub.Hub) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
hub: h,
|
||||
auth: newAuthProxy(cfg.PocketBaseURL),
|
||||
admin: newAdminClient(cfg.PocketBaseURL, cfg.PocketBaseAdminEmail, cfg.PocketBaseAdminPassword),
|
||||
plugins: plugins.NewManager(cfg.PluginsFile),
|
||||
}
|
||||
}
|
||||
|
||||
// StartPlugins loads persisted plugin state and initialises enabled plugins.
|
||||
func (s *Server) StartPlugins() error { return s.plugins.Load() }
|
||||
|
||||
// Stop releases server-held resources (currently: plugin instances).
|
||||
func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) }
|
||||
|
||||
// Handler returns the root HTTP handler with all routes registered.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit panel
|
||||
// paths are routed to it so unknown /api/* paths still 404 as JSON.
|
||||
panel := panelHandler()
|
||||
mux.Handle("GET /{$}", panel)
|
||||
mux.Handle("GET /assets/", panel)
|
||||
mux.Handle("GET /favicon.svg", panel)
|
||||
|
||||
// Health (public)
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/health", s.handleHealth)
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
|
||||
// Auth — proxied to the PocketBase kept behind this server.
|
||||
mux.HandleFunc("POST /api/auth/login", s.handleAuthLogin)
|
||||
mux.HandleFunc("GET /api/auth/validate", s.handleAuthValidate)
|
||||
|
||||
// Current user (id, email, role) resolved from the caller's token.
|
||||
mux.HandleFunc("GET /api/me", s.handleMe)
|
||||
|
||||
// User preferences — persisted on the caller's own PocketBase user record.
|
||||
mux.HandleFunc("GET /api/preferences", s.handleGetPreferences)
|
||||
mux.HandleFunc("PUT /api/preferences", s.handlePutPreferences)
|
||||
|
||||
// Plugin integrations for end users — per-user/per-org settings resolved
|
||||
// through the superadmin→org→user cascade. Role logic lives inside the
|
||||
// handlers (org users must reach them too), so no requireManager wrapper.
|
||||
mux.HandleFunc("GET /api/integrations/opensky", s.handleGetOpenSky)
|
||||
mux.HandleFunc("PUT /api/integrations/opensky", s.handlePutOpenSky)
|
||||
mux.HandleFunc("POST /api/integrations/opensky/health", s.handleOpenSkyHealth)
|
||||
mux.HandleFunc("GET /api/integrations/filetransfer", s.handleGetFileTransfer)
|
||||
mux.HandleFunc("PUT /api/integrations/filetransfer", s.handlePutFileTransfer)
|
||||
mux.HandleFunc("POST /api/integrations/filetransfer/health", s.handleFileTransferHealth)
|
||||
mux.HandleFunc("GET /api/integrations/localstorage", s.handleGetLocalStorage)
|
||||
mux.HandleFunc("PUT /api/integrations/localstorage", s.handlePutLocalStorage)
|
||||
mux.HandleFunc("POST /api/integrations/localstorage/health", s.handleLocalStorageHealth)
|
||||
mux.HandleFunc("GET /api/integrations/webdav", s.handleGetWebDav)
|
||||
mux.HandleFunc("PUT /api/integrations/webdav", s.handlePutWebDav)
|
||||
mux.HandleFunc("POST /api/integrations/webdav/health", s.handleWebDavHealth)
|
||||
|
||||
// User-management — gated on the caller being a manager (admin or superadmin).
|
||||
// Admins are scoped to their own organization inside each handler.
|
||||
mux.HandleFunc("GET /api/users", s.requireManager(s.handleListUsers))
|
||||
mux.HandleFunc("POST /api/users", s.requireManager(s.handleCreateUser))
|
||||
mux.HandleFunc("PATCH /api/users/{id}", s.requireManager(s.handleUpdateUser))
|
||||
mux.HandleFunc("DELETE /api/users/{id}", s.requireManager(s.handleDeleteUser))
|
||||
|
||||
// Organizations — listing is manager-scoped; create/edit/delete are
|
||||
// superadmin-only (a superadmin spans all organizations).
|
||||
mux.HandleFunc("GET /api/orgs", s.requireManager(s.handleListOrgs))
|
||||
mux.HandleFunc("POST /api/orgs", s.requireSuperadmin(s.handleCreateOrg))
|
||||
mux.HandleFunc("PATCH /api/orgs/{id}", s.requireSuperadmin(s.handleUpdateOrg))
|
||||
mux.HandleFunc("DELETE /api/orgs/{id}", s.requireSuperadmin(s.handleDeleteOrg))
|
||||
|
||||
// PocketBase connection settings — superadmin only. These do NOT require the
|
||||
// service account to already be configured (they exist to configure it).
|
||||
mux.HandleFunc("GET /api/admin/pb-config", s.requireSuperadminAuth(s.handleGetPBConfig))
|
||||
mux.HandleFunc("POST /api/admin/pb-config/test", s.requireSuperadminAuth(s.handleTestPBConfig))
|
||||
mux.HandleFunc("PUT /api/admin/pb-config", s.requireSuperadminAuth(s.handleUpdatePBConfig))
|
||||
|
||||
// Plugins — external-service integrations, managed by a superadmin.
|
||||
mux.HandleFunc("GET /api/admin/plugins", s.requireSuperadminAuth(s.handleListPlugins))
|
||||
mux.HandleFunc("POST /api/admin/plugins", s.requireSuperadminAuth(s.handleRegisterPlugin))
|
||||
mux.HandleFunc("GET /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleGetPlugin))
|
||||
mux.HandleFunc("PUT /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleUpdatePlugin))
|
||||
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
||||
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
||||
|
||||
// Device / dashboard API.
|
||||
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
||||
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
|
||||
mux.HandleFunc("POST /api/devices/{id}/command", s.handleCommand)
|
||||
mux.HandleFunc("DELETE /api/devices/{id}", s.handleForget)
|
||||
mux.HandleFunc("POST /api/telemetry", s.handleTelemetryPost)
|
||||
|
||||
// Websockets: device uplink (Fly App) and dashboard stream (Web App/panel).
|
||||
mux.HandleFunc("GET /ws/device", s.handleDeviceWS)
|
||||
mux.HandleFunc("GET /ws/ui", s.handleUIWS)
|
||||
|
||||
return s.withMiddleware(mux)
|
||||
}
|
||||
|
||||
// withMiddleware applies panic recovery, CORS, and request logging globally.
|
||||
func (s *Server) withMiddleware(next http.Handler) http.Handler {
|
||||
return s.recoverer(s.cors(s.logger(next)))
|
||||
}
|
||||
|
||||
func (s *Server) logger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(sw, r)
|
||||
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
log.Printf("panic: %v", rec)
|
||||
writeError(w, http.StatusInternalServerError, "internal error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) cors(next http.Handler) http.Handler {
|
||||
allowed := map[string]bool{}
|
||||
wildcard := false
|
||||
for _, o := range s.cfg.AllowOrigins {
|
||||
if o == "*" {
|
||||
wildcard = true
|
||||
}
|
||||
allowed[o] = true
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && (wildcard || allowed[origin]) {
|
||||
if wildcard {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// statusWriter captures the response status code for logging.
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
wrote bool
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
if !w.wrote {
|
||||
w.status = code
|
||||
w.wrote = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||||
w.wrote = true
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Hijack lets the websocket upgrader take over the underlying connection even
|
||||
// though the logger has wrapped the ResponseWriter.
|
||||
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
h, ok := w.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
return h.Hijack()
|
||||
}
|
||||
Reference in New Issue
Block a user