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:
tajniak81andClaude Opus 4.8 committed 2026-07-16 22:29:45 +02:00
1 parent 7d55f0a4cd
commit ae6ed4ac1e
56 files changed
+4474 -1475

No files matched your search

-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.
+17
View File
@@ -0,0 +1,17 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const o of i)if(o.type==="childList")for(const r of o.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function s(i){const o={};return i.integrity&&(o.integrity=i.integrity),i.referrerPolicy&&(o.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?o.credentials="include":i.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function n(i){if(i.ep)return;i.ep=!0;const o=s(i);fetch(i.href,o)}})();/**
* @vue/shared v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function on(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const J={},xt=[],He=()=>{},ui=()=>!1,xs=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ws=e=>e.startsWith("onUpdate:"),de=Object.assign,rn=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},wo=Object.prototype.hasOwnProperty,W=(e,t)=>wo.call(e,t),U=Array.isArray,wt=e=>Zt(e)==="[object Map]",At=e=>Zt(e)==="[object Set]",$n=e=>Zt(e)==="[object Date]",H=e=>typeof e=="function",ee=e=>typeof e=="string",Ve=e=>typeof e=="symbol",z=e=>e!==null&&typeof e=="object",fi=e=>(z(e)||H(e))&&H(e.then)&&H(e.catch),di=Object.prototype.toString,Zt=e=>di.call(e),So=e=>Zt(e).slice(8,-1),pi=e=>Zt(e)==="[object Object]",ln=e=>ee(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,Nt=on(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},To=/-\w/g,Ce=Ss(e=>e.replace(To,t=>t.slice(1).toUpperCase())),Co=/\B([A-Z])/g,rt=Ss(e=>e.replace(Co,"-$1").toLowerCase()),hi=Ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),Is=Ss(e=>e?`on${hi(e)}`:""),Ne=(e,t)=>!Object.is(e,t),us=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},gi=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Ts=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let An;const Cs=()=>An||(An=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function an(e){if(U(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],i=ee(n)?$o(n):an(n);if(i)for(const o in i)t[o]=i[o]}return t}else if(ee(e)||z(e))return e}const Eo=/;(?![^(]*\))/g,ko=/:([^]+)/,Po=/\/\*[^]*?\*\//g;function $o(e){const t={};return e.replace(Po,"").split(Eo).forEach(s=>{if(s){const n=s.split(ko);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function je(e){let t="";if(ee(e))t=e;else if(U(e))for(let s=0;s<e.length;s++){const n=je(e[s]);n&&(t+=n+" ")}else if(z(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const Ao="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Oo=on(Ao);function mi(e){return!!e||e===""}function Mo(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=nt(e[n],t[n]);return s}function nt(e,t){if(e===t)return!0;let s=$n(e),n=$n(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Ve(e),n=Ve(t),s||n)return e===t;if(s=U(e),n=U(t),s||n)return s&&n?Mo(e,t):!1;if(s=z(e),n=z(t),s||n){if(!s||!n)return!1;const i=Object.keys(e).length,o=Object.keys(t).length;if(i!==o)return!1;for(const r in e){const l=e.hasOwnProperty(r),a=t.hasOwnProperty(r);if(l&&!a||!l&&a||!nt(e[r],t[r]))return!1}}return String(e)===String(t)}function cn(e,t){return e.findIndex(s=>nt(s,t))}const bi=e=>!!(e&&e.__v_isRef===!0),D=e=>ee(e)?e:e==null?"":U(e)||z(e)&&(e.toString===di||!H(e.toString))?bi(e)?D(e.value):JSON.stringify(e,vi,2):String(e),vi=(e,t)=>bi(t)?vi(e,t.value):wt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,i],o)=>(s[Ds(n,o)+" =>"]=i,s),{})}:At(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>Ds(s))}:Ve(t)?Ds(t):z(t)&&!U(t)&&!pi(t)?String(t):t,Ds=(e,t="")=>{var s;return Ve(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
* @vue/reactivity v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let re;class Ro{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&re&&(re.active?(this.parent=re,this.index=(re.scopes||(re.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=re;try{return re=this,t()}finally{re=s}}}on(){++this._on===1&&(this.prevScope=re,re=this)}off(){if(this._on>0&&--this._on===0){if(re===this)re=this.prevScope;else{let t=re;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const i=this.parent.scopes.pop();i&&i!==this&&(this.parent.scopes[this.index]=i,i.index=this.index)}this.parent=void 0}}}function Io(){return re}let X;const Fs=new WeakSet;class _i{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,re&&(re.active?re.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,Fs.has(this)&&(Fs.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||xi(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,On(this),wi(this);const t=X,s=Ee;X=this,Ee=!0;try{return this.fn()}finally{Si(this),X=t,Ee=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)dn(t);this.deps=this.depsTail=void 0,On(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?Fs.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Gs(this)&&this.run()}get dirty(){return Gs(this)}}let yi=0,Ht,Vt;function xi(e,t=!1){if(e.flags|=8,t){e.next=Vt,Vt=e;return}e.next=Ht,Ht=e}function un(){yi++}function fn(){if(--yi>0)return;if(Vt){let t=Vt;for(Vt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;Ht;){let t=Ht;for(Ht=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function wi(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Si(e){let t,s=e.depsTail,n=s;for(;n;){const i=n.prevDep;n.version===-1?(n===s&&(s=i),dn(n),Do(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=i}e.deps=t,e.depsTail=s}function Gs(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Ti(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Ti(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Gt)||(e.globalVersion=Gt,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Gs(e))))return;e.flags|=2;const t=e.dep,s=X,n=Ee;X=e,Ee=!0;try{wi(e);const i=e.fn(e._value);(t.version===0||Ne(i,e._value))&&(e.flags|=128,e._value=i,t.version++)}catch(i){throw t.version++,i}finally{X=s,Ee=n,Si(e),e.flags&=-3}}function dn(e,t=!1){const{dep:s,prevSub:n,nextSub:i}=e;if(n&&(n.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let o=s.computed.deps;o;o=o.nextDep)dn(o,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function Do(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let Ee=!0;const Ci=[];function Be(){Ci.push(Ee),Ee=!1}function Ke(){const e=Ci.pop();Ee=e===void 0?!0:e}function On(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=X;X=void 0;try{t()}finally{X=s}}}let Gt=0;class Fo{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class pn{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!X||!Ee||X===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==X)s=this.activeLink=new Fo(X,this),X.deps?(s.prevDep=X.depsTail,X.depsTail.nextDep=s,X.depsTail=s):X.deps=X.depsTail=s,Ei(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=X.depsTail,s.nextDep=void 0,X.depsTail.nextDep=s,X.depsTail=s,X.deps===s&&(X.deps=n)}return s}trigger(t){this.version++,Gt++,this.notify(t)}Line truncated
* @vue/runtime-core v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function Qt(e,t,s,n){try{return n?e(...n):e()}catch(i){ks(i,t,s)}}function Pe(e,t,s,n){if(H(e)){const i=Qt(e,t,s,n);return i&&fi(i)&&i.catch(o=>{ks(o,t,s)}),i}if(U(e)){const i=[];for(let o=0;o<e.length;o++)i.push(Pe(e[o],t,s,n));return i}}function ks(e,t,s,n=!0){const i=t?t.vnode:null,{errorHandler:o,throwUnhandledErrorInProduction:r}=t&&t.appContext.config||J;if(t){let l=t.parent;const a=t.proxy,p=`https://vuejs.org/error-reference/#runtime-${s}`;for(;l;){const u=l.ec;if(u){for(let h=0;h<u.length;h++)if(u[h](e,a,p)===!1)return}l=l.parent}if(o){Be(),Qt(o,null,10,[e,a,p]),Ke();return}}lr(e,s,i,n,r)}function lr(e,t,s,n=!0,i=!1){if(i)throw e;console.error(e)}const ge=[];let Fe=-1;const St=[];let st=null,yt=0;const Ii=Promise.resolve();let hs=null;function Di(e){const t=hs||Ii;return e?t.then(this?e.bind(this):e):t}function ar(e){let t=Fe+1,s=ge.length;for(;t<s;){const n=t+s>>>1,i=ge[n],o=Jt(i);o<e||o===e&&i.flags&2?t=n+1:s=n}return t}function bn(e){if(!(e.flags&1)){const t=Jt(e),s=ge[ge.length-1];!s||!(e.flags&2)&&t>=Jt(s)?ge.push(e):ge.splice(ar(t),0,e),e.flags|=1,Fi()}}function Fi(){hs||(hs=Ii.then(Ui))}function cr(e){U(e)?St.push(...e):st&&e.id===-1?st.splice(yt+1,0,e):e.flags&1||(St.push(e),e.flags|=1),Fi()}function Rn(e,t,s=Fe+1){for(;s<ge.length;s++){const n=ge[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;ge.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function Li(e){if(St.length){const t=[...new Set(St)].sort((s,n)=>Jt(s)-Jt(n));if(St.length=0,st){st.push(...t);return}for(st=t,yt=0;yt<st.length;yt++){const s=st[yt];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}st=null,yt=0}}const Jt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function Ui(e){try{for(Fe=0;Fe<ge.length;Fe++){const t=ge[Fe];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),Qt(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Fe<ge.length;Fe++){const t=ge[Fe];t&&(t.flags&=-2)}Fe=-1,ge.length=0,Li(),hs=null,(ge.length||St.length)&&Ui()}}let xe=null,Ni=null;function gs(e){const t=xe;return xe=e,Ni=e&&e.type.__scopeId||null,t}function ur(e,t=xe,s){if(!t||e._n)return e;const n=(...i)=>{n._d&&Kn(-1);const o=gs(t);let r;try{r=e(...i)}finally{gs(o),n._d&&Kn(1)}return r};return n._n=!0,n._c=!0,n._d=!0,n}function ue(e,t){if(xe===null)return e;const s=Os(xe),n=e.dirs||(e.dirs=[]);for(let i=0;i<t.length;i++){let[o,r,l,a=J]=t[i];o&&(H(o)&&(o={mounted:o,updated:o}),o.deep&&Je(r),n.push({dir:o,instance:s,value:r,oldValue:void 0,arg:l,modifiers:a}))}return e}function ut(e,t,s,n){const i=e.dirs,o=t&&t.dirs;for(let r=0;r<i.length;r++){const l=i[r];o&&(l.oldValue=o[r].value);let a=l.dir[n];a&&(Be(),Pe(a,s,8,[e.el,l,e,t]),Ke())}}function fr(e,t){if(me){let s=me.provides;const n=me.parent&&me.parent.provides;n===s&&(s=me.provides=Object.create(n)),s[e]=t}}function fs(e,t,s=!1){const n=al();if(n||Tt){let i=Tt?Tt._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(i&&e in i)return i[e];if(arguments.length>1)return s&&H(t)?t.call(n&&n.proxy):t}}const dr=Symbol.for("v-scx"),pr=()=>fs(dr);function Ns(e,t,s){return Hi(e,t,s)}function Hi(e,t,s=J){const{immediate:n,deep:i,flush:o,once:r}=s,l=de({},s),a=t&&n||!t&&o!=="post";let p;if(Xt){if(o==="sync"){const $=pr();p=$.__watcherHandles||($.__watcherHandles=[])}else if(!a){const $=()=>{};return $.stop=He,$.resume=He,$.pause=He,$}}const u=me;l.call=($,F,y)=>Pe($,u,F,y);let h=!1;o==="post"?l.scheduler=$=>{be($,u&&u.suspense)}:o!=="sync"&&(h=!0,l.scheduler=($,F)=>{F?$():bn($)}),l.augmentJob=$=>{t&&($.flags|=4),h&&($.flags|=2,u&&($.id=u.uid,$.i=u))};const T=rr(e,t,l);return Xt&&(p?p.push(T):a&&T()),T}function hr(e,t,s){const n=this.proxy,i=ee(e)?e.includes(".")?Vi(n,e):()=>n[e]:e.bind(n,n);let o;H(t)?o=t:(o=t.handler,s=t);const r=es(this),l=Hi(i,o.bind(n),s);return r(),l}function Vi(e,t){const s=t.split(".");return()=>{let n=e;for(let i=0;i<s.length&&n;i++)n=n[s[i]];return n}}const gr=Symbol("_vte"),mr=e=>e.__isTeleport,Hs=Symbol("_leaveCb");function vn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,vn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function ji(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function In(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const ms=new WeakMap;function jt(e,t,s,n,i=!1){if(U(e)){e.forEach((y,S)=>jt(y,t&&(U(t)?t[S]:t),s,n,i));return}if(Bt(n)&&!i){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&jt(e,t,s,n.component.subTree);return}const o=n.shapeFlag&4?Os(n.component):n.el,r=i?null:o,{i:l,r:a}=e,p=t&&t.r,u=l.refs===J?l.refs={}:l.refs,h=l.setupState,T=K(h),$=h===J?ui:y=>In(u,y)?!1:W(T,y),F=(y,S)=>!(S&&In(u,S));if(p!=null&&p!==a){if(Dn(t),ee(p))u[p]=null,$(p)&&(h[p]=null);else if(fe(p)){const y=t;F(p,y.k)&&(p.value=null),y.k&&(u[y.k]=null)}}if(H(a)){Be();try{Qt(a,l,12,[r,u])}finally{Ke()}}else{const y=ee(a),S=fe(a);if(y||S){const x=()=>{if(e.f){con Line truncated
* @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let sn;const zn=typeof window<"u"&&window.trustedTypes;if(zn)try{sn=zn.createPolicy("vue",{createHTML:e=>e})}catch{}const ho=sn?e=>sn.createHTML(e):e=>e,gl="http://www.w3.org/2000/svg",ml="http://www.w3.org/1998/Math/MathML",Ge=typeof document<"u"?document:null,qn=Ge&&Ge.createElement("template"),bl={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const i=t==="svg"?Ge.createElementNS(gl,e):t==="mathml"?Ge.createElementNS(ml,e):s?Ge.createElement(e,{is:s}):Ge.createElement(e);return e==="select"&&n&&n.multiple!=null&&i.setAttribute("multiple",n.multiple),i},createText:e=>Ge.createTextNode(e),createComment:e=>Ge.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Ge.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,i,o){const r=s?s.previousSibling:t.lastChild;if(i&&(i===o||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),s),!(i===o||!(i=i.nextSibling)););else{qn.innerHTML=ho(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=qn.content;if(n==="svg"||n==="mathml"){const a=l.firstChild;for(;a.firstChild;)l.appendChild(a.firstChild);l.removeChild(a)}t.insertBefore(l,s)}return[r?r.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},vl=Symbol("_vtc");function _l(e,t,s){const n=e[vl];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const Jn=Symbol("_vod"),yl=Symbol("_vsh"),xl=Symbol(""),wl=/(?:^|;)\s*display\s*:/;function Sl(e,t,s){const n=e.style,i=ee(s);let o=!1;if(s&&!i){if(t)if(ee(t))for(const r of t.split(";")){const l=r.slice(0,r.indexOf(":")).trim();s[l]==null&&Ut(n,l,"")}else for(const r in t)s[r]==null&&Ut(n,r,"");for(const r in s){r==="display"&&(o=!0);const l=s[r];l!=null?Cl(e,r,!ee(t)&&t?t[r]:void 0,l)||Ut(n,r,l):Ut(n,r,"")}}else if(i){if(t!==s){const r=n[xl];r&&(s+=";"+r),n.cssText=s,o=wl.test(s)}}else t&&e.removeAttribute("style");Jn in e&&(e[Jn]=o?n.display:"",e[yl]&&(n.display="none"))}const Yn=/\s*!important$/;function Ut(e,t,s){if(U(s))s.forEach(n=>Ut(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=Tl(e,t);Yn.test(s)?e.setProperty(rt(n),s.replace(Yn,""),"important"):e[n]=s}}const Xn=["Webkit","Moz","ms"],Ks={};function Tl(e,t){const s=Ks[t];if(s)return s;let n=Ce(t);if(n!=="filter"&&n in e)return Ks[t]=n;n=hi(n);for(let i=0;i<Xn.length;i++){const o=Xn[i]+n;if(o in e)return Ks[t]=o}return t}function Cl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&ee(n)&&s===n}const Zn="http://www.w3.org/1999/xlink";function Qn(e,t,s,n,i,o=Oo(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Zn,t.slice(6,t.length)):e.setAttributeNS(Zn,t,s):s==null||o&&!mi(s)?e.removeAttribute(t):e.setAttribute(t,o?"":Ve(s)?String(s):s)}function ei(e,t,s,n,i){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?ho(s):s);return}const o=e.tagName;if(t==="value"&&o!=="PROGRESS"&&!o.includes("-")){const l=o==="OPTION"?e.getAttribute("value")||"":e.value,a=s==null?e.type==="checkbox"?"on":"":String(s);(l!==a||!("_value"in e))&&(e.value=a),s==null&&e.removeAttribute(t),e._value=s;return}let r=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=mi(s):s==null&&l==="string"?(s="",r=!0):l==="number"&&(s=0,r=!0)}try{e[t]=s}catch{}r&&e.removeAttribute(i||t)}function Ye(e,t,s,n){e.addEventListener(t,s,n)}function El(e,t,s,n){e.removeEventListener(t,s,n)}const ti=Symbol("_vei");function kl(e,t,s,n,i=null){const o=e[ti]||(e[ti]={}),r=o[t];if(n&&r)r.value=n;else{const[l,a]=Al(t);if(n){const p=o[t]=Rl(n,i);Ye(e,l,p,a)}else r&&(El(e,l,r,a),o[t]=void 0)}}const Pl=/(Once|Passive|Capture)$/,$l=/^on:?(?:Once|Passive|Capture)$/;function Al(e){let t,s;for(;(s=e.match(Pl))&&!$l.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):rt(e.slice(2)),t]}let Ws=0;const Ol=Promise.resolve(),Ml=()=>Ws||(Ol.then(()=>Ws=0),Ws=Date.now());function Rl(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const i=s.value;if(U(i)){const o=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{o.call(n),n._stopped=!0};const r=i.slice(),l=[n];for(let a=0;a<r.length&&!n._stopped;a++){const p=r[a];p&&Pe(p,t,5,l)}}else Pe(i,t,5,[n])};return s.value=e,s.attached=Ml(),s}const si=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,Il=(e,t,s,n,i,o)=>{const r=i==="svg";t==="class"?_l(e,n,r):t==="style"?Sl(e,s,n):xs(t)?ws(t)||kl(e,t,s,n,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):Dl(e,t,n,r))?(ei(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Qn(e,t,n,r,o,t!=="value")):e._isVueCE&&(Fl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!ee(n)))?ei(e,Ce(t),n,o,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._fals Line truncated
@@ -1 +0,0 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.flex{display:flex}.inline-flex{display:inlLine truncated
@@ -0,0 +1 @@
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial}}}@layer theme{:root,:host{--font-sans:var(--font-sans);--font-mono:var(--font-mono);--spacing:.25rem;--container-sm:24rem;--container-4xl:56rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-extrabold:800;--shadow-xs:var(--shadow-xs);--shadow-sm:var(--shadow-sm);--shadow-md:0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--radius-control:12px;--radius-card:18px;--radius-pill:999px}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-Line truncated
-17
View File
@@ -1,17 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const i of r)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function s(r){const i={};return r.integrity&&(i.integrity=r.integrity),r.referrerPolicy&&(i.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?i.credentials="include":r.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(r){if(r.ep)return;r.ep=!0;const i=s(r);fetch(r.href,i)}})();/**
* @vue/shared v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function Ls(e){const t=Object.create(null);for(const s of e.split(","))t[s]=1;return s=>s in t}const V={},nt=[],Pe=()=>{},jn=()=>!1,es=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ts=e=>e.startsWith("onUpdate:"),se=Object.assign,Hs=(e,t)=>{const s=e.indexOf(t);s>-1&&e.splice(s,1)},qr=Object.prototype.hasOwnProperty,$=(e,t)=>qr.call(e,t),R=Array.isArray,rt=e=>It(e)==="[object Map]",$n=e=>It(e)==="[object Set]",ln=e=>It(e)==="[object Date]",F=e=>typeof e=="function",J=e=>typeof e=="string",Me=e=>typeof e=="symbol",U=e=>e!==null&&typeof e=="object",Nn=e=>(U(e)||F(e))&&F(e.then)&&F(e.catch),Un=Object.prototype.toString,It=e=>Un.call(e),Jr=e=>It(e).slice(8,-1),Wn=e=>It(e)==="[object Object]",js=e=>J(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,xt=Ls(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),ss=e=>{const t=Object.create(null);return(s=>t[s]||(t[s]=e(s)))},Yr=/-\w/g,de=ss(e=>e.replace(Yr,t=>t.slice(1).toUpperCase())),zr=/\B([A-Z])/g,et=ss(e=>e.replace(zr,"-$1").toLowerCase()),Bn=ss(e=>e.charAt(0).toUpperCase()+e.slice(1)),as=ss(e=>e?`on${Bn(e)}`:""),Ce=(e,t)=>!Object.is(e,t),ds=(e,...t)=>{for(let s=0;s<e.length;s++)e[s](...t)},Kn=(e,t,s,n=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:n,value:s})},Xr=e=>{const t=parseFloat(e);return isNaN(t)?e:t};let cn;const ns=()=>cn||(cn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function $s(e){if(R(e)){const t={};for(let s=0;s<e.length;s++){const n=e[s],r=J(n)?ti(n):$s(n);if(r)for(const i in r)t[i]=r[i]}return t}else if(J(e)||U(e))return e}const Zr=/;(?![^(]*\))/g,Qr=/:([^]+)/,ei=/\/\*[^]*?\*\//g;function ti(e){const t={};return e.replace(ei,"").split(Zr).forEach(s=>{if(s){const n=s.split(Qr);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function Ft(e){let t="";if(J(e))t=e;else if(R(e))for(let s=0;s<e.length;s++){const n=Ft(e[s]);n&&(t+=n+" ")}else if(U(e))for(const s in e)e[s]&&(t+=s+" ");return t.trim()}const si="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",ni=Ls(si);function Vn(e){return!!e||e===""}function ri(e,t){if(e.length!==t.length)return!1;let s=!0;for(let n=0;s&&n<e.length;n++)s=Ns(e[n],t[n]);return s}function Ns(e,t){if(e===t)return!0;let s=ln(e),n=ln(t);if(s||n)return s&&n?e.getTime()===t.getTime():!1;if(s=Me(e),n=Me(t),s||n)return e===t;if(s=R(e),n=R(t),s||n)return s&&n?ri(e,t):!1;if(s=U(e),n=U(t),s||n){if(!s||!n)return!1;const r=Object.keys(e).length,i=Object.keys(t).length;if(r!==i)return!1;for(const o in e){const l=e.hasOwnProperty(o),f=t.hasOwnProperty(o);if(l&&!f||!l&&f||!Ns(e[o],t[o]))return!1}}return String(e)===String(t)}const Gn=e=>!!(e&&e.__v_isRef===!0),Ae=e=>J(e)?e:e==null?"":R(e)||U(e)&&(e.toString===Un||!F(e.toString))?Gn(e)?Ae(e.value):JSON.stringify(e,kn,2):String(e),kn=(e,t)=>Gn(t)?kn(e,t.value):rt(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((s,[n,r],i)=>(s[hs(n,i)+" =>"]=r,s),{})}:$n(t)?{[`Set(${t.size})`]:[...t.values()].map(s=>hs(s))}:Me(t)?hs(t):U(t)&&!R(t)&&!Wn(t)?String(t):t,hs=(e,t="")=>{var s;return Me(e)?`Symbol(${(s=e.description)!=null?s:t})`:e};/**
* @vue/reactivity v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let Z;class ii{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!t&&Z&&(Z.active?(this.parent=Z,this.index=(Z.scopes||(Z.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].pause();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].pause()}}resume(){if(this._active&&this._isPaused){this._isPaused=!1;let t,s;if(this.scopes)for(t=0,s=this.scopes.length;t<s;t++)this.scopes[t].resume();for(t=0,s=this.effects.length;t<s;t++)this.effects[t].resume()}}run(t){if(this._active){const s=Z;try{return Z=this,t()}finally{Z=s}}}on(){++this._on===1&&(this.prevScope=Z,Z=this)}off(){if(this._on>0&&--this._on===0){if(Z===this)Z=this.prevScope;else{let t=Z;for(;t;){if(t.prevScope===this){t.prevScope=this.prevScope;break}t=t.prevScope}}this.prevScope=void 0}}stop(t){if(this._active){this._active=!1;let s,n;for(s=0,n=this.effects.length;s<n;s++)this.effects[s].stop();for(this.effects.length=0,s=0,n=this.cleanups.length;s<n;s++)this.cleanups[s]();if(this.cleanups.length=0,this.scopes){for(s=0,n=this.scopes.length;s<n;s++)this.scopes[s].stop(!0);this.scopes.length=0}if(!this.detached&&this.parent&&!t){const r=this.parent.scopes.pop();r&&r!==this&&(this.parent.scopes[this.index]=r,r.index=this.index)}this.parent=void 0}}}function oi(){return Z}let K;const ps=new WeakSet;class qn{constructor(t){this.fn=t,this.deps=void 0,this.depsTail=void 0,this.flags=5,this.next=void 0,this.cleanup=void 0,this.scheduler=void 0,Z&&(Z.active?Z.effects.push(this):this.flags&=-2)}pause(){this.flags|=64}resume(){this.flags&64&&(this.flags&=-65,ps.has(this)&&(ps.delete(this),this.trigger()))}notify(){this.flags&2&&!(this.flags&32)||this.flags&8||Yn(this)}run(){if(!(this.flags&1))return this.fn();this.flags|=2,fn(this),zn(this);const t=K,s=he;K=this,he=!0;try{return this.fn()}finally{Xn(this),K=t,he=s,this.flags&=-3}}stop(){if(this.flags&1){for(let t=this.deps;t;t=t.nextDep)Bs(t);this.deps=this.depsTail=void 0,fn(this),this.onStop&&this.onStop(),this.flags&=-2}}trigger(){this.flags&64?ps.add(this):this.scheduler?this.scheduler():this.runIfDirty()}runIfDirty(){Ss(this)&&this.run()}get dirty(){return Ss(this)}}let Jn=0,yt,wt;function Yn(e,t=!1){if(e.flags|=8,t){e.next=wt,wt=e;return}e.next=yt,yt=e}function Us(){Jn++}function Ws(){if(--Jn>0)return;if(wt){let t=wt;for(wt=void 0;t;){const s=t.next;t.next=void 0,t.flags&=-9,t=s}}let e;for(;yt;){let t=yt;for(yt=void 0;t;){const s=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(n){e||(e=n)}t=s}}if(e)throw e}function zn(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Xn(e){let t,s=e.depsTail,n=s;for(;n;){const r=n.prevDep;n.version===-1?(n===s&&(s=r),Bs(n),li(n)):t=n,n.dep.activeLink=n.prevActiveLink,n.prevActiveLink=void 0,n=r}e.deps=t,e.depsTail=s}function Ss(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Zn(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Zn(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===At)||(e.globalVersion=At,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ss(e))))return;e.flags|=2;const t=e.dep,s=K,n=he;K=e,he=!0;try{zn(e);const r=e.fn(e._value);(t.version===0||Ce(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{K=s,he=n,Xn(e),e.flags&=-3}}function Bs(e,t=!1){const{dep:s,prevSub:n,nextSub:r}=e;if(n&&(n.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=n,e.nextSub=void 0),s.subs===e&&(s.subs=n,!n&&s.computed)){s.computed.flags&=-5;for(let i=s.computed.deps;i;i=i.nextDep)Bs(i,!0)}!t&&!--s.sc&&s.map&&s.map.delete(s.key)}function li(e){const{prevDep:t,nextDep:s}=e;t&&(t.nextDep=s,e.prevDep=void 0),s&&(s.prevDep=t,e.nextDep=void 0)}let he=!0;const Qn=[];function Re(){Qn.push(he),he=!1}function Ie(){const e=Qn.pop();he=e===void 0?!0:e}function fn(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const s=K;K=void 0;try{t()}finally{K=s}}}let At=0;class ci{constructor(t,s){this.sub=t,this.dep=s,this.version=s.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class Ks{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!K||!he||K===this.computed)return;let s=this.activeLink;if(s===void 0||s.sub!==K)s=this.activeLink=new ci(K,this),K.deps?(s.prevDep=K.depsTail,K.depsTail.nextDep=s,K.depsTail=s):K.deps=K.depsTail=s,er(s);else if(s.version===-1&&(s.version=this.version,s.nextDep)){const n=s.nextDep;n.prevDep=s.prevDep,s.prevDep&&(s.prevDep.nextDep=n),s.prevDep=K.depsTail,s.nextDep=void 0,K.depsTail.nextDep=s,K.depsTail=s,K.deps===s&&(K.deps=n)}return s}trigger(t){this.version++,At++,this.notify(t)}notify(t){Us();tryLine truncated
* @vue/runtime-core v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function Dt(e,t,s,n){try{return n?e(...n):e()}catch(r){is(r,t,s)}}function ge(e,t,s,n){if(F(e)){const r=Dt(e,t,s,n);return r&&Nn(r)&&r.catch(i=>{is(i,t,s)}),r}if(R(e)){const r=[];for(let i=0;i<e.length;i++)r.push(ge(e[i],t,s,n));return r}}function is(e,t,s,n=!0){const r=t?t.vnode:null,{errorHandler:i,throwUnhandledErrorInProduction:o}=t&&t.appContext.config||V;if(t){let l=t.parent;const f=t.proxy,d=`https://vuejs.org/error-reference/#runtime-${s}`;for(;l;){const a=l.ec;if(a){for(let p=0;p<a.length;p++)if(a[p](e,f,d)===!1)return}l=l.parent}if(i){Re(),Dt(i,null,10,[e,f,d]),Ie();return}}Fi(e,s,r,n,o)}function Fi(e,t,s,n=!0,r=!1){if(r)throw e;console.error(e)}const ie=[];let Te=-1;const it=[];let We=null,st=0;const cr=Promise.resolve();let Jt=null;function Di(e){const t=Jt||cr;return e?t.then(this?e.bind(this):e):t}function Li(e){let t=Te+1,s=ie.length;for(;t<s;){const n=t+s>>>1,r=ie[n],i=Pt(r);i<e||i===e&&r.flags&2?t=n+1:s=n}return t}function Js(e){if(!(e.flags&1)){const t=Pt(e),s=ie[ie.length-1];!s||!(e.flags&2)&&t>=Pt(s)?ie.push(e):ie.splice(Li(t),0,e),e.flags|=1,fr()}}function fr(){Jt||(Jt=cr.then(ar))}function Hi(e){R(e)?it.push(...e):We&&e.id===-1?We.splice(st+1,0,e):e.flags&1||(it.push(e),e.flags|=1),fr()}function an(e,t,s=Te+1){for(;s<ie.length;s++){const n=ie[s];if(n&&n.flags&2){if(e&&n.id!==e.uid)continue;ie.splice(s,1),s--,n.flags&4&&(n.flags&=-2),n(),n.flags&4||(n.flags&=-2)}}}function ur(e){if(it.length){const t=[...new Set(it)].sort((s,n)=>Pt(s)-Pt(n));if(it.length=0,We){We.push(...t);return}for(We=t,st=0;st<We.length;st++){const s=We[st];s.flags&4&&(s.flags&=-2),s.flags&8||s(),s.flags&=-2}We=null,st=0}}const Pt=e=>e.id==null?e.flags&2?-1:1/0:e.id;function ar(e){try{for(Te=0;Te<ie.length;Te++){const t=ie[Te];t&&!(t.flags&8)&&(t.flags&4&&(t.flags&=-2),Dt(t,t.i,t.i?15:14),t.flags&4||(t.flags&=-2))}}finally{for(;Te<ie.length;Te++){const t=ie[Te];t&&(t.flags&=-2)}Te=-1,ie.length=0,ur(),Jt=null,(ie.length||it.length)&&ar()}}let Oe=null,dr=null;function Yt(e){const t=Oe;return Oe=e,dr=e&&e.type.__scopeId||null,t}function ji(e,t=Oe,s){if(!t||e._n)return e;const n=(...r)=>{n._d&&wn(-1);const i=Yt(t);let o;try{o=e(...r)}finally{Yt(i),n._d&&wn(1)}return o};return n._n=!0,n._c=!0,n._d=!0,n}function Je(e,t,s,n){const r=e.dirs,i=t&&t.dirs;for(let o=0;o<r.length;o++){const l=r[o];i&&(l.oldValue=i[o].value);let f=l.dir[n];f&&(Re(),ge(f,s,8,[e.el,l,e,t]),Ie())}}function $i(e,t){if(oe){let s=oe.provides;const n=oe.parent&&oe.parent.provides;n===s&&(s=oe.provides=Object.create(n)),s[e]=t}}function Vt(e,t,s=!1){const n=No();if(n||ot){let r=ot?ot._context.provides:n?n.parent==null||n.ce?n.vnode.appContext&&n.vnode.appContext.provides:n.parent.provides:void 0;if(r&&e in r)return r[e];if(arguments.length>1)return s&&F(t)?t.call(n&&n.proxy):t}}const Ni=Symbol.for("v-scx"),Ui=()=>Vt(Ni);function _s(e,t,s){return hr(e,t,s)}function hr(e,t,s=V){const{immediate:n,deep:r,flush:i,once:o}=s,l=se({},s),f=t&&n||!t&&i!=="post";let d;if(Rt){if(i==="sync"){const S=Ui();d=S.__watcherHandles||(S.__watcherHandles=[])}else if(!f){const S=()=>{};return S.stop=Pe,S.resume=Pe,S.pause=Pe,S}}const a=oe;l.call=(S,H,M)=>ge(S,a,H,M);let p=!1;i==="post"?l.scheduler=S=>{le(S,a&&a.suspense)}:i!=="sync"&&(p=!0,l.scheduler=(S,H)=>{H?S():Js(S)}),l.augmentJob=S=>{t&&(S.flags|=4),p&&(S.flags|=2,a&&(S.id=a.uid,S.i=a))};const T=Ii(e,t,l);return Rt&&(d?d.push(T):f&&T()),T}function Wi(e,t,s){const n=this.proxy,r=J(e)?e.includes(".")?pr(n,e):()=>n[e]:e.bind(n,n);let i;F(t)?i=t:(i=t.handler,s=t);const o=Lt(this),l=hr(r,i.bind(n),s);return o(),l}function pr(e,t){const s=t.split(".");return()=>{let n=e;for(let r=0;r<s.length&&n;r++)n=n[s[r]];return n}}const Bi=Symbol("_vte"),Ki=e=>e.__isTeleport,bs=Symbol("_leaveCb");function Ys(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Ys(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function gr(e){e.ids=[e.ids[0]+e.ids[2]+++"-",0,0]}function dn(e,t){let s;return!!((s=Object.getOwnPropertyDescriptor(e,t))&&!s.configurable)}const zt=new WeakMap;function Tt(e,t,s,n,r=!1){if(R(e)){e.forEach((M,k)=>Tt(M,t&&(R(t)?t[k]:t),s,n,r));return}if(St(n)&&!r){n.shapeFlag&512&&n.type.__asyncResolved&&n.component.subTree.component&&Tt(e,t,s,n.component.subTree);return}const i=n.shapeFlag&4?Qs(n.component):n.el,o=r?null:i,{i:l,r:f}=e,d=t&&t.r,a=l.refs===V?l.refs={}:l.refs,p=l.setupState,T=j(p),S=p===V?jn:M=>dn(a,M)?!1:$(T,M),H=(M,k)=>!(k&&dn(a,k));if(d!=null&&d!==f){if(hn(t),J(d))a[d]=null,S(d)&&(p[d]=null);else if(te(d)){const M=t;H(d,M.k)&&(d.value=null),M.k&&(a[M.k]=null)}}if(F(f)){Re();try{Dt(f,l,12,[o,a])}finally{Ie()}}else{const M=J(f),k=te(f);if(M||k){const C=()=>{if(e.f){const D=M?S(f)?p[f]:a[f]:H()||!e.k?f.value:a[e.k];if(r)R(D)&&Hs(D,i);else if(R(D))D.includes(i)||D.push(i);else if(M)a[f]=[i],S(f)&&(p[f]=a[f]);else{const G=[i];H(f,e.k)&&(f.value=G),e.k&&(a[e.k]=G)}}else M?(a[f]=o,S(f)&&(p[f]=o)):k&&(H(f,e.k)&&(f.value=o),e. Line truncated
* @vue/runtime-dom v3.5.39
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/let Ds;const En=typeof window<"u"&&window.trustedTypes;if(En)try{Ds=En.createPolicy("vue",{createHTML:e=>e})}catch{}const Br=Ds?e=>Ds.createHTML(e):e=>e,ko="http://www.w3.org/2000/svg",qo="http://www.w3.org/1998/Math/MathML",De=typeof document<"u"?document:null,Cn=De&&De.createElement("template"),Jo={insert:(e,t,s)=>{t.insertBefore(e,s||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,s,n)=>{const r=t==="svg"?De.createElementNS(ko,e):t==="mathml"?De.createElementNS(qo,e):s?De.createElement(e,{is:s}):De.createElement(e);return e==="select"&&n&&n.multiple!=null&&r.setAttribute("multiple",n.multiple),r},createText:e=>De.createTextNode(e),createComment:e=>De.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>De.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,s,n,r,i){const o=s?s.previousSibling:t.lastChild;if(r&&(r===i||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),s),!(r===i||!(r=r.nextSibling)););else{Cn.innerHTML=Br(n==="svg"?`<svg>${e}</svg>`:n==="mathml"?`<math>${e}</math>`:e);const l=Cn.content;if(n==="svg"||n==="mathml"){const f=l.firstChild;for(;f.firstChild;)l.appendChild(f.firstChild);l.removeChild(f)}t.insertBefore(l,s)}return[o?o.nextSibling:t.firstChild,s?s.previousSibling:t.lastChild]}},Yo=Symbol("_vtc");function zo(e,t,s){const n=e[Yo];n&&(t=(t?[t,...n]:[...n]).join(" ")),t==null?e.removeAttribute("class"):s?e.setAttribute("class",t):e.className=t}const An=Symbol("_vod"),Xo=Symbol("_vsh"),Zo=Symbol(""),Qo=/(?:^|;)\s*display\s*:/;function el(e,t,s){const n=e.style,r=J(s);let i=!1;if(s&&!r){if(t)if(J(t))for(const o of t.split(";")){const l=o.slice(0,o.indexOf(":")).trim();s[l]==null&&vt(n,l,"")}else for(const o in t)s[o]==null&&vt(n,o,"");for(const o in s){o==="display"&&(i=!0);const l=s[o];l!=null?sl(e,o,!J(t)&&t?t[o]:void 0,l)||vt(n,o,l):vt(n,o,"")}}else if(r){if(t!==s){const o=n[Zo];o&&(s+=";"+o),n.cssText=s,i=Qo.test(s)}}else t&&e.removeAttribute("style");An in e&&(e[An]=i?n.display:"",e[Xo]&&(n.display="none"))}const On=/\s*!important$/;function vt(e,t,s){if(R(s))s.forEach(n=>vt(e,t,n));else if(s==null&&(s=""),t.startsWith("--"))e.setProperty(t,s);else{const n=tl(e,t);On.test(s)?e.setProperty(et(n),s.replace(On,""),"important"):e[n]=s}}const Pn=["Webkit","Moz","ms"],ws={};function tl(e,t){const s=ws[t];if(s)return s;let n=de(t);if(n!=="filter"&&n in e)return ws[t]=n;n=Bn(n);for(let r=0;r<Pn.length;r++){const i=Pn[r]+n;if(i in e)return ws[t]=i}return t}function sl(e,t,s,n){return e.tagName==="TEXTAREA"&&(t==="width"||t==="height")&&J(n)&&s===n}const Mn="http://www.w3.org/1999/xlink";function Rn(e,t,s,n,r,i=ni(t)){n&&t.startsWith("xlink:")?s==null?e.removeAttributeNS(Mn,t.slice(6,t.length)):e.setAttributeNS(Mn,t,s):s==null||i&&!Vn(s)?e.removeAttribute(t):e.setAttribute(t,i?"":Me(s)?String(s):s)}function In(e,t,s,n,r){if(t==="innerHTML"||t==="textContent"){s!=null&&(e[t]=t==="innerHTML"?Br(s):s);return}const i=e.tagName;if(t==="value"&&i!=="PROGRESS"&&!i.includes("-")){const l=i==="OPTION"?e.getAttribute("value")||"":e.value,f=s==null?e.type==="checkbox"?"on":"":String(s);(l!==f||!("_value"in e))&&(e.value=f),s==null&&e.removeAttribute(t),e._value=s;return}let o=!1;if(s===""||s==null){const l=typeof e[t];l==="boolean"?s=Vn(s):s==null&&l==="string"?(s="",o=!0):l==="number"&&(s=0,o=!0)}try{e[t]=s}catch{}o&&e.removeAttribute(r||t)}function nl(e,t,s,n){e.addEventListener(t,s,n)}function rl(e,t,s,n){e.removeEventListener(t,s,n)}const Fn=Symbol("_vei");function il(e,t,s,n,r=null){const i=e[Fn]||(e[Fn]={}),o=i[t];if(n&&o)o.value=n;else{const[l,f]=cl(t);if(n){const d=i[t]=al(n,r);nl(e,l,d,f)}else o&&(rl(e,l,o,f),i[t]=void 0)}}const ol=/(Once|Passive|Capture)$/,ll=/^on:?(?:Once|Passive|Capture)$/;function cl(e){let t,s;for(;(s=e.match(ol))&&!ll.test(e);)t||(t={}),e=e.slice(0,e.length-s[1].length),t[s[1].toLowerCase()]=!0;return[e[2]===":"?e.slice(3):et(e.slice(2)),t]}let Ts=0;const fl=Promise.resolve(),ul=()=>Ts||(fl.then(()=>Ts=0),Ts=Date.now());function al(e,t){const s=n=>{if(!n._vts)n._vts=Date.now();else if(n._vts<=s.attached)return;const r=s.value;if(R(r)){const i=n.stopImmediatePropagation;n.stopImmediatePropagation=()=>{i.call(n),n._stopped=!0};const o=r.slice(),l=[n];for(let f=0;f<o.length&&!n._stopped;f++){const d=o[f];d&&ge(d,t,5,l)}}else ge(r,t,5,[n])};return s.value=e,s.attached=ul(),s}const Dn=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,dl=(e,t,s,n,r,i)=>{const o=r==="svg";t==="class"?zo(e,n,o):t==="style"?el(e,s,n):es(t)?ts(t)||il(e,t,s,n,i):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):hl(e,t,n,o))?(In(e,t,n),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&Rn(e,t,n,o,i,t!=="value")):e._isVueCE&&(pl(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!J(n)))?In(e,de(t),n,i,t):(t==="true-value"?e._trueValue=n:t==="false-value"&&(e._falseValu Line truncated
+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
}