Rebuild API Server on the PilotVault structure

Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.

Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).

Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.

Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.

Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.

PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.

Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.

Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.

Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.

Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-16 22:29:45 +02:00
co-authored by Claude Opus 4.8
parent 7d55f0a4cd
commit ae6ed4ac1e
56 changed files with 4474 additions and 1475 deletions
-260
View File
@@ -1,260 +0,0 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// adminUser is the API shape returned by the admin user-management endpoints.
type adminUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Verified bool `json:"verified"`
Created string `json:"created"`
}
func (rec userRecord) toAdminUser() adminUser {
return adminUser{
ID: rec.ID,
Email: rec.Email,
Name: rec.Name,
Role: orDefault(rec.Role, "user"),
Verified: rec.Verified,
Created: rec.Created,
}
}
// requireAdmin ensures the current request is from an admin. It re-reads the
// user's role from PocketBase (rather than trusting the token) so a demotion
// takes effect immediately. On failure it writes the response and returns false.
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
me, err := s.fetchUser(r, s.currentUserID(r))
if err != nil {
writePBError(w, err)
return false
}
if orDefault(me.Role, "user") != "admin" {
writeError(w, http.StatusForbidden, "admin access required")
return false
}
return true
}
// countAdmins returns how many users currently hold the admin role. Used to
// prevent removing the last admin (which would lock everyone out of admin).
func (s *Server) countAdmins(ctx context.Context) (int, error) {
res, err := s.pb.List(ctx, s.usersCollection, url.Values{
"filter": {"role='admin'"},
"perPage": {"1"},
})
if err != nil {
return 0, err
}
return res.TotalItems, nil
}
// handleListUsers serves GET /api/admin/users.
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{
"sort": {"email"},
"perPage": {"500"},
})
if err != nil {
writePBError(w, err)
return
}
var recs []userRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]adminUser, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.toAdminUser())
}
writeJSON(w, http.StatusOK, out)
}
type createUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role"`
}
// handleCreateUser serves POST /api/admin/users.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in createUserRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
in.Email = strings.TrimSpace(strings.ToLower(in.Email))
if in.Email == "" {
writeError(w, http.StatusBadRequest, "email is required")
return
}
if len(in.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role := orDefault(in.Role, "user")
if role != "user" && role != "admin" {
writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'")
return
}
name := in.Name
if name == "" {
name = strings.SplitN(in.Email, "@", 2)[0]
}
payload := map[string]any{
"email": in.Email,
"password": in.Password,
"passwordConfirm": in.Password,
"name": name,
"role": role,
"emailVisibility": true,
"verified": true,
}
var rec userRecord
if err := s.pb.Create(r.Context(), s.usersCollection, payload, &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusCreated, rec.toAdminUser())
}
type updateUserRequest struct {
Name *string `json:"name"`
Role *string `json:"role"`
}
// handleUpdateUser serves PATCH /api/admin/users/{id} (name and/or role).
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
id := r.PathValue("id")
var in updateUserRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
payload := map[string]any{}
if in.Name != nil {
payload["name"] = *in.Name
}
if in.Role != nil {
role := *in.Role
if role != "user" && role != "admin" {
writeError(w, http.StatusBadRequest, "role must be 'user' or 'admin'")
return
}
// Guard: don't demote the last remaining admin.
if role != "admin" {
if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil {
writePBError(w, err)
return
} else if blocked {
writeError(w, http.StatusBadRequest, "cannot demote the last admin")
return
}
}
payload["role"] = role
}
if len(payload) == 0 {
writeError(w, http.StatusBadRequest, "nothing to update")
return
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection, id, payload, &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, rec.toAdminUser())
}
type setPasswordRequest struct {
NewPassword string `json:"newPassword"`
}
// handleSetUserPassword serves POST /api/admin/users/{id}/password.
func (s *Server) handleSetUserPassword(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
var in setPasswordRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if len(in.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
payload := map[string]any{
"password": in.NewPassword,
"passwordConfirm": in.NewPassword,
}
if err := s.pb.Update(r.Context(), s.usersCollection, r.PathValue("id"), payload, nil); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDeleteUser serves DELETE /api/admin/users/{id}.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
}
id := r.PathValue("id")
if id == s.currentUserID(r) {
writeError(w, http.StatusBadRequest, "you cannot delete your own account here")
return
}
// Guard: don't delete the last remaining admin.
if blocked, err := s.wouldRemoveLastAdmin(r, id); err != nil {
writePBError(w, err)
return
} else if blocked {
writeError(w, http.StatusBadRequest, "cannot delete the last admin")
return
}
if err := s.pb.Delete(r.Context(), s.usersCollection, id); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// wouldRemoveLastAdmin reports whether removing/demoting user `id` would leave
// zero admins (i.e. `id` is currently an admin and is the only one).
func (s *Server) wouldRemoveLastAdmin(r *http.Request, id string) (bool, error) {
target, err := s.fetchUser(r, id)
if err != nil {
return false, err
}
if orDefault(target.Role, "user") != "admin" {
return false, nil // not an admin; removing them changes nothing
}
count, err := s.countAdmins(r.Context())
if err != nil {
return false, err
}
return count <= 1, nil
}
+239 -225
View File
@@ -3,30 +3,117 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"carcontrol/api/internal/auth"
)
// tokenTTL is how long an issued login token stays valid.
const tokenTTL = 7 * 24 * time.Hour
// Role names as stored in the PocketBase users.role select field. A missing or
// empty value is treated as roleUser.
const (
roleUser = "user"
roleAdmin = "admin"
roleSuperadmin = "superadmin"
)
// publicPaths bypass authentication. Everything else requires a valid token.
// publicPaths bypass authentication. Everything else under /api/ requires a
// valid PocketBase token.
var publicPaths = map[string]bool{
"/api/health": true,
"/api/auth/login": true,
"/api/health": true,
"/api/status": true,
"/api/auth/login": true,
"/api/auth/validate": true,
"/healthz": true,
}
type ctxKey string
type ctxKey int
const claimsKey ctxKey = "claims"
const ctxCaller ctxKey = iota
// withAuth rejects requests to non-public paths that lack a valid bearer token.
// callerIdentity is who the request token belongs to. It is resolved from
// PocketBase on each request, so a role change or a deletion takes effect
// immediately rather than lingering until a token expires.
type callerIdentity struct {
ID string
Email string
Name string
Role string
OrgID string // organization record id ("" when the user belongs to no org)
}
func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin }
func (c *callerIdentity) isManager() bool {
return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin)
}
// caller returns the identity stashed on the request context by withAuth.
func caller(r *http.Request) *callerIdentity {
if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok {
return v
}
return nil
}
// currentUserID returns the authenticated user's id. Handlers on non-public
// paths can treat "" as "not authenticated" — withAuth already rejected those.
func (s *Server) currentUserID(r *http.Request) string {
if who := caller(r); who != nil {
return who.ID
}
return ""
}
// bearerToken extracts the caller's token, accepting both "Bearer <token>" and
// a raw token (PocketBase's own SDKs send the latter).
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after)
}
return strings.TrimSpace(h)
}
// identify resolves the caller's id/email/name/role/org from their PocketBase
// token by asking PocketBase to refresh it. A non-200 status means the token is
// invalid or expired.
func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) {
raw, status, err := s.pb.AuthRefresh(ctx, s.usersCollection(), token)
if err != nil {
return nil, 0, err
}
if status != http.StatusOK {
return nil, status, nil
}
var out struct {
Record struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Organization string `json:"organization"`
} `json:"record"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, status, err
}
role := out.Record.Role
if role == "" {
role = roleUser
}
return &callerIdentity{
ID: out.Record.ID,
Email: out.Record.Email,
Name: out.Record.Name,
Role: role,
OrgID: out.Record.Organization,
}, http.StatusOK, nil
}
// withAuth rejects requests to non-public /api/ paths that lack a valid
// PocketBase token, and stashes the resolved identity on the request context.
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public API paths, plus everything outside /api/ (the embedded web
@@ -35,64 +122,155 @@ func (s *Server) withAuth(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
who, ok := s.authenticate(w, r)
if !ok {
return
}
claims, err := auth.Verify(s.authSecret, token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
if s.sessionRevoked(r.Context(), claims.Jti) {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
})
}
// sessionRevoked reports whether the token's backing session is missing or
// revoked (e.g. via "log out this device" from the settings panel). Tokens
// minted before session-tracking existed have no jti and are rejected too,
// which simply forces one fresh login.
func (s *Server) sessionRevoked(ctx context.Context, jti string) bool {
if jti == "" {
return true
// authenticate resolves and validates the caller, writing the error response
// itself and returning ok=false when the request should not proceed.
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*callerIdentity, bool) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return nil, false
}
res, err := s.pb.List(ctx, colSessions, url.Values{
"filter": {fmt.Sprintf("jti='%s'", jti)},
"perPage": {"1"},
})
who, status, err := s.identify(r.Context(), token)
if err != nil {
return true
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return nil, false
}
var recs []sessionRecord
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
return true
if status != http.StatusOK || who == nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return nil, false
}
return recs[0].Revoked
return who, true
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
// requireRole is the shared gate for privileged handlers: it needs the service
// account (every privileged flow runs through it) and a caller satisfying ok.
// withAuth has already established the identity.
func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.pb.Configured() {
writeError(w, http.StatusServiceUnavailable, "user management not configured on the server")
return
}
if !ok(caller(r)) {
writeError(w, http.StatusForbidden, denied)
return
}
next(w, r)
}
// Accept both "Bearer <token>" and a raw token.
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after)
}
return strings.TrimSpace(h)
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
// requireManager wraps a handler so only managers (admin or superadmin) proceed.
func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc {
return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required")
}
// requireSuperadmin wraps a handler so only superadmins proceed.
func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc {
return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required")
}
// requireSuperadminAuth gates a handler on a superadmin caller WITHOUT requiring
// the service account to already be configured. Used by the PocketBase settings
// endpoints, whose whole purpose is to configure that service account.
func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !caller(r).isSuperadmin() {
writeError(w, http.StatusForbidden, "superadmin role required")
return
}
next(w, r)
}
}
// POST /api/auth/login
// Body: {"email"|"identity":"...","password":"..."}
// Proxies to the PocketBase users auth-with-password and relays its response —
// the client gets PocketBase's own token and user record. PocketBase's address
// lives only in this server and is never exposed to clients.
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
Identity string `json:"identity"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
identity := body.Identity
if identity == "" {
identity = body.Email
}
if identity == "" || body.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
raw, status, err := s.pb.LoginWithPassword(r.Context(), s.usersCollection(), identity, body.Password)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
return
}
relay(w, status, raw)
}
// GET /api/auth/validate (Authorization: <pb token>)
// Proxies to PocketBase auth-refresh to confirm a token is still valid.
func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false})
return
}
raw, status, err := s.pb.AuthRefresh(r.Context(), s.usersCollection(), token)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()})
return
}
relay(w, status, raw)
}
// GET /api/auth/me — the caller's identity, from their token.
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
writeJSON(w, http.StatusOK, userInfo{ID: who.ID, Email: who.Email, Name: who.Name, Role: who.Role})
}
// GET /api/identity — like /api/auth/me, plus the caller's organization. The
// panel uses this to decide which management cards to show.
func (s *Server) handleIdentity(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if who == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
orgName := ""
if who.OrgID != "" {
orgName = s.orgName(r.Context(), who.OrgID)
}
writeJSON(w, http.StatusOK, map[string]any{
"id": who.ID,
"email": who.Email,
"name": who.Name,
"role": who.Role,
"organization": who.OrgID,
"organizationName": orgName,
})
}
// userInfo is the compact identity shape returned by /api/auth/me.
type userInfo struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -100,173 +278,9 @@ type userInfo struct {
Role string `json:"role,omitempty"`
}
type loginResponse struct {
Token string `json:"token"`
User userInfo `json:"user"`
}
// handleLogin verifies credentials against the PocketBase users collection and,
// on success, mints an API Server JWT.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var in loginRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Email == "" || in.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
rec, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, in.Email, in.Password)
if err != nil {
// Don't leak whether it was the email or the password.
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
// The auth record doesn't carry the role field; fetch it so the token and
// login response reflect the user's access role. Default to "user".
role := "user"
if u, err := s.fetchUser(r, rec.ID); err == nil {
role = orDefault(u.Role, "user")
}
jti, err := auth.NewJTI()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
if err := s.createSession(r.Context(), rec.ID, jti, r); err != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
token, err := auth.Sign(s.authSecret, auth.Claims{
Sub: rec.ID,
Email: rec.Email,
Name: rec.Name,
Role: role,
Jti: jti,
}, tokenTTL)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
User: userInfo{ID: rec.ID, Email: rec.Email, Name: rec.Name, Role: role},
})
}
// sessionRecord is one row of the "sessions" collection — a login token's
// device/revocation record, used to power "active sessions" in the settings
// panel and to let a user log a device out remotely.
type sessionRecord struct {
ID string `json:"id"`
User string `json:"user"`
Jti string `json:"jti"`
DeviceLabel string `json:"device_label"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Revoked bool `json:"revoked"`
ExpiresAt string `json:"expires_at"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (s *Server) createSession(ctx context.Context, userID, jti string, r *http.Request) error {
payload := map[string]any{
"user": userID,
"jti": jti,
"device_label": deviceLabelFromUA(r.UserAgent()),
"ip": clientIP(r),
"user_agent": r.UserAgent(),
"revoked": false,
"expires_at": time.Now().Add(tokenTTL).UTC().Format(time.RFC3339),
}
return s.pb.Create(ctx, colSessions, payload, nil)
}
// clientIP prefers a forwarding header (in case of a future reverse proxy)
// and otherwise strips the port from the raw remote address.
func clientIP(r *http.Request) string {
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
return strings.TrimSpace(strings.Split(xf, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// deviceLabelFromUA turns a User-Agent header into a short human label like
// "Chrome on Windows" for the active-sessions list. Best-effort only.
func deviceLabelFromUA(ua string) string {
if ua == "" {
return "Unknown device"
}
var os string
switch {
case strings.Contains(ua, "Android"):
os = "Android"
case strings.Contains(ua, "iPhone"), strings.Contains(ua, "iPad"):
os = "iOS"
case strings.Contains(ua, "Windows"):
os = "Windows"
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
os = "macOS"
case strings.Contains(ua, "Linux"):
os = "Linux"
}
var app string
switch {
case strings.Contains(ua, "Edg/"):
app = "Edge"
case strings.Contains(ua, "Chrome/"):
app = "Chrome"
case strings.Contains(ua, "Firefox/"):
app = "Firefox"
case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome"):
app = "Safari"
case strings.Contains(ua, "Dart") || strings.Contains(ua, "okhttp"):
app = "Car Control app"
}
switch {
case app != "" && os != "":
return app + " on " + os
case app != "":
return app
case os != "":
return os
case len(ua) > 60:
return ua[:60]
default:
return ua
}
}
// currentUserID returns the authenticated user's id from the request context.
// Returns "" only if called on an unauthenticated request (withAuth already
// guards every non-public path, so handlers can treat "" as "not authenticated").
func (s *Server) currentUserID(r *http.Request) string {
if claims, ok := r.Context().Value(claimsKey).(*auth.Claims); ok {
return claims.Sub
}
return ""
}
// handleMe returns the authenticated user from the token claims.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
writeJSON(w, http.StatusOK, userInfo{ID: claims.Sub, Email: claims.Email, Name: claims.Name, Role: claims.Role})
// relay copies an upstream PocketBase status + JSON body to the client.
func relay(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http"
"net/url"
"carcontrol/api/internal/models"
"drivervault/apiserver/internal/models"
)
// Access levels a user can have on a car. accessNone means no access at all.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2563eb" />
<title>DriverVault · API Server</title>
<script type="module" crossorigin src="/assets/index-o_I931vi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DfVJ8vbT.css">
<script type="module" crossorigin src="/assets/index-DKHgRvVM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-i1JZk1ZM.css">
</head>
<body>
<div id="app"></div>
+17
View File
@@ -0,0 +1,17 @@
package api
import (
"net/http"
"time"
)
// handleHealth is the liveness probe. It reports only on this process — it does
// not touch PocketBase, so it stays fast and stays "ok" even while a dependency
// is down. Use /api/status for dependency health.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "drivervault-api",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
+43 -44
View File
@@ -9,8 +9,7 @@ import (
"strings"
"time"
"carcontrol/api/internal/auth"
"carcontrol/api/internal/models"
"drivervault/apiserver/internal/models"
)
// deletionCooldown is how long an account-deletion request sits before it can
@@ -65,19 +64,19 @@ func orDefault(v, fallback string) string {
func (s *Server) fetchUser(r *http.Request, id string) (*userRecord, error) {
var rec userRecord
if err := s.pb.GetOne(r.Context(), s.usersCollection, id, &rec); err != nil {
if err := s.pb.GetOne(r.Context(), s.usersCollection(), id, &rec); err != nil {
return nil, err
}
return &rec, nil
}
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -102,8 +101,8 @@ var validFontSizes = map[string]bool{"small": true, "medium": true, "large": tru
// body are touched, so the Account/Profile/Appearance sections of the settings
// panel can each save independently without clobbering the others.
func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -146,7 +145,7 @@ func (s *Server) handleUpdateMe(w http.ResponseWriter, r *http.Request) {
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, &rec); err != nil {
writePBError(w, err)
return
}
@@ -159,8 +158,8 @@ type changePasswordRequest struct {
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -180,13 +179,13 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
// Verify the current password the same way login does, since the API
// Server otherwise only ever talks to PocketBase as a superuser.
if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, claims.Email, in.OldPassword); err != nil {
if _, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection(), claims.Email, in.OldPassword); err != nil {
writeError(w, http.StatusUnauthorized, "current password is incorrect")
return
}
payload := map[string]any{"password": in.NewPassword, "passwordConfirm": in.NewPassword}
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
@@ -194,8 +193,8 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -216,11 +215,11 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
return
}
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil {
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection(), claims.ID, nil, "avatar", header.Filename, data); err != nil {
writePBError(w, err)
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -229,12 +228,12 @@ func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, map[string]any{"avatar": ""}, nil); err != nil {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, map[string]any{"avatar": ""}, nil); err != nil {
writePBError(w, err)
return
}
@@ -242,12 +241,12 @@ func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -256,7 +255,7 @@ func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "no avatar set")
return
}
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar)
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection(), rec.ID, rec.Avatar)
if err != nil {
writePBError(w, err)
return
@@ -267,12 +266,12 @@ func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil {
if err := s.pb.RequestVerification(r.Context(), s.usersCollection(), claims.Email); err != nil {
writePBError(w, err)
return
}
@@ -283,19 +282,19 @@ func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Reques
// (with its service records and parts) into one downloadable JSON file. Cars
// merely shared with the user are not exported — only cars they own.
func (s *Server) handleExportData(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := s.fetchUser(r, claims.Sub)
user, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
}
carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
"filter": {fmt.Sprintf("owner='%s'", claims.Sub)},
"filter": {fmt.Sprintf("owner='%s'", claims.ID)},
"sort": {"name"},
"perPage": {"200"},
})
@@ -396,8 +395,8 @@ type importResult struct {
// export's "account"/"exportedAt"), since round-tripping the exact export
// file is the main use case.
func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -423,7 +422,7 @@ func (s *Server) handleImportData(w http.ResponseWriter, r *http.Request) {
// Imported cars are owned by the importing user, regardless of any
// owner in the file.
payload := carPayload(car)
payload["owner"] = claims.Sub
payload["owner"] = claims.ID
var rec carRecord
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
@@ -460,8 +459,8 @@ type deleteAccountRequest struct {
// handleRequestDeletion starts the cooldown. The account is not touched yet —
// handleFinalizeDeletion is a separate, later call once the cooldown elapses.
func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
@@ -477,7 +476,7 @@ func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC()
payload := map[string]any{"deletion_requested_at": formatPBDate(now)}
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
@@ -488,13 +487,13 @@ func (s *Server) handleRequestDeletion(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
payload := map[string]any{"deletion_requested_at": ""}
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, nil); err != nil {
if err := s.pb.Update(r.Context(), s.usersCollection(), claims.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
@@ -507,12 +506,12 @@ func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
// shared cars/service-records/parts data is untouched, since it belongs to
// the household, not to one account.
func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
claims := caller(r)
if claims == nil {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
rec, err := s.fetchUser(r, claims.ID)
if err != nil {
writePBError(w, err)
return
@@ -526,7 +525,7 @@ func (s *Server) handleFinalizeDeletion(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusForbidden, "the cooldown period has not elapsed yet")
return
}
if err := s.pb.Delete(r.Context(), s.usersCollection, claims.Sub); err != nil {
if err := s.pb.Delete(r.Context(), s.usersCollection(), claims.ID); err != nil {
writePBError(w, err)
return
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// orgView is the trimmed organization shape returned to clients.
type orgView struct {
ID string `json:"id"`
Name string `json:"name"`
Created string `json:"created"`
}
// orgNameMap returns an id→name map of all organizations via the service
// account. On any error it returns an empty (non-nil) map so callers can index
// it safely.
func (s *Server) orgNameMap(ctx context.Context) map[string]string {
out := map[string]string{}
if !s.pb.Configured() {
return out
}
data, status, err := s.pb.Raw(ctx, http.MethodGet,
"/api/collections/"+colOrgs+"/records?perPage=500&fields=id,name", nil)
if err != nil || status != http.StatusOK {
return out
}
var list struct {
Items []orgView `json:"items"`
}
_ = json.Unmarshal(data, &list)
for _, o := range list.Items {
out[o.ID] = o.Name
}
return out
}
// orgName resolves a single organization's name (best effort; "" on miss).
func (s *Server) orgName(ctx context.Context, id string) string {
if id == "" || !s.pb.Configured() {
return ""
}
data, status, err := s.pb.Raw(ctx, http.MethodGet,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id)+"?fields=id,name", nil)
if err != nil || status != http.StatusOK {
return ""
}
var o orgView
_ = json.Unmarshal(data, &o)
return o.Name
}
// GET /api/orgs — list organizations (manager only). Superadmins see all;
// admins see only their own organization.
func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
who := caller(r)
path := "/api/collections/" + colOrgs + "/records?perPage=500&sort=name&fields=id,name,created"
if !who.isSuperadmin() {
if who.OrgID == "" {
writeJSON(w, http.StatusOK, map[string]any{"organizations": []orgView{}})
return
}
path += "&filter=" + url.QueryEscape("id = \""+who.OrgID+"\"")
}
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK {
relay(w, status, data)
return
}
var list struct {
Items []orgView `json:"items"`
}
_ = json.Unmarshal(data, &list)
writeJSON(w, http.StatusOK, map[string]any{"organizations": list.Items})
}
// POST /api/orgs — create an organization (superadmin only). Body: {name}.
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
name, ok := decodeOrgName(w, r)
if !ok {
return
}
data, status, err := s.pb.Raw(r.Context(), http.MethodPost,
"/api/collections/"+colOrgs+"/records", map[string]any{"name": name})
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK {
// Relay PocketBase's error (e.g. duplicate name violates the unique index).
relay(w, status, data)
return
}
var org orgView
_ = json.Unmarshal(data, &org)
writeJSON(w, http.StatusCreated, map[string]any{"organization": org})
}
// PATCH /api/orgs/{id} — rename an organization (superadmin only). Body: {name}.
func (s *Server) handleUpdateOrg(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing organization id")
return
}
name, ok := decodeOrgName(w, r)
if !ok {
return
}
data, status, err := s.pb.Raw(r.Context(), http.MethodPatch,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), map[string]any{"name": name})
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK {
relay(w, status, data)
return
}
var org orgView
_ = json.Unmarshal(data, &org)
writeJSON(w, http.StatusOK, map[string]any{"organization": org})
}
// DELETE /api/orgs/{id} — delete an organization (superadmin only). Refused
// while the org still has members, to avoid silently orphaning users.
func (s *Server) handleDeleteOrg(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing organization id")
return
}
// Guard: block deletion if any user still belongs to this org.
countPath := "/api/collections/" + s.usersCollection() + "/records?perPage=1&fields=id&filter=" +
url.QueryEscape("organization = \""+id+"\"")
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, countPath, nil)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status == http.StatusOK {
var page struct {
TotalItems int `json:"totalItems"`
}
_ = json.Unmarshal(data, &page)
if page.TotalItems > 0 {
writeError(w, http.StatusConflict, "organization still has members; reassign or remove them first")
return
}
}
data, status, err = s.pb.Raw(r.Context(), http.MethodDelete,
"/api/collections/"+colOrgs+"/records/"+url.PathEscape(id), nil)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK && status != http.StatusNoContent {
relay(w, status, data)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// decodeOrgName parses and validates a {name} body, writing an error response
// and returning ok=false on failure.
func decodeOrgName(w http.ResponseWriter, r *http.Request) (string, bool) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return "", false
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "organization name is required")
return "", false
}
if len(name) > 120 {
writeError(w, http.StatusBadRequest, "organization name is too long (max 120)")
return "", false
}
return name, true
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http"
"net/url"
"carcontrol/api/internal/models"
"drivervault/apiserver/internal/models"
)
func (s *Server) listParts(w http.ResponseWriter, r *http.Request) {
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"drivervault/apiserver/internal/plugins"
)
// GET /api/admin/plugins — every known plugin (registry persisted), secrets masked.
func (s *Server) handleListPlugins(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"plugins": s.plugins.List()})
}
// GET /api/admin/plugins/{name} — one plugin's view.
func (s *Server) handleGetPlugin(w http.ResponseWriter, r *http.Request) {
v, ok := s.plugins.Get(r.PathValue("name"))
if !ok {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
}
// PUT /api/admin/plugins/{name} — enable/disable + merge config. Body:
// {enabled?, config?}. A secret left at the mask keeps its stored value.
func (s *Server) handleUpdatePlugin(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
current, ok := s.plugins.Get(name)
if !ok {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
var body struct {
Enabled *bool `json:"enabled"`
Config map[string]string `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
enabled := current.Enabled
if body.Enabled != nil {
enabled = *body.Enabled
}
v, err := s.plugins.Upsert(r.Context(), name, enabled, body.Config)
if err != nil {
if plugins.IsUnknown(err) {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
// A failed init (e.g. bad credentials) is reported but the state was saved.
writeJSON(w, http.StatusOK, map[string]any{"plugin": v, "warning": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"plugin": v})
}
// POST /api/admin/plugins — register an external (remote HTTP) plugin. Body:
// {name, baseURL, provider?}. This is the "add a plugin without a rebuild" path.
func (s *Server) handleRegisterPlugin(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
BaseURL string `json:"baseURL"`
Provider string `json:"provider"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Name = strings.TrimSpace(body.Name)
if body.Name == "" || body.BaseURL == "" {
writeError(w, http.StatusBadRequest, "name and baseURL are required")
return
}
if err := s.plugins.RegisterExternal(body.Name, body.BaseURL, body.Provider); err != nil {
writeError(w, http.StatusConflict, err.Error())
return
}
v, _ := s.plugins.Get(body.Name)
writeJSON(w, http.StatusCreated, map[string]any{"plugin": v})
}
// DELETE /api/admin/plugins/{name} — remove an external plugin (builtins can
// only be disabled).
func (s *Server) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
if err := s.plugins.Remove(r.Context(), r.PathValue("name")); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// POST /api/admin/plugins/{name}/health — run a health check now. Works on
// disabled plugins too, so a config can be verified before enabling it.
func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) {
h, err := s.plugins.HealthCheck(r.Context(), r.PathValue("name"))
if err != nil {
if plugins.IsUnknown(err) {
writeError(w, http.StatusNotFound, "unknown plugin")
return
}
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"health": h})
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings"
"time"
"carcontrol/api/internal/models"
"drivervault/apiserver/internal/models"
)
// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts
+61
View File
@@ -0,0 +1,61 @@
package api
import (
"encoding/json"
"log"
"net/http"
"drivervault/apiserver/internal/pb"
)
// writeJSON writes v as a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v == nil {
return
}
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON: %v", err)
}
}
// errorBody is the standard error envelope.
type errorBody struct {
Error string `json:"error"`
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
// writeUpstreamDown reports that PocketBase could not be reached at all (a
// transport error, as opposed to PocketBase answering with an error status).
func writeUpstreamDown(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusBadGateway, map[string]any{
"error": "cannot reach PocketBase",
"detail": err.Error(),
})
}
// writePBError maps a PocketBase error from the typed CRUD helpers to an
// appropriate HTTP status.
func writePBError(w http.ResponseWriter, err error) {
if apiErr, ok := err.(*pb.APIError); ok {
status := apiErr.Status
if status < 400 {
status = http.StatusBadGateway
}
writeError(w, status, apiErr.Body)
return
}
writeError(w, http.StatusBadGateway, err.Error())
}
// decodeJSON strictly decodes a request body, rejecting unknown fields.
func decodeJSON(r *http.Request, dest any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dest)
}
+201 -121
View File
@@ -1,58 +1,71 @@
// Package api exposes the HTTP REST surface of the car-control API Server.
// Package api exposes the HTTP REST surface of the DriverVault API Server.
//
// Clients (web app, phone app, ...) talk only to this server; this server is
// the only thing that talks to PocketBase. Endpoints:
// Clients (web app, phone app, Home Assistant plugin, ESP32 device) talk only to
// this server; this server is the only thing that talks to PocketBase. It also
// serves the superadmin web panel at the root.
//
// Authentication is PocketBase's own: /api/auth/login is proxied to the
// PocketBase users collection and the client keeps the token PocketBase minted.
// Every protected request re-resolves that token against PocketBase, so a role
// change or a deletion takes effect immediately.
//
// # public
// GET /healthz
// GET /api/health
// GET /api/cars
// POST /api/cars
// GET /api/cars/{id}
// PATCH /api/cars/{id}
// DELETE /api/cars/{id}
// GET /api/status
// POST /api/auth/login
// GET /api/auth/validate
//
// # identity
// GET /api/auth/me
// GET /api/identity
//
// # current user
// GET /api/me PATCH /api/me DELETE /api/me
// POST /api/me/password
// POST /api/me/avatar GET /api/me/avatar DELETE /api/me/avatar
// POST /api/me/verify/request
// GET /api/me/export POST /api/me/import
// POST /api/me/delete POST /api/me/delete/cancel
//
// # users + organizations (manager; writes to orgs are superadmin-only)
// GET /api/users POST /api/users
// PATCH /api/users/{id} DELETE /api/users/{id}
// GET /api/orgs POST /api/orgs
// PATCH /api/orgs/{id} DELETE /api/orgs/{id}
//
// # superadmin
// GET /api/admin/pb-config PUT /api/admin/pb-config
// POST /api/admin/pb-config/test
// GET /api/admin/plugins POST /api/admin/plugins
// GET /api/admin/plugins/{name} PUT /api/admin/plugins/{name}
// DELETE /api/admin/plugins/{name} POST /api/admin/plugins/{name}/health
//
// # cars, service records, parts, shares
// GET /api/cars POST /api/cars
// GET /api/cars/{id} PATCH /api/cars/{id} DELETE /api/cars/{id}
// GET /api/cars/{id}/service-records
// GET /api/cars/{id}/parts
// GET /api/cars/{id}/shares
// POST /api/cars/{id}/shares
// GET /api/cars/{id}/shares POST /api/cars/{id}/shares
// DELETE /api/cars/{id}/shares/{userId}
// GET /api/service-records
// POST /api/service-records
// GET /api/service-records/{id}
// PATCH /api/service-records/{id}
// GET /api/service-records POST /api/service-records
// GET /api/service-records/{id} PATCH /api/service-records/{id}
// DELETE /api/service-records/{id}
// GET /api/parts
// POST /api/parts
// GET /api/parts/{id}
// PATCH /api/parts/{id}
// DELETE /api/parts/{id}
// GET /api/me
// PATCH /api/me
// DELETE /api/me
// POST /api/me/password
// POST /api/me/avatar
// GET /api/me/avatar
// DELETE /api/me/avatar
// POST /api/me/verify/request
// GET /api/me/export
// POST /api/me/import
// POST /api/me/delete
// POST /api/me/delete/cancel
// GET /api/sessions
// DELETE /api/sessions/{id}
// DELETE /api/sessions
// GET /api/admin/users
// POST /api/admin/users
// PATCH /api/admin/users/{id}
// POST /api/admin/users/{id}/password
// DELETE /api/admin/users/{id}
// GET /api/parts POST /api/parts
// GET /api/parts/{id} PATCH /api/parts/{id} DELETE /api/parts/{id}
package api
import (
"encoding/json"
"context"
"log"
"net/http"
"sync"
"time"
"carcontrol/api/internal/pb"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb"
"drivervault/apiserver/internal/plugins"
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
)
// PocketBase collection names.
@@ -60,53 +73,90 @@ const (
colCars = "cars"
colServices = "service_records"
colParts = "parts"
colSessions = "sessions"
colShares = "car_shares"
colOrgs = "organizations"
)
// Server wires together the HTTP handlers and their dependencies.
type Server struct {
pb *pb.Client
corsOrigins map[string]bool
authSecret string
usersCollection string
mu sync.RWMutex // guards the mutable PocketBase connection in cfg
cfg config.Config
pb *pb.Client
plugins *plugins.Manager
}
type Options struct {
CORSOrigins []string
AuthSecret string
UsersCollection string
}
func NewServer(client *pb.Client, opts Options) *Server {
set := make(map[string]bool, len(opts.CORSOrigins))
for _, o := range opts.CORSOrigins {
set[o] = true
}
// New constructs a Server around an already-built PocketBase client.
func New(cfg config.Config, client *pb.Client) *Server {
return &Server{
pb: client,
corsOrigins: set,
authSecret: opts.AuthSecret,
usersCollection: opts.UsersCollection,
cfg: cfg,
pb: client,
plugins: plugins.NewManager(cfg.PluginsFile),
}
}
// Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux).
// 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) }
// usersCollection returns the PocketBase auth collection holding app users.
func (s *Server) usersCollection() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.UsersCollection
}
// webAppURL returns the Web App address probed by /api/status.
func (s *Server) webAppURL() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg.WebAppURL
}
// 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 the client (which drops its cached superuser token,
// so the next call re-authenticates against the new target).
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.pb.Reconfigure(url, adminEmail, adminPassword)
}
// Handler returns the root HTTP handler with all routes registered.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// DriverVault web panel (public) — embedded Vue + Tailwind app served at the
// root. Only explicit panel paths are routed to it, so unknown /api/* paths
// still 404 as JSON rather than serving the SPA shell.
// 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)
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("GET /api/auth/me", s.handleMe)
// 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)
mux.HandleFunc("GET /api/auth/me", s.handleAuthMe)
mux.HandleFunc("GET /api/identity", s.handleIdentity)
// Current user (profile / appearance / avatar / data / account lifecycle).
mux.HandleFunc("GET /api/me", s.handleGetMe)
mux.HandleFunc("PATCH /api/me", s.handleUpdateMe)
mux.HandleFunc("POST /api/me/password", s.handleChangePassword)
@@ -120,16 +170,36 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/me/delete/cancel", s.handleCancelDeletion)
mux.HandleFunc("DELETE /api/me", s.handleFinalizeDeletion)
mux.HandleFunc("GET /api/sessions", s.handleListSessions)
mux.HandleFunc("DELETE /api/sessions/{id}", s.handleRevokeSession)
mux.HandleFunc("DELETE /api/sessions", s.handleRevokeOtherSessions)
// 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))
mux.HandleFunc("GET /api/admin/users", s.handleListUsers)
mux.HandleFunc("POST /api/admin/users", s.handleCreateUser)
mux.HandleFunc("PATCH /api/admin/users/{id}", s.handleUpdateUser)
mux.HandleFunc("POST /api/admin/users/{id}/password", s.handleSetUserPassword)
mux.HandleFunc("DELETE /api/admin/users/{id}", 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))
// Cars + sharing.
mux.HandleFunc("GET /api/cars", s.listCars)
mux.HandleFunc("POST /api/cars", s.createCar)
mux.HandleFunc("GET /api/cars/{id}", s.getCar)
@@ -137,43 +207,74 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/cars/{id}", s.deleteCar)
mux.HandleFunc("GET /api/cars/{id}/service-records", s.listCarServiceRecords)
mux.HandleFunc("GET /api/cars/{id}/parts", s.listCarParts)
mux.HandleFunc("GET /api/cars/{id}/shares", s.handleListShares)
mux.HandleFunc("POST /api/cars/{id}/shares", s.handleUpsertShare)
mux.HandleFunc("DELETE /api/cars/{id}/shares/{userId}", s.handleDeleteShare)
// Service records.
mux.HandleFunc("GET /api/service-records", s.listServiceRecords)
mux.HandleFunc("POST /api/service-records", s.createServiceRecord)
mux.HandleFunc("GET /api/service-records/{id}", s.getServiceRecord)
mux.HandleFunc("PATCH /api/service-records/{id}", s.updateServiceRecord)
mux.HandleFunc("DELETE /api/service-records/{id}", s.deleteServiceRecord)
// Parts.
mux.HandleFunc("GET /api/parts", s.listParts)
mux.HandleFunc("POST /api/parts", s.createPart)
mux.HandleFunc("GET /api/parts/{id}", s.getPart)
mux.HandleFunc("PATCH /api/parts/{id}", s.updatePart)
mux.HandleFunc("DELETE /api/parts/{id}", s.deletePart)
return s.withCORS(s.withLogging(s.withAuth(mux)))
return s.withMiddleware(mux)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
// withMiddleware applies panic recovery, CORS, request logging, and
// authentication globally.
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverer(s.cors(s.logger(s.withAuth(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))
})
}
// --- middleware ---
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) withCORS(next http.Handler) http.Handler {
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 != "" && (s.corsOrigins[origin] || s.corsOrigins["*"]) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "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", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
@@ -183,43 +284,22 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
})
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
})
// statusWriter captures the response status code for logging.
type statusWriter struct {
http.ResponseWriter
status int
wrote bool
}
// --- helpers ---
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v != nil {
_ = json.NewEncoder(w).Encode(v)
func (w *statusWriter) WriteHeader(code int) {
if !w.wrote {
w.status = code
w.wrote = true
}
w.ResponseWriter.WriteHeader(code)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// writePBError maps a PocketBase error to an appropriate HTTP status.
func writePBError(w http.ResponseWriter, err error) {
if apiErr, ok := err.(*pb.APIError); ok {
status := apiErr.Status
if status < 400 {
status = http.StatusBadGateway
}
writeError(w, status, apiErr.Body)
return
}
writeError(w, http.StatusBadGateway, err.Error())
}
func decodeJSON(r *http.Request, dest any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dest)
func (w *statusWriter) Write(b []byte) (int, error) {
w.wrote = true
return w.ResponseWriter.Write(b)
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"net/http"
"net/url"
"carcontrol/api/internal/models"
"drivervault/apiserver/internal/models"
)
func (s *Server) listServiceRecords(w http.ResponseWriter, r *http.Request) {
-106
View File
@@ -1,106 +0,0 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"carcontrol/api/internal/auth"
"carcontrol/api/internal/models"
)
// handleListSessions lists the current user's active (non-revoked) logins,
// flagging which one is the request being made right now.
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
res, err := s.pb.List(r.Context(), colSessions, url.Values{
"filter": {fmt.Sprintf("user='%s' && revoked=false", claims.Sub)},
"sort": {"-created"},
"perPage": {"50"},
})
if err != nil {
writePBError(w, err)
return
}
var recs []sessionRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]models.Session, 0, len(recs))
for _, rec := range recs {
out = append(out, models.Session{
ID: rec.ID,
DeviceLabel: rec.DeviceLabel,
IP: rec.IP,
Current: rec.Jti == claims.Jti,
Created: parsePBDate(rec.Created),
ExpiresAt: parsePBDate(rec.ExpiresAt),
})
}
writeJSON(w, http.StatusOK, out)
}
// handleRevokeSession logs out one specific device (including possibly the
// current one, same as an ordinary logout).
func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
id := r.PathValue("id")
var rec sessionRecord
if err := s.pb.GetOne(r.Context(), colSessions, id, &rec); err != nil {
writePBError(w, err)
return
}
if rec.User != claims.Sub {
writeError(w, http.StatusNotFound, "session not found")
return
}
if err := s.pb.Update(r.Context(), colSessions, id, map[string]any{"revoked": true}, nil); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRevokeOtherSessions logs out every device except the one making this
// request ("log out everywhere else").
func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
res, err := s.pb.List(r.Context(), colSessions, url.Values{
"filter": {fmt.Sprintf("user='%s' && revoked=false && jti!='%s'", claims.Sub, claims.Jti)},
"perPage": {"200"},
})
if err != nil {
writePBError(w, err)
return
}
var recs []sessionRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, rec := range recs {
if err := s.pb.Update(r.Context(), colSessions, rec.ID, map[string]any{"revoked": true}, nil); err != nil {
writePBError(w, err)
return
}
}
writeJSON(w, http.StatusOK, map[string]int{"revoked": len(recs)})
}
+159
View File
@@ -0,0 +1,159 @@
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb"
)
// 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 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 := pb.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, "/")
}
// viewFor builds the panel's connection view, including a live probe.
func viewFor(ctx context.Context, url, email, password string) pbConfigView {
return pbConfigView{
URL: url,
AdminEmail: email,
AdminConfigured: email != "" && password != "",
Probe: probePB(ctx, url, email, password),
}
}
// 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, viewFor(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, 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": viewFor(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": viewFor(r.Context(), url, email, password),
})
}
+1 -1
View File
@@ -184,7 +184,7 @@ func (s *Server) findShare(r *http.Request, carID, userID string) (*shareRecord,
// findUserByEmail looks up a user in the auth collection by email, returning nil
// if none matches.
func (s *Server) findUserByEmail(r *http.Request, email string) (*userRecord, error) {
res, err := s.pb.List(r.Context(), s.usersCollection, url.Values{
res, err := s.pb.List(r.Context(), s.usersCollection(), url.Values{
"filter": {fmt.Sprintf("email='%s'", strings.ReplaceAll(email, "'", ""))},
"perPage": {"1"},
})
+61
View File
@@ -0,0 +1,61 @@
package api
import (
"context"
"io"
"net/http"
"sync"
"time"
)
// svcHealth is the health of one upstream service, as shown on the panel.
type svcHealth struct {
Status string `json:"status"` // "ok" | "down"
LatencyMs int64 `json:"latencyMs,omitempty"`
HTTPStatus int `json:"httpStatus,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
// healthClient is a short-timeout client for probing upstreams so a hung
// dependency can't stall the status endpoint.
var healthClient = &http.Client{Timeout: 4 * time.Second}
// probe does a GET against url and classifies the result.
func probe(ctx context.Context, url string) svcHealth {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return svcHealth{Status: "down", URL: url, Error: err.Error()}
}
resp, err := healthClient.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return svcHealth{Status: "down", URL: url, LatencyMs: lat, Error: err.Error()}
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
status := "ok"
if resp.StatusCode >= 400 {
status = "down"
}
return svcHealth{Status: status, LatencyMs: lat, HTTPStatus: resp.StatusCode, URL: url}
}
// GET /api/status — aggregate health of the API Server and its neighbours
// (PocketBase and the Web App), probed server-side. The panel polls this so the
// browser never has to reach PocketBase or the Web App directly.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
var pbHealth, web svcHealth
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); pbHealth = probe(r.Context(), s.pb.BaseURL()+"/api/health") }()
go func() { defer wg.Done(); web = probe(r.Context(), s.webAppURL()+"/healthz") }()
wg.Wait()
writeJSON(w, http.StatusOK, map[string]any{
"apiServer": map[string]any{"status": "ok"},
"pocketBase": pbHealth,
"webApp": web,
})
}
+345
View File
@@ -0,0 +1,345 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
)
// userView is the trimmed user shape returned to managers.
type userView struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Verified bool `json:"verified"`
Created string `json:"created"`
Organization string `json:"organization"` // org record id ("" = none)
OrganizationName string `json:"organizationName"` // resolved name ("" = none)
}
// userFields is the field set fetched for a userView.
const userFields = "id,email,name,role,verified,created,organization"
// getUserRecord fetches a single user via the service account. Returns nil (not
// an error) when the user does not exist.
func (s *Server) getUserRecord(ctx context.Context, id string) (*userView, error) {
path := "/api/collections/" + s.usersCollection() + "/records/" + url.PathEscape(id) + "?fields=" + userFields
data, status, err := s.pb.Raw(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, nil
}
var v userView
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
if v.Role == "" {
v.Role = roleUser
}
return &v, nil
}
// GET /api/users — list users (manager only). Superadmins see everyone; admins
// see only their own organization's members.
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
who := caller(r)
path := "/api/collections/" + s.usersCollection() + "/records?perPage=500&sort=email&fields=" + userFields
if !who.isSuperadmin() {
// Admin: scope to their own organization. An org-less admin manages nobody.
if who.OrgID == "" {
writeJSON(w, http.StatusOK, map[string]any{"users": []userView{}})
return
}
path += "&filter=" + url.QueryEscape("organization = \""+who.OrgID+"\"")
}
data, status, err := s.pb.Raw(r.Context(), http.MethodGet, path, nil)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK {
relay(w, status, data)
return
}
var list struct {
Items []userView `json:"items"`
}
_ = json.Unmarshal(data, &list)
names := s.orgNameMap(r.Context())
for i := range list.Items {
if list.Items[i].Role == "" {
list.Items[i].Role = roleUser
}
list.Items[i].OrganizationName = names[list.Items[i].Organization]
}
writeJSON(w, http.StatusOK, map[string]any{"users": list.Items})
}
// POST /api/users — create a user (manager only). Body: {email, password, name?,
// role?, organization?}. Admins may only create within their own org and may not
// mint superadmins; superadmins may target any org (or none) and any role.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
who := caller(r)
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role"`
Organization string `json:"organization"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
body.Email = strings.TrimSpace(strings.ToLower(body.Email))
if body.Email == "" || !strings.Contains(body.Email, "@") {
writeError(w, http.StatusBadRequest, "a valid email is required")
return
}
if len(body.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
role, ok := normalizeRole(body.Role)
if !ok {
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
return
}
org := strings.TrimSpace(body.Organization)
if !who.isSuperadmin() {
// Admin: no superadmins, and members are forced into the admin's own org.
if role == roleSuperadmin {
writeError(w, http.StatusForbidden, "only a superadmin can create superadmins")
return
}
if who.OrgID == "" {
writeError(w, http.StatusForbidden, "your account is not attached to an organization")
return
}
org = who.OrgID
}
create := map[string]any{
"email": body.Email,
"password": body.Password,
"passwordConfirm": body.Password,
"name": strings.TrimSpace(body.Name),
"role": role,
"verified": true,
"emailVisibility": false,
}
// Only send organization when set; a superadmin may deliberately omit it to
// create an org-less account.
if org != "" {
create["organization"] = org
}
data, status, err := s.pb.Raw(r.Context(), http.MethodPost, "/api/collections/"+s.usersCollection()+"/records", create)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK {
// Relay PocketBase's validation error (e.g. duplicate email, bad org id).
relay(w, status, data)
return
}
var rec userView
_ = json.Unmarshal(data, &rec)
if rec.Role == "" {
rec.Role = role
}
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
writeJSON(w, http.StatusCreated, map[string]any{"user": rec})
}
// PATCH /api/users/{id} — edit a user (manager only). Any subset of
// {email, name, role, password, verified, organization} may be supplied. Admins
// are scoped to their own org and cannot touch superadmins or grant the
// superadmin role; nobody can change their own role (avoids self-lockout).
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing user id")
return
}
var body struct {
Email string `json:"email"`
Name *string `json:"name"`
Role string `json:"role"`
Password string `json:"password"`
Verified *bool `json:"verified"`
Organization *string `json:"organization"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
// Resolve the target so we can enforce org/role scoping.
target, err := s.getUserRecord(r.Context(), id)
if err != nil {
writeUpstreamDown(w, err)
return
}
if target == nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
if !who.isSuperadmin() {
// Admin scoping: target must be inside the admin's org and not a superadmin.
if who.OrgID == "" || target.Organization != who.OrgID {
writeError(w, http.StatusForbidden, "user is outside your organization")
return
}
if target.Role == roleSuperadmin {
writeError(w, http.StatusForbidden, "you cannot edit a superadmin")
return
}
}
patch := map[string]any{}
if email := strings.TrimSpace(strings.ToLower(body.Email)); email != "" {
if !strings.Contains(email, "@") {
writeError(w, http.StatusBadRequest, "a valid email is required")
return
}
patch["email"] = email
}
if body.Name != nil {
patch["name"] = strings.TrimSpace(*body.Name)
}
if body.Role != "" {
role, ok := normalizeRole(body.Role)
if !ok {
writeError(w, http.StatusBadRequest, "role must be 'user', 'admin', or 'superadmin'")
return
}
if !who.isSuperadmin() && role == roleSuperadmin {
writeError(w, http.StatusForbidden, "only a superadmin can grant the superadmin role")
return
}
if who.ID == id && role != who.Role {
writeError(w, http.StatusBadRequest, "you cannot change your own role")
return
}
patch["role"] = role
}
if body.Password != "" {
if len(body.Password) < 8 {
writeError(w, http.StatusBadRequest, "password must be at least 8 characters")
return
}
patch["password"] = body.Password
patch["passwordConfirm"] = body.Password
}
if body.Verified != nil {
patch["verified"] = *body.Verified
}
// Organization moves are superadmin-only; admins cannot reassign membership.
if body.Organization != nil {
if !who.isSuperadmin() {
if *body.Organization != who.OrgID {
writeError(w, http.StatusForbidden, "you cannot move users to another organization")
return
}
// no-op for admins staying in their own org
} else {
patch["organization"] = *body.Organization // "" clears membership
}
}
if len(patch) == 0 {
writeError(w, http.StatusBadRequest, "no changes provided")
return
}
data, status, err := s.pb.Raw(r.Context(), http.MethodPatch,
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), patch)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK {
relay(w, status, data)
return
}
var rec userView
_ = json.Unmarshal(data, &rec)
if rec.Role == "" {
rec.Role = roleUser
}
rec.OrganizationName = s.orgName(r.Context(), rec.Organization)
writeJSON(w, http.StatusOK, map[string]any{"user": rec})
}
// DELETE /api/users/{id} — delete a user (manager only). Admins may delete only
// non-superadmin members of their own org; nobody can delete their own account.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "missing user id")
return
}
if who.ID == id {
writeError(w, http.StatusBadRequest, "you cannot delete your own account")
return
}
if !who.isSuperadmin() {
target, err := s.getUserRecord(r.Context(), id)
if err != nil {
writeUpstreamDown(w, err)
return
}
if target == nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
if who.OrgID == "" || target.Organization != who.OrgID {
writeError(w, http.StatusForbidden, "user is outside your organization")
return
}
if target.Role == roleSuperadmin {
writeError(w, http.StatusForbidden, "you cannot delete a superadmin")
return
}
}
data, status, err := s.pb.Raw(r.Context(), http.MethodDelete,
"/api/collections/"+s.usersCollection()+"/records/"+url.PathEscape(id), nil)
if err != nil {
writeUpstreamDown(w, err)
return
}
if status != http.StatusOK && status != http.StatusNoContent {
relay(w, status, data)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// normalizeRole validates a client-supplied role. Returns the canonical value
// and whether it was recognised.
func normalizeRole(role string) (string, bool) {
switch strings.TrimSpace(strings.ToLower(role)) {
case "", roleUser:
return roleUser, true
case roleAdmin:
return roleAdmin, true
case roleSuperadmin:
return roleSuperadmin, true
default:
return "", false
}
}
-96
View File
@@ -1,96 +0,0 @@
// Package auth implements minimal HS256 JSON Web Tokens using only the standard
// library. The API Server issues a token after verifying a user's credentials
// against PocketBase, and verifies that token on every protected request.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrExpired = errors.New("token expired")
)
// Claims is the JWT payload carried for an authenticated user.
type Claims struct {
Sub string `json:"sub"` // user id
Email string `json:"email"` // user email
Name string `json:"name"` // display name (optional)
Role string `json:"role,omitempty"` // access role: "user" | "admin"
Jti string `json:"jti"` // id of the backing "sessions" record, for revocation
Iat int64 `json:"iat"` // issued-at (unix seconds)
Exp int64 `json:"exp"` // expiry (unix seconds)
}
// NewJTI generates a random session identifier, hex-encoded so it's always a
// safe, quote-free literal to embed directly in PocketBase filter strings.
func NewJTI() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
const headerB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" // {"alg":"HS256","typ":"JWT"}
// Sign creates a signed token for the given claims and lifetime.
func Sign(secret string, c Claims, ttl time.Duration) (string, error) {
now := time.Now()
c.Iat = now.Unix()
c.Exp = now.Add(ttl).Unix()
payload, err := json.Marshal(c)
if err != nil {
return "", err
}
signingInput := headerB64 + "." + b64(payload)
sig := sign(signingInput, secret)
return signingInput + "." + sig, nil
}
// Verify checks the signature and expiry, returning the embedded claims.
func Verify(secret, token string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrInvalidToken
}
signingInput := parts[0] + "." + parts[1]
expected := sign(signingInput, secret)
// Constant-time comparison to avoid timing leaks.
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
return nil, ErrInvalidToken
}
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrInvalidToken
}
var c Claims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, ErrInvalidToken
}
if time.Now().Unix() >= c.Exp {
return nil, ErrExpired
}
return &c, nil
}
func sign(input, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(input))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func b64(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
+126 -46
View File
@@ -1,65 +1,147 @@
// Package config loads server configuration from environment variables,
// optionally seeded from a .env file in the working directory.
// optionally seeded from a .env file in the working directory. The PocketBase
// connection is also editable at runtime from the panel, which persists the
// change back into the same .env via UpdateEnvFile.
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
// Config holds all runtime configuration for the API Server.
type Config struct {
Port string
PBURL string
PBAdminEmail string
PBAdminPasswd string
CORSOrigins []string
AuthSecret string
Addr string
PocketBaseURL string
WebAppURL string
AllowOrigins []string
// UsersCollection is the PocketBase auth collection holding app users.
UsersCollection string
// PluginsFile is the local JSON store for plugin enable-state + config.
PluginsFile string
// Superuser service account. Every privileged flow (user/organization
// management, all car-domain database access) runs through it. Optional at
// startup: when unset those endpoints return 503 and a superadmin can still
// log in to the panel to configure it.
PocketBaseAdminEmail string
PocketBaseAdminPassword string
}
// devAuthSecret is used only when AUTH_SECRET is unset, so the server still
// runs out-of-the-box in development. Set AUTH_SECRET in production.
const devAuthSecret = "dev-insecure-secret-change-me"
// EnvFile is the .env path (relative to the working directory) that Load reads
// and that runtime settings changes persist back into.
const EnvFile = ".env"
// Load reads .env (if present) into the process environment, then builds a
// Config from environment variables. Required values that are missing produce
// an error so the server fails fast instead of misbehaving later.
func Load() (*Config, error) {
loadDotEnv(".env")
// AdminConfigured reports whether a service account has been supplied.
func (c Config) AdminConfigured() bool {
return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != ""
}
cfg := &Config{
Port: getenv("PORT", "8080"),
PBURL: strings.TrimRight(getenv("PB_URL", "http://10.2.1.10:8027"), "/"),
PBAdminEmail: os.Getenv("PB_ADMIN_EMAIL"),
PBAdminPasswd: os.Getenv("PB_ADMIN_PASSWORD"),
CORSOrigins: splitCSV(getenv("CORS_ORIGINS", "http://localhost:5173")),
AuthSecret: getenv("AUTH_SECRET", devAuthSecret),
UsersCollection: getenv("AUTH_USERS_COLLECTION", "users"),
// Load reads configuration from environment variables, applying sensible
// defaults. A .env file, if present in the working directory, is loaded first.
func Load() Config {
loadDotEnv(EnvFile)
return Config{
Addr: normalizeAddr(firstEnv("API_ADDR", "PORT"), ":8080"),
PocketBaseURL: strings.TrimRight(firstEnvOr("http://10.2.1.10:8027", "POCKETBASE_URL", "PB_URL"), "/"),
WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:5173"), "/"),
AllowOrigins: splitCSV(firstEnvOr("*", "CORS_ALLOW_ORIGINS", "CORS_ORIGINS")),
UsersCollection: getenv("AUTH_USERS_COLLECTION", "users"),
PluginsFile: getenv("PLUGINS_FILE", "plugins.json"),
PocketBaseAdminEmail: firstEnv("POCKETBASE_ADMIN_EMAIL", "PB_ADMIN_EMAIL"),
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
}
}
// normalizeAddr accepts either a full listen address (":8080") or a bare port
// ("8080", which is what the legacy PORT variable held) and returns a listen
// address.
func normalizeAddr(v, def string) string {
if v == "" {
return def
}
if strings.Contains(v, ":") {
return v
}
return ":" + v
}
// UpdateEnvFile persists the given KEY=VALUE pairs into the .env file at path,
// replacing existing keys in place and appending new ones, while preserving all
// other lines (comments, ordering, unrelated keys). The file is created if it
// does not exist. Written with 0600 perms since it holds secrets.
func UpdateEnvFile(path string, updates map[string]string) error {
existing, _ := os.ReadFile(path) // missing file → start empty
remaining := make(map[string]string, len(updates))
for k, v := range updates {
remaining[k] = v
}
if cfg.PBAdminEmail == "" || cfg.PBAdminPasswd == "" {
return nil, fmt.Errorf("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required")
var out []string
for _, line := range strings.Split(string(existing), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
out = append(out, line)
continue
}
key, _, ok := strings.Cut(trimmed, "=")
key = strings.TrimSpace(key)
if ok {
if v, found := remaining[key]; found {
out = append(out, key+"="+v)
delete(remaining, key)
continue
}
}
out = append(out, line)
}
return cfg, nil
// Append any keys that weren't already present.
for k, v := range remaining {
out = append(out, k+"="+v)
}
content := strings.Join(out, "\n")
if !strings.HasSuffix(content, "\n") {
content += "\n"
}
return os.WriteFile(path, []byte(content), 0o600)
}
// UsingDevAuthSecret reports whether the insecure development secret is in use.
func (c *Config) UsingDevAuthSecret() bool {
return c.AuthSecret == devAuthSecret
}
func getenv(key, fallback string) string {
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
return def
}
// firstEnv returns the first of keys that is set to a non-empty value. It lets
// the modern POCKETBASE_* names take precedence while the legacy PB_* names from
// older deployments keep working.
func firstEnv(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
// firstEnvOr is firstEnv with a fallback when none of the keys are set.
func firstEnvOr(def string, keys ...string) string {
if v := firstEnv(keys...); v != "" {
return v
}
return def
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
@@ -67,18 +149,16 @@ func splitCSV(s string) []string {
return out
}
// loadDotEnv parses a simple KEY=VALUE file and sets any variables that are not
// already present in the environment. Lines starting with # are comments.
// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they
// are not already set. It is intentionally minimal (no quoting rules beyond
// trimming surrounding quotes).
func loadDotEnv(path string) {
f, err := os.Open(path)
data, err := os.ReadFile(path)
if err != nil {
return // .env is optional
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
@@ -89,7 +169,7 @@ func loadDotEnv(path string) {
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
_ = os.Setenv(key, val)
}
}
}
+150 -11
View File
@@ -17,14 +17,18 @@ import (
)
// Client is a concurrency-safe PocketBase REST client with auto re-auth.
//
// The target address and service-account credentials are guarded by mu because
// a superadmin can retarget them at runtime (Settings → PocketBase in the panel)
// while requests are in flight.
type Client struct {
http *http.Client
mu sync.RWMutex
baseURL string
email string
password string
http *http.Client
mu sync.RWMutex
token string
token string
}
func New(baseURL, email, password string) *Client {
@@ -36,6 +40,38 @@ func New(baseURL, email, password string) *Client {
}
}
// creds snapshots the connection under lock so a concurrent Reconfigure can't
// tear it mid-request.
func (c *Client) creds() (baseURL, email, password string) {
c.mu.RLock()
defer c.mu.RUnlock()
return c.baseURL, c.email, c.password
}
// BaseURL returns the PocketBase address currently in use.
func (c *Client) BaseURL() string {
baseURL, _, _ := c.creds()
return baseURL
}
// Configured reports whether a service account has been supplied. Endpoints that
// need superuser access check this and return 503 when it is false.
func (c *Client) Configured() bool {
_, email, password := c.creds()
return email != "" && password != ""
}
// Reconfigure retargets the client at a new PocketBase and/or new credentials,
// invalidating any cached superuser token so the next call re-authenticates.
func (c *Client) Reconfigure(baseURL, email, password string) {
c.mu.Lock()
c.baseURL = baseURL
c.email = email
c.password = password
c.token = ""
c.mu.Unlock()
}
// APIError carries the HTTP status and body from a failed PocketBase call.
type APIError struct {
Status int
@@ -49,9 +85,10 @@ func (e *APIError) Error() string {
// Authenticate obtains a superuser token. It tries the PocketBase v0.23+
// (_superusers collection) endpoint first, then the legacy admins endpoint.
func (c *Client) Authenticate(ctx context.Context) error {
baseURL, email, password := c.creds()
body, _ := json.Marshal(map[string]string{
"identity": c.email,
"password": c.password,
"identity": email,
"password": password,
})
endpoints := []string{
@@ -61,7 +98,7 @@ func (c *Client) Authenticate(ctx context.Context) error {
var lastErr error
for _, ep := range endpoints {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+ep, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
if err != nil {
return err
}
@@ -129,7 +166,7 @@ func (c *Client) attempt(ctx context.Context, method, path string, payload any)
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL()+path, reader)
if err != nil {
return nil, 0, err
}
@@ -149,6 +186,108 @@ func (c *Client) attempt(ctx context.Context, method, path string, payload any)
return raw, resp.StatusCode, err
}
// Raw performs a superuser request and returns the upstream body and status
// WITHOUT translating a non-2xx into an error. Handlers that want to relay
// PocketBase's own validation errors to the client verbatim (user/organization
// management) use this; handlers that want Go errors use the typed CRUD helpers.
func (c *Client) Raw(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
raw, status, err := c.attempt(ctx, method, path, payload)
if err != nil {
return nil, 0, err
}
if status == http.StatusUnauthorized {
if err := c.Authenticate(ctx); err != nil {
return nil, 0, err
}
return c.attempt(ctx, method, path, payload)
}
return raw, status, nil
}
// AuthRefresh validates an end user's auth token against PocketBase and returns
// the refreshed auth response body and status. Unlike the superuser calls this
// carries the *caller's* token, not the service account's — it is how the server
// resolves who a request belongs to.
func (c *Client) AuthRefresh(ctx context.Context, collection, token string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL()+"/api/collections/"+collection+"/auth-refresh", nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", token)
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return raw, resp.StatusCode, err
}
// LoginWithPassword forwards a login to PocketBase's auth-with-password and
// returns its response body and status untouched, so the caller can relay both
// (token + record) straight back to the client.
func (c *Client) LoginWithPassword(ctx context.Context, collection, identity, password string) ([]byte, int, error) {
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL()+"/api/collections/"+collection+"/auth-with-password", bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
return raw, resp.StatusCode, err
}
// SuperuserAuth performs a one-off superuser auth-with-password against an
// arbitrary PocketBase and returns the HTTP status. It shares no state with any
// Client, so the settings endpoints can test a *candidate* connection before
// applying it. Both the v0.23+ (_superusers) and legacy (admins) endpoints are
// tried, matching Client.Authenticate.
func SuperuserAuth(ctx context.Context, httpClient *http.Client, baseURL, email, password string) (int, error) {
body, _ := json.Marshal(map[string]string{"identity": email, "password": password})
var lastStatus int
var lastErr error
for _, ep := range []string{
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
} {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+ep, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return 0, err
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
return resp.StatusCode, fmt.Errorf("superuser auth: no token in response")
}
return resp.StatusCode, nil
}
lastStatus = resp.StatusCode
lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
}
return lastStatus, lastErr
}
// AuthRecord is the user record returned by a successful password auth.
type AuthRecord struct {
ID string `json:"id"`
@@ -161,7 +300,7 @@ type AuthRecord struct {
// the superuser token. Returns the matched user record on success.
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthRecord, error) {
body, _ := json.Marshal(map[string]string{"identity": identity, "password": password})
url := c.baseURL + "/api/collections/" + collection + "/auth-with-password"
url := c.BaseURL() + "/api/collections/" + collection + "/auth-with-password"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -277,7 +416,7 @@ func (c *Client) GetFile(ctx context.Context, collection, recordID, filename str
}
func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL()+path, nil)
if err != nil {
return nil, "", 0, err
}
@@ -340,7 +479,7 @@ func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fi
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+"/api/collections/"+collection+"/records/"+id, &buf)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.BaseURL()+"/api/collections/"+collection+"/records/"+id, &buf)
if err != nil {
return 0, err
}
+309
View File
@@ -0,0 +1,309 @@
# Building DriverVault Plugins
A **plugin** integrates an external third-party service (vehicle data, parts
catalogs, notifications, file storage, …) behind one uniform contract. There are
two kinds:
| Kind | Written as | Added by | Rebuild? | Use when |
|---|---|---|---|---|
| **built-in** | Go code in this repo | a rebuild | yes | first-party, high-trust, type-safe connectors |
| **external** | any HTTP service | registering a URL at runtime | **no** | third-party / less-trusted / independently deployed |
Both implement the same behaviour; the server treats them identically. Enable
state and per-plugin config persist to `plugins.json` and load on boot. Every
plugin is managed by a **superadmin** from the panel (`/`) or the
`/api/admin/plugins*` API.
---
## The contract
All plugins satisfy the Go interface in [`plugin.go`](plugin.go):
```go
type Plugin interface {
Descriptor() Descriptor
Init(ctx context.Context, config map[string]string) error
HealthCheck(ctx context.Context) Health
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
Shutdown(ctx context.Context) error
}
```
- **`Descriptor`** — static metadata (name, provider, version, capabilities,
auth type, config fields). Drives the panel UI.
- **`Init`** — called with the resolved config (secrets included) whenever the
plugin is enabled or its config changes. Prepare clients/tokens here.
- **`HealthCheck`** — probe the upstream and classify: `Health{Status, LatencyMs, Detail}`
where `Status` is `StatusOK` / `StatusDegraded` / `StatusDown`.
- **`Invoke`** — run a named capability. **Part of the contract for the future;
no HTTP endpoint exposes it in v1.** Implement it anyway so the connector is
ready.
- **`Shutdown`** — release resources.
### Descriptor & config fields
```go
Descriptor{
Name: "acme", // unique id, [a-z0-9-]
Provider: "ACME Corp", // human label
Version: "1.0.0",
Kind: plugins.KindBuiltin, // or KindExternal
Capabilities: []plugins.Capability{
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
},
AuthType: plugins.AuthAPIKey, // None | APIKey | Basic | OAuth2 | Webhook (metadata only)
ConfigFields: []plugins.ConfigField{
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true,
Help: "Found under ACME → Settings → API."},
{Key: "region", Label: "Region", Type: "text", Help: "e.g. eu-west-1"},
},
}
```
`ConfigField.Type` is `"text"`, `"password"`, or `"number"` (form input hint).
Set **`Secret: true`** for credentials — the server never echoes them back in
clear; the panel shows a mask (`••••••••`), and on save a field left at the mask
keeps its stored value (so operators don't retype secrets). **`Required: true`**
fields must be non-empty before the plugin can be enabled.
---
## Building a built-in plugin
1. **Create a package** under `internal/plugins/builtin/<name>/`.
2. **Implement `Plugin`** and **register it in `init()`**.
3. **Blank-import** your package from [`builtin/builtin.go`](builtin/builtin.go).
4. **Rebuild** the server.
### Minimal example — `internal/plugins/builtin/acme/acme.go`
```go
package acme
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"drivervault/apiserver/internal/plugins"
)
func init() {
plugins.Register("acme", func() plugins.Plugin { return &Plugin{} })
}
type Plugin struct {
apiKey string
region string
client *http.Client
}
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "acme", Provider: "ACME Corp", Version: "1.0.0",
Kind: plugins.KindBuiltin, AuthType: plugins.AuthAPIKey,
Capabilities: []plugins.Capability{
{ID: "widgets.list", Method: "GET", Endpoint: "/widgets", Description: "List widgets."},
},
ConfigFields: []plugins.ConfigField{
{Key: "apiKey", Label: "API key", Type: "password", Required: true, Secret: true},
{Key: "region", Label: "Region", Type: "text"},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.apiKey = strings.TrimSpace(config["apiKey"])
p.region = strings.TrimSpace(config["region"])
p.client = &http.Client{Timeout: 10 * time.Second}
return nil
}
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.acme.example/ping", nil)
req.Header.Set("Authorization", "Bearer "+p.apiKey)
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()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: "reachable"}
}
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: "HTTP " + resp.Status}
}
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
// Implement your capabilities; return normalized JSON. (Not yet called in v1.)
return json.RawMessage(`{"ok":true}`), nil
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
```
### Register it for compilation — `internal/plugins/builtin/builtin.go`
```go
import (
_ "drivervault/apiserver/internal/plugins/builtin/acme"
)
```
### Rebuild
```powershell
cd "API Server"
go build -o bin/api-server.exe ./cmd/server
```
Restart the server. The plugin appears in the panel's **Plugins** card,
**disabled** by default.
> DriverVault ships no built-in connectors yet, so `builtin/builtin.go` has an
> empty import block. The **external** kind below needs no rebuild and is the
> easier place to start.
---
## Building an external plugin (no rebuild)
An external plugin is **any HTTP service** you host (Go recommended, but any
language works). You register its base URL at runtime; the server drives it over
a tiny JSON contract.
### The HTTP contract
| Method & path | Purpose | Response |
|---|---|---|
| `GET {base}/manifest` | describe the plugin (optional) | `{provider, version, capabilities, authType, configFields}` |
| `GET {base}/health` | health probe (required) | `2xx` = healthy; optional body `{status, detail}` |
| `POST {base}/invoke` | run a capability (optional; unused in v1) | `{action, params}` in → arbitrary JSON out |
Health rules the server applies: transport error or `5xx``down`; `2xx``ok`;
anything else → `degraded`. An explicit `{"status":"ok|degraded|down","detail":"…"}`
body overrides the status-code heuristic. Bodies are size-limited (health 64 KiB,
manifest 1 MiB).
### Minimal example — a Go plugin service
```go
package main
import (
"encoding/json"
"net/http"
)
func main() {
http.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"provider": "ACME Cloud",
"version": "2.1.0",
"authType": "apikey",
"capabilities": []map[string]any{
{"id": "widgets.list", "method": "GET", "endpoint": "/widgets", "description": "List widgets."},
}, // a plain []string{"widgets.list"} is also accepted
"configFields": []map[string]any{
{"key": "apiKey", "label": "API key", "type": "password", "required": true, "secret": true},
},
})
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "detail": "acme cloud reachable"})
})
http.HandleFunc("/invoke", func(w http.ResponseWriter, r *http.Request) {
var in struct {
Action string `json:"action"`
Params json.RawMessage `json:"params"`
}
json.NewDecoder(r.Body).Decode(&in)
json.NewEncoder(w).Encode(map[string]any{"ok": true, "action": in.Action})
})
http.ListenAndServe(":9100", nil)
}
```
### Register it
From the panel's **Plugins** card → *Register external plugin* (name + base URL),
or via the API:
```bash
curl -X POST http://localhost:8080/api/admin/plugins \
-H "Authorization: $SUPERADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"acme-cloud","baseURL":"http://127.0.0.1:9100","provider":"ACME Cloud"}'
```
It starts **disabled**; enable it and run a health check from the panel. Because
it runs as its own process/container, an external plugin is also the
**sandboxing** path for less-trusted integrations.
---
## Lifecycle, config & secrets
- **Enable/disable** and **config** persist to `plugins.json` (gitignored; override
the path with `PLUGINS_FILE`). Enabling calls `Init`; disabling calls `Shutdown`.
- **Secrets** (`Secret: true` fields) are returned masked. On save, a field still
equal to the mask keeps its stored value; send a new value to change it, or an
empty string to clear it.
- **Required** fields are validated when enabling — enabling fails with a clear
error if one is blank.
- If `Init` fails (e.g. bad credentials), the state is still saved and the API
returns the plugin plus a `warning`; fix the config and re-save.
---
## Managing plugins (superadmin API)
All endpoints require a superadmin bearer token (`Authorization: <token>` from
`POST /api/auth/login`). See the panel's **Management API** reference too.
| Method | Path | Body | Purpose |
|---|---|---|---|
| `GET` | `/api/admin/plugins` | — | list all plugins + state + last health |
| `GET` | `/api/admin/plugins/{name}` | — | one plugin |
| `PUT` | `/api/admin/plugins/{name}` | `{enabled?, config?}` | enable/disable + configure |
| `POST` | `/api/admin/plugins` | `{name, baseURL, provider?}` | register an external plugin |
| `DELETE` | `/api/admin/plugins/{name}` | — | remove an external plugin (built-ins only disable) |
| `POST` | `/api/admin/plugins/{name}/health` | — | run a health check now |
---
## Testing your plugin
1. Build + restart (built-in) or start your service (external) and register it.
2. `GET /api/admin/plugins` → confirm your descriptor, config fields, capabilities.
3. `PUT /api/admin/plugins/{name} {"enabled":true, "config":{…}}` → enable with config.
4. `POST /api/admin/plugins/{name}/health` → confirm the live probe classifies correctly.
5. Restart the server → confirm state reloads from `plugins.json`.
A Go unit test can exercise a built-in directly:
```go
p := &acme.Plugin{}
_ = p.Init(context.Background(), map[string]string{"apiKey": "test"})
if h := p.HealthCheck(context.Background()); h.Status == "" {
t.Fatal("expected a health status")
}
```
---
## Not yet implemented (roadmap)
The contract is shaped for these; see [`doc.go`](doc.go):
- **Invocation API** — an endpoint to call `Invoke` from clients, with a normalized
request/response envelope and a provider→internal mapper.
- **Resilience** — retry/backoff, circuit breaker, per-plugin latency/error metrics.
- **Per-tenant credentials** — config keyed by org/user so users connect their own accounts.
- **Audit logging** of plugin access.
Until the invocation API lands, `Invoke` is dormant — plugins are discoverable,
configurable, and health-checked, but not yet callable over HTTP.
@@ -0,0 +1,12 @@
// Package builtin blank-imports every built-in plugin so their init() functions
// register them with the plugin registry. Import this package once (from the api
// package) to make all built-in connectors available.
//
// DriverVault ships no built-in connectors yet — add one under
// internal/plugins/builtin/<name>/ and blank-import it here, e.g.
//
// import _ "drivervault/apiserver/internal/plugins/builtin/acme"
//
// Until then, plugins are added at runtime as the "external" HTTP kind, which
// needs no rebuild. See ../README.md.
package builtin
+20
View File
@@ -0,0 +1,20 @@
package plugins
// Deferred extension points (deliberately NOT in v1 — the "Management MVP").
// The contract and manager are shaped so these can be added without a redesign:
//
// - Invocation API: the Plugin.Invoke method already exists; a
// POST /api/admin/plugins/{name}/action endpoint + a normalized request/
// response envelope would expose it. Add a mapper layer so core logic never
// depends on a provider's schema.
// - Resilience: wrap plugin calls with retry/backoff + a circuit breaker, and
// record per-plugin latency/error/quota metrics for the panel.
// - Per-tenant credentials: today config is a single global blob per plugin.
// A (pluginName, orgID/userID) → config store would let users connect their
// own third-party accounts.
// - Audit logging: record which plugin accessed what and when.
// - Sandboxing: the "external" plugin kind is the isolation story — run less
// trusted plugins as separate processes/containers behind the HTTP contract.
// - Hot-adding builtin Go code without a rebuild is intentionally unsupported
// (Go .so plugins are Linux-only and toolchain-fragile); use the external
// HTTP kind to add plugins at runtime instead.
+149
View File
@@ -0,0 +1,149 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"time"
)
// externalPlugin adapts a remote HTTP service to the Plugin contract. The remote
// side implements a tiny JSON contract:
//
// GET {baseURL}/manifest → { provider, version, capabilities, authType, configFields }
// GET {baseURL}/health → 2xx, optionally { status, detail }
// POST {baseURL}/invoke → { action, params } → arbitrary JSON (v1: unused)
//
// This is the "add a plugin without a rebuild" path: register a base URL at
// runtime and the server drives it over HTTP. It is also the sandboxing story —
// a less-trusted plugin runs as its own process/container.
type externalPlugin struct {
name string
baseURL string
desc Descriptor
client *http.Client
}
func newExternalPlugin(name, baseURL, provider string) *externalPlugin {
if provider == "" {
provider = "External"
}
return &externalPlugin{
name: name,
baseURL: baseURL,
client: &http.Client{Timeout: 8 * time.Second},
desc: Descriptor{
Name: name,
Provider: provider,
Version: "external",
Kind: KindExternal,
Category: CategoryAPIsExternal, // remote HTTP service; a manifest may override
AuthType: AuthNone,
},
}
}
func (e *externalPlugin) Descriptor() Descriptor { return e.desc }
// Init best-effort fetches the remote manifest to enrich the descriptor. A
// missing/broken manifest is non-fatal — the basic descriptor stands.
func (e *externalPlugin) Init(ctx context.Context, _ map[string]string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/manifest", nil)
if err != nil {
return nil
}
resp, err := e.client.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var man struct {
Provider string `json:"provider"`
Version string `json:"version"`
Category string `json:"category"`
Capabilities []Capability `json:"capabilities"`
AuthType AuthType `json:"authType"`
ConfigFields []ConfigField `json:"configFields"`
}
if json.Unmarshal(data, &man) == nil {
if man.Provider != "" {
e.desc.Provider = man.Provider
}
if man.Version != "" {
e.desc.Version = man.Version
}
if man.AuthType != "" {
e.desc.AuthType = man.AuthType
}
if man.Category != "" {
e.desc.Category = man.Category
}
e.desc.Capabilities = man.Capabilities
e.desc.ConfigFields = man.ConfigFields
}
return nil
}
func (e *externalPlugin) HealthCheck(ctx context.Context) Health {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, e.baseURL+"/health", nil)
if err != nil {
return Health{Status: StatusDown, Detail: err.Error()}
}
resp, err := e.client.Do(req)
lat := time.Since(start).Milliseconds()
if err != nil {
return Health{Status: StatusDown, LatencyMs: lat, Detail: err.Error()}
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
// Honour an explicit {status, detail} body when present.
var body struct {
Status string `json:"status"`
Detail string `json:"detail"`
}
_ = json.Unmarshal(data, &body)
h := Health{LatencyMs: lat, Detail: body.Detail}
switch {
case body.Status != "":
h.Status = body.Status
case resp.StatusCode >= 200 && resp.StatusCode < 300:
h.Status = StatusOK
case resp.StatusCode >= 500:
h.Status = StatusDown
default:
h.Status = StatusDegraded
}
if h.Detail == "" && h.Status != StatusOK {
h.Detail = "HTTP " + resp.Status
}
return h
}
// Invoke proxies to the remote /invoke endpoint. Part of the contract; no HTTP
// endpoint exposes it in v1.
func (e *externalPlugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
payload, _ := json.Marshal(map[string]any{"action": action, "params": params})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/invoke", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := e.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
return data, nil
}
func (e *externalPlugin) Shutdown(context.Context) error { return nil }
+346
View File
@@ -0,0 +1,346 @@
package plugins
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// secretMask is what a set secret value is echoed back as. On save, a field that
// still equals the mask is left unchanged (mirrors the pb-config password flow).
const secretMask = "••••••••"
// record is the persisted state for one plugin. For builtins, Kind/BaseURL are
// omitted (the descriptor comes from the registry); external plugins set them.
type record struct {
Kind string `json:"kind,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
Provider string `json:"provider,omitempty"`
Enabled bool `json:"enabled"`
Config map[string]string `json:"config,omitempty"`
}
// View is the plugin shape returned to the panel (secrets masked).
type View struct {
Descriptor
Enabled bool `json:"enabled"`
Config map[string]string `json:"config"`
BaseURL string `json:"baseURL,omitempty"`
Health *Health `json:"health,omitempty"`
}
// Manager owns the plugin registry, persisted state, and live instances.
type Manager struct {
path string
mu sync.Mutex
factories map[string]Factory
records map[string]*record
live map[string]Plugin
health map[string]*Health
client *http.Client
}
// NewManager builds a Manager backed by the JSON state file at path.
func NewManager(path string) *Manager {
return &Manager{
path: path,
factories: builtinFactories(),
records: map[string]*record{},
live: map[string]Plugin{},
health: map[string]*Health{},
client: &http.Client{Timeout: 12 * time.Second},
}
}
// Load reads the state file and initialises every enabled plugin. A missing file
// is fine (no plugins configured yet).
func (m *Manager) Load() error {
m.mu.Lock()
defer m.mu.Unlock()
if data, err := os.ReadFile(m.path); err == nil {
var recs map[string]*record
if err := json.Unmarshal(data, &recs); err != nil {
return err
}
m.records = recs
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
ctx := context.Background()
for name, rec := range m.records {
if !rec.Enabled {
continue
}
p := construct(name, m.factories[name], rec)
if p == nil {
log.Printf("plugins: cannot construct %q (unknown builtin?)", name)
continue
}
if err := p.Init(ctx, rec.Config); err != nil {
log.Printf("plugins: init %q failed: %v", name, err)
continue
}
m.live[name] = p
}
return nil
}
// construct builds a plugin instance from a builtin factory or an external record.
func construct(name string, f Factory, rec *record) Plugin {
if f != nil {
return f()
}
if rec != nil && rec.Kind == KindExternal {
return newExternalPlugin(name, rec.BaseURL, rec.Provider)
}
return nil
}
// descriptorFor returns a plugin's descriptor without needing a live instance.
func (m *Manager) descriptorFor(name string, rec *record) Descriptor {
if p := m.live[name]; p != nil {
return p.Descriptor()
}
if f := m.factories[name]; f != nil {
return f().Descriptor()
}
if rec != nil && rec.Kind == KindExternal {
return newExternalPlugin(name, rec.BaseURL, rec.Provider).Descriptor()
}
return Descriptor{Name: name}
}
// maskConfig echoes config back with secret fields masked when set.
func maskConfig(d Descriptor, cfg map[string]string) map[string]string {
out := map[string]string{}
for k, v := range cfg {
out[k] = v
}
for _, f := range d.ConfigFields {
if f.Secret && out[f.Key] != "" {
out[f.Key] = secretMask
}
}
return out
}
// List returns every known plugin (registry persisted), sorted by name.
func (m *Manager) List() []View {
m.mu.Lock()
defer m.mu.Unlock()
names := map[string]bool{}
for n := range m.factories {
names[n] = true
}
for n := range m.records {
names[n] = true
}
out := make([]View, 0, len(names))
for name := range names {
rec := m.records[name]
d := m.descriptorFor(name, rec)
v := View{Descriptor: d, Health: m.health[name]}
if rec != nil {
v.Enabled = rec.Enabled
v.BaseURL = rec.BaseURL
v.Config = maskConfig(d, rec.Config)
} else {
v.Config = map[string]string{}
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Get returns a single plugin view (ok=false when unknown).
func (m *Manager) Get(name string) (View, bool) {
for _, v := range m.List() {
if v.Name == name {
return v, true
}
}
return View{}, false
}
// Upsert enables/disables a plugin and merges its config, then (re)initialises or
// shuts down the live instance to match. Secrets left at the mask are preserved.
func (m *Manager) Upsert(ctx context.Context, name string, enabled bool, incoming map[string]string) (View, error) {
m.mu.Lock()
_, isBuiltin := m.factories[name]
rec := m.records[name]
if !isBuiltin && (rec == nil || rec.Kind != KindExternal) {
m.mu.Unlock()
return View{}, errUnknown
}
if rec == nil {
rec = &record{}
m.records[name] = rec
}
d := m.descriptorFor(name, rec)
merged := map[string]string{}
for k, v := range rec.Config {
merged[k] = v
}
// Apply incoming values, honouring the secret-mask keep-current rule.
secretKeys := map[string]bool{}
for _, f := range d.ConfigFields {
if f.Secret {
secretKeys[f.Key] = true
}
}
for k, v := range incoming {
if secretKeys[k] && v == secretMask {
continue // keep existing secret
}
merged[k] = strings.TrimSpace(v)
}
// Validate required fields when enabling.
if enabled {
for _, f := range d.ConfigFields {
if f.Required && merged[f.Key] == "" {
m.mu.Unlock()
return View{}, errors.New("missing required setting: " + f.Label)
}
}
}
rec.Enabled = enabled
rec.Config = merged
if err := m.persistLocked(); err != nil {
m.mu.Unlock()
return View{}, err
}
// Reconcile the live instance.
if old := m.live[name]; old != nil {
_ = old.Shutdown(ctx)
delete(m.live, name)
}
var initErr error
if enabled {
p := construct(name, m.factories[name], rec)
if p != nil {
if err := p.Init(ctx, merged); err != nil {
initErr = err
} else {
m.live[name] = p
}
}
}
m.mu.Unlock()
v, _ := m.Get(name)
return v, initErr
}
// RegisterExternal adds a new external (remote HTTP) plugin at runtime — the
// "add a plugin without a rebuild" path. It starts disabled.
func (m *Manager) RegisterExternal(name, baseURL, provider string) error {
name = strings.TrimSpace(name)
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if name == "" || baseURL == "" {
return errors.New("name and baseURL are required")
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
baseURL = "http://" + baseURL
}
m.mu.Lock()
defer m.mu.Unlock()
if _, dup := m.factories[name]; dup {
return errors.New("a builtin plugin already uses that name")
}
if _, dup := m.records[name]; dup {
return errors.New("a plugin with that name already exists")
}
m.records[name] = &record{Kind: KindExternal, BaseURL: baseURL, Provider: provider}
return m.persistLocked()
}
// Remove deletes an external plugin registration. Builtins can only be disabled.
func (m *Manager) Remove(ctx context.Context, name string) error {
m.mu.Lock()
defer m.mu.Unlock()
rec := m.records[name]
if rec == nil || rec.Kind != KindExternal {
return errors.New("only external plugins can be removed")
}
if p := m.live[name]; p != nil {
_ = p.Shutdown(ctx)
delete(m.live, name)
}
delete(m.records, name)
delete(m.health, name)
return m.persistLocked()
}
// HealthCheck probes a plugin now, building a transient instance if it is not
// currently live (so disabled plugins can still be tested). Result is cached.
func (m *Manager) HealthCheck(ctx context.Context, name string) (Health, error) {
m.mu.Lock()
p := m.live[name]
transient := false
var cfg map[string]string
if p == nil {
rec := m.records[name]
if rec != nil {
cfg = rec.Config
}
p = construct(name, m.factories[name], rec)
transient = true
}
m.mu.Unlock()
if p == nil {
return Health{}, errUnknown
}
if transient {
_ = p.Init(ctx, cfg)
defer func() { _ = p.Shutdown(context.Background()) }()
}
h := p.HealthCheck(ctx)
m.mu.Lock()
hc := h
m.health[name] = &hc
m.mu.Unlock()
return h, nil
}
// Shutdown tears down every live plugin instance. Wire into graceful shutdown.
func (m *Manager) Shutdown(ctx context.Context) {
m.mu.Lock()
defer m.mu.Unlock()
for name, p := range m.live {
_ = p.Shutdown(ctx)
delete(m.live, name)
}
}
// persistLocked writes the state file. Caller must hold m.mu.
func (m *Manager) persistLocked() error {
data, err := json.MarshalIndent(m.records, "", " ")
if err != nil {
return err
}
return os.WriteFile(m.path, append(data, '\n'), 0o600)
}
var errUnknown = errors.New("unknown plugin")
// IsUnknown reports whether err came from addressing a plugin that doesn't exist.
func IsUnknown(err error) bool { return errors.Is(err, errUnknown) }
+181
View File
@@ -0,0 +1,181 @@
// Package plugins is the API Server's plugin system: a uniform contract for
// integrating external third-party services (vehicle data, parts catalogs,
// notifications, file storage, …).
//
// Two plugin kinds share one contract:
// - "builtin" — a Go connector compiled into the server (type-safe, first-party).
// Adding a new builtin requires a rebuild. See builtin/builtin.go.
// - "external" — a remote service registered at runtime (no rebuild) that speaks
// a small JSON contract over HTTP. See external.go.
//
// Enable-state and per-plugin config (including secrets) are persisted to a local
// plugins.json by the Manager, mirroring how the PocketBase connection persists to
// .env. See doc.go for the deliberately-deferred extension points.
package plugins
import (
"context"
"encoding/json"
)
// Plugin kinds.
const (
KindBuiltin = "builtin"
KindExternal = "external"
)
// AuthType describes how a plugin authenticates to its upstream. It is metadata
// for the UI/operators; each plugin implements the mechanics itself.
type AuthType string
const (
AuthNone AuthType = "none"
AuthAPIKey AuthType = "apikey"
AuthBasic AuthType = "basic"
AuthOAuth2 AuthType = "oauth2"
AuthWebhook AuthType = "webhook"
)
// Health status values.
const (
StatusOK = "ok"
StatusDegraded = "degraded"
StatusDown = "down"
)
// SelectOption is one choice for a ConfigField of Type "select".
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ConfigField declares one configurable setting a plugin accepts. It drives the
// panel's generated config form and controls secret masking.
type ConfigField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"` // "text" | "password" | "number" | "select"
Required bool `json:"required"`
Secret bool `json:"secret"` // never echoed back to clients in clear
Help string `json:"help,omitempty"`
Default string `json:"default,omitempty"` // effective default when unset
Options []SelectOption `json:"options,omitempty"` // for Type "select"
}
// Capability is one operation a plugin exposes. It maps a stable id to the
// upstream endpoint it calls and a human description shown in the panel.
type Capability struct {
ID string `json:"id"`
Method string `json:"method,omitempty"` // e.g. "GET"
Endpoint string `json:"endpoint,omitempty"` // upstream path, e.g. "/states/all"
Description string `json:"description,omitempty"`
}
// UnmarshalJSON accepts either a bare string ("states.all") or a full object, so
// external manifests can advertise capabilities in either form.
func (c *Capability) UnmarshalJSON(b []byte) error {
var s string
if json.Unmarshal(b, &s) == nil {
c.ID = s
return nil
}
type alias Capability
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*c = Capability(a)
return nil
}
// Category groups a plugin under a tab in the admin panel. A plugin with an
// empty category is treated as CategoryAPIsExternal by the panel.
const (
CategoryAPIsExternal = "apis-external" // remote HTTP APIs (external plugins)
CategoryDrivesExternal = "drives-external" // remote file stores (FTP/SFTP)
CategoryDrivesLocal = "drives-local" // drives on the host machine
)
// Descriptor is the static metadata a plugin advertises about itself.
type Descriptor struct {
Name string `json:"name"`
Provider string `json:"provider"`
Version string `json:"version"`
Kind string `json:"kind"` // KindBuiltin | KindExternal
Category string `json:"category"` // one of Category* — groups the plugin in the panel
Capabilities []Capability `json:"capabilities"`
AuthType AuthType `json:"authType"`
ConfigFields []ConfigField `json:"configFields"`
}
// Health is the outcome of a plugin's HealthCheck.
type Health struct {
Status string `json:"status"` // StatusOK | StatusDegraded | StatusDown
LatencyMs int64 `json:"latencyMs,omitempty"`
Detail string `json:"detail,omitempty"`
Credits *HealthCredits `json:"credits,omitempty"`
Usage *HealthUsage `json:"usage,omitempty"`
}
// HealthUsage is optional call-usage accounting a plugin may report when its
// upstream does NOT expose remaining quota (e.g. OpenWeather). Unlike
// HealthCredits — which reflects a balance the upstream reports — these are
// process-local counts of the calls this server has made, bucketed into the
// current minute and day, so the UI can render an approximate usage gauge.
type HealthUsage struct {
MinuteUsed int `json:"minuteUsed"` // calls made in the current minute
MinuteLimit int `json:"minuteLimit,omitempty"` // the plan's per-minute limit
DayUsed int `json:"dayUsed"` // calls made so far today (UTC)
}
// HealthCredits is optional structured rate-limit/credit accounting a plugin may
// report alongside a probe, when its upstream exposes a remaining balance. It
// lets the UI render a dedicated usage meter instead of parsing it back out of
// Detail.
type HealthCredits struct {
Remaining *int `json:"remaining,omitempty"` // credits left today; nil when the upstream didn't report it (e.g. anonymous)
Daily int `json:"daily,omitempty"` // the plan's daily allowance
ProbeCost int `json:"probeCost,omitempty"` // credits one query/probe costs
Mode string `json:"mode,omitempty"` // "authenticated" | "anonymous"
}
// Plugin is the contract every plugin (builtin or external) implements.
type Plugin interface {
// Descriptor returns the plugin's static metadata. It may be enriched after
// Init (e.g. an external plugin fetching its manifest).
Descriptor() Descriptor
// Init prepares the plugin with its resolved config (secrets included). It is
// called when the plugin is enabled or its config changes.
Init(ctx context.Context, config map[string]string) error
// HealthCheck probes the upstream and classifies the result.
HealthCheck(ctx context.Context) Health
// Invoke runs a named capability. Part of the contract for future use; v1
// exposes no HTTP endpoint for it.
Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error)
// Shutdown releases any resources held by the plugin.
Shutdown(ctx context.Context) error
}
// Factory builds a fresh instance of a builtin plugin.
type Factory func() Plugin
// registry holds the builtin plugin factories keyed by descriptor name.
var registry = map[string]Factory{}
// Register adds a builtin plugin factory. Called from a builtin package's init().
// Panics on a duplicate name so wiring mistakes surface at startup.
func Register(name string, f Factory) {
if _, dup := registry[name]; dup {
panic("plugins: duplicate registration for " + name)
}
registry[name] = f
}
// builtinFactories returns a copy of the registered builtin factories.
func builtinFactories() map[string]Factory {
out := make(map[string]Factory, len(registry))
for k, v := range registry {
out[k] = v
}
return out
}