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)) // Logbook — drones + flights (BEK 1649 §5). Available to any authenticated // user; per-role scoping (user→own, admin→org, superadmin→all) is enforced // inside the handlers, so the shared requireUser gate suffices. mux.HandleFunc("GET /api/drones", s.requireUser(s.handleListDrones)) mux.HandleFunc("POST /api/drones", s.requireUser(s.handleCreateDrone)) mux.HandleFunc("PATCH /api/drones/{id}", s.requireUser(s.handleUpdateDrone)) mux.HandleFunc("DELETE /api/drones/{id}", s.requireUser(s.handleDeleteDrone)) mux.HandleFunc("GET /api/flights", s.requireUser(s.handleListFlights)) mux.HandleFunc("POST /api/flights", s.requireUser(s.handleCreateFlight)) mux.HandleFunc("PATCH /api/flights/{id}", s.requireUser(s.handleUpdateFlight)) mux.HandleFunc("DELETE /api/flights/{id}", s.requireUser(s.handleDeleteFlight)) mux.HandleFunc("GET /api/logbook/export", s.requireUser(s.handleExportLogbook)) // Documents — the compliance + operational document store (metadata in // PocketBase, blob in PocketBase file storage for now). Available to any // authenticated user; per-role scoping is enforced inside the handlers. mux.HandleFunc("GET /api/documents", s.requireUser(s.handleListDocuments)) mux.HandleFunc("POST /api/documents", s.requireUser(s.handleCreateDocument)) mux.HandleFunc("PATCH /api/documents/{id}", s.requireUser(s.handleUpdateDocument)) mux.HandleFunc("DELETE /api/documents/{id}", s.requireUser(s.handleDeleteDocument)) mux.HandleFunc("GET /api/documents/{id}/file", s.requireUser(s.handleDownloadDocument)) // 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() }