Initial commit

This commit is contained in:
tajniak81
2026-07-06 08:50:52 +02:00
commit ba3f227361
153 changed files with 18116 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Keep the build context small and avoid leaking local artifacts/secrets.
.env
*.log
bin/
tmp/
# Panel source & tooling — the built output in internal/api/dist is committed
# and embedded at compile time, so the source tree is not needed in the image.
panel/node_modules/
panel/dist/
# VCS / editor noise
.git/
.gitignore
.vscode/
.idea/
+22
View File
@@ -0,0 +1,22 @@
# Copy to .env and fill in. The server reads these at startup.
# Address the API Server listens on.
PORT=8080
# PocketBase instance (database). All DB access goes through this server.
PB_URL=http://10.2.1.10:8027
# PocketBase superuser (admin) credentials. Used by the API Server to
# authenticate against PocketBase. Never commit the real .env file.
PB_ADMIN_EMAIL=
PB_ADMIN_PASSWORD=
# Comma-separated list of allowed CORS origins for the web app (dev default).
CORS_ORIGINS=http://localhost:5173
# Secret used to sign auth (JWT) tokens. MUST be set to a long random value in
# production. If unset, the server falls back to an insecure dev secret.
AUTH_SECRET=
# PocketBase auth collection holding app users (default: users).
AUTH_USERS_COLLECTION=users
+4
View File
@@ -0,0 +1,4 @@
.env
*.exe
/tmp/
/bin/
+39
View File
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
# --- Build stage -------------------------------------------------------------
# Compile a static Go binary. The Vue panel is pre-built into internal/api/dist
# and embedded via //go:embed, so no Node toolchain is needed here.
FROM golang:1.22-alpine AS build
WORKDIR /src
# Cache module downloads separately from the source for faster rebuilds.
COPY go.mod ./
# go.sum is optional (stdlib-only module today); copy it if present.
COPY go.su[m] ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 produces a static binary that runs on a bare alpine image.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api-server .
# --- Runtime stage -----------------------------------------------------------
FROM alpine:latest
# HTTPS calls to PocketBase need CA certificates; tzdata for correct timestamps.
RUN apk add --no-cache ca-certificates tzdata
# Run as an unprivileged user.
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY --from=build /out/api-server /app/api-server
# Config comes entirely from environment variables (see .env.example).
# PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required at startup.
ENV PORT=8080
EXPOSE 8080
USER app
ENTRYPOINT ["/app/api-server"]
+190
View File
@@ -0,0 +1,190 @@
# Car Control — API Server
The central API Server for the Car Control project. Written in Go (standard
library only, module `carcontrol/api`). It is the **single gateway** between all
clients (web app, phone app, Home Assistant plugin, ESP32 device) and the
PocketBase database — **clients never talk to PocketBase directly**. The API
Server authenticates to PocketBase as superuser and all collection access rules
are left null, so data is only reachable through this server.
## Data model (from `Car Service.xlsx`)
| Collection | Purpose | Key fields |
|---|---|---|
| `cars` | one per car (was: one spreadsheet sheet) | name, make, model, year, registration, vin, `currentKm`, `serviceIntervalDays` (365), `serviceIntervalKm` (15000), `oilSpec`, `transmissionOilSpec`, `differentialOilSpec`, `brakeFluidSpec`, `coolantSpec`, `owner` |
| `service_records` | the service log | car, date, km, changed_oil, changed_engine_air_filter, changed_cabin_air_filter, notes |
| `parts` | per-car parts catalog (cols M/N) | car, name, part_number, category |
| `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) |
| `sessions` | active login sessions (device/IP/expiry/revoked) | user, label, ip, user_agent, expires, revoked |
| `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin), bio, theme, locale, date_format, font_size, deletion_requested_at |
**Spreadsheet formulas**, reproduced by the API on read:
```
Next Service Date = service date + serviceIntervalDays (Excel: =A+365)
Next Service Km = service km + serviceIntervalKm (Excel: =B+15000)
```
These come back on each service record as `nextServiceDate` / `nextServiceKm`.
## Auth & access control
All endpoints except `/api/health` and `/api/auth/login` require a bearer token.
Login verifies credentials against the PocketBase `users` collection, then the
API Server issues its own HS256 JWT (valid 7 days).
- **Sessions** — each login also creates a `sessions` record (device label, IP,
user-agent, expiry) whose id is embedded as the JWT `jti`. `withAuth` rejects
any token whose session is missing or revoked, which powers the "active
sessions" list and remote logout in Settings. (Any JWT minted before sessions
were introduced has no `jti` and is treated as revoked.)
- **Per-user car ownership + sharing** — cars are not a global list. `cars.owner`
marks ownership and `car_shares` grants other users `read` or `write` access.
Every car/service/part handler is gated by `requireCarAccess`:
- **read** — view the car, its service records and parts.
- **write** — edit the car and full service/part CRUD.
- **owner only** — delete the car and manage its shares.
- **Admin role** — `users.role` (`user` | `admin`), embedded in the JWT and
re-checked from PocketBase on each admin call (so demotion is immediate).
Admins manage users under `/api/admin/*`. Guards prevent deleting your own
account or removing/demoting the last admin.
```
POST /api/auth/login { "email": "...", "password": "..." } -> { token, user }
GET /api/auth/me (Authorization: Bearer <token>) -> { id, email, name, role }
```
## Endpoints
```
GET /api/health
POST /api/auth/login
GET /api/auth/me
# current user (profile / appearance / avatar / data / account lifecycle)
GET /api/me PATCH /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 DELETE /api/me
# active sessions
GET /api/sessions
DELETE /api/sessions/{id} DELETE /api/sessions (revoke all others)
# admin (admin role required)
GET /api/admin/users POST /api/admin/users
PATCH /api/admin/users/{id} POST /api/admin/users/{id}/password
DELETE /api/admin/users/{id}
# cars + sharing
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}
# service records + parts
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/cars` returns the caller's owned cars plus any shared with them, each
annotated with an `access` field. `GET /api/service-records?car={id}` and
`GET /api/parts?car={id}` filter by car.
> **Gotcha:** `updateCar` rewrites **all** car columns from the payload, so a
> `PATCH /api/cars/{id}` must send the **full** car object — omitted spec fields
> get blanked. (The phone's odometer quick-edit sends the whole car for this
> reason.)
## Layout
```
main.go
internal/
├── api/ # HTTP handlers + router (server.go)
│ ├── auth.go services.go records.go parts.go cars.go
│ ├── me.go sessions.go shares.go admin.go
├── auth/jwt.go # HS256 JWT mint/verify
├── config/config.go
├── models/models.go
└── pb/client.go # PocketBase superuser client
scripts/ # Node/Python maintenance scripts (see below)
bin/api-server.exe # prebuilt binary the deployment runs
```
## Configuration
Copy `.env.example` to `.env` and fill in:
```
PORT=8080
PB_URL=http://10.2.1.10:8027
PB_ADMIN_EMAIL=...
PB_ADMIN_PASSWORD=...
AUTH_SECRET=<long random value>
CORS_ORIGINS=http://localhost:5173
```
`CORS_ORIGINS` only matters for **browser** clients (the web app). Native mobile
apps are not subject to CORS. Set `AUTH_SECRET` to a long random value — the
server warns and falls back to an insecure dev secret if it is unset.
## Scripts (`scripts/`)
```powershell
node scripts/setup-pocketbase.mjs # create/reconcile collections (idempotent)
node scripts/create-user.mjs <email> <pw> "Name" # create an app login
node scripts/set-role.mjs <email> user|admin # promote/demote
node scripts/backfill-car-owners.mjs # one-off: assign owner to legacy cars
python scripts/seed_from_excel.py "C:/Users/jania/Desktop/Car Service.xlsx"
```
The Node scripts read `PB_URL` / `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` from the
environment (or `.env`).
> **PocketBase note:** collections created by the setup script do **not** get
> automatic `created`/`updated` autodate fields in this PocketBase version —
> sorting on `created` fails unless an explicit `F.autodate(...)` is added. When
> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`,
> add it to `models.Car` + the record mapping in `records.go`, then rebuild.
## First-time setup
1. **Create the PocketBase collections** (idempotent):
```powershell
$env:PB_URL="http://10.2.1.10:8027"
$env:PB_ADMIN_EMAIL="you@example.com"
$env:PB_ADMIN_PASSWORD="secret"
node scripts/setup-pocketbase.mjs
```
2. **Run the server** — `go run .`, or build and run the binary (below).
3. **(Optional) Seed from the spreadsheet** with the server running (see scripts).
## Build & run
```powershell
go build -o bin/api-server.exe .
```
The deployment runs the **prebuilt binary** `bin/api-server.exe` (not `go run`),
started detached so it survives the shell:
```powershell
Start-Process -FilePath ".\bin\api-server.exe" -WorkingDirectory "." `
-WindowStyle Hidden -RedirectStandardOutput api-server.out.log `
-RedirectStandardError api-server.err.log
```
Go's `log` package writes to **stderr**, so check `api-server.err.log` for
request logs and errors. After editing any Go source, rebuild and restart the
process — editing source alone does nothing until the binary is rebuilt.
+20
View File
@@ -0,0 +1,20 @@
services:
api-server:
build:
context: .
dockerfile: Dockerfile
image: carcontrol-api
container_name: carcontrol-api
restart: unless-stopped
ports:
- "${PORT:-8080}:8080"
environment:
# Container always listens on 8080; map the host port above.
PORT: "8080"
# External PocketBase instance (all DB access goes through this server).
PB_URL: "${PB_URL:-http://10.2.1.10:8027}"
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
CORS_ORIGINS: "${CORS_ORIGINS:-http://localhost:5173}"
AUTH_SECRET: "${AUTH_SECRET}"
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
+3
View File
@@ -0,0 +1,3 @@
module carcontrol/api
go 1.22
+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
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+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
}
+96
View File
@@ -0,0 +1,96 @@
// 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)
}
+95
View File
@@ -0,0 +1,95 @@
// Package config loads server configuration from environment variables,
// optionally seeded from a .env file in the working directory.
package config
import (
"bufio"
"fmt"
"os"
"strings"
)
type Config struct {
Port string
PBURL string
PBAdminEmail string
PBAdminPasswd string
CORSOrigins []string
AuthSecret string
UsersCollection 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"
// 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")
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"),
}
if cfg.PBAdminEmail == "" || cfg.PBAdminPasswd == "" {
return nil, fmt.Errorf("PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD are required")
}
return cfg, nil
}
// 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 {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
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.
func loadDotEnv(path string) {
f, err := os.Open(path)
if err != nil {
return // .env is optional
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, val)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package models defines the domain types for the car maintenance tracker.
//
// The shapes mirror the original "Car Service.xlsx": one Car per sheet, a log
// of ServiceRecords (date + km, plus which parts were changed), and a per-car
// catalog of Parts (cols M/N). Derived fields follow the spreadsheet formulas:
//
// Next Service Date = Service Date + ServiceIntervalDays (Excel: A + 365)
// Next Service Km = Service Km + ServiceIntervalKm (Excel: B + 15000)
package models
import "time"
// Car corresponds to one worksheet in the original spreadsheet.
type Car struct {
ID string `json:"id"`
Name string `json:"name"` // e.g. "Toyota Yaris"
Make string `json:"make"` // e.g. "Toyota"
Model string `json:"model"` // e.g. "Yaris"
Year int `json:"year"` // optional
Registration string `json:"registration"` // optional plate
RegistrationCountry string `json:"registrationCountry"` // optional (country of registration)
VIN string `json:"vin"` // optional
// Maintenance intervals, configurable per car. The spreadsheet hard-coded
// 365 days and 15000 km; here they are stored so each car can differ.
ServiceIntervalDays int `json:"serviceIntervalDays"`
ServiceIntervalKm int `json:"serviceIntervalKm"`
// CurrentKm is the car's present odometer reading, updated by the user. Used
// to flag km-based overdue service (current_km >= last service km + interval).
CurrentKm int `json:"currentKm"`
OilSpec string `json:"oilSpec"` // e.g. "Toyota Advanced Fuel Economy 0W20"
TransmissionOilSpec string `json:"transmissionOilSpec"` // e.g. "Toyota WS"
DifferentialOilSpec string `json:"differentialOilSpec"` // e.g. "SAE 75W-90 GL-5"
BrakeFluidSpec string `json:"brakeFluidSpec"` // e.g. "DOT 4"
CoolantSpec string `json:"coolantSpec"` // e.g. "Toyota Super Long Life Coolant"
FuelType string `json:"fuelType"` // petrol | diesel | hybrid | electric
BuildDate string `json:"buildDate"` // ISO YYYY-MM-DD (date-only)
FirstRegistrationDate string `json:"firstRegistrationDate"` // ISO YYYY-MM-DD (date-only)
// Owner is the user id that owns this car. Access is the requesting user's
// permission on it — "owner", "write", or "read" — computed by the API at
// read time and never persisted (omitempty; not part of the write payload).
Owner string `json:"owner,omitempty"`
Access string `json:"access,omitempty"`
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// ServiceRecord is one row of the Service log for a car.
type ServiceRecord struct {
ID string `json:"id"`
Car string `json:"car"` // relation -> Car.ID
Date time.Time `json:"date"` // service date (Excel col A)
Km int `json:"km"` // odometer at service (Excel col B)
// "Changed Parts" checkboxes (Excel cols E/F/G).
ChangedOil bool `json:"changedOil"` // Oil & Oil Filter
ChangedEngineAirFilter bool `json:"changedEngineAirFilter"` // Engine Air Filter
ChangedCabinAirFilter bool `json:"changedCabinAirFilter"` // Cabin Air Filter
Notes string `json:"notes,omitempty"`
// Derived (not stored): filled in by the API on read.
NextServiceDate *time.Time `json:"nextServiceDate,omitempty"` // Excel col C
NextServiceKm *int `json:"nextServiceKm,omitempty"` // Excel col D
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// Part is one entry in a car's parts catalog (Excel cols M/N).
type Part struct {
ID string `json:"id"`
Car string `json:"car"` // relation -> Car.ID
Name string `json:"name"` // e.g. "Oil Filter"
PartNumber string `json:"partNumber"` // e.g. "04152-YZZA7"
Category string `json:"category"` // optional: oil|filter|wiper|other
Created string `json:"created,omitempty"`
Updated string `json:"updated,omitempty"`
}
// User is the authenticated account's profile, covering the Settings panel's
// Account/Profile/Appearance sections.
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Verified bool `json:"verified"`
Name string `json:"name"`
Bio string `json:"bio"`
HasAvatar bool `json:"hasAvatar"`
Theme string `json:"theme"` // light | dark | system
Locale string `json:"locale"` // e.g. "en-US"
DateFormat string `json:"dateFormat"` // YMD | DMY | MDY
FontSize string `json:"fontSize"` // small | medium | large
Role string `json:"role"` // user | admin
// Non-empty while an account-deletion request is pending its cooldown.
DeletionRequestedAt *time.Time `json:"deletionRequestedAt,omitempty"`
Created string `json:"created,omitempty"`
}
// Session is one active login (device) for the current user.
type Session struct {
ID string `json:"id"`
DeviceLabel string `json:"deviceLabel"`
IP string `json:"ip"`
Current bool `json:"current"`
Created time.Time `json:"created"`
ExpiresAt time.Time `json:"expiresAt"`
}
// ComputeDerived fills NextServiceDate / NextServiceKm from the car's intervals,
// reproducing the spreadsheet formulas. Intervals of 0 fall back to the
// spreadsheet defaults (365 days, 15000 km).
func (r *ServiceRecord) ComputeDerived(c *Car) {
days := c.ServiceIntervalDays
if days <= 0 {
days = 365
}
km := c.ServiceIntervalKm
if km <= 0 {
km = 15000
}
if !r.Date.IsZero() {
d := r.Date.AddDate(0, 0, days)
r.NextServiceDate = &d
}
if r.Km > 0 {
n := r.Km + km
r.NextServiceKm = &n
}
}
+362
View File
@@ -0,0 +1,362 @@
// Package pb is a small PocketBase REST client. The API Server is the only
// component that talks to PocketBase, so all database access funnels through
// here. It authenticates as a superuser and performs CRUD on collection records.
package pb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"sync"
"time"
)
// Client is a concurrency-safe PocketBase REST client with auto re-auth.
type Client struct {
baseURL string
email string
password string
http *http.Client
mu sync.RWMutex
token string
}
func New(baseURL, email, password string) *Client {
return &Client{
baseURL: baseURL,
email: email,
password: password,
http: &http.Client{Timeout: 15 * time.Second},
}
}
// APIError carries the HTTP status and body from a failed PocketBase call.
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("pocketbase: status %d: %s", e.Status, e.Body)
}
// 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 {
body, _ := json.Marshal(map[string]string{
"identity": c.email,
"password": c.password,
})
endpoints := []string{
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
}
var lastErr error
for _, ep := range endpoints {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+ep, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return 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 {
return err
}
c.mu.Lock()
c.token = out.Token
c.mu.Unlock()
return nil
}
lastErr = &APIError{Status: resp.StatusCode, Body: string(raw)}
}
return fmt.Errorf("authentication failed: %w", lastErr)
}
func (c *Client) currentToken() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token
}
// do executes a request, attaching the auth token. On a 401 it re-authenticates
// once and retries.
func (c *Client) do(ctx context.Context, method, path string, payload any) ([]byte, error) {
raw, status, err := c.attempt(ctx, method, path, payload)
if err != nil {
return nil, err
}
if status == http.StatusUnauthorized {
if err := c.Authenticate(ctx); err != nil {
return nil, err
}
raw, status, err = c.attempt(ctx, method, path, payload)
if err != nil {
return nil, err
}
}
if status < 200 || status >= 300 {
return nil, &APIError{Status: status, Body: string(raw)}
}
return raw, nil
}
func (c *Client) attempt(ctx context.Context, method, path string, payload any) ([]byte, int, error) {
var reader io.Reader
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return nil, 0, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if tok := c.currentToken(); tok != "" {
req.Header.Set("Authorization", tok)
}
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
}
// AuthRecord is the user record returned by a successful password auth.
type AuthRecord struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// AuthWithPassword verifies a user's credentials against an auth collection
// (e.g. "users"). This is an unauthenticated PocketBase call — it does not use
// 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"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, &APIError{Status: resp.StatusCode, Body: string(raw)}
}
var out struct {
Record AuthRecord `json:"record"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out.Record, nil
}
// ListResult is the envelope PocketBase returns from list endpoints.
type ListResult struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items json.RawMessage `json:"items"`
}
// List fetches records from a collection. The query (filter, sort, perPage,
// expand, ...) is passed through as URL query parameters.
func (c *Client) List(ctx context.Context, collection string, query url.Values) (*ListResult, error) {
path := "/api/collections/" + collection + "/records"
if len(query) > 0 {
path += "?" + query.Encode()
}
raw, err := c.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var lr ListResult
if err := json.Unmarshal(raw, &lr); err != nil {
return nil, err
}
return &lr, nil
}
// GetOne fetches a single record by id and unmarshals it into dest.
func (c *Client) GetOne(ctx context.Context, collection, id string, dest any) error {
raw, err := c.do(ctx, http.MethodGet, "/api/collections/"+collection+"/records/"+id, nil)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
// Create inserts a record and unmarshals the created record into dest.
func (c *Client) Create(ctx context.Context, collection string, payload any, dest any) error {
raw, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/records", payload)
if err != nil {
return err
}
if dest != nil {
return json.Unmarshal(raw, dest)
}
return nil
}
// Update patches a record and unmarshals the updated record into dest.
func (c *Client) Update(ctx context.Context, collection, id string, payload any, dest any) error {
raw, err := c.do(ctx, http.MethodPatch, "/api/collections/"+collection+"/records/"+id, payload)
if err != nil {
return err
}
if dest != nil {
return json.Unmarshal(raw, dest)
}
return nil
}
// Delete removes a record by id.
func (c *Client) Delete(ctx context.Context, collection, id string) error {
_, err := c.do(ctx, http.MethodDelete, "/api/collections/"+collection+"/records/"+id, nil)
return err
}
// GetFile downloads a file field's stored content, authenticating as the
// superuser (this project's collections have no public access rules).
func (c *Client) GetFile(ctx context.Context, collection, recordID, filename string) ([]byte, string, error) {
path := "/api/files/" + collection + "/" + recordID + "/" + filename
raw, ctype, status, err := c.attemptGetFile(ctx, path)
if err != nil {
return nil, "", err
}
if status == http.StatusUnauthorized {
if aerr := c.Authenticate(ctx); aerr != nil {
return nil, "", aerr
}
raw, ctype, status, err = c.attemptGetFile(ctx, path)
if err != nil {
return nil, "", err
}
}
if status < 200 || status >= 300 {
return nil, "", &APIError{Status: status, Body: string(raw)}
}
return raw, ctype, nil
}
func (c *Client) attemptGetFile(ctx context.Context, path string) ([]byte, string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, "", 0, err
}
if tok := c.currentToken(); tok != "" {
req.Header.Set("Authorization", tok)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", 0, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", 0, err
}
return raw, resp.Header.Get("Content-Type"), resp.StatusCode, nil
}
// RequestVerification asks PocketBase to send a verification email for the
// given address (a public PocketBase endpoint; it always "succeeds" whether or
// not the email exists, to avoid leaking account existence — and it only
// actually sends mail if the PocketBase instance has SMTP configured).
func (c *Client) RequestVerification(ctx context.Context, collection, email string) error {
_, err := c.do(ctx, http.MethodPost, "/api/collections/"+collection+"/request-verification", map[string]string{"email": email})
return err
}
// UpdateMultipart patches a record via multipart/form-data — required for file
// fields (e.g. the users.avatar upload), which PocketBase doesn't accept as
// plain JSON. Pass fileField == "" to send only the plain fields.
func (c *Client) UpdateMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) error {
status, err := c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent)
if status == http.StatusUnauthorized {
if aerr := c.Authenticate(ctx); aerr != nil {
return aerr
}
_, err = c.attemptMultipart(ctx, collection, id, fields, fileField, filename, fileContent)
}
return err
}
func (c *Client) attemptMultipart(ctx context.Context, collection, id string, fields map[string]string, fileField, filename string, fileContent []byte) (int, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
if err := mw.WriteField(k, v); err != nil {
return 0, err
}
}
if fileField != "" {
fw, err := mw.CreateFormFile(fileField, filename)
if err != nil {
return 0, err
}
if _, err := fw.Write(fileContent); err != nil {
return 0, err
}
}
if err := mw.Close(); err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+"/api/collections/"+collection+"/records/"+id, &buf)
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if tok := c.currentToken(); tok != "" {
req.Header.Set("Authorization", tok)
}
resp, err := c.http.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp.StatusCode, &APIError{Status: resp.StatusCode, Body: string(raw)}
}
return resp.StatusCode, nil
}
+79
View File
@@ -0,0 +1,79 @@
// Command server is the central Car Control API Server. It is the single
// gateway between clients (web app, phone app, Home Assistant, ESP32 device)
// and the PocketBase database.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"carcontrol/api/internal/api"
"carcontrol/api/internal/config"
"carcontrol/api/internal/pb"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[api] ")
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
client := pb.New(cfg.PBURL, cfg.PBAdminEmail, cfg.PBAdminPasswd)
authCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := client.Authenticate(authCtx); err != nil {
log.Fatalf("pocketbase auth (%s): %v", cfg.PBURL, err)
}
log.Printf("authenticated to PocketBase at %s", cfg.PBURL)
if cfg.UsingDevAuthSecret() {
log.Println("WARNING: AUTH_SECRET is unset — using an insecure development secret. Set AUTH_SECRET in production.")
}
srv := api.NewServer(client, api.Options{
CORSOrigins: cfg.CORSOrigins,
AuthSecret: cfg.AuthSecret,
UsersCollection: cfg.UsersCollection,
})
httpSrv := &http.Server{
Addr: announcedAddr(cfg.Port),
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("listening on %s", httpSrv.Addr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server: %v", err)
}
}()
// Graceful shutdown on SIGINT/SIGTERM.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("shutting down...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
func announcedAddr(port string) string {
if port == "" {
port = "8080"
}
return ":" + port
}
+14
View File
@@ -0,0 +1,14 @@
<!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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1964
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "drivervault-api-panel",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
+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

+196
View File
@@ -0,0 +1,196 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from "vue";
import { theme, toggleTheme } from "./theme";
import EndpointTable from "./components/EndpointTable.vue";
// Live health poll against this server.
const status = ref("checking"); // checking | ok | error | unreachable
const httpStatus = ref(null);
const latency = ref(null);
const checkedAt = ref(null);
let timer = null;
async function check() {
const started = performance.now();
try {
const r = await fetch("/api/health");
latency.value = Math.round(performance.now() - started);
httpStatus.value = r.status;
status.value = r.ok ? "ok" : "error";
} catch {
latency.value = null;
httpStatus.value = null;
status.value = "unreachable";
}
checkedAt.value = new Date();
}
onMounted(() => {
check();
timer = setInterval(check, 10000);
});
onUnmounted(() => clearInterval(timer));
const badge = {
checking: { label: "checking", cls: "bg-sunken text-muted" },
ok: { label: "operational", cls: "bg-success-soft text-success" },
error: { label: "error", cls: "bg-danger-soft text-danger" },
unreachable: { label: "unreachable", cls: "bg-danger-soft text-danger" },
};
// Logo bar fills — flip for legibility on the dark shell.
const barFills = computed(() =>
theme.value === "dark"
? ["#60a5fa", "#93c5fd", "#ffffff"]
: ["var(--brand-700)", "var(--brand-500)", "var(--brand-400)"],
);
// The DriverVault REST surface, grouped by resource. Mirrors the routes registered
// in internal/api/server.go.
const publicApi = [
{ method: "GET", path: "/api/health", desc: "Liveness probe (no auth)" },
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
{ method: "GET", path: "/api/auth/me", desc: "Identity of the bearer token" },
];
const carsApi = [
{ method: "GET", path: "/api/cars", desc: "List owned + shared cars" },
{ method: "POST", path: "/api/cars", desc: "Create a car" },
{ method: "GET", path: "/api/cars/{id}", desc: "Fetch one car" },
{ method: "PATCH", path: "/api/cars/{id}", desc: "Update a car" },
{ method: "DELETE", path: "/api/cars/{id}", desc: "Delete a car (owner only)" },
{ method: "GET", path: "/api/cars/{id}/service-records", desc: "A car's service history" },
{ method: "GET", path: "/api/cars/{id}/parts", desc: "A car's parts catalog" },
{ method: "GET", path: "/api/cars/{id}/shares", desc: "Who a car is shared with (owner)" },
{ method: "POST", path: "/api/cars/{id}/shares", desc: "Share a car by email (owner)" },
{ method: "DELETE", path: "/api/cars/{id}/shares/{userId}", desc: "Revoke a share (owner)" },
];
const serviceApi = [
{ method: "GET", path: "/api/service-records", desc: "List service records" },
{ method: "POST", path: "/api/service-records", desc: "Log a service record" },
{ method: "GET", path: "/api/service-records/{id}", desc: "Fetch one record" },
{ method: "PATCH", path: "/api/service-records/{id}", desc: "Update a record" },
{ method: "DELETE", path: "/api/service-records/{id}", desc: "Delete a record" },
];
const partsApi = [
{ method: "GET", path: "/api/parts", desc: "List parts" },
{ method: "POST", path: "/api/parts", desc: "Add a part" },
{ method: "GET", path: "/api/parts/{id}", desc: "Fetch one part" },
{ method: "PATCH", path: "/api/parts/{id}", desc: "Update a part" },
{ method: "DELETE", path: "/api/parts/{id}", desc: "Delete a part" },
];
const accountApi = [
{ method: "GET", path: "/api/me", desc: "Current user profile" },
{ method: "PATCH", path: "/api/me", desc: "Update profile" },
{ method: "POST", path: "/api/me/password", desc: "Change password" },
{ method: "POST", path: "/api/me/avatar", desc: "Upload avatar" },
{ method: "GET", path: "/api/me/avatar", desc: "Fetch avatar" },
{ method: "DELETE", path: "/api/me/avatar", desc: "Remove avatar" },
{ method: "POST", path: "/api/me/verify/request", desc: "Request email verification" },
{ method: "GET", path: "/api/me/export", desc: "Export your data" },
{ method: "POST", path: "/api/me/import", desc: "Import data" },
{ method: "POST", path: "/api/me/delete", desc: "Request account deletion" },
{ method: "POST", path: "/api/me/delete/cancel", desc: "Cancel deletion request" },
{ method: "DELETE", path: "/api/me", desc: "Finalize account deletion" },
];
const sessionsApi = [
{ method: "GET", path: "/api/sessions", desc: "List active sessions (devices)" },
{ method: "DELETE", path: "/api/sessions/{id}", desc: "Revoke one session" },
{ method: "DELETE", path: "/api/sessions", desc: "Revoke all other sessions" },
];
const adminApi = [
{ method: "GET", path: "/api/admin/users", desc: "List users" },
{ method: "POST", path: "/api/admin/users", desc: "Create a user" },
{ method: "PATCH", path: "/api/admin/users/{id}", desc: "Update name / role" },
{ method: "POST", path: "/api/admin/users/{id}/password", desc: "Reset a user's password" },
{ method: "DELETE", path: "/api/admin/users/{id}", desc: "Delete a user" },
];
</script>
<template>
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-2.5 select-none">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<g transform="translate(7 0) skewX(-13)">
<rect x="9" y="16" width="6" height="16" rx="3" :fill="barFills[0]" />
<rect x="19" y="12" width="6" height="24" rx="3" :fill="barFills[1]" />
<rect x="29" y="8" width="6" height="32" rx="3" :fill="barFills[2]" />
</g>
</svg>
<span class="text-2xl leading-none font-extrabold tracking-[-0.03em] italic">
<span class="text-strong">Driver</span><span class="text-brandtext">Vault</span>
</span>
</span>
<span class="eyebrow mt-1.5">API server</span>
<div class="flex-1"></div>
<button
class="dh-btn-ghost"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<svg
v-if="theme === 'dark'"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
class="h-4 w-4"
>
<circle cx="12" cy="12" r="4" />
<path stroke-linecap="round" d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
class="h-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
</svg>
Theme
</button>
</div>
<!-- Status -->
<div class="dh-card">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">Status</div>
<span
class="data inline-flex items-center gap-1.5 rounded-pill px-2.5 py-1 text-xs font-medium"
:class="badge[status].cls"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
</span>
</div>
<div class="data flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 text-xs text-muted">
<span>GET /api/health</span>
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
</div>
</div>
<EndpointTable title="Public" auth="No auth" :endpoints="publicApi" />
<EndpointTable title="Cars" auth="Bearer JWT" :endpoints="carsApi" />
<EndpointTable title="Service records" auth="Bearer JWT" :endpoints="serviceApi" />
<EndpointTable title="Parts" auth="Bearer JWT" :endpoints="partsApi" />
<EndpointTable title="Account" auth="Bearer JWT" :endpoints="accountApi" />
<EndpointTable title="Sessions" auth="Bearer JWT" :endpoints="sessionsApi" />
<EndpointTable title="Admin" auth="Admin JWT" :endpoints="adminApi" />
<p class="eyebrow text-center">
DriverVault car maintenance &amp; service tracker.
</p>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup>
defineProps({
title: String,
auth: String,
endpoints: Array, // [{ method, path, desc }]
});
const methodClass = {
GET: "text-success",
POST: "text-brandtext",
PATCH: "text-warning",
DELETE: "text-danger",
};
</script>
<template>
<div class="dh-card overflow-hidden">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-bold tracking-[-0.02em] text-strong">{{ title }}</div>
<span class="eyebrow">{{ auth }}</span>
</div>
<table class="w-full text-left text-sm">
<thead>
<tr class="[&>th]:eyebrow [&>th]:px-5 [&>th]:py-2.5 [&>th]:font-medium">
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in endpoints"
:key="e.method + e.path"
class="transition-colors hover:bg-sunken"
>
<td class="data border-t border-subtle px-5 py-2.5 text-xs whitespace-nowrap">
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
<span class="text-strong"> {{ e.path }}</span>
</td>
<td class="border-t border-subtle px-5 py-2.5 text-body">{{ e.desc }}</td>
</tr>
</tbody>
</table>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+219
View File
@@ -0,0 +1,219 @@
@import "tailwindcss";
/* DriverVault webfonts — Archivo (UI + display, italic 800 is the brand voice) + DM Mono (data/labels). */
@import url("https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;0,700;0,800;1,700;1,800&family=DM+Mono:ital,wght@0,400;0,500&display=swap");
/* Tailwind v4 defaults dark: to prefers-color-scheme; switch to a class strategy
so theme.js can toggle `.dark` on <html>. */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================================
DriverVault design tokens (subset used by the API panel). Raw ramps + semantic
aliases; html.dark re-points the aliases so utilities flip for free.
========================================================================== */
:root {
color-scheme: light;
--brand-900: #0b1730;
--brand-800: #0f1e3d;
--brand-700: #1e40af;
--brand-600: #2563eb;
--brand-500: #3b82f6;
--brand-400: #60a5fa;
--brand-300: #93c5fd;
--brand-200: #c7dbfb;
--brand-100: #e8f0fd;
--ink-900: #0f1e3d;
--ink-700: #28374f;
--ink-600: #3e4e68;
--ink-500: #5c6b85;
--ink-400: #7a8aa6;
--ink-300: #b4bece;
--ink-200: #d6deea;
--ink-100: #e4e9f2;
--ink-50: #eef2f8;
--ink-25: #f7f9fc;
--white: #ffffff;
--success-600: #1f8a5b;
--success-100: #e1f3ea;
--warning-600: #d9822b;
--warning-100: #fbeddd;
--danger-600: #dc2a45;
--danger-100: #fbe3e7;
--info-600: #2563eb;
--info-100: #e8f0fd;
--surface-page: var(--ink-25);
--surface-card: var(--white);
--surface-sunken: var(--ink-50);
--border-subtle: var(--ink-100);
--border-default: var(--ink-200);
--border-strong: var(--ink-300);
--text-strong: var(--ink-900);
--text-body: var(--ink-600);
--text-muted: var(--ink-400);
--text-brand: var(--brand-700);
--accent: var(--brand-600);
--accent-hover: var(--brand-700);
--focus-ring: var(--brand-400);
--shadow-xs: 0 1px 2px rgba(15, 30, 61, 0.06);
--shadow-sm: 0 1px 2px rgba(15, 30, 61, 0.04), 0 2px 6px rgba(15, 30, 61, 0.06);
--shadow-md: 0 1px 2px rgba(15, 30, 61, 0.04), 0 12px 30px -12px rgba(15, 30, 61, 0.14);
--font-display: "Archivo", system-ui, sans-serif;
--font-sans: "Archivo", system-ui, sans-serif;
--font-mono: "DM Mono", ui-monospace, "SF Mono", monospace;
}
html.dark {
color-scheme: dark;
--success-100: #12352a;
--warning-100: #3a2a16;
--danger-100: #3a1620;
--info-100: #122a4d;
--surface-page: #0b1730;
--surface-card: #13233f;
--surface-sunken: #0f1e38;
--border-subtle: #21324f;
--border-default: #2c3f5e;
--border-strong: #3c5173;
--text-strong: #f2f6fc;
--text-body: #b7c4d9;
--text-muted: #7c8ca8;
--text-brand: #93c5fd;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--focus-ring: #60a5fa;
--success-600: #35b27a;
--warning-600: #e0a03a;
--danger-600: #e85c74;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.4);
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 30px -12px rgba(0, 0, 0, 0.55);
}
/* ============================================================================
Map tokens into Tailwind's theme so semantic utilities exist and flip in dark
mode without a dark: variant. Utility names mirror the DriverVault web app.
========================================================================== */
@theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-900: var(--brand-900);
--color-page: var(--surface-page);
--color-card: var(--surface-card);
--color-sunken: var(--surface-sunken);
--color-strong: var(--text-strong);
--color-body: var(--text-body);
--color-muted: var(--text-muted);
--color-brandtext: var(--text-brand);
--color-subtle: var(--border-subtle);
--color-default: var(--border-default);
--color-strongline: var(--border-strong);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-success: var(--success-600);
--color-success-soft: var(--success-100);
--color-warning: var(--warning-600);
--color-warning-soft: var(--warning-100);
--color-danger: var(--danger-600);
--color-danger-soft: var(--danger-100);
--color-info: var(--info-600);
--color-info-soft: var(--info-100);
--radius-control: 12px;
--radius-card: 18px;
--radius-pill: 999px;
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow-card: var(--shadow-md);
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
background: var(--surface-page);
color: var(--text-body);
-webkit-font-smoothing: antialiased;
}
/* Small mono eyebrow labels (uppercase, tracked out). */
.eyebrow {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 11px;
color: var(--text-muted);
}
/* Monospaced data (methods, paths, latencies) so columns align. */
.data {
font-family: var(--font-mono);
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
}
/* Secondary button (theme toggle). */
.dh-btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 34px;
padding: 0 12px;
border-radius: var(--radius-control);
border: 1px solid var(--border-default);
background: var(--surface-card);
color: var(--text-strong);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.8125rem;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.dh-btn-ghost:hover {
background: var(--surface-sunken);
border-color: var(--border-strong);
}
.dh-btn-ghost:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring);
}
.dh-card {
border: 1px solid var(--border-subtle);
background: var(--surface-card);
border-radius: var(--radius-card);
box-shadow: var(--shadow-sm);
}
+35
View File
@@ -0,0 +1,35 @@
import { ref } from "vue";
// Persisted light/dark theme for the panel. Matches the DriverVault web app's
// class strategy (`.dark` on <html>), but under its own storage key so it
// doesn't collide with the main app's `theme` preference.
const KEY = "dh-panel-theme";
function initial() {
try {
const saved = localStorage.getItem(KEY);
if (saved === "dark" || saved === "light") return saved;
} catch {
/* private mode */
}
// Fall back to the OS preference.
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export const theme = ref(initial());
export function applyTheme(t) {
theme.value = t;
document.documentElement.classList.toggle("dark", t === "dark");
try {
localStorage.setItem(KEY, t);
} catch {
/* private mode — theme just won't persist */
}
}
export function toggleTheme() {
applyTheme(theme.value === "dark" ? "light" : "dark");
}
applyTheme(theme.value);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Builds into internal/api/dist, which the Go server embeds via go:embed
// and serves at the server root (the DriverVault API panel).
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../internal/api/dist",
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
// Dev-mode proxy to a locally running API Server.
"/api": "http://localhost:8080",
},
},
});
@@ -0,0 +1,82 @@
// One-off backfill: assign an owner to every car that has none.
//
// Before per-user ownership existed, all cars were ownerless and globally
// visible. This sets `owner` on any car missing it so those cars keep showing
// up once the API Server starts filtering by owner. Safe to re-run: cars that
// already have an owner are skipped.
//
// Usage (PowerShell):
// $env:PB_URL="http://10.2.1.10:8027"
// $env:PB_ADMIN_EMAIL="admin@carcontrole.local"
// $env:PB_ADMIN_PASSWORD="..."
// node scripts/backfill-car-owners.mjs <ownerUserId>
//
// Defaults <ownerUserId> to Dariusz (u7jxozp9jbspp5r) when omitted.
const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const OWNER_ID = process.argv[2] || "u7jxozp9jbspp5r"; // Dariusz
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.");
process.exit(1);
}
async function authenticate() {
for (const ep of [
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
]) {
const res = await fetch(PB_URL + ep, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (res.ok) return (await res.json()).token;
}
throw new Error("Admin authentication failed.");
}
async function main() {
console.log(`Connecting to ${PB_URL} ...`);
const token = await authenticate();
// Verify the target owner exists, so we fail loudly on a bad id.
const ures = await fetch(`${PB_URL}/api/collections/users/records/${OWNER_ID}`, {
headers: { Authorization: token },
});
if (!ures.ok) throw new Error(`owner user ${OWNER_ID} not found (${ures.status})`);
const owner = await ures.json();
console.log(`Backfilling ownerless cars → ${owner.email} (${OWNER_ID})`);
const res = await fetch(`${PB_URL}/api/collections/cars/records?perPage=500`, {
headers: { Authorization: token },
});
if (!res.ok) throw new Error(`list cars failed: ${res.status} ${await res.text()}`);
const cars = (await res.json()).items || [];
let updated = 0;
for (const car of cars) {
if (car.owner) {
console.log(`${car.name} — already owned (${car.owner})`);
continue;
}
const upd = await fetch(`${PB_URL}/api/collections/cars/records/${car.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: token },
body: JSON.stringify({ owner: OWNER_ID }),
});
if (!upd.ok) throw new Error(`update ${car.name} failed: ${upd.status} ${await upd.text()}`);
console.log(`${car.name} — owner set`);
updated++;
}
console.log(`\nDone. ${updated} car(s) updated, ${cars.length - updated} already owned.`);
}
main().catch((err) => {
console.error("\nBackfill failed:", err.message);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
// Create (or report) an app user in the PocketBase "users" collection, using
// superuser credentials. App users log in to the web/phone apps via the API
// Server's /api/auth/login.
//
// Usage (PowerShell):
// $env:PB_URL="http://10.2.1.10:8027"
// $env:PB_ADMIN_EMAIL="admin@carcontrole.local"
// $env:PB_ADMIN_PASSWORD="..."
// node scripts/create-user.mjs <email> <password> [name]
const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const COLLECTION = process.env.AUTH_USERS_COLLECTION || "users";
const [, , email, password, name] = process.argv;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.");
process.exit(1);
}
if (!email || !password) {
console.error("Usage: node scripts/create-user.mjs <email> <password> [name]");
process.exit(1);
}
if (password.length < 8) {
console.error("Password must be at least 8 characters (PocketBase requirement).");
process.exit(1);
}
async function authenticate() {
for (const ep of [
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
]) {
const res = await fetch(PB_URL + ep, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (res.ok) return (await res.json()).token;
}
throw new Error("Admin authentication failed.");
}
async function main() {
const token = await authenticate();
const res = await fetch(PB_URL + `/api/collections/${COLLECTION}/records`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: token },
body: JSON.stringify({
email,
password,
passwordConfirm: password,
name: name || email.split("@")[0],
emailVisibility: true,
verified: true,
}),
});
const text = await res.text();
if (!res.ok) {
if (text.includes("validation_not_unique") || res.status === 400) {
console.error(`Could not create user (maybe it already exists):\n${text}`);
} else {
console.error(`Failed: ${res.status} ${text}`);
}
process.exit(1);
}
console.log(`✓ User created: ${email}`);
console.log(" Log in via the web app or POST /api/auth/login.");
}
main().catch((e) => {
console.error("Error:", e.message);
process.exit(1);
});
+156
View File
@@ -0,0 +1,156 @@
"""Seed the Car Control database from the original "Car Service.xlsx".
Imports each car sheet (service log + parts catalog) by POSTing through the
API Server, so the full stack (client -> API Server -> PocketBase) is exercised.
Usage:
pip install openpyxl requests
set API_BASE=http://localhost:8080/api (default)
python scripts/seed_from_excel.py "C:/Users/jania/Desktop/Car Service.xlsx"
Idempotency: a car is created only if no car with the same name exists yet.
"""
import os
import sys
import datetime as dt
# Force UTF-8 stdout so console output works on Windows code pages (cp1250).
sys.stdout.reconfigure(encoding="utf-8")
import requests
import openpyxl
API_BASE = os.environ.get("API_BASE", "http://localhost:8080/api").rstrip("/")
DEFAULT_XLSX = r"C:/Users/jania/Desktop/Car Service.xlsx"
def yesno(v):
return isinstance(v, str) and v.strip().lower() == "yes"
def split_make_model(title):
parts = title.split(" ", 1)
if len(parts) == 2:
return parts[0], parts[1]
return title, ""
def get_existing_cars():
r = requests.get(f"{API_BASE}/cars", timeout=15)
r.raise_for_status()
return {c["name"]: c for c in r.json()}
def create_car(name, oil_spec):
make, model = split_make_model(name)
payload = {
"name": name,
"make": make,
"model": model,
"serviceIntervalDays": 365,
"serviceIntervalKm": 15000,
"oilSpec": oil_spec or "",
}
r = requests.post(f"{API_BASE}/cars", json=payload, timeout=15)
r.raise_for_status()
return r.json()
def create_service(car_id, date, km, e, f, g):
payload = {
"car": car_id,
"date": date.strftime("%Y-%m-%dT00:00:00Z"),
"km": int(km) if km else 0,
"changedOil": yesno(e),
"changedEngineAirFilter": yesno(f),
"changedCabinAirFilter": yesno(g),
}
r = requests.post(f"{API_BASE}/service-records", json=payload, timeout=15)
r.raise_for_status()
return r.json()
def create_part(car_id, name, number):
payload = {"car": car_id, "name": str(name), "partNumber": str(number) if number else ""}
r = requests.post(f"{API_BASE}/parts", json=payload, timeout=15)
r.raise_for_status()
return r.json()
def seed_service_sheet(ws, existing):
"""Sheets like 'Toyota Yaris' / 'Toyota Avensis': service log + parts (M/N)."""
name = ws.title
# Oil spec lives in N2 (with the oil-filter etc. catalog below in M/N).
oil_spec = ws["N2"].value
if isinstance(oil_spec, str):
oil_spec = " / ".join(line.strip() for line in oil_spec.splitlines() if line.strip())
if name in existing:
print(f"{name} — car exists, skipping")
return
car = create_car(name, oil_spec)
cid = car["id"]
print(f"{name} — car created ({cid})")
services = parts = 0
for row in range(4, ws.max_row + 1):
date = ws.cell(row=row, column=1).value # A
if isinstance(date, dt.datetime):
km = ws.cell(row=row, column=2).value # B
create_service(
cid, date, km,
ws.cell(row=row, column=5).value, # E
ws.cell(row=row, column=6).value, # F
ws.cell(row=row, column=7).value, # G
)
services += 1
pname = ws.cell(row=row, column=13).value # M
pnum = ws.cell(row=row, column=14).value # N
if pname and row >= 3: # M3 onward are real parts (M2/N2 = oil header)
create_part(cid, pname, pnum)
parts += 1
print(f" {services} service records, {parts} parts")
def seed_reference_sheet(ws, existing):
"""Small sheets like 'Corolla Mama': just a parts reference (A/B columns)."""
name = ws.title
if name in existing:
print(f"{name} — car exists, skipping")
return
car = create_car(name, None)
cid = car["id"]
print(f"{name} — car created ({cid})")
parts = 0
for row in range(1, ws.max_row + 1):
pname = ws.cell(row=row, column=1).value # A
pnum = ws.cell(row=row, column=2).value # B
if pname:
create_part(cid, pname, pnum)
parts += 1
print(f" {parts} parts")
def main():
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_XLSX
print(f"Reading {path}")
print(f"Seeding via {API_BASE}")
wb = openpyxl.load_workbook(path, data_only=True)
existing = get_existing_cars()
for ws in wb.worksheets:
# Heuristic: a service sheet has the "Service" header layout (>= col G).
if ws.max_column >= 7 and ws["A3"].value == "Date":
seed_service_sheet(ws, existing)
else:
seed_reference_sheet(ws, existing)
print("\nSeed complete.")
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
// Set a user's access role ("user" or "admin") by email.
//
// Usage (PowerShell):
// $env:PB_URL="http://10.2.1.10:8027"
// $env:PB_ADMIN_EMAIL="admin@carcontrole.local"
// $env:PB_ADMIN_PASSWORD="..."
// node scripts/set-role.mjs <email> <user|admin>
const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL;
const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD;
const [, , email, role] = process.argv;
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.");
process.exit(1);
}
if (!email || !role || !["user", "admin"].includes(role)) {
console.error("Usage: node scripts/set-role.mjs <email> <user|admin>");
process.exit(1);
}
async function authenticate() {
for (const ep of [
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
]) {
const res = await fetch(PB_URL + ep, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: ADMIN_EMAIL, password: ADMIN_PASSWORD }),
});
if (res.ok) return (await res.json()).token;
}
throw new Error("Admin authentication failed.");
}
async function main() {
console.log(`Connecting to ${PB_URL} ...`);
const token = await authenticate();
const q = new URLSearchParams({ filter: `email='${email.replace(/'/g, "")}'`, perPage: "1" });
const res = await fetch(`${PB_URL}/api/collections/users/records?${q}`, {
headers: { Authorization: token },
});
if (!res.ok) throw new Error(`lookup failed: ${res.status} ${await res.text()}`);
const items = (await res.json()).items || [];
if (items.length === 0) throw new Error(`no user with email ${email}`);
const upd = await fetch(`${PB_URL}/api/collections/users/records/${items[0].id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: token },
body: JSON.stringify({ role }),
});
if (!upd.ok) throw new Error(`update failed: ${upd.status} ${await upd.text()}`);
console.log(`${email} role set to "${role}"`);
}
main().catch((err) => {
console.error("\nFailed:", err.message);
process.exit(1);
});
+311
View File
@@ -0,0 +1,311 @@
// Idempotent PocketBase schema setup for the Car Control project.
//
// Creates three collections — cars, service_records, parts — matching the
// original "Car Service.xlsx". Access rules are left admin-only (null) on
// purpose: every client goes through the API Server, which authenticates as a
// superuser, so the database is never exposed directly.
//
// Usage (PowerShell):
// $env:PB_URL="http://10.2.1.10:8027"
// $env:PB_ADMIN_EMAIL="you@example.com"
// $env:PB_ADMIN_PASSWORD="secret"
// node scripts/setup-pocketbase.mjs
//
// Re-running is safe: existing collections are skipped.
const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!EMAIL || !PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
process.exit(1);
}
async function authenticate() {
const endpoints = [
"/api/collections/_superusers/auth-with-password",
"/api/admins/auth-with-password",
];
for (const ep of endpoints) {
const res = await fetch(PB_URL + ep, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity: EMAIL, password: PASSWORD }),
});
if (res.ok) {
const data = await res.json();
return data.token;
}
}
throw new Error("Authentication failed. Check PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD.");
}
async function listCollections(token) {
const res = await fetch(PB_URL + "/api/collections?perPage=200", {
headers: { Authorization: token },
});
if (!res.ok) throw new Error(`list collections failed: ${res.status} ${await res.text()}`);
const data = await res.json();
return Array.isArray(data) ? data : data.items || [];
}
// Detects whether this PocketBase version serializes fields under "fields"
// (v0.23+) or the legacy "schema" key.
function detectFormat(collections) {
for (const c of collections) {
if (Array.isArray(c.fields)) return "fields";
if (Array.isArray(c.schema)) return "schema";
}
return "fields"; // default to modern format
}
// Field builders normalized to {name,type,required,relTo}. They are rendered
// into the right wire shape per detected format.
const F = {
text: (name, required = false) => ({ name, type: "text", required }),
number: (name) => ({ name, type: "number", required: false }),
bool: (name) => ({ name, type: "bool", required: false }),
date: (name, required = false) => ({ name, type: "date", required }),
relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }),
select: (name, values, required = false) => ({ name, type: "select", required, values }),
autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
};
function renderField(def, format, idByName) {
if (format === "schema") {
// Legacy: options nested under "options".
const options = {};
if (def.type === "relation") {
options.collectionId = idByName[def.relTo];
options.cascadeDelete = def.cascadeDelete !== false;
options.maxSelect = 1;
options.minSelect = 0;
}
if (def.type === "select") {
options.values = def.values;
options.maxSelect = 1;
}
return { name: def.name, type: def.type, required: def.required, options };
}
// Modern: options flattened onto the field.
const field = { name: def.name, type: def.type, required: def.required };
if (def.type === "relation") {
field.collectionId = idByName[def.relTo];
field.cascadeDelete = def.cascadeDelete !== false;
field.maxSelect = 1;
field.minSelect = 0;
}
if (def.type === "select") {
field.values = def.values;
field.maxSelect = 1;
}
if (def.type === "autodate") {
field.onCreate = def.onCreate;
field.onUpdate = def.onUpdate;
}
return field;
}
async function createCollection(token, name, defs, format, idByName) {
const rendered = defs.map((d) => renderField(d, format, idByName));
const body = {
name,
type: "base",
[format]: rendered, // "fields" or "schema"
// Rules left null => superuser-only access (API Server is the only client).
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
};
const res = await fetch(PB_URL + "/api/collections", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: token },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`create ${name} failed: ${res.status} ${await res.text()}`);
const created = await res.json();
idByName[name] = created.id;
return created;
}
async function getCollection(token, idOrName) {
const res = await fetch(PB_URL + "/api/collections/" + idOrName, {
headers: { Authorization: token },
});
if (!res.ok) throw new Error(`get ${idOrName} failed: ${res.status} ${await res.text()}`);
return res.json();
}
// reconcileFields brings an existing collection's schema in line with the desired
// definition: it appends any missing fields AND updates relation options
// (currently cascadeDelete) and select options (the "values" list) on existing
// fields. Existing field ids/data are kept. Safe to re-run as the schema
// evolves (e.g. adding cars.current_km, enabling cascade delete, or adding a
// new select choice like a date-format option).
async function reconcileFields(token, name, defs, format, idByName) {
const col = await getCollection(token, name);
const current = col[format] || [];
const byName = new Map(current.map((f) => [f.name, f]));
const changes = [];
// Update relation cascadeDelete and select values on existing fields to match desired.
const merged = current.map((f) => {
const def = defs.find((d) => d.name === f.name);
if (def && def.type === "relation") {
const wantCascade = def.cascadeDelete !== false;
if (f.cascadeDelete !== wantCascade) {
changes.push(`${f.name}.cascadeDelete=${wantCascade}`);
return { ...f, cascadeDelete: wantCascade };
}
}
if (def && def.type === "select") {
const same =
Array.isArray(f.values) &&
f.values.length === def.values.length &&
def.values.every((v) => f.values.includes(v));
if (!same) {
changes.push(`${f.name}.values=[${def.values.join(",")}]`);
return { ...f, values: def.values };
}
}
return f;
});
// Append missing fields.
const missing = defs.filter((d) => !byName.has(d.name));
for (const d of missing) {
merged.push(renderField(d, format, idByName));
changes.push(`+${d.name}`);
}
if (changes.length === 0) {
console.log(`${name} — up to date`);
return;
}
const res = await fetch(PB_URL + "/api/collections/" + col.id, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: token },
body: JSON.stringify({ [format]: merged }),
});
if (!res.ok) throw new Error(`update ${name} failed: ${res.status} ${await res.text()}`);
console.log(`${name}${changes.join(", ")}`);
}
// Desired schema. Edit here to evolve collections; re-run the script to apply.
const DESIRED = {
cars: [
F.text("name", true),
F.text("make"),
F.text("model"),
F.number("year"),
F.text("registration"),
F.text("registration_country"),
F.text("vin"),
F.number("service_interval_days"),
F.number("service_interval_km"),
F.text("oil_spec"),
F.text("transmission_oil_spec"),
F.text("differential_oil_spec"),
F.text("brake_fluid_spec"),
F.text("coolant_spec"),
F.number("current_km"),
F.select("fuel_type", ["petrol", "diesel", "hybrid", "electric"]),
F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year)
F.text("first_registration_date"), // ISO YYYY-MM-DD
// Owner of this car. Non-cascading on purpose: deleting a user must not
// wipe their cars (account deletion in me.go intentionally leaves cars).
// required:false at the DB level — the API always sets owner on create and
// existing rows are backfilled (scripts/backfill-car-owners.mjs).
F.relation("owner", "users", false, false),
],
service_records: [
F.relation("car", "cars", true),
F.date("date", true),
F.number("km"),
F.bool("changed_oil"),
F.bool("changed_engine_air_filter"),
F.bool("changed_cabin_air_filter"),
F.text("notes"),
],
parts: [
F.relation("car", "cars", true),
F.text("name", true),
F.text("part_number"),
F.text("category"),
],
// Per-car sharing grants. One row = "this user may access this car" at the
// given permission. Cascades on both relations so grants disappear when
// either the car or the user is deleted. (Owner access is NOT stored here —
// it's implied by cars.owner.)
car_shares: [
F.relation("car", "cars", true),
F.relation("user", "users", true),
F.select("permission", ["read", "write"], true),
F.autodate("created", true, false),
],
// Custom fields layered onto the built-in "users" auth collection (which
// already ships with email/name/avatar). Settings-panel additions:
users: [
F.text("bio"),
F.select("theme", ["light", "dark", "system"]),
F.text("locale"),
F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
F.select("font_size", ["small", "medium", "large"]),
F.date("deletion_requested_at"),
// Access role. Empty value is treated as "user" by the API.
F.select("role", ["user", "admin"]),
],
// One row per issued login token, so "active sessions" can be listed and
// individually revoked without needing a stateful session store elsewhere.
sessions: [
F.relation("user", "users", true),
F.text("jti", true),
F.text("device_label"),
F.text("ip"),
F.text("user_agent"),
F.bool("revoked"),
F.date("expires_at", true),
F.autodate("created", true, false),
],
};
async function main() {
console.log(`Connecting to ${PB_URL} ...`);
const token = await authenticate();
console.log("Authenticated as superuser.");
let collections = await listCollections(token);
const format = detectFormat(collections);
console.log(`Schema format: "${format}"`);
const idByName = {};
for (const c of collections) idByName[c.name] = c.id;
// Create in dependency order (cars before its relations; "users" already
// exists as PocketBase's built-in auth collection, so it's never created
// here — only reconciled below).
for (const name of ["cars", "service_records", "parts", "sessions", "car_shares"]) {
if (collections.some((c) => c.name === name)) continue;
await createCollection(token, name, DESIRED[name], format, idByName);
console.log(`${name} — created`);
// Refresh so later relations can reference newly-created collection ids.
collections = await listCollections(token);
for (const c of collections) idByName[c.name] = c.id;
}
// Reconcile fields on existing collections (add missing + fix relation options).
for (const name of ["users", "cars", "service_records", "parts", "sessions", "car_shares"]) {
await reconcileFields(token, name, DESIRED[name], format, idByName);
}
console.log("\nDone. Collections ready: users, cars, service_records, parts, sessions, car_shares.");
}
main().catch((err) => {
console.error("\nSetup failed:", err.message);
process.exit(1);
});