diff --git a/API Server/README.md b/API Server/README.md index 38c68ef..6953a1b 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -288,6 +288,15 @@ The Node scripts read `POCKETBASE_URL` / `POCKETBASE_ADMIN_EMAIL` / > 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. +> **Startup bootstrap:** the server also runs this same create/reconcile on boot +> (`internal/bootstrap`, a Go mirror of `setup-pocketbase.mjs`) whenever a service +> account is configured, so the Docker prod stack needs no manual setup step. It +> is idempotent and gated by `PB_BOOTSTRAP` (default `true`; set to `false` to +> skip). With `DRIVERVAULT_SUPERADMIN_EMAIL` + `DRIVERVAULT_SUPERADMIN_PASSWORD` +> set it also creates the first `superadmin` user when absent. **Keep the two +> schemas in sync:** a change to `DESIRED` in the script must be mirrored in +> `internal/bootstrap/schema.go` (guarded by `TestSchemaConsistency`). + ## First-time setup 1. **Create the PocketBase collections** (idempotent — safe to re-run on an diff --git a/API Server/cmd/server/main.go b/API Server/cmd/server/main.go index a23e0f4..8c18eec 100644 --- a/API Server/cmd/server/main.go +++ b/API Server/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "time" "drivervault/apiserver/internal/api" + "drivervault/apiserver/internal/bootstrap" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/pb" ) @@ -44,6 +45,26 @@ func main() { log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured") } + // Bring PocketBase up to the expected schema (create missing collections, + // reconcile existing ones) and ensure the super-admin. Idempotent, so it runs + // on every boot. Non-fatal: a fresh PocketBase that isn't reachable yet, or a + // bad service account, must not stop the panel from coming up so a superadmin + // can log in and fix the connection. + if cfg.Bootstrap && cfg.AdminConfigured() { + bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + if err := bootstrap.Run(bootCtx, client, bootstrap.Options{ + UsersCollection: cfg.UsersCollection, + SuperAdminEmail: cfg.SuperAdminEmail, + SuperAdminPassword: cfg.SuperAdminPassword, + SuperAdminName: cfg.SuperAdminName, + }); err != nil { + log.Printf("WARNING: bootstrap failed: %v", err) + } else { + log.Println("bootstrap: PocketBase schema ready") + } + cancel() + } + srv := api.New(cfg, client) if err := srv.StartPlugins(); err != nil { diff --git a/API Server/internal/bootstrap/bootstrap.go b/API Server/internal/bootstrap/bootstrap.go new file mode 100644 index 0000000..a962d02 --- /dev/null +++ b/API Server/internal/bootstrap/bootstrap.go @@ -0,0 +1,425 @@ +// Package bootstrap brings a fresh PocketBase up to the schema the API Server +// expects, on server startup. It is the Go port of scripts/setup-pocketbase.mjs +// and is idempotent: existing collections are reconciled (missing fields added, +// relation/select options fixed) rather than recreated, and an already-present +// super-admin user is left untouched. +// +// It creates the app-level collections and, optionally, the DriverVault +// super-admin account. It does NOT create the PocketBase *superuser* — that is a +// chicken-and-egg the REST API can't solve (creating a _superusers record needs +// an existing superuser token), so the PocketBase container upserts it from env +// on boot instead. Bootstrap authenticates with that same superuser. +package bootstrap + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + + "drivervault/apiserver/internal/pb" +) + +// Options configures a bootstrap run. SuperAdmin* are optional: when either the +// email or password is empty, the super-admin step is skipped. +type Options struct { + UsersCollection string + SuperAdminEmail string + SuperAdminPassword string + SuperAdminName string +} + +// fieldDef is a schema field normalized to a single shape; it is rendered into +// the right PocketBase wire format ("fields" for v0.23+, legacy "schema") per +// the detected server version. +type fieldDef struct { + name string + typ string + required bool + relTo string // relation target collection name + cascadeDelete bool // relations only; default true + values []string + onCreate bool // autodate only + onUpdate bool // autodate only + maxSize int // file/json only, bytes + mimeTypes []string +} + +// Field builders, mirroring the F.* helpers in setup-pocketbase.mjs. +func fText(name string, required bool) fieldDef { + return fieldDef{name: name, typ: "text", required: required} +} +func fNumber(name string) fieldDef { return fieldDef{name: name, typ: "number"} } +func fBool(name string) fieldDef { return fieldDef{name: name, typ: "bool"} } +func fDate(name string, required bool) fieldDef { + return fieldDef{name: name, typ: "date", required: required} +} +func fRelation(name, relTo string, required, cascade bool) fieldDef { + return fieldDef{name: name, typ: "relation", required: required, relTo: relTo, cascadeDelete: cascade} +} +func fSelect(name string, values []string, required bool) fieldDef { + return fieldDef{name: name, typ: "select", required: required, values: values} +} +func fAutodate(name string, onCreate, onUpdate bool) fieldDef { + return fieldDef{name: name, typ: "autodate", onCreate: onCreate, onUpdate: onUpdate} +} +func fFile(name string, maxSize int, mimeTypes []string) fieldDef { + return fieldDef{name: name, typ: "file", maxSize: maxSize, mimeTypes: mimeTypes} +} +func fJSON(name string, maxSize int) fieldDef { + return fieldDef{name: name, typ: "json", maxSize: maxSize} +} + +// attachment is the single optional file a record can carry (scan/receipt/photo). +// The 10MB cap matches maxAttachmentUpload in the API server. +func attachment() fieldDef { + return fFile("file", 10485760, []string{ + "application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic", + }) +} + +// renderField turns a fieldDef into the map PocketBase expects, in either the +// modern ("fields") or legacy ("schema") layout. idByName resolves a relation's +// target collection name to its id. +func renderField(d fieldDef, format string, idByName map[string]string) map[string]any { + if format == "schema" { + options := map[string]any{} + switch d.typ { + case "relation": + options["collectionId"] = idByName[d.relTo] + options["cascadeDelete"] = d.cascadeDelete + options["maxSelect"] = 1 + options["minSelect"] = 0 + case "select": + options["values"] = d.values + options["maxSelect"] = 1 + case "file": + options["maxSelect"] = 1 + options["maxSize"] = d.maxSize + options["mimeTypes"] = mimeOrEmpty(d.mimeTypes) + case "json": + options["maxSize"] = d.maxSize + } + return map[string]any{"name": d.name, "type": d.typ, "required": d.required, "options": options} + } + + field := map[string]any{"name": d.name, "type": d.typ, "required": d.required} + switch d.typ { + case "relation": + field["collectionId"] = idByName[d.relTo] + field["cascadeDelete"] = d.cascadeDelete + field["maxSelect"] = 1 + field["minSelect"] = 0 + case "select": + field["values"] = d.values + field["maxSelect"] = 1 + case "autodate": + field["onCreate"] = d.onCreate + field["onUpdate"] = d.onUpdate + case "file": + field["maxSelect"] = 1 + field["maxSize"] = d.maxSize + field["mimeTypes"] = mimeOrEmpty(d.mimeTypes) + case "json": + field["maxSize"] = d.maxSize + } + return field +} + +func mimeOrEmpty(m []string) []string { + if m == nil { + return []string{} + } + return m +} + +// Run authenticates as the superuser, creates any missing collections, reconciles +// existing ones, and (when configured) ensures the DriverVault super-admin exists. +// It is safe to call on every startup. +func Run(ctx context.Context, client *pb.Client, opts Options) error { + if err := client.Authenticate(ctx); err != nil { + return fmt.Errorf("authenticate: %w", err) + } + + existing, err := listCollections(ctx, client) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + format := detectFormat(existing) + log.Printf("bootstrap: schema format %q", format) + + present := make(map[string]bool, len(existing)) + idByName := make(map[string]string, len(existing)) + for _, c := range existing { + present[c.Name] = true + idByName[c.Name] = c.ID + } + + // Create in dependency order: organizations before users references it; cars + // before its relations. "users" is PocketBase's built-in auth collection and + // is never created here — only reconciled below. + for _, name := range createOrder { + if present[name] { + continue + } + if err := createCollection(ctx, client, name, format, idByName); err != nil { + return fmt.Errorf("create %s: %w", name, err) + } + log.Printf("bootstrap: ✓ %s created", name) + // Refresh so later relations resolve newly-created collection ids. + refreshed, err := listCollections(ctx, client) + if err != nil { + return fmt.Errorf("re-list collections: %w", err) + } + for _, c := range refreshed { + present[c.Name] = true + idByName[c.Name] = c.ID + } + } + + // Reconcile fields on existing collections (add missing + fix relation + // cascade and select value lists). + for _, name := range reconcileOrder { + if err := reconcileFields(ctx, client, name, format, idByName); err != nil { + return fmt.Errorf("reconcile %s: %w", name, err) + } + } + + if err := ensureSuperAdmin(ctx, client, opts); err != nil { + return fmt.Errorf("super-admin: %w", err) + } + return nil +} + +// --- PocketBase collection calls ------------------------------------------- + +type collectionMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Fields []map[string]any `json:"fields"` + Schema []map[string]any `json:"schema"` +} + +func listCollections(ctx context.Context, client *pb.Client) ([]collectionMeta, error) { + raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections?perPage=200", nil) + if err != nil { + return nil, err + } + if status < 200 || status >= 300 { + return nil, fmt.Errorf("status %d: %s", status, raw) + } + var env struct { + Items []collectionMeta `json:"items"` + } + if err := json.Unmarshal(raw, &env); err != nil { + return nil, err + } + return env.Items, nil +} + +// detectFormat reports whether this PocketBase serializes fields under "fields" +// (v0.23+) or the legacy "schema" key, defaulting to the modern format. +func detectFormat(cols []collectionMeta) string { + for _, c := range cols { + if len(c.Fields) > 0 { + return "fields" + } + if len(c.Schema) > 0 { + return "schema" + } + } + return "fields" +} + +func createCollection(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error { + rendered := make([]map[string]any, 0, len(collectionsSchema[name])) + for _, d := range collectionsSchema[name] { + rendered = append(rendered, renderField(d, format, idByName)) + } + body := map[string]any{ + "name": name, + "type": "base", + format: rendered, // "fields" or "schema" + // Rules left null => superuser-only access (the API Server is the only client). + "listRule": nil, + "viewRule": nil, + "createRule": nil, + "updateRule": nil, + "deleteRule": nil, + } + if idx := indexes[name]; len(idx) > 0 { + body["indexes"] = idx + } + raw, status, err := client.Raw(ctx, http.MethodPost, "/api/collections", body) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("status %d: %s", status, raw) + } + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &created); err == nil { + idByName[name] = created.ID + } + return nil +} + +// reconcileFields brings an existing collection in line with the desired schema: +// it appends missing fields and updates relation cascadeDelete and select value +// lists on existing fields, preserving every existing field (including system +// fields) and their ids. +func reconcileFields(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error { + raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections/"+name, nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("get: status %d: %s", status, raw) + } + var col collectionMeta + if err := json.Unmarshal(raw, &col); err != nil { + return err + } + current := col.Fields + if format == "schema" { + current = col.Schema + } + + defs := collectionsSchema[name] + defByName := make(map[string]fieldDef, len(defs)) + for _, d := range defs { + defByName[d.name] = d + } + + var changes []string + merged := make([]map[string]any, 0, len(current)+len(defs)) + haveName := make(map[string]bool, len(current)) + for _, f := range current { + fname, _ := f["name"].(string) + haveName[fname] = true + if def, ok := defByName[fname]; ok { + switch def.typ { + case "relation": + if asBool(f["cascadeDelete"]) != def.cascadeDelete { + f["cascadeDelete"] = def.cascadeDelete + changes = append(changes, fname+".cascadeDelete") + } + case "select": + if !sameValues(f["values"], def.values) { + f["values"] = def.values + changes = append(changes, fname+".values") + } + } + } + merged = append(merged, f) + } + // Append missing fields. + for _, d := range defs { + if !haveName[d.name] { + merged = append(merged, renderField(d, format, idByName)) + changes = append(changes, "+"+d.name) + } + } + + if len(changes) == 0 { + log.Printf("bootstrap: • %s up to date", name) + return nil + } + patch := map[string]any{format: merged} + raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/collections/"+col.ID, patch) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("patch: status %d: %s", status, raw) + } + log.Printf("bootstrap: ✓ %s — %v", name, changes) + return nil +} + +// ensureSuperAdmin creates the DriverVault super-admin user when it does not yet +// exist. An already-present account (matched by email) is left untouched. +func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) error { + if opts.SuperAdminEmail == "" || opts.SuperAdminPassword == "" { + return nil // not requested + } + coll := opts.UsersCollection + if coll == "" { + coll = "users" + } + + filter := url.QueryEscape(fmt.Sprintf("email='%s'", opts.SuperAdminEmail)) + raw, status, err := client.Raw(ctx, http.MethodGet, + "/api/collections/"+coll+"/records?perPage=1&filter="+filter, nil) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("lookup: status %d: %s", status, raw) + } + var found struct { + TotalItems int `json:"totalItems"` + } + if err := json.Unmarshal(raw, &found); err != nil { + return err + } + if found.TotalItems > 0 { + log.Printf("bootstrap: • super-admin %s already exists", opts.SuperAdminEmail) + return nil + } + + name := opts.SuperAdminName + if name == "" { + name = "Administrator" + } + create := map[string]any{ + "email": opts.SuperAdminEmail, + "password": opts.SuperAdminPassword, + "passwordConfirm": opts.SuperAdminPassword, + "name": name, + "role": "superadmin", + "verified": true, + "emailVisibility": false, + } + raw, status, err = client.Raw(ctx, http.MethodPost, "/api/collections/"+coll+"/records", create) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return fmt.Errorf("create: status %d: %s", status, raw) + } + log.Printf("bootstrap: ✓ super-admin %s created", opts.SuperAdminEmail) + return nil +} + +// --- helpers --------------------------------------------------------------- + +func asBool(v any) bool { + b, _ := v.(bool) + return b +} + +// sameValues reports whether a select field's current values equal the desired +// set (order-insensitive), matching the reconcile check in setup-pocketbase.mjs. +func sameValues(current any, want []string) bool { + arr, ok := current.([]any) + if !ok || len(arr) != len(want) { + return false + } + have := make(map[string]bool, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok { + have[s] = true + } + } + for _, w := range want { + if !have[w] { + return false + } + } + return true +} diff --git a/API Server/internal/bootstrap/bootstrap_test.go b/API Server/internal/bootstrap/bootstrap_test.go new file mode 100644 index 0000000..c3a3305 --- /dev/null +++ b/API Server/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,96 @@ +package bootstrap + +import "testing" + +// TestRenderFieldModern locks the v0.23+ ("fields") wire shape for each field +// type against what PocketBase expects. +func TestRenderFieldModern(t *testing.T) { + idByName := map[string]string{"cars": "col_cars"} + + rel := renderField(fRelation("car", "cars", true, true), "fields", idByName) + if rel["collectionId"] != "col_cars" || rel["cascadeDelete"] != true || + rel["maxSelect"] != 1 || rel["minSelect"] != 0 || rel["required"] != true { + t.Fatalf("relation rendered wrong: %#v", rel) + } + + nonCascade := renderField(fRelation("owner", "users", false, false), "fields", map[string]string{"users": "u"}) + if nonCascade["cascadeDelete"] != false { + t.Fatalf("non-cascading relation should keep cascadeDelete=false: %#v", nonCascade) + } + + sel := renderField(fSelect("result", []string{"passed", "failed"}, false), "fields", idByName) + if sel["maxSelect"] != 1 { + t.Fatalf("select maxSelect: %#v", sel) + } + if vals, ok := sel["values"].([]string); !ok || len(vals) != 2 { + t.Fatalf("select values: %#v", sel["values"]) + } + + ad := renderField(fAutodate("created", true, false), "fields", idByName) + if ad["onCreate"] != true || ad["onUpdate"] != false { + t.Fatalf("autodate: %#v", ad) + } + + file := renderField(attachment(), "fields", idByName) + if file["maxSize"] != 10485760 || file["maxSelect"] != 1 { + t.Fatalf("file: %#v", file) + } + if mt, ok := file["mimeTypes"].([]string); !ok || len(mt) != 5 { + t.Fatalf("file mimeTypes: %#v", file["mimeTypes"]) + } + + j := renderField(fJSON("params", 10000), "fields", idByName) + if j["maxSize"] != 10000 { + t.Fatalf("json: %#v", j) + } +} + +// TestRenderFieldLegacy checks that the legacy ("schema") layout nests options. +func TestRenderFieldLegacy(t *testing.T) { + rel := renderField(fRelation("car", "cars", true, true), "schema", map[string]string{"cars": "col_cars"}) + opts, ok := rel["options"].(map[string]any) + if !ok { + t.Fatalf("legacy relation should nest options: %#v", rel) + } + if opts["collectionId"] != "col_cars" || opts["cascadeDelete"] != true { + t.Fatalf("legacy relation options: %#v", opts) + } +} + +// TestSameValues covers the select-reconcile comparison (order-insensitive). +func TestSameValues(t *testing.T) { + if !sameValues([]any{"a", "b"}, []string{"b", "a"}) { + t.Fatal("same set in different order should compare equal") + } + if sameValues([]any{"a"}, []string{"a", "b"}) { + t.Fatal("differing lengths should not be equal") + } + if sameValues([]any{"a", "c"}, []string{"a", "b"}) { + t.Fatal("differing members should not be equal") + } + if sameValues(nil, []string{"a"}) { + t.Fatal("nil current should not equal non-empty want") + } +} + +// TestSchemaConsistency guards the ordered lists against the schema map. +func TestSchemaConsistency(t *testing.T) { + for _, name := range createOrder { + if _, ok := collectionsSchema[name]; !ok { + t.Errorf("createOrder references unknown collection %q", name) + } + if name == "users" { + t.Errorf("users must not be in createOrder (built-in auth collection)") + } + } + for _, name := range reconcileOrder { + if _, ok := collectionsSchema[name]; !ok { + t.Errorf("reconcileOrder references unknown collection %q", name) + } + } + for name := range indexes { + if _, ok := collectionsSchema[name]; !ok { + t.Errorf("indexes references unknown collection %q", name) + } + } +} diff --git a/API Server/internal/bootstrap/schema.go b/API Server/internal/bootstrap/schema.go new file mode 100644 index 0000000..53292e0 --- /dev/null +++ b/API Server/internal/bootstrap/schema.go @@ -0,0 +1,218 @@ +package bootstrap + +// This file is the Go mirror of the DESIRED schema, create order, reconcile +// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit +// here and re-run the server (bootstrap applies on startup), or run the script. +// +// Access rules are left null on every collection on purpose — every client goes +// through the API Server, which authenticates as a superuser, so the database is +// never exposed directly (including attachments, proxied by the API). + +// collectionsSchema is the desired field set per collection. +var collectionsSchema = map[string][]fieldDef{ + "cars": { + fText("name", true), + fText("make", false), + fText("model", false), + fNumber("year"), + fText("registration", false), + fText("registration_country", false), + fText("vin", false), + fNumber("service_interval_days"), + fNumber("service_interval_km"), + // Roadworthiness inspection cycle. Only prefills a check's next-due date. + fNumber("technical_check_interval_days"), + fText("oil_spec", false), + fText("transmission_oil_spec", false), + fText("differential_oil_spec", false), + fText("brake_fluid_spec", false), + fText("coolant_spec", false), + fNumber("current_km"), + // Bi-fuel LPG conversions are their own choice: the car runs on either tank. + fSelect("fuel_type", []string{ + "petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen", + }, false), + fText("build_date", false), // ISO YYYY-MM-DD (date-only) + fText("first_registration_date", false), // ISO YYYY-MM-DD + // Owner of this car. Non-cascading: deleting a user must not wipe their cars. + fRelation("owner", "users", false, false), + }, + "service_records": { + fRelation("car", "cars", true, true), + fDate("date", true), + fNumber("km"), + fBool("changed_oil"), + fBool("changed_engine_air_filter"), + fBool("changed_cabin_air_filter"), + fText("notes", false), + attachment(), // the workshop receipt / stamped service-book page + }, + // Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV). + "technical_checks": { + fRelation("car", "cars", true, true), + fDate("date", true), + fSelect("result", []string{"passed", "failed"}, false), + fNumber("cost"), + fText("station", false), + fDate("valid_until", false), + fText("notes", false), + attachment(), // the certificate + }, + "parts": { + fRelation("car", "cars", true, true), + fText("name", true), + fText("part_number", false), + fText("category", false), + fText("notes", false), + attachment(), // a photo of the box, or the part's spec sheet + }, + // Fuel refills. Consumption is derived on read, not stored. + "fuel_entries": { + fRelation("car", "cars", true, true), + fDate("date", true), + fNumber("km"), // odometer at the pump + fNumber("liters"), + fNumber("cost"), + fBool("full_tank"), + fBool("missed_fill"), + fText("station", false), + fText("notes", false), + attachment(), // the pump receipt + }, + // Workshop visits and repairs (unplanned/one-off garage work with a labour bill). + "maintenance_entries": { + fRelation("car", "cars", true, true), + fDate("date", true), + fNumber("km"), + fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false), + fSelect("status", []string{"scheduled", "in_progress", "completed"}, false), + fText("workshop", false), + fText("location", false), + fText("description", false), + fText("parts_used", false), + fNumber("labor_cost"), + fNumber("parts_cost"), + fText("invoice_number", false), + fDate("warranty_until", false), + fText("notes", false), + attachment(), // the workshop's invoice + }, + // Insurance, pollution certificates, registration papers … expiry drives reminders. + "car_documents": { + fRelation("car", "cars", true, true), + fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false), + fText("title", true), + fText("provider", false), + fText("reference", false), + fDate("issue_date", false), + fDate("expiry_date", false), + fNumber("cost"), + fText("notes", false), + attachment(), // the scan/PDF of the paperwork + }, + // User-set reminders (derived document/service ones are computed on read). + "reminders": { + fRelation("car", "cars", true, true), + fText("title", true), + fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false), + fDate("due_date", false), + fNumber("due_km"), + fNumber("repeat_days"), + fNumber("repeat_km"), + fBool("done"), + fDate("done_at", false), + fText("notes", false), + }, + // Per-car sharing grants. Cascades on both relations. + "car_shares": { + fRelation("car", "cars", true, true), + fRelation("user", "users", true, true), + fSelect("permission", []string{"read", "write"}, true), + fAutodate("created", true, false), + }, + // Append-only audit trail for OCPP charger control. Actor/org stored as plain + // text ids (not relations) so the trail survives user or org deletion. + "control_audit": { + fText("user_id", false), + fText("org_id", false), + fText("serial", false), + fText("action", true), + fText("result", false), + fJSON("params", 10000), + fAutodate("created", true, false), + }, + // Tenants that users belong to. + "organizations": { + fText("name", true), + fAutodate("created", true, false), + // Per-organization plugin/integration config (middle layer of the cascade). + fJSON("pluginSettings", 100000), + }, + // Custom fields layered onto the built-in "users" auth collection. + "users": { + fText("bio", false), + fSelect("theme", []string{"light", "dark", "system"}, false), + fText("locale", false), + fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false), + fSelect("currency", []string{ + "EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK", + "ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB", + "TRY", "UAH", "USD", "CAD", "AUD", "JPY", + }, false), + fSelect("font_size", []string{"small", "medium", "large"}, false), + fDate("deletion_requested_at", false), + // Access role. Empty value is treated as "user" by the API. + fSelect("role", []string{"user", "admin", "superadmin"}, false), + // Organization membership. Non-cascading: deleting an org keeps its people. + fRelation("organization", "organizations", false, false), + // Per-user plugin/integration config (bottom layer of the cascade). + fJSON("pluginSettings", 100000), + }, +} + +// createOrder is the dependency order for creating missing collections. +// "users" is PocketBase's built-in auth collection and is never created here. +var createOrder = []string{ + "organizations", + "cars", + "service_records", + "technical_checks", + "parts", + "car_shares", + "fuel_entries", + "maintenance_entries", + "car_documents", + "reminders", + "control_audit", +} + +// reconcileOrder additionally includes "users" so its custom fields (role, +// organization, preferences) are added to the built-in collection. +var reconcileOrder = []string{ + "organizations", + "users", + "cars", + "service_records", + "technical_checks", + "parts", + "car_shares", + "fuel_entries", + "maintenance_entries", + "car_documents", + "reminders", + "control_audit", +} + +// indexes are extra SQL indexes applied at collection-create time. +var indexes = map[string][]string{ + "organizations": {"CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"}, + "fuel_entries": {"CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"}, + "maintenance_entries": {"CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"}, + "car_documents": {"CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"}, + "reminders": {"CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"}, + "technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"}, + "control_audit": { + "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)", + "CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)", + }, +} diff --git a/API Server/internal/config/config.go b/API Server/internal/config/config.go index 9a08cc8..ba0cc47 100644 --- a/API Server/internal/config/config.go +++ b/API Server/internal/config/config.go @@ -36,6 +36,18 @@ type Config struct { // the charger at, instead of deriving it from request headers. OCPPRequireTLS bool OCPPPublicURL string + + // Bootstrap controls the on-startup PocketBase schema setup: when true (the + // default) the server creates any missing collections and reconciles existing + // ones against the desired schema, then ensures the super-admin below exists. + // It requires a configured service account; without one it is a no-op. + Bootstrap bool + + // Super-admin account created during bootstrap when both fields are set and no + // user with that email exists yet. Existing accounts are left untouched. + SuperAdminEmail string + SuperAdminPassword string + SuperAdminName string } // EnvFile is the .env path (relative to the working directory) that Load reads @@ -63,6 +75,10 @@ func Load() Config { PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"), OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true), OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"), + Bootstrap: boolEnv("PB_BOOTSTRAP", true), + SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"), + SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"), + SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"), } } diff --git a/Docker AIO/.env.example b/Docker AIO/.env.example index d565e38..04b9cf4 100644 --- a/Docker AIO/.env.example +++ b/Docker AIO/.env.example @@ -5,9 +5,22 @@ PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=change-me-long-password +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Set to false to skip schema creation/reconcile once the database is set up. +PB_BOOTSTRAP=true + +# Allowed CORS origin(s) — match your web origin / WEB_PORT. +CORS_ALLOW_ORIGINS=http://localhost:8090 + # --- Host port mappings (optional; defaults shown) -------------------------- -WEB_PORT=80 -PB_PORT=8090 +WEB_PORT=8090 +PB_PORT=8070 # API Server + its embedded web panel (served at the API root, http://host:8080/). API_PORT=8080 diff --git a/Docker AIO/.env.prod.example b/Docker AIO/.env.prod.example new file mode 100644 index 0000000..e9d40ea --- /dev/null +++ b/Docker AIO/.env.prod.example @@ -0,0 +1,35 @@ +# DriverVault all-in-one — production config. +# Copy to .env and fill in, then: +# docker compose -f docker-compose.prod.yml pull +# docker compose -f docker-compose.prod.yml up -d + +# --- Registry image ---------------------------------------------------------- +AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest + +# --- PocketBase superuser (required) ----------------------------------------- +# Created/updated on first boot. The API Server uses these to manage the database. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Set to false to skip schema creation/reconcile once the database is set up. +PB_BOOTSTRAP=true + +# Allowed CORS origin(s) — match your public web URL / WEB_PORT. +CORS_ALLOW_ORIGINS=http://localhost:8090 + +# --- Host port mappings (optional; defaults shown) -------------------------- +WEB_PORT=8090 +PB_PORT=8070 +API_PORT=8080 + +# --- Storage ----------------------------------------------------------------- +# Default is a Docker-managed named volume ("pb_data"). Set PB_DATA to an +# absolute host path for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data +PB_DATA=pb_data diff --git a/Docker AIO/Dockerfile b/Docker AIO/Dockerfile index 355643f..d02868b 100644 --- a/Docker AIO/Dockerfile +++ b/Docker AIO/Dockerfile @@ -9,13 +9,16 @@ # # Run it (all three services start together): # -# docker run -d --name drivervault -p 80:80 -p 8090:8090 \ +# docker run -d --name drivervault -p 80:80 -p 8070:8070 \ # -e PB_ADMIN_EMAIL=admin@example.com \ # -e PB_ADMIN_PASSWORD=change-me \ +# -e DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com \ +# -e DRIVERVAULT_SUPERADMIN_PASSWORD=change-me \ # -v drivervault_pb:/pb/pb_data \ # drivervault-aio # -# Then: web app on http://host/ and PocketBase admin on http://host:8090/_/ +# Then: web app on http://host/ and PocketBase admin on http://host:8070/_/ +# On first boot the API Server creates the collections and the super-admin. # --- Stage 1: build the Go API Server --------------------------------------- FROM golang:1.26-alpine AS api-build @@ -115,7 +118,7 @@ logfile_maxbytes=0 ; PocketBase: upsert the superuser (idempotent) then serve. [program:pocketbase] directory=/pb -command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8090' +command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070' priority=10 autostart=true autorestart=true @@ -126,7 +129,7 @@ stderr_logfile_maxbytes=0 ; API Server: wait for PocketBase to be healthy, then start. [program:api-server] -command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8090/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server' +command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server' priority=20 autostart=true autorestart=true @@ -148,15 +151,17 @@ SUPERVISOR # API Server config: everything is local to this container. ENV PORT=8080 \ - PB_URL=http://127.0.0.1:8090 \ - CORS_ALLOW_ORIGINS=http://localhost \ + PB_URL=http://127.0.0.1:8070 \ + CORS_ALLOW_ORIGINS=http://localhost:8090 \ AUTH_USERS_COLLECTION=users # Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD. +# Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create +# the first app super-admin on boot; PB_BOOTSTRAP=false skips schema setup. # Pass them with `docker run -e ...`. VOLUME /pb/pb_data -# 80 = Web App, 8090 = PocketBase admin, 8080 = API Server + embedded API panel. -EXPOSE 80 8090 8080 +# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel. +EXPOSE 80 8070 8080 CMD ["supervisord", "-c", "/etc/supervisord.conf"] diff --git a/Docker AIO/docker-compose.prod.yml b/Docker AIO/docker-compose.prod.yml new file mode 100644 index 0000000..a1a9fcb --- /dev/null +++ b/Docker AIO/docker-compose.prod.yml @@ -0,0 +1,41 @@ +name: drivervault-aio + +# Production all-in-one — pulls the prebuilt image from the registry instead of +# building. One container runs PocketBase + API Server + Web App (nginx). +# Everything an operator needs to set lives in .env. +# +# 1. cp .env.prod.example .env (then edit it) +# 2. docker compose -f docker-compose.prod.yml pull +# 3. docker compose -f docker-compose.prod.yml up -d +# +# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API +# Server creates any missing collections and the DriverVault super-admin from +# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent. + +services: + drivervault: + image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}" + container_name: drivervault-aio + restart: unless-stopped + environment: + # Superuser (also used by the API Server to authenticate to PocketBase). + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Match CORS to the web origin (only used if a browser calls the API directly). + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false + # to skip once the database is established. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + ports: + - "${WEB_PORT:-8090}:80" # Web App + - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API + - "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /) + volumes: + # Named volume by default; set PB_DATA to a host path in .env for a bind mount. + - "${PB_DATA:-pb_data}:/pb/pb_data" + +volumes: + pb_data: diff --git a/Docker AIO/docker-compose.yml b/Docker AIO/docker-compose.yml index 0ea644c..359e690 100644 --- a/Docker AIO/docker-compose.yml +++ b/Docker AIO/docker-compose.yml @@ -22,9 +22,17 @@ services: # Superuser (also used by the API Server to authenticate to PocketBase). PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + # Match CORS to the web origin (only used if a browser calls the API directly). + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + # Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false + # to skip once the database is established. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" ports: - - "${WEB_PORT:-80}:80" # Web App - - "${PB_PORT:-8090}:8090" # PocketBase admin UI / API + - "${WEB_PORT:-8090}:80" # Web App + - "${PB_PORT:-8070}:8070" # PocketBase admin UI / API - "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /) volumes: - pb_data:/pb/pb_data diff --git a/Docker/.env.example b/Docker/.env.example index cb574e4..a284c86 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -7,13 +7,13 @@ PB_ADMIN_PASSWORD=change-me-long-password # --- API Server ------------------------------------------------------------- # Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL). # Native mobile apps are not subject to CORS. -CORS_ALLOW_ORIGINS=http://localhost:8081 +CORS_ALLOW_ORIGINS=http://localhost:8090 AUTH_USERS_COLLECTION=users # --- Host port mappings (optional; defaults shown) -------------------------- -PB_PORT=8090 +PB_PORT=8070 API_PORT=8080 -WEB_PORT=8081 +WEB_PORT=8090 # --- Web App build ----------------------------------------------------------- # Leave empty so the browser uses same-origin /api (proxied by the BFF). diff --git a/Docker/.env.prod.example b/Docker/.env.prod.example new file mode 100644 index 0000000..1b52267 --- /dev/null +++ b/Docker/.env.prod.example @@ -0,0 +1,49 @@ +# DriverVault — production stack config. +# Copy to .env and fill in, then: +# docker compose -f docker-compose.prod.yml pull +# docker compose -f docker-compose.prod.yml up -d + +# --- Registry images --------------------------------------------------------- +# Defaults point at the internal registry; override to pin a tag or use a mirror. +PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest +API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest +WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest + +# --- PocketBase superuser ---------------------------------------------------- +# Created/updated on the PocketBase container's first boot. The API Server uses +# these same credentials to manage the database. REQUIRED. +PB_ADMIN_EMAIL=admin@example.com +PB_ADMIN_PASSWORD=change-me-long-password + +# --- DriverVault super-admin (app login) ------------------------------------- +# The first application user, created by the API Server on boot with role +# "superadmin" if no user with this email exists yet. Leave blank to skip and +# create the first user by hand. This is the account you log in to the web app +# with — distinct from the PocketBase superuser above. +DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com +DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password +DRIVERVAULT_SUPERADMIN_NAME=Administrator + +# Set to false to skip schema creation/reconcile once the database is set up. +PB_BOOTSTRAP=true + +# --- API Server -------------------------------------------------------------- +# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT). +CORS_ALLOW_ORIGINS=http://localhost:8090 +AUTH_USERS_COLLECTION=users + +# --- Ports ------------------------------------------------------------------- +# WEB_PORT is the public front door (bound on all interfaces). +WEB_PORT=8090 +# PocketBase admin UI and the API panel are bound to localhost only by default. +# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network. +PB_PORT=8070 +PB_BIND=127.0.0.1 +API_PORT=8080 +API_BIND=127.0.0.1 + +# --- Storage ----------------------------------------------------------------- +# Default is a Docker-managed named volume ("pb_data"). To store the database on +# a host path instead, set PB_DATA to an absolute path, e.g.: +# PB_DATA=/srv/drivervault/pb_data +PB_DATA=pb_data diff --git a/Docker/docker-compose.prod.yml b/Docker/docker-compose.prod.yml new file mode 100644 index 0000000..49d9c41 --- /dev/null +++ b/Docker/docker-compose.prod.yml @@ -0,0 +1,83 @@ +name: drivervault + +# Production DriverVault stack — pulls prebuilt images from the registry instead +# of building from source. Everything an operator needs to set lives in .env. +# +# 1. cp .env.prod.example .env (then edit it — all secrets/ports/volumes) +# 2. docker compose -f docker-compose.prod.yml pull +# 3. docker compose -f docker-compose.prod.yml up -d +# +# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase. +# +# On first boot: +# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing). +# • the API Server creates any missing collections, reconciles existing ones, +# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*. +# Both steps are idempotent, so restarts and upgrades are safe. + +services: + pocketbase: + image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}" + container_name: drivervault-pocketbase + restart: unless-stopped + environment: + # The superuser is created/updated on boot (the API Server authenticates + # with it). This is the only place the first superuser can be created — the + # REST API cannot bootstrap it. + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}" + volumes: + # Named volume by default; set PB_DATA to a host path in .env for a bind mount. + - "${PB_DATA:-pb_data}:/pb/pb_data" + ports: + # Bound to localhost by default — the admin UI (/_/) is reachable only on + # the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network. + - "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 + start_period: 10s + + api-server: + image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}" + container_name: drivervault-api + restart: unless-stopped + depends_on: + pocketbase: + condition: service_healthy + environment: + PORT: "8080" + # Reach PocketBase by its service name on the internal network. + PB_URL: "http://pocketbase:8070" + PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" + PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" + AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" + # Schema + super-admin bootstrap (idempotent). Set PB_BOOTSTRAP=false to + # skip it once the database is established. + PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" + DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" + DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" + DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}" + ports: + # Localhost-only by default (the Web App reaches it over the internal + # network). Set API_BIND=0.0.0.0 to expose the API panel on the network. + - "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080" + + web-app: + image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}" + container_name: drivervault-web + restart: unless-stopped + depends_on: + - api-server + environment: + # The BFF reverse-proxies /api/* to the API Server over the internal network. + API_BASE: "http://api-server:8080" + ports: + # The public front door. Bound on all interfaces so browsers can reach it. + - "${WEB_PORT:-8090}:8090" + +volumes: + pb_data: diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index 3489acc..000858a 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -18,10 +18,10 @@ services: volumes: - pb_data:/pb/pb_data ports: - # Admin UI / API exposed on the host for management (http://host:8090/_/). - - "${PB_PORT:-8090}:8090" + # Admin UI / API exposed on the host for management (http://host:8070/_/). + - "${PB_PORT:-8070}:8070" healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/api/health || exit 1"] + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"] interval: 10s timeout: 3s retries: 12 @@ -39,12 +39,12 @@ services: environment: PORT: "8080" # Reach PocketBase by its service name on the internal network. - PB_URL: "http://pocketbase:8090" + PB_URL: "http://pocketbase:8070" PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}" PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}" # Same-origin requests go through the Web App BFF, so CORS is only needed # if the browser ever calls the API Server directly. Default to the web origin. - CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8081}" + CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" ports: # Optional direct access to the API Server (and its panel at /); the Web @@ -66,7 +66,7 @@ services: # The BFF reverse-proxies /api/* to the API Server over the internal network. API_BASE: "http://api-server:8080" ports: - - "${WEB_PORT:-8081}:8090" + - "${WEB_PORT:-8090}:8090" volumes: pb_data: diff --git a/Docker/pocketbase/Dockerfile b/Docker/pocketbase/Dockerfile index 0c4f416..60f808c 100644 --- a/Docker/pocketbase/Dockerfile +++ b/Docker/pocketbase/Dockerfile @@ -29,6 +29,6 @@ RUN chmod +x /entrypoint.sh # pb_data holds the SQLite database and uploads — mount a volume here. VOLUME /pb/pb_data -EXPOSE 8090 +EXPOSE 8070 ENTRYPOINT ["/entrypoint.sh"] diff --git a/Docker/pocketbase/entrypoint.sh b/Docker/pocketbase/entrypoint.sh index 38b5fbf..011e916 100644 --- a/Docker/pocketbase/entrypoint.sh +++ b/Docker/pocketbase/entrypoint.sh @@ -9,4 +9,4 @@ if [ -n "$PB_ADMIN_EMAIL" ] && [ -n "$PB_ADMIN_PASSWORD" ]; then || echo "warning: superuser upsert failed; create an admin via the UI at /_/" fi -exec /pb/pocketbase serve --http=0.0.0.0:8090 +exec /pb/pocketbase serve --http=0.0.0.0:8070