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), }, }) }