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>
160 lines
5.1 KiB
Go
160 lines
5.1 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"pilotvault/apiserver/internal/config"
|
|
)
|
|
|
|
// pbProbe is the outcome of testing a PocketBase connection: whether the base
|
|
// URL answers its health check and whether the service-account credentials
|
|
// authenticate as a superuser.
|
|
type pbProbe struct {
|
|
Reachable bool `json:"reachable"`
|
|
HTTPStatus int `json:"httpStatus,omitempty"`
|
|
LatencyMs int64 `json:"latencyMs,omitempty"`
|
|
Superuser bool `json:"superuser"`
|
|
Detail string `json:"detail,omitempty"`
|
|
}
|
|
|
|
// pbConfigView is the PocketBase-connection shape returned to the panel. The
|
|
// password itself is never sent back — only whether one is set.
|
|
type pbConfigView struct {
|
|
URL string `json:"url"`
|
|
AdminEmail string `json:"adminEmail"`
|
|
AdminConfigured bool `json:"adminConfigured"`
|
|
Probe pbProbe `json:"probe"`
|
|
}
|
|
|
|
// probePB checks a PocketBase base URL's health and, when credentials are given,
|
|
// whether they authenticate as a superuser. It uses the short-timeout
|
|
// healthClient so a hung PocketBase cannot stall the request.
|
|
func (s *Server) probePB(ctx context.Context, url, email, password string) pbProbe {
|
|
h := probe(ctx, url+"/api/health")
|
|
p := pbProbe{Reachable: h.Status == "ok", HTTPStatus: h.HTTPStatus, LatencyMs: h.LatencyMs}
|
|
if h.Error != "" {
|
|
p.Detail = h.Error
|
|
}
|
|
if email != "" && password != "" {
|
|
_, st, err := superuserAuth(ctx, healthClient, url, email, password)
|
|
if err == nil {
|
|
p.Superuser = true
|
|
} else if p.Reachable {
|
|
p.Detail = "superuser auth failed"
|
|
if st > 0 {
|
|
p.Detail += " (HTTP " + strconv.Itoa(st) + ")"
|
|
}
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// normalizePBURL trims, defaults the scheme to http, and drops a trailing slash.
|
|
func normalizePBURL(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, "/")
|
|
}
|
|
|
|
// GET /api/admin/pb-config — current PocketBase connection + a live probe.
|
|
func (s *Server) handleGetPBConfig(w http.ResponseWriter, r *http.Request) {
|
|
url, email, password := s.pbSettings()
|
|
writeJSON(w, http.StatusOK, pbConfigView{
|
|
URL: url,
|
|
AdminEmail: email,
|
|
AdminConfigured: email != "" && password != "",
|
|
Probe: s.probePB(r.Context(), url, email, password),
|
|
})
|
|
}
|
|
|
|
// pbConfigBody is the editable connection payload. A blank adminPassword means
|
|
// "keep the current one"; a blank adminEmail/url means "keep current".
|
|
type pbConfigBody struct {
|
|
URL string `json:"url"`
|
|
AdminEmail string `json:"adminEmail"`
|
|
AdminPassword string `json:"adminPassword"`
|
|
}
|
|
|
|
// resolve merges a request body onto the current settings, applying the
|
|
// keep-current semantics for blank fields.
|
|
func (s *Server) resolve(b pbConfigBody) (url, email, password string) {
|
|
curURL, curEmail, curPassword := s.pbSettings()
|
|
url = normalizePBURL(b.URL)
|
|
if url == "" {
|
|
url = curURL
|
|
}
|
|
email = strings.TrimSpace(b.AdminEmail)
|
|
if email == "" {
|
|
email = curEmail
|
|
}
|
|
password = b.AdminPassword
|
|
if password == "" {
|
|
password = curPassword
|
|
}
|
|
return
|
|
}
|
|
|
|
// POST /api/admin/pb-config/test — probe a candidate connection WITHOUT applying
|
|
// it, so a superadmin can verify before saving.
|
|
func (s *Server) handleTestPBConfig(w http.ResponseWriter, r *http.Request) {
|
|
var b pbConfigBody
|
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
url, email, password := s.resolve(b)
|
|
writeJSON(w, http.StatusOK, s.probePB(r.Context(), url, email, password))
|
|
}
|
|
|
|
// PUT /api/admin/pb-config — apply a new PocketBase connection at runtime and
|
|
// persist it to .env. Returns the new config plus a fresh probe.
|
|
func (s *Server) handleUpdatePBConfig(w http.ResponseWriter, r *http.Request) {
|
|
var b pbConfigBody
|
|
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid json")
|
|
return
|
|
}
|
|
if normalizePBURL(b.URL) == "" {
|
|
writeError(w, http.StatusBadRequest, "a PocketBase URL is required")
|
|
return
|
|
}
|
|
url, email, password := s.resolve(b)
|
|
|
|
// Apply at runtime, then persist so the change survives a restart.
|
|
s.setPBConfig(url, email, password)
|
|
if err := config.UpdateEnvFile(config.EnvFile, map[string]string{
|
|
"POCKETBASE_URL": url,
|
|
"POCKETBASE_ADMIN_EMAIL": email,
|
|
"POCKETBASE_ADMIN_PASSWORD": password,
|
|
}); err != nil {
|
|
// The runtime change already took effect; report that persistence failed.
|
|
log.Printf("pb-config: persist to %s failed: %v", config.EnvFile, err)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"config": pbConfigView{
|
|
URL: url, AdminEmail: email, AdminConfigured: email != "" && password != "",
|
|
Probe: s.probePB(r.Context(), url, email, password),
|
|
},
|
|
"warning": "applied for this session, but could not be saved to .env: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
log.Printf("pb-config: PocketBase connection updated to %s (by superadmin)", url)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"config": pbConfigView{
|
|
URL: url, AdminEmail: email, AdminConfigured: email != "" && password != "",
|
|
Probe: s.probePB(r.Context(), url, email, password),
|
|
},
|
|
})
|
|
}
|