Initial commit

This commit is contained in:
tajniak81 committed 2026-07-06 08:50:52 +02:00
commit ba3f227361
153 files changed
+18116

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
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
}
+272
View File
@@ -0,0 +1,272 @@
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
// publicPaths bypass authentication. Everything else requires a valid token.
var publicPaths = map[string]bool{
"/api/health": true,
"/api/auth/login": true,
}
type ctxKey string
const claimsKey ctxKey = "claims"
// withAuth rejects requests to non-public paths that lack a valid bearer token.
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
// panel and its static assets), bypass authentication.
if publicPaths[r.URL.Path] || !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
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))
})
}
// 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
}
res, err := s.pb.List(ctx, colSessions, url.Values{
"filter": {fmt.Sprintf("jti='%s'", jti)},
"perPage": {"1"},
})
if err != nil {
return true
}
var recs []sessionRecord
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
return true
}
return recs[0].Revoked
}
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
// 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"`
}
type userInfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
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})
}
+235
View File
@@ -0,0 +1,235 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"carcontrol/api/internal/models"
)
// Access levels a user can have on a car. accessNone means no access at all.
const (
accessNone = ""
accessRead = "read"
accessWrite = "write"
accessOwner = "owner"
)
// carAccessLevel reports the requesting user's permission on a car: "owner" if
// they own it, otherwise the permission from any car_shares grant ("read"/
// "write"), otherwise "" (no access). It also returns the car record so callers
// that already need it avoid a second fetch.
func (s *Server) carAccessLevel(ctx context.Context, userID, carID string) (string, *carRecord, error) {
var rec carRecord
if err := s.pb.GetOne(ctx, colCars, carID, &rec); err != nil {
return accessNone, nil, err
}
if rec.Owner == userID {
return accessOwner, &rec, nil
}
perm, err := s.sharePermission(ctx, carID, userID)
if err != nil {
return accessNone, &rec, err
}
return perm, &rec, nil
}
// sharePermission returns the permission ("read"/"write") granted to userID on
// carID via car_shares, or "" if there is no grant.
func (s *Server) sharePermission(ctx context.Context, carID, userID string) (string, error) {
res, err := s.pb.List(ctx, colShares, url.Values{
"filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)},
"perPage": {"1"},
})
if err != nil {
return "", err
}
var recs []shareRecord
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
return "", err
}
return recs[0].Permission, nil
}
func canWrite(level string) bool { return level == accessOwner || level == accessWrite }
// requireCarAccess enforces that the current user's access to carID meets the
// minimum `need` (accessRead = any access, accessWrite = write or owner,
// accessOwner = owner only). On failure it writes the HTTP response and returns
// false, so callers can `if !s.requireCarAccess(...) { return }`.
func (s *Server) requireCarAccess(w http.ResponseWriter, r *http.Request, carID, need string) bool {
if carID == "" {
writeError(w, http.StatusBadRequest, "car is required")
return false
}
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID)
if err != nil {
writePBError(w, err)
return false
}
var ok bool
switch need {
case accessWrite:
ok = canWrite(level)
case accessOwner:
ok = level == accessOwner
default: // accessRead / any
ok = level != accessNone
}
if !ok {
writeError(w, http.StatusForbidden, "you do not have access to this car")
return false
}
return true
}
func (s *Server) listCars(w http.ResponseWriter, r *http.Request) {
me := s.currentUserID(r)
// Cars the user owns.
ownedRes, err := s.pb.List(r.Context(), colCars, url.Values{
"filter": {fmt.Sprintf("owner='%s'", me)},
"sort": {"name"},
"perPage": {"200"},
})
if err != nil {
writePBError(w, err)
return
}
var owned []carRecord
if err := json.Unmarshal(ownedRes.Items, &owned); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]models.Car, 0, len(owned))
for _, rec := range owned {
m := rec.toModel()
m.Access = accessOwner
out = append(out, m)
}
// Cars shared with the user (each grant → fetch the car, annotate access).
sharesRes, err := s.pb.List(r.Context(), colShares, url.Values{
"filter": {fmt.Sprintf("user='%s'", me)},
"perPage": {"200"},
})
if err != nil {
writePBError(w, err)
return
}
var shares []shareRecord
if err := json.Unmarshal(sharesRes.Items, &shares); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, sh := range shares {
var rec carRecord
if err := s.pb.GetOne(r.Context(), colCars, sh.Car, &rec); err != nil {
continue // grant points at a deleted car; skip defensively
}
m := rec.toModel()
m.Access = sh.Permission
out = append(out, m)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) getCar(w http.ResponseWriter, r *http.Request) {
level, rec, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
if err != nil {
writePBError(w, err)
return
}
if level == accessNone {
writeError(w, http.StatusForbidden, "you do not have access to this car")
return
}
m := rec.toModel()
m.Access = level
writeJSON(w, http.StatusOK, m)
}
func (s *Server) createCar(w http.ResponseWriter, r *http.Request) {
var in models.Car
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
applyCarDefaults(&in)
// Owner is always the authenticated user; ignore any client-supplied owner.
payload := carPayload(in)
payload["owner"] = s.currentUserID(r)
var rec carRecord
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
m.Access = accessOwner
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) updateCar(w http.ResponseWriter, r *http.Request) {
var in models.Car
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
if err != nil {
writePBError(w, err)
return
}
if !canWrite(level) {
writeError(w, http.StatusForbidden, "you cannot edit this car")
return
}
// carPayload deliberately omits owner, so a PATCH never reassigns ownership.
var rec carRecord
if err := s.pb.Update(r.Context(), colCars, r.PathValue("id"), carPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
m.Access = level
writeJSON(w, http.StatusOK, m)
}
func (s *Server) deleteCar(w http.ResponseWriter, r *http.Request) {
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), r.PathValue("id"))
if err != nil {
writePBError(w, err)
return
}
if level != accessOwner {
writeError(w, http.StatusForbidden, "only the owner can delete this car")
return
}
if err := s.pb.Delete(r.Context(), colCars, r.PathValue("id")); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// applyCarDefaults fills the spreadsheet's default maintenance intervals when
// the client didn't specify them.
func applyCarDefaults(c *models.Car) {
if c.ServiceIntervalDays <= 0 {
c.ServiceIntervalDays = 365
}
if c.ServiceIntervalKm <= 0 {
c.ServiceIntervalKm = 15000
}
}
@@ -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-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
+17
View File
@@ -0,0 +1,17 @@
(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
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" rx="56" fill="#1E40AF"></rect>
<svg x="53" y="53" width="150" height="150" viewBox="0 0 48 48">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" fill="#ffffff" opacity="0.55"></rect>
<rect x="19" y="12" width="6" height="24" rx="3" fill="#ffffff" opacity="0.8"></rect>
<rect x="29" y="8" width="6" height="32" rx="3" fill="#ffffff"></rect>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 550 B

+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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">
</head>
<body>
<div id="app"></div>
</body>
</html>
+534
View File
@@ -0,0 +1,534 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"carcontrol/api/internal/auth"
"carcontrol/api/internal/models"
)
// deletionCooldown is how long an account-deletion request sits before it can
// be finalized, giving the user a window to change their mind.
const deletionCooldown = 3 * 24 * time.Hour
// userRecord is the PocketBase-facing shape of a user (mixes PocketBase's own
// camelCase system fields with our snake_case custom ones).
type userRecord struct {
ID string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Bio string `json:"bio"`
Theme string `json:"theme"`
Locale string `json:"locale"`
DateFormat string `json:"date_format"`
FontSize string `json:"font_size"`
DeletionRequestedAt string `json:"deletion_requested_at"`
Role string `json:"role"`
Created string `json:"created"`
}
func (rec userRecord) toModel() models.User {
u := models.User{
ID: rec.ID,
Email: rec.Email,
Verified: rec.Verified,
Name: rec.Name,
Bio: rec.Bio,
HasAvatar: rec.Avatar != "",
Theme: orDefault(rec.Theme, "system"),
Locale: orDefault(rec.Locale, "en-US"),
DateFormat: orDefault(rec.DateFormat, "YMD"),
FontSize: orDefault(rec.FontSize, "medium"),
Role: orDefault(rec.Role, "user"),
Created: rec.Created,
}
if t := parsePBDate(rec.DeletionRequestedAt); !t.IsZero() {
u.DeletionRequestedAt = &t
}
return u
}
func orDefault(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
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 {
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 {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
if err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, rec.toModel())
}
type updateMeRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
Theme *string `json:"theme"`
Locale *string `json:"locale"`
DateFormat *string `json:"dateFormat"`
FontSize *string `json:"fontSize"`
}
var validThemes = map[string]bool{"light": true, "dark": true, "system": true}
var validDateFormats = map[string]bool{"YMD": true, "DMY_NUM": true, "DMY": true, "MDY": true}
var validFontSizes = map[string]bool{"small": true, "medium": true, "large": true}
// handleUpdateMe applies a partial update — only fields present in the request
// 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 {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var in updateMeRequest
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.Bio != nil {
payload["bio"] = *in.Bio
}
if in.Theme != nil {
if !validThemes[*in.Theme] {
writeError(w, http.StatusBadRequest, "theme must be light, dark, or system")
return
}
payload["theme"] = *in.Theme
}
if in.Locale != nil {
payload["locale"] = *in.Locale
}
if in.DateFormat != nil {
if !validDateFormats[*in.DateFormat] {
writeError(w, http.StatusBadRequest, "dateFormat must be YMD, DMY, or MDY")
return
}
payload["date_format"] = *in.DateFormat
}
if in.FontSize != nil {
if !validFontSizes[*in.FontSize] {
writeError(w, http.StatusBadRequest, "fontSize must be small, medium, or large")
return
}
payload["font_size"] = *in.FontSize
}
var rec userRecord
if err := s.pb.Update(r.Context(), s.usersCollection, claims.Sub, payload, &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, rec.toModel())
}
type changePasswordRequest struct {
OldPassword string `json:"oldPassword"`
NewPassword string `json:"newPassword"`
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var in changePasswordRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.OldPassword == "" {
writeError(w, http.StatusBadRequest, "current password is required")
return
}
if len(in.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "new password must be at least 8 characters")
return
}
// 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 {
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 {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleUploadAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if err := r.ParseMultipartForm(5 << 20); err != nil {
writeError(w, http.StatusBadRequest, "avatar upload must be under 5MB")
return
}
file, header, err := r.FormFile("avatar")
if err != nil {
writeError(w, http.StatusBadRequest, "missing avatar file")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read upload")
return
}
if err := s.pb.UpdateMultipart(r.Context(), s.usersCollection, claims.Sub, nil, "avatar", header.Filename, data); err != nil {
writePBError(w, err)
return
}
rec, err := s.fetchUser(r, claims.Sub)
if err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, rec.toModel())
}
func (s *Server) handleDeleteAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
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 {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleGetAvatar(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
if err != nil {
writePBError(w, err)
return
}
if rec.Avatar == "" {
writeError(w, http.StatusNotFound, "no avatar set")
return
}
data, contentType, err := s.pb.GetFile(r.Context(), s.usersCollection, rec.ID, rec.Avatar)
if err != nil {
writePBError(w, err)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=300")
w.Write(data)
}
func (s *Server) handleRequestVerification(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if err := s.pb.RequestVerification(r.Context(), s.usersCollection, claims.Email); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "requested"})
}
// handleExportData bundles the account profile plus every car the user owns
// (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 {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := s.fetchUser(r, claims.Sub)
if err != nil {
writePBError(w, err)
return
}
carsRes, err := s.pb.List(r.Context(), colCars, url.Values{
"filter": {fmt.Sprintf("owner='%s'", claims.Sub)},
"sort": {"name"},
"perPage": {"200"},
})
if err != nil {
writePBError(w, err)
return
}
var carRecs []carRecord
if err := json.Unmarshal(carsRes.Items, &carRecs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type carExport struct {
models.Car
ServiceRecords []models.ServiceRecord `json:"serviceRecords"`
Parts []models.Part `json:"parts"`
}
exportCars := make([]carExport, 0, len(carRecs))
for _, cr := range carRecs {
car := cr.toModel()
svcRes, err := s.pb.List(r.Context(), colServices, url.Values{
"filter": {fmt.Sprintf("car='%s'", car.ID)}, "sort": {"-date"}, "perPage": {"500"},
})
if err != nil {
writePBError(w, err)
return
}
var svcRecs []serviceRecord
if err := json.Unmarshal(svcRes.Items, &svcRecs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
services := make([]models.ServiceRecord, 0, len(svcRecs))
for _, sr := range svcRecs {
m := sr.toModel()
m.ComputeDerived(&car)
services = append(services, m)
}
partsRes, err := s.pb.List(r.Context(), colParts, url.Values{
"filter": {fmt.Sprintf("car='%s'", car.ID)}, "perPage": {"500"},
})
if err != nil {
writePBError(w, err)
return
}
var partRecs []partRecord
if err := json.Unmarshal(partsRes.Items, &partRecs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
parts := make([]models.Part, 0, len(partRecs))
for _, p := range partRecs {
parts = append(parts, p.toModel())
}
exportCars = append(exportCars, carExport{Car: car, ServiceRecords: services, Parts: parts})
}
out := map[string]any{
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"account": user.toModel(),
"cars": exportCars,
}
filename := fmt.Sprintf("car-control-export-%s.json", time.Now().UTC().Format("2006-01-02"))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
_ = json.NewEncoder(w).Encode(out)
}
// importCar mirrors handleExportData's per-car shape. The "id" and the
// service records'/parts' "car" fields in the uploaded file are ignored —
// this always creates brand-new records, remapped to the newly created car,
// rather than trying to match or overwrite anything that already exists.
type importCar struct {
models.Car
ServiceRecords []models.ServiceRecord `json:"serviceRecords"`
Parts []models.Part `json:"parts"`
}
type importRequest struct {
Cars []importCar `json:"cars"`
}
type importResult struct {
CarsImported int `json:"carsImported"`
ServicesImported int `json:"servicesImported"`
PartsImported int `json:"partsImported"`
}
// handleImportData adds cars/service-records/parts from a previously exported
// JSON file (or a hand-built one in the same shape). It only ever creates new
// records — it does not merge with or overwrite existing shared household
// data. Deliberately lenient about unknown top-level fields (e.g. the
// 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 {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var in importRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid import file: "+err.Error())
return
}
if len(in.Cars) == 0 {
writeError(w, http.StatusBadRequest, "import file has no cars")
return
}
var result importResult
for _, ic := range in.Cars {
car := ic.Car
if car.Name == "" {
continue // skip malformed entries rather than failing the whole import
}
applyCarDefaults(&car)
// Imported cars are owned by the importing user, regardless of any
// owner in the file.
payload := carPayload(car)
payload["owner"] = claims.Sub
var rec carRecord
if err := s.pb.Create(r.Context(), colCars, payload, &rec); err != nil {
writePBError(w, err)
return
}
result.CarsImported++
for _, svc := range ic.ServiceRecords {
svc.Car = rec.ID
if err := s.pb.Create(r.Context(), colServices, servicePayload(svc), nil); err != nil {
writePBError(w, err)
return
}
result.ServicesImported++
}
for _, p := range ic.Parts {
p.Car = rec.ID
if err := s.pb.Create(r.Context(), colParts, partPayload(p), nil); err != nil {
writePBError(w, err)
return
}
result.PartsImported++
}
}
writeJSON(w, http.StatusOK, result)
}
type deleteAccountRequest struct {
ConfirmEmail string `json:"confirmEmail"`
}
// 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 {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var in deleteAccountRequest
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if !strings.EqualFold(strings.TrimSpace(in.ConfirmEmail), claims.Email) {
writeError(w, http.StatusBadRequest, "typed email does not match your account email")
return
}
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 {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deletionRequestedAt": now.Format(time.RFC3339),
"eligibleAt": now.Add(deletionCooldown).Format(time.RFC3339),
})
}
func (s *Server) handleCancelDeletion(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
if !ok {
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 {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
}
// handleFinalizeDeletion actually deletes the account, but only once the
// cooldown started by handleRequestDeletion has elapsed. Deleting the user
// record cascades to their "sessions" rows (cascadeDelete relation); the
// 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 {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
rec, err := s.fetchUser(r, claims.Sub)
if err != nil {
writePBError(w, err)
return
}
requestedAt := parsePBDate(rec.DeletionRequestedAt)
if requestedAt.IsZero() {
writeError(w, http.StatusBadRequest, "no deletion request is pending")
return
}
if time.Now().UTC().Before(requestedAt.Add(deletionCooldown)) {
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 {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"embed"
"io/fs"
"net/http"
)
// The DriverVault API panel: a Vue 3 + Tailwind app (source in panel/, built with
// `npm run build` into internal/api/dist) embedded at compile time and served
// at the server root. It shows a live health status and the REST reference.
//
//go:embed all:dist
var panelFS embed.FS
// panelHandler serves the built panel assets from the embedded dist directory.
func panelHandler() http.Handler {
sub, err := fs.Sub(panelFS, "dist")
if err != nil {
panic(err) // embedded dist is malformed; unreachable in a valid build
}
return http.FileServerFS(sub)
}
+124
View File
@@ -0,0 +1,124 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"carcontrol/api/internal/models"
)
func (s *Server) listParts(w http.ResponseWriter, r *http.Request) {
carID := r.URL.Query().Get("car")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
q := url.Values{}
q.Set("sort", "name")
q.Set("perPage", "500")
q.Set("filter", fmt.Sprintf("car='%s'", carID))
s.respondPartList(w, r, q)
}
// listCarParts serves GET /api/cars/{id}/parts.
func (s *Server) listCarParts(w http.ResponseWriter, r *http.Request) {
carID := r.PathValue("id")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
q := url.Values{}
q.Set("sort", "name")
q.Set("perPage", "500")
q.Set("filter", fmt.Sprintf("car='%s'", carID))
s.respondPartList(w, r, q)
}
func (s *Server) respondPartList(w http.ResponseWriter, r *http.Request, q url.Values) {
res, err := s.pb.List(r.Context(), colParts, q)
if err != nil {
writePBError(w, err)
return
}
var recs []partRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]models.Part, 0, len(recs))
for _, rec := range recs {
out = append(out, rec.toModel())
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) getPart(w http.ResponseWriter, r *http.Request) {
var rec partRecord
if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &rec); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, rec.Car, accessRead) {
return
}
writeJSON(w, http.StatusOK, rec.toModel())
}
func (s *Server) createPart(w http.ResponseWriter, r *http.Request) {
var in models.Part
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Car == "" || in.Name == "" {
writeError(w, http.StatusBadRequest, "car and name are required")
return
}
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
return
}
var rec partRecord
if err := s.pb.Create(r.Context(), colParts, partPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusCreated, rec.toModel())
}
func (s *Server) updatePart(w http.ResponseWriter, r *http.Request) {
var in models.Part
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var existing partRecord
if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
var rec partRecord
if err := s.pb.Update(r.Context(), colParts, r.PathValue("id"), partPayload(in), &rec); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, rec.toModel())
}
func (s *Server) deletePart(w http.ResponseWriter, r *http.Request) {
var existing partRecord
if err := s.pb.GetOne(r.Context(), colParts, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
if err := s.pb.Delete(r.Context(), colParts, r.PathValue("id")); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+192
View File
@@ -0,0 +1,192 @@
package api
import (
"strings"
"time"
"carcontrol/api/internal/models"
)
// PocketBase stores datetimes as e.g. "2015-06-12 00:00:00.000Z". These layouts
// are tried (in order) when parsing values coming back from PocketBase.
var pbDateLayouts = []string{
"2006-01-02 15:04:05.000Z",
"2006-01-02 15:04:05Z",
time.RFC3339,
"2006-01-02",
}
func parsePBDate(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, l := range pbDateLayouts {
if t, err := time.Parse(l, s); err == nil {
return t
}
}
return time.Time{}
}
// formatPBDate renders a date in the format PocketBase expects on write.
func formatPBDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04:05.000Z")
}
// --- cars ---
// carRecord is the PocketBase-facing shape of a car (snake_case fields).
type carRecord struct {
ID string `json:"id"`
Name string `json:"name"`
Make string `json:"make"`
Model string `json:"model"`
Year int `json:"year"`
Registration string `json:"registration"`
RegistrationCountry string `json:"registration_country"`
VIN string `json:"vin"`
ServiceIntervalDays int `json:"service_interval_days"`
ServiceIntervalKm int `json:"service_interval_km"`
OilSpec string `json:"oil_spec"`
TransmissionOilSpec string `json:"transmission_oil_spec"`
DifferentialOilSpec string `json:"differential_oil_spec"`
BrakeFluidSpec string `json:"brake_fluid_spec"`
CoolantSpec string `json:"coolant_spec"`
CurrentKm int `json:"current_km"`
FuelType string `json:"fuel_type"`
BuildDate string `json:"build_date"`
FirstRegistrationDate string `json:"first_registration_date"`
Owner string `json:"owner"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (rec carRecord) toModel() models.Car {
return models.Car{
ID: rec.ID,
Name: rec.Name,
Make: rec.Make,
Model: rec.Model,
Year: rec.Year,
Registration: rec.Registration,
RegistrationCountry: rec.RegistrationCountry,
VIN: rec.VIN,
ServiceIntervalDays: rec.ServiceIntervalDays,
ServiceIntervalKm: rec.ServiceIntervalKm,
OilSpec: rec.OilSpec,
TransmissionOilSpec: rec.TransmissionOilSpec,
DifferentialOilSpec: rec.DifferentialOilSpec,
BrakeFluidSpec: rec.BrakeFluidSpec,
CoolantSpec: rec.CoolantSpec,
CurrentKm: rec.CurrentKm,
FuelType: rec.FuelType,
BuildDate: rec.BuildDate,
FirstRegistrationDate: rec.FirstRegistrationDate,
Owner: rec.Owner,
Created: rec.Created,
Updated: rec.Updated,
}
}
// carPayload builds the write payload for create/update from a domain Car.
func carPayload(c models.Car) map[string]any {
return map[string]any{
"name": c.Name,
"make": c.Make,
"model": c.Model,
"year": c.Year,
"registration": c.Registration,
"registration_country": c.RegistrationCountry,
"vin": c.VIN,
"service_interval_days": c.ServiceIntervalDays,
"service_interval_km": c.ServiceIntervalKm,
"oil_spec": c.OilSpec,
"transmission_oil_spec": c.TransmissionOilSpec,
"differential_oil_spec": c.DifferentialOilSpec,
"brake_fluid_spec": c.BrakeFluidSpec,
"coolant_spec": c.CoolantSpec,
"current_km": c.CurrentKm,
"fuel_type": c.FuelType,
"build_date": c.BuildDate,
"first_registration_date": c.FirstRegistrationDate,
}
}
// --- service records ---
type serviceRecord struct {
ID string `json:"id"`
Car string `json:"car"`
Date string `json:"date"`
Km int `json:"km"`
ChangedOil bool `json:"changed_oil"`
ChangedEngineAirFilter bool `json:"changed_engine_air_filter"`
ChangedCabinAirFilter bool `json:"changed_cabin_air_filter"`
Notes string `json:"notes"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (rec serviceRecord) toModel() models.ServiceRecord {
return models.ServiceRecord{
ID: rec.ID,
Car: rec.Car,
Date: parsePBDate(rec.Date),
Km: rec.Km,
ChangedOil: rec.ChangedOil,
ChangedEngineAirFilter: rec.ChangedEngineAirFilter,
ChangedCabinAirFilter: rec.ChangedCabinAirFilter,
Notes: rec.Notes,
Created: rec.Created,
Updated: rec.Updated,
}
}
func servicePayload(r models.ServiceRecord) map[string]any {
return map[string]any{
"car": r.Car,
"date": formatPBDate(r.Date),
"km": r.Km,
"changed_oil": r.ChangedOil,
"changed_engine_air_filter": r.ChangedEngineAirFilter,
"changed_cabin_air_filter": r.ChangedCabinAirFilter,
"notes": r.Notes,
}
}
// --- parts ---
type partRecord struct {
ID string `json:"id"`
Car string `json:"car"`
Name string `json:"name"`
PartNumber string `json:"part_number"`
Category string `json:"category"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func (rec partRecord) toModel() models.Part {
return models.Part{
ID: rec.ID,
Car: rec.Car,
Name: rec.Name,
PartNumber: rec.PartNumber,
Category: rec.Category,
Created: rec.Created,
Updated: rec.Updated,
}
}
func partPayload(p models.Part) map[string]any {
return map[string]any{
"car": p.Car,
"name": p.Name,
"part_number": p.PartNumber,
"category": p.Category,
}
}
+225
View File
@@ -0,0 +1,225 @@
// Package api exposes the HTTP REST surface of the car-control API Server.
//
// Clients (web app, phone app, ...) talk only to this server; this server is
// the only thing that talks to PocketBase. Endpoints:
//
// GET /api/health
// 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
// DELETE /api/cars/{id}/shares/{userId}
// 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}
package api
import (
"encoding/json"
"log"
"net/http"
"time"
"carcontrol/api/internal/pb"
)
// PocketBase collection names.
const (
colCars = "cars"
colServices = "service_records"
colParts = "parts"
colSessions = "sessions"
colShares = "car_shares"
)
type Server struct {
pb *pb.Client
corsOrigins map[string]bool
authSecret string
usersCollection string
}
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
}
return &Server{
pb: client,
corsOrigins: set,
authSecret: opts.AuthSecret,
usersCollection: opts.UsersCollection,
}
}
// Handler builds the routed, CORS-wrapped HTTP handler (Go 1.22 ServeMux).
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.
panel := panelHandler()
mux.Handle("GET /{$}", panel)
mux.Handle("GET /assets/", panel)
mux.Handle("GET /favicon.svg", panel)
mux.HandleFunc("GET /api/health", s.handleHealth)
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("GET /api/auth/me", s.handleMe)
mux.HandleFunc("GET /api/me", s.handleGetMe)
mux.HandleFunc("PATCH /api/me", s.handleUpdateMe)
mux.HandleFunc("POST /api/me/password", s.handleChangePassword)
mux.HandleFunc("POST /api/me/avatar", s.handleUploadAvatar)
mux.HandleFunc("GET /api/me/avatar", s.handleGetAvatar)
mux.HandleFunc("DELETE /api/me/avatar", s.handleDeleteAvatar)
mux.HandleFunc("POST /api/me/verify/request", s.handleRequestVerification)
mux.HandleFunc("GET /api/me/export", s.handleExportData)
mux.HandleFunc("POST /api/me/import", s.handleImportData)
mux.HandleFunc("POST /api/me/delete", s.handleRequestDeletion)
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)
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)
mux.HandleFunc("GET /api/cars", s.listCars)
mux.HandleFunc("POST /api/cars", s.createCar)
mux.HandleFunc("GET /api/cars/{id}", s.getCar)
mux.HandleFunc("PATCH /api/cars/{id}", s.updateCar)
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)
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)
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)))
}
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),
})
}
// --- middleware ---
func (s *Server) withCORS(next http.Handler) http.Handler {
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")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
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))
})
}
// --- 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 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)
}
+188
View File
@@ -0,0 +1,188 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"carcontrol/api/internal/models"
)
func (s *Server) listServiceRecords(w http.ResponseWriter, r *http.Request) {
// Records are always scoped to a single car so access can be enforced; a
// cross-car listing would leak other users' data.
carID := r.URL.Query().Get("car")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
q := url.Values{}
q.Set("sort", "-date") // most-recent service first
q.Set("perPage", "500")
q.Set("filter", fmt.Sprintf("car='%s'", carID))
s.respondServiceList(w, r, q)
}
// listCarServiceRecords serves GET /api/cars/{id}/service-records.
func (s *Server) listCarServiceRecords(w http.ResponseWriter, r *http.Request) {
carID := r.PathValue("id")
if !s.requireCarAccess(w, r, carID, accessRead) {
return
}
q := url.Values{}
q.Set("sort", "-date")
q.Set("perPage", "500")
q.Set("filter", fmt.Sprintf("car='%s'", carID))
s.respondServiceList(w, r, q)
}
func (s *Server) respondServiceList(w http.ResponseWriter, r *http.Request, q url.Values) {
res, err := s.pb.List(r.Context(), colServices, q)
if err != nil {
writePBError(w, err)
return
}
var recs []serviceRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cars := newCarCache(s)
out := make([]models.ServiceRecord, 0, len(recs))
for _, rec := range recs {
m := rec.toModel()
if car, err := cars.get(r.Context(), m.Car); err == nil {
m.ComputeDerived(car)
}
out = append(out, m)
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) getServiceRecord(w http.ResponseWriter, r *http.Request) {
var rec serviceRecord
if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
if !s.requireCarAccess(w, r, m.Car, accessRead) {
return
}
if car, err := s.carModel(r.Context(), m.Car); err == nil {
m.ComputeDerived(car)
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) createServiceRecord(w http.ResponseWriter, r *http.Request) {
var in models.ServiceRecord
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if in.Car == "" {
writeError(w, http.StatusBadRequest, "car is required")
return
}
if in.Date.IsZero() {
writeError(w, http.StatusBadRequest, "date is required")
return
}
if !s.requireCarAccess(w, r, in.Car, accessWrite) {
return
}
var rec serviceRecord
if err := s.pb.Create(r.Context(), colServices, servicePayload(in), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
if car, err := s.carModel(r.Context(), m.Car); err == nil {
m.ComputeDerived(car)
}
writeJSON(w, http.StatusCreated, m)
}
func (s *Server) updateServiceRecord(w http.ResponseWriter, r *http.Request) {
var in models.ServiceRecord
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Enforce write access via the record's existing parent car (the body's
// car field, if any, can't be used to escape into another user's car).
var existing serviceRecord
if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
var rec serviceRecord
if err := s.pb.Update(r.Context(), colServices, r.PathValue("id"), servicePayload(in), &rec); err != nil {
writePBError(w, err)
return
}
m := rec.toModel()
if car, err := s.carModel(r.Context(), m.Car); err == nil {
m.ComputeDerived(car)
}
writeJSON(w, http.StatusOK, m)
}
func (s *Server) deleteServiceRecord(w http.ResponseWriter, r *http.Request) {
var existing serviceRecord
if err := s.pb.GetOne(r.Context(), colServices, r.PathValue("id"), &existing); err != nil {
writePBError(w, err)
return
}
if !s.requireCarAccess(w, r, existing.Car, accessWrite) {
return
}
if err := s.pb.Delete(r.Context(), colServices, r.PathValue("id")); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// carModel fetches a single car as a domain model.
func (s *Server) carModel(ctx context.Context, id string) (*models.Car, error) {
if id == "" {
return nil, fmt.Errorf("empty car id")
}
var rec carRecord
if err := s.pb.GetOne(ctx, colCars, id, &rec); err != nil {
return nil, err
}
m := rec.toModel()
return &m, nil
}
// carCache memoizes car lookups within a single list request to avoid N+1
// fetches when many service records share the same car.
type carCache struct {
s *Server
cache map[string]*models.Car
}
func newCarCache(s *Server) *carCache {
return &carCache{s: s, cache: map[string]*models.Car{}}
}
func (c *carCache) get(ctx context.Context, id string) (*models.Car, error) {
if car, ok := c.cache[id]; ok {
return car, nil
}
car, err := c.s.carModel(ctx, id)
if err != nil {
return nil, err
}
c.cache[id] = car
return car, nil
}
+106
View File
@@ -0,0 +1,106 @@
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)})
}
+202
View File
@@ -0,0 +1,202 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
// shareRecord is the PocketBase-facing shape of a car_shares row: a grant that
// lets `user` access `car` at `permission` ("read"/"write").
type shareRecord struct {
ID string `json:"id"`
Car string `json:"car"`
User string `json:"user"`
Permission string `json:"permission"`
Created string `json:"created"`
}
// shareView is the API shape returned to clients: the grantee's public identity
// plus their permission on the car.
type shareView struct {
User userInfo `json:"user"`
Permission string `json:"permission"`
}
// requireCarOwner ensures the current user owns the car in the {id} path param.
// Sharing management is owner-only. Returns the car id on success, else writes
// the response and returns "".
func (s *Server) requireCarOwner(w http.ResponseWriter, r *http.Request) string {
carID := r.PathValue("id")
level, _, err := s.carAccessLevel(r.Context(), s.currentUserID(r), carID)
if err != nil {
writePBError(w, err)
return ""
}
if level != accessOwner {
writeError(w, http.StatusForbidden, "only the owner can manage sharing")
return ""
}
return carID
}
// handleListShares serves GET /api/cars/{id}/shares (owner only).
func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) {
carID := s.requireCarOwner(w, r)
if carID == "" {
return
}
res, err := s.pb.List(r.Context(), colShares, url.Values{
"filter": {fmt.Sprintf("car='%s'", carID)},
"perPage": {"200"},
})
if err != nil {
writePBError(w, err)
return
}
var recs []shareRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]shareView, 0, len(recs))
for _, rec := range recs {
u, err := s.fetchUser(r, rec.User)
if err != nil {
continue // grantee user was deleted; skip defensively
}
out = append(out, shareView{
User: userInfo{ID: u.ID, Email: u.Email, Name: u.Name},
Permission: rec.Permission,
})
}
writeJSON(w, http.StatusOK, out)
}
type shareRequest struct {
Email string `json:"email"`
Permission string `json:"permission"`
}
// handleUpsertShare serves POST /api/cars/{id}/shares (owner only). It grants
// or updates a share for the user identified by email. Body: {email,
// permission}. Idempotent: an existing grant for that user is updated.
func (s *Server) handleUpsertShare(w http.ResponseWriter, r *http.Request) {
carID := s.requireCarOwner(w, r)
if carID == "" {
return
}
var in shareRequest
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 in.Permission != accessRead && in.Permission != accessWrite {
writeError(w, http.StatusBadRequest, "permission must be 'read' or 'write'")
return
}
target, err := s.findUserByEmail(r, in.Email)
if err != nil {
writePBError(w, err)
return
}
if target == nil {
writeError(w, http.StatusNotFound, "no user with that email")
return
}
if target.ID == s.currentUserID(r) {
writeError(w, http.StatusBadRequest, "you already own this car")
return
}
// Upsert: update the existing grant's permission, else create a new one.
existing, err := s.findShare(r, carID, target.ID)
if err != nil {
writePBError(w, err)
return
}
payload := map[string]any{"car": carID, "user": target.ID, "permission": in.Permission}
if existing != nil {
if err := s.pb.Update(r.Context(), colShares, existing.ID, payload, nil); err != nil {
writePBError(w, err)
return
}
} else if err := s.pb.Create(r.Context(), colShares, payload, nil); err != nil {
writePBError(w, err)
return
}
writeJSON(w, http.StatusOK, shareView{
User: userInfo{ID: target.ID, Email: target.Email, Name: target.Name},
Permission: in.Permission,
})
}
// handleDeleteShare serves DELETE /api/cars/{id}/shares/{userId} (owner only).
func (s *Server) handleDeleteShare(w http.ResponseWriter, r *http.Request) {
carID := s.requireCarOwner(w, r)
if carID == "" {
return
}
existing, err := s.findShare(r, carID, r.PathValue("userId"))
if err != nil {
writePBError(w, err)
return
}
if existing == nil {
w.WriteHeader(http.StatusNoContent) // already not shared — idempotent
return
}
if err := s.pb.Delete(r.Context(), colShares, existing.ID); err != nil {
writePBError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// findShare returns the car_shares grant for (carID, userID), or nil if none.
func (s *Server) findShare(r *http.Request, carID, userID string) (*shareRecord, error) {
res, err := s.pb.List(r.Context(), colShares, url.Values{
"filter": {fmt.Sprintf("car='%s' && user='%s'", carID, userID)},
"perPage": {"1"},
})
if err != nil {
return nil, err
}
var recs []shareRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
return nil, err
}
if len(recs) == 0 {
return nil, nil
}
return &recs[0], nil
}
// 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{
"filter": {fmt.Sprintf("email='%s'", strings.ReplaceAll(email, "'", ""))},
"perPage": {"1"},
})
if err != nil {
return nil, err
}
var recs []userRecord
if err := json.Unmarshal(res.Items, &recs); err != nil {
return nil, err
}
if len(recs) == 0 {
return nil, nil
}
return &recs[0], nil
}