diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile index c65bec0..e9cb550 100644 --- a/app/backend/Dockerfile +++ b/app/backend/Dockerfile @@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \ WORKDIR /app COPY --from=go-builder /app/main ./main -COPY --from=go-builder /app/migrations ./migrations COPY --from=go-builder /app/migrations_goose ./migrations_goose COPY --from=auth-builder /auth ./auth diff --git a/app/backend/README_BACKEND.md b/app/backend/README_BACKEND.md index 95d6535..e889063 100644 --- a/app/backend/README_BACKEND.md +++ b/app/backend/README_BACKEND.md @@ -170,8 +170,7 @@ internal/ ├── config/ - Configuration management ├── database/ - Database connections and migrations └── middleware/ - HTTP middleware -migrations/ - SQL migration files -migrations_goose/ - Goose-managed migrations for new schema changes +migrations_goose/ - Goose-managed migrations (single migration path) ``` ### Adding New Endpoints @@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes 1. Create handler functions in `internal/api/` 2. Add routes in `internal/api/routes.go` 3. Update database schema: - - Legacy chain lives in `migrations/` and is executed first. - - New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`). - - Keep legacy files immutable after they are shipped. + - All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`). + - `20260401163000_baseline.sql` carries the consolidated schema; append new files after it. + - Never edit the baseline in place; always add a new migration file. ### SQLC (Type-Safe Queries) diff --git a/app/backend/cmd/apwhy/main.go b/app/backend/cmd/apwhy/main.go deleted file mode 100644 index 852e0c7..0000000 --- a/app/backend/cmd/apwhy/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net/http" - - "containr/internal/apwhy/api" - "containr/internal/apwhy/config" - "containr/internal/apwhy/storage" -) - -func main() { - cfg := config.Load() - - store, err := storage.Open(cfg) - if err != nil { - log.Fatalf("failed to initialize storage: %v", err) - } - defer store.Close() - - server := api.NewServer(store, cfg) - - addr := fmt.Sprintf(":%d", cfg.Port) - log.Printf("APwhy server listening on http://localhost%s", addr) - if err := http.ListenAndServe(addr, server.Handler()); err != nil { - log.Fatalf("server exited: %v", err) - } -} diff --git a/app/backend/cmd/migrate/main.go b/app/backend/cmd/migrate/main.go index 63aabc2..b57c0a5 100644 --- a/app/backend/cmd/migrate/main.go +++ b/app/backend/cmd/migrate/main.go @@ -25,11 +25,6 @@ func main() { } defer db.Close() - legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR") - if legacyDir == "" { - legacyDir = "migrations" - } - gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR") if gooseDir == "" { gooseDir = "migrations_goose" @@ -41,24 +36,14 @@ func main() { } switch command { - case "up": + case "up", "goose-up": migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) - if err := db.MigrateAllWithLock(migrationCtx, legacyDir, gooseDir); err != nil { + if err := db.MigrateAllWithLock(migrationCtx, gooseDir); err != nil { migrationCancel() log.Fatalf("Migration failed: %v", err) } migrationCancel() - log.Println("Legacy + goose migrations completed successfully") - case "legacy-up": - if err := db.Migrate(legacyDir); err != nil { - log.Fatalf("Legacy migration failed: %v", err) - } - log.Println("Legacy migrations completed successfully") - case "goose-up": - if err := db.MigrateGoose(gooseDir); err != nil { - log.Fatalf("Goose migration failed: %v", err) - } - log.Println("Goose migrations completed successfully") + log.Println("Migrations completed successfully") case "goose-status": if err := db.GooseStatus(gooseDir); err != nil { log.Fatalf("Goose status failed: %v", err) @@ -73,6 +58,6 @@ func main() { } log.Printf("Created goose migration %q in %q", name, gooseDir) default: - log.Fatalf("Unknown command %q. Supported commands: up | legacy-up | goose-up | goose-status | goose-create", command) + log.Fatalf("Unknown command %q. Supported commands: up | goose-status | goose-create", command) } } diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index fbebca7..97e0cdb 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -39,7 +39,7 @@ func main() { // Run startup migrations unless explicitly disabled. if cfg.AutoMigrate { migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) - if err := db.MigrateAllWithLock(migrationCtx, "migrations", "migrations_goose"); err != nil { + if err := db.MigrateAllWithLock(migrationCtx, "migrations_goose"); err != nil { migrationCancel() log.Fatalf("Failed to run database migrations: %v", err) } diff --git a/app/backend/cmd/test-build-manager/main.go b/app/backend/cmd/test-build-manager/main.go deleted file mode 100644 index 21435b6..0000000 --- a/app/backend/cmd/test-build-manager/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "containr/internal/build" -) - -func main() { - fmt.Println("🧪 Testing Build Manager Detection...") - - // Test build type detection on the current project - fmt.Println("\n📁 Testing on current project (has package.json)...") - - // Note: We can't fully test BuildManager without a docker client, - // but we can test the detection logic - - // Create mock scenarios - testCases := []struct { - name string - expected string - }{ - {"Node.js project (package.json)", "railpack"}, - {"Python project (requirements.txt)", "railpack"}, - {"Go project (go.mod)", "railpack"}, - {"Dockerfile project", "dockerfile"}, - {"Unknown project", "nixpacks"}, - } - - fmt.Println("\n🎯 Expected detection priorities:") - for _, tc := range testCases { - fmt.Printf(" • %s → %s\n", tc.name, tc.expected) - } - - // Test Railpack builder directly - builder := build.NewRailpackBuilder("/tmp/test", nil) - - fmt.Println("\n🔍 Testing Railpack detection on current project:") - err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr") - if err != nil { - fmt.Printf("❌ Detection failed: %v\n", err) - } else { - fmt.Println("✅ Railpack can build this project!") - } - - fmt.Println("\n📋 Build Priority Order:") - fmt.Println(" 1. Dockerfile (if present)") - fmt.Println(" 2. Railpack (primary choice)") - fmt.Println(" 3. Nixpacks (fallback)") - fmt.Println(" 4. Prebuilt (manual)") - - fmt.Println("\n🚀 Build Manager Integration Test Complete!") -} diff --git a/app/backend/cmd/test-railpack/main.go b/app/backend/cmd/test-railpack/main.go deleted file mode 100644 index 59c0667..0000000 --- a/app/backend/cmd/test-railpack/main.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "containr/internal/build" -) - -func main() { - // Create a RailpackBuilder - builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil) - - // Test detection on the current project (has package.json) - fmt.Println("Testing Railpack detection on current project...") - - err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr") - if err != nil { - fmt.Printf("❌ Railpack detection failed: %v\n", err) - } else { - fmt.Println("✅ Railpack can build this project!") - } - - // Show supported frameworks - fmt.Println("\nSupported frameworks:") - frameworks := builder.GetSupportedFrameworks() - for _, fw := range frameworks { - fmt.Printf(" - %s\n", fw) - } - - fmt.Println("\n✅ Railpack integration test completed successfully!") -} diff --git a/app/backend/go.mod b/app/backend/go.mod index 7fcbb54..ca785d9 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -8,11 +8,9 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-playground/validator/v10 v10.30.1 github.com/go-redis/redis/v8 v8.11.5 - github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.7.1 github.com/lib/pq v1.10.9 github.com/miekg/dns v1.1.72 github.com/pressly/goose/v3 v3.24.1 @@ -22,13 +20,9 @@ require ( github.com/sqlc-dev/pqtype v0.3.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.47.0 - gorm.io/driver/postgres v1.6.0 - gorm.io/gorm v1.31.1 - modernc.org/sqlite v1.39.0 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -40,7 +34,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect @@ -52,11 +45,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -68,13 +56,11 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.1.0 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -95,7 +81,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect @@ -106,7 +91,5 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect - modernc.org/libc v1.66.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.0 // indirect ) diff --git a/app/backend/go.sum b/app/backend/go.sum index 22eb2f5..c009c2a 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -1,5 +1,3 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -63,8 +61,6 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= @@ -74,8 +70,6 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -84,18 +78,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= -github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -259,36 +241,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= -gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/app/backend/internal/analytics/umami.go b/app/backend/internal/analytics/umami.go deleted file mode 100644 index 5ed8d56..0000000 --- a/app/backend/internal/analytics/umami.go +++ /dev/null @@ -1,74 +0,0 @@ -package analytics - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "time" -) - -type Client struct { - BaseURL string - APIKey string - WebsiteID string - HTTP *http.Client -} - -func NewClient(baseURL, apiKey, websiteID string) *Client { - return &Client{ - BaseURL: baseURL, - APIKey: apiKey, - WebsiteID: websiteID, - HTTP: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -func (c *Client) Enabled() bool { - return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != "" -} - -func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) { - if !c.Enabled() { - return map[string]any{ - "enabled": false, - }, nil - } - - u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID)) - if err != nil { - return nil, err - } - - query := u.Query() - query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli())) - query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli())) - u.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.APIKey) - req.Header.Set("Accept", "application/json") - - res, err := c.HTTP.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - return nil, fmt.Errorf("umami returned %d", res.StatusCode) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - return nil, err - } - payload["enabled"] = true - return payload, nil -} diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index dd79fc4..0d8e901 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -1,7 +1,10 @@ package api import ( + "context" "crypto/subtle" + "database/sql" + "encoding/json" "fmt" "math" "net/http" @@ -9,26 +12,29 @@ import ( "strings" "time" + "containr/internal/database" + "containr/internal/database/sqlcdb" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "gorm.io/gorm" + "github.com/sqlc-dev/pqtype" ) // NodeAgent represents a container orchestration agent type NodeAgent struct { - ID string `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Hostname string `json:"hostname" gorm:"not null"` - IPAddress string `json:"ip_address" gorm:"not null"` - Port int `json:"port" gorm:"not null"` - Status string `json:"status" gorm:"default:'offline'"` + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IPAddress string `json:"ip_address"` + Port int `json:"port"` + Status string `json:"status"` Version string `json:"version"` - Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"` - Resources NodeResources `json:"resources" gorm:"serializer:json"` + Capabilities AgentCapabilities `json:"capabilities"` + Resources NodeResources `json:"resources"` LastHeartbeat time.Time `json:"last_heartbeat"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"` + Metadata map[string]interface{} `json:"metadata"` } // AgentCapabilities defines what the agent can do @@ -89,20 +95,20 @@ type BandwidthInfo struct { // ContainerInstance represents a container running on an agent type ContainerInstance struct { - ID string `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Image string `json:"image" gorm:"not null"` - ProjectID string `json:"project_id" gorm:"not null"` - ServiceID string `json:"service_id" gorm:"not null"` - NodeAgentID string `json:"node_agent_id" gorm:"not null"` - Status ContainerStatus `json:"status" gorm:"serializer:json"` - Resources ContainerResources `json:"resources" gorm:"serializer:json"` - Ports []PortMapping `json:"ports" gorm:"serializer:json"` - Environment map[string]string `json:"environment" gorm:"serializer:json"` - Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"` - Networks []string `json:"networks" gorm:"serializer:json"` - RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"` - HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"` + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + ProjectID string `json:"project_id"` + ServiceID string `json:"service_id"` + NodeAgentID string `json:"node_agent_id"` + Status ContainerStatus `json:"status"` + Resources ContainerResources `json:"resources"` + Ports []PortMapping `json:"ports"` + Environment map[string]string `json:"environment"` + Volumes []VolumeMount `json:"volumes"` + Networks []string `json:"networks"` + RestartPolicy RestartPolicy `json:"restart_policy"` + HealthCheck *HealthCheck `json:"health_check"` CreatedAt time.Time `json:"created_at"` StartedAt *time.Time `json:"started_at"` UpdatedAt time.Time `json:"updated_at"` @@ -155,12 +161,12 @@ type HealthCheck struct { // AgentCommand represents a command sent to an agent type AgentCommand struct { - ID string `json:"id" gorm:"primaryKey"` - Type string `json:"type" gorm:"not null"` - NodeAgentID string `json:"node_agent_id" gorm:"not null"` + ID string `json:"id"` + Type string `json:"type"` + NodeAgentID string `json:"node_agent_id"` ContainerID *string `json:"container_id"` - Payload map[string]interface{} `json:"payload" gorm:"serializer:json"` - Status string `json:"status" gorm:"default:'pending'"` + Payload map[string]interface{} `json:"payload"` + Status string `json:"status"` Result *string `json:"result"` Error *string `json:"error"` CreatedAt time.Time `json:"created_at"` @@ -181,23 +187,6 @@ type AgentHeartbeat struct { Version string `json:"version"` } -type AgentHeartbeatRecord struct { - ID string `json:"id" gorm:"primaryKey"` - NodeAgentID string `json:"node_agent_id" gorm:"index;not null"` - Timestamp time.Time `json:"timestamp" gorm:"index;not null"` - Status string `json:"status"` - Resources NodeResources `json:"resources" gorm:"serializer:json"` - ContainerCount int `json:"container_count"` - SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"` - Uptime int64 `json:"uptime"` - Version string `json:"version"` - CreatedAt time.Time `json:"created_at"` -} - -func (AgentHeartbeatRecord) TableName() string { - return "agent_heartbeats" -} - type SystemLoad struct { Load1M float64 `json:"load_1m"` Load5M float64 `json:"load_5m"` @@ -206,13 +195,125 @@ type SystemLoad struct { // NodeAgentHandler handles agent-related endpoints type NodeAgentHandler struct { - db *gorm.DB + q *sqlcdb.Queries } -func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler { - return &NodeAgentHandler{db: db} +func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler { + return &NodeAgentHandler{q: sqlcdb.New(db.DB)} } +// --- row <-> API type conversion helpers --- + +func rawJSON(v interface{}) pqtype.NullRawMessage { + if v == nil { + return pqtype.NullRawMessage{} + } + b, err := json.Marshal(v) + if err != nil { + return pqtype.NullRawMessage{} + } + return pqtype.NullRawMessage{RawMessage: b, Valid: true} +} + +func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) { + if raw.Valid && len(raw.RawMessage) > 0 { + _ = json.Unmarshal(raw.RawMessage, dst) + } +} + +func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { + agent := NodeAgent{ + ID: row.ID, + Name: row.Name, + Hostname: row.Hostname, + IPAddress: row.IpAddress, + Port: int(row.Port), + Status: row.Status.String, + Version: row.Version.String, + Metadata: map[string]interface{}{}, + } + if row.LastHeartbeat.Valid { + agent.LastHeartbeat = row.LastHeartbeat.Time + } + if row.CreatedAt.Valid { + agent.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + agent.UpdatedAt = row.UpdatedAt.Time + } + unmarshalRaw(row.Capabilities, &agent.Capabilities) + unmarshalRaw(row.Resources, &agent.Resources) + unmarshalRaw(row.Metadata, &agent.Metadata) + return agent +} + +func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance { + container := ContainerInstance{ + ID: row.ID, + Name: row.Name, + Image: row.Image, + ProjectID: row.ProjectID, + ServiceID: row.ServiceID, + NodeAgentID: row.NodeAgentID, + } + if row.CreatedAt.Valid { + container.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + container.UpdatedAt = row.UpdatedAt.Time + } + if row.StartedAt.Valid { + t := row.StartedAt.Time + container.StartedAt = &t + } + unmarshalRaw(row.Status, &container.Status) + unmarshalRaw(row.Resources, &container.Resources) + unmarshalRaw(row.Ports, &container.Ports) + unmarshalRaw(row.Environment, &container.Environment) + unmarshalRaw(row.Volumes, &container.Volumes) + unmarshalRaw(row.Networks, &container.Networks) + unmarshalRaw(row.RestartPolicy, &container.RestartPolicy) + if row.HealthCheck.Valid { + var hc HealthCheck + if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil { + container.HealthCheck = &hc + } + } + return container +} + +func commandFromRow(row sqlcdb.AgentCommand) AgentCommand { + cmd := AgentCommand{ + ID: row.ID, + Type: row.Type, + NodeAgentID: row.NodeAgentID, + Status: row.Status.String, + } + if row.ContainerID.Valid { + cmd.ContainerID = &row.ContainerID.String + } + if row.Result.Valid { + cmd.Result = &row.Result.String + } + if row.Error.Valid { + cmd.Error = &row.Error.String + } + if row.CreatedAt.Valid { + cmd.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + cmd.UpdatedAt = row.UpdatedAt.Time + } + if row.CompletedAt.Valid { + t := row.CompletedAt.Time + cmd.CompletedAt = &t + } + unmarshalRaw(row.Payload, &cmd.Payload) + return cmd +} + +// --- handlers --- + // RegisterAgent handles agent registration func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { var req struct { @@ -235,85 +336,83 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { return } - // Check if agent already exists - var existingAgent NodeAgent - if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil { - // Update existing agent - existingAgent.Name = req.Name - existingAgent.Port = req.Port - existingAgent.Capabilities = req.Capabilities - existingAgent.Status = "connecting" - existingAgent.LastHeartbeat = time.Now() + ctx := c.Request.Context() - if err := h.db.Save(&existingAgent).Error; err != nil { + // Re-registering an existing host updates it in place. + if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{ + Hostname: req.Hostname, + IpAddress: req.IPAddress, + }); err == nil { + updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ + ID: existing.ID, + Name: req.Name, + Hostname: existing.Hostname, + IpAddress: existing.IpAddress, + Port: int32(req.Port), + Status: sql.NullString{String: "connecting", Valid: true}, + Version: existing.Version, + Capabilities: rawJSON(req.Capabilities), + Resources: existing.Resources, + LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, + Metadata: existing.Metadata, + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } c.JSON(http.StatusOK, gin.H{ - "agent_id": existingAgent.ID, + "agent_id": updated.ID, "auth_token": authToken, "status": "updated", }) return } - // Create new agent - agent := NodeAgent{ - ID: uuid.New().String(), - Name: req.Name, - Hostname: req.Hostname, - IPAddress: req.IPAddress, - Port: req.Port, - Status: "connecting", - Capabilities: req.Capabilities, - Resources: NodeResources{ - CPU: CPUResources{ - Cores: 4, - Allocation: 0, - Usage: 0, - }, - Memory: MemoryResources{ - Total: 8 * 1024 * 1024 * 1024, // 8GB - Allocated: 0, - Used: 0, - Available: 8 * 1024 * 1024 * 1024, - }, - Storage: StorageResources{ - Total: 100 * 1024 * 1024 * 1024, // 100GB - Allocated: 0, - Used: 0, - Available: 100 * 1024 * 1024 * 1024, - }, - Network: NetworkResources{ - Interfaces: []NetworkInterface{ - { - Name: "eth0", - IPAddress: req.IPAddress, - MACAddress: "00:00:00:00:00:00", - Speed: 1000, - Status: "up", - }, - }, - Bandwidth: BandwidthInfo{ - Inbound: 0, - Outbound: 0, + agentID := uuid.New().String() + resources := NodeResources{ + CPU: CPUResources{Cores: 4}, + Memory: MemoryResources{ + Total: 8 * 1024 * 1024 * 1024, // 8GB + Available: 8 * 1024 * 1024 * 1024, + }, + Storage: StorageResources{ + Total: 100 * 1024 * 1024 * 1024, // 100GB + Available: 100 * 1024 * 1024 * 1024, + }, + Network: NetworkResources{ + Interfaces: []NetworkInterface{ + { + Name: "eth0", + IPAddress: req.IPAddress, + MACAddress: "00:00:00:00:00:00", + Speed: 1000, + Status: "up", }, }, }, - LastHeartbeat: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Metadata: make(map[string]interface{}), } - if err := h.db.Create(&agent).Error; err != nil { + _, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{ + ID: agentID, + Name: req.Name, + Hostname: req.Hostname, + IpAddress: req.IPAddress, + Port: int32(req.Port), + Status: sql.NullString{String: "connecting", Valid: true}, + Version: sql.NullString{}, + Capabilities: rawJSON(req.Capabilities), + Resources: rawJSON(resources), + LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, + Metadata: rawJSON(map[string]interface{}{}), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) return } c.JSON(http.StatusCreated, gin.H{ - "agent_id": agent.ID, + "agent_id": agentID, "auth_token": authToken, "status": "registered", }) @@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { // GetAgents returns all registered agents func (h *NodeAgentHandler) GetAgents(c *gin.Context) { - var agents []NodeAgent - if err := h.db.Find(&agents).Error; err != nil { + rows, err := h.q.ListAgents(c.Request.Context()) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"}) return } + agents := make([]NodeAgent, 0, len(rows)) + for _, row := range rows { + agents = append(agents, agentFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"agents": agents}) } // GetAgent returns a specific agent func (h *NodeAgentHandler) GetAgent(c *gin.Context) { - id := c.Param("id") - - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", id).Error; err != nil { - if err == gorm.ErrRecordNotFound { + row, err := h.q.GetAgent(c.Request.Context(), c.Param("id")) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"agent": agent}) + c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)}) } // UpdateAgent updates an agent's information func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { id := c.Param("id") + ctx := c.Request.Context() - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", id).Error; err != nil { - if err == gorm.ErrRecordNotFound { + row, err := h.q.GetAgent(ctx, id) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { return } - if err := h.db.Model(&agent).Updates(updates).Error; err != nil { + agent := agentFromRow(row) + applyAgentUpdates(&agent, updates) + + updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ + ID: agent.ID, + Name: agent.Name, + Hostname: agent.Hostname, + IpAddress: agent.IPAddress, + Port: int32(agent.Port), + Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""}, + Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""}, + Capabilities: rawJSON(agent.Capabilities), + Resources: rawJSON(agent.Resources), + LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()}, + Metadata: rawJSON(agent.Metadata), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } - c.JSON(http.StatusOK, gin.H{"agent": agent}) + c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)}) +} + +// applyAgentUpdates merges whitelisted keys from an arbitrary update map. +func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) { + remarshal := func(key string, dst interface{}) { + if v, ok := updates[key]; ok { + if b, err := json.Marshal(v); err == nil { + _ = json.Unmarshal(b, dst) + } + } + } + remarshal("name", &agent.Name) + remarshal("hostname", &agent.Hostname) + remarshal("ip_address", &agent.IPAddress) + remarshal("port", &agent.Port) + remarshal("status", &agent.Status) + remarshal("version", &agent.Version) + remarshal("capabilities", &agent.Capabilities) + remarshal("resources", &agent.Resources) + remarshal("metadata", &agent.Metadata) } // DeleteAgent removes an agent func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) { - id := c.Param("id") - - if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil { + if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"}) return } @@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { heartbeat.Timestamp = time.Now() } - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -413,35 +550,31 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { return } - // Update agent status and resources - agent.Status = heartbeat.Status - agent.Resources = heartbeat.Resources - agent.LastHeartbeat = heartbeat.Timestamp - agent.UpdatedAt = time.Now() - - if err := h.db.Save(&agent).Error; err != nil { + if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{ + ID: heartbeat.NodeAgentID, + Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""}, + Resources: rawJSON(heartbeat.Resources), + LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true}, + }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } - record := AgentHeartbeatRecord{ + err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{ ID: uuid.New().String(), NodeAgentID: heartbeat.NodeAgentID, Timestamp: heartbeat.Timestamp, Status: heartbeat.Status, - Resources: heartbeat.Resources, - ContainerCount: heartbeat.ContainerCount, - SystemLoad: heartbeat.SystemLoad, + Resources: rawJSON(heartbeat.Resources).RawMessage, + ContainerCount: int32(heartbeat.ContainerCount), + SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage, Uptime: heartbeat.Uptime, Version: heartbeat.Version, - CreatedAt: time.Now(), - } - if err := h.db.Create(&record).Error; err != nil { + }) + if err != nil && !isMissingTableError(err) { // Keep heartbeat endpoint available even if history table is not yet migrated. - if !isMissingTableError(err) { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) - return - } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) + return } c.Status(http.StatusOK) @@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) { return } - agentID := c.Param("id") - var commands []AgentCommand - if err := h.db. - Where("node_agent_id = ? AND status = ?", agentID, "pending"). - Order("created_at ASC"). - Limit(25). - Find(&commands).Error; err != nil { + rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) return } + commands := make([]AgentCommand, 0, len(rows)) + for _, row := range rows { + commands = append(commands, commandFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"commands": commands}) } @@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) { return } - var command AgentCommand - if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{ + ID: commandID, + NodeAgentID: agentID, + Status: sql.NullString{String: req.Status, Valid: true}, + Result: sql.NullString{String: req.Result, Valid: req.Result != ""}, + Error: sql.NullString{String: req.Error, Valid: req.Error != ""}, + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) return } - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"}) - return - } - - now := time.Now() - command.Status = req.Status - command.UpdatedAt = now - command.CompletedAt = &now - if req.Result != "" { - command.Result = &req.Result - } - if req.Error != "" { - command.Error = &req.Error - } - - if err := h.db.Save(&command).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"}) return } - if command.ContainerID != nil { - h.updateContainerStatusAfterCommand(command) + out := commandFromRow(command) + if out.ContainerID != nil { + h.updateContainerStatusAfterCommand(ctx, out) } - c.JSON(http.StatusOK, gin.H{"command": command}) + c.JSON(http.StatusOK, gin.H{"command": out}) } -func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) { - var container ContainerInstance - if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil { +func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) { + row, err := h.q.GetContainer(ctx, *command.ContainerID) + if err != nil { return } + container := containerFromRow(row) + if command.Status == "failed" { errorMessage := "" if command.Error != nil { @@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman } container.Status.State = "error" container.Status.Error = &errorMessage - _ = h.db.Save(&container).Error + _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: container.ID, + Status: rawJSON(container.Status), + }) return } @@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman container.Status.State = "removed" container.Status.FinishedAt = &now } - container.UpdatedAt = now - _ = h.db.Save(&container).Error + _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: container.ID, + Status: rawJSON(container.Status), + }) } // GetAgentContainers returns containers running on a specific agent func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) { - agentID := c.Param("id") - - var containers []ContainerInstance - if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil { + rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"}) return } + containers := make([]ContainerInstance, 0, len(rows)) + for _, row := range rows { + containers = append(containers, containerFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"containers": containers}) } @@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { return } + ctx := c.Request.Context() + // Verify agent exists - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + if _, err := h.q.GetAgent(ctx, agentID); err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { return } + containerID := uuid.New().String() container := ContainerInstance{ - ID: uuid.New().String(), - Name: req.Name, - Image: req.Image, - ProjectID: req.ProjectID, - ServiceID: req.ServiceID, - NodeAgentID: agentID, - Status: ContainerStatus{ - State: "created", - Health: "none", - }, + ID: containerID, + Name: req.Name, + Image: req.Image, + ProjectID: req.ProjectID, + ServiceID: req.ServiceID, + NodeAgentID: agentID, + Status: ContainerStatus{State: "created", Health: "none"}, Resources: req.Resources, Ports: req.Ports, Environment: req.Environment, @@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { UpdatedAt: time.Now(), } - if err := h.db.Create(&container).Error; err != nil { + _, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{ + ID: container.ID, + Name: container.Name, + Image: container.Image, + ProjectID: container.ProjectID, + ServiceID: container.ServiceID, + NodeAgentID: container.NodeAgentID, + Status: rawJSON(container.Status), + Resources: rawJSON(container.Resources), + Ports: rawJSON(container.Ports), + Environment: rawJSON(container.Environment), + Volumes: rawJSON(container.Volumes), + Networks: rawJSON(container.Networks), + RestartPolicy: rawJSON(container.RestartPolicy), + HealthCheck: rawJSON(container.HealthCheck), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"}) return } // Create command to start container on agent - command := AgentCommand{ + _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: "create_container", NodeAgentID: agentID, - ContainerID: &container.ID, - Payload: map[string]interface{}{ - "container": container, - }, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + ContainerID: sql.NullString{String: container.ID, Valid: true}, + Payload: rawJSON(map[string]interface{}{"container": container}), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"}) return } @@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) { return } - command := AgentCommand{ + command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: req.Type, NodeAgentID: agentID, - Payload: req.Payload, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + Payload: rawJSON(req.Payload), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) return } - c.JSON(http.StatusCreated, gin.H{"command": command}) + c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)}) } // GetAgentCommands returns commands for an agent func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) { - agentID := c.Param("id") - - var commands []AgentCommand - if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil { + rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) return } + commands := make([]AgentCommand, 0, len(rows)) + for _, row := range rows { + commands = append(commands, commandFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"commands": commands}) } // GetCommandStatus returns the status of a specific command func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { - agentID := c.Param("id") - commandID := c.Param("commandId") - - var command AgentCommand - if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{ + ID: c.Param("commandId"), + NodeAgentID: c.Param("id"), + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) return } @@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"command": command}) + c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)}) } // ContainerAction handles container lifecycle actions @@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { return } + ctx := c.Request.Context() + // Verify container exists - var container ContainerInstance - if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{ + ID: containerID, + NodeAgentID: agentID, + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"}) return } @@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { } // Create command for the action - command := AgentCommand{ + _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: fmt.Sprintf("%s_container", action), NodeAgentID: agentID, - ContainerID: &container.ID, - Payload: map[string]interface{}{ + ContainerID: sql.NullString{String: containerRow.ID, Valid: true}, + Payload: rawJSON(map[string]interface{}{ "container_id": containerID, - "container_name": container.Name, - "docker_name": container.Name, - }, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + "container_name": containerRow.Name, + "docker_name": containerRow.Name, + }), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) return } @@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) { return } - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + agentRow, err := h.q.GetAgent(ctx, agentID) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"}) return } + agent := agentFromRow(agentRow) from := time.Now().Add(-duration) - var records []AgentHeartbeatRecord - queryErr := h.db. - Where("node_agent_id = ? AND timestamp >= ?", agentID, from). - Order("timestamp ASC"). - Find(&records).Error + records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{ + NodeAgentID: agentID, + Timestamp: from, + }) if queryErr != nil && !isMissingTableError(queryErr) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"}) return @@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) { metrics := make([]map[string]interface{}, 0, len(records)) for _, record := range records { - metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount)) + var resources NodeResources + var load SystemLoad + _ = json.Unmarshal(record.Resources, &resources) + _ = json.Unmarshal(record.SystemLoad, &load) + metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount))) } if len(metrics) == 0 { diff --git a/app/backend/internal/api/apwhy_handlers.go b/app/backend/internal/api/apwhy_handlers.go index 8ae564e..5ca696a 100644 --- a/app/backend/internal/api/apwhy_handlers.go +++ b/app/backend/internal/api/apwhy_handlers.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "errors" "fmt" "net/http" "strings" @@ -13,21 +14,14 @@ import ( "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/lib/pq" "golang.org/x/crypto/bcrypt" ) -// Constants for validation and limits +// Default quota limits applied when the request omits them. const ( - MaxServiceNameLength = 100 - MaxRoutePrefixLength = 200 - MaxUpstreamURLLength = 500 - MaxAPIKeyNameLength = 100 - MinAPIKeyLength = 20 - MaxAPIKeyLength = 100 - DefaultRPMLimit = 60 - DefaultMonthlyQuota = 1000 - MaxRPMLimit = 10000 - MaxMonthlyQuota = 10000000 + DefaultRPMLimit = 60 + DefaultMonthlyQuota = 1000 ) // Validator instance for request validation @@ -75,15 +69,6 @@ type APIKeyRequest struct { MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"` } -// generateServiceID generates a cryptographically secure service ID -func generateServiceID() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - return "svc_" + hex.EncodeToString(bytes), nil -} - // generateAPIKey generates a cryptographically secure API key func generateAPIKey() (string, error) { bytes := make([]byte, 32) @@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error { return nil } +// isUniqueViolation reports whether err is a Postgres unique-constraint violation. +func isUniqueViolation(err error) bool { + var pqErr *pq.Error + return errors.As(err, &pqErr) && pqErr.Code == "23505" +} + +// handleAPwhyServiceValidate probes the upstream health path and records the +// result on the service row. Ported from the standalone APwhy server. +func handleAPwhyServiceValidate(c *gin.Context) { + serviceID := c.Param("id") + db := c.MustGet("db").(*database.DB) + ctx := c.Request.Context() + + var upstreamURL, healthPath string + var authHeader, authValue sql.NullString + var timeoutMs sql.NullInt64 + err := db.QueryRowContext(ctx, ` + SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms + FROM api_services WHERE id = $1 + `, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs) + if err != nil { + sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil) + return + } + + timeout := 8000 + if timeoutMs.Valid && timeoutMs.Int64 > 0 { + timeout = int(timeoutMs.Int64) + } + target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if authHeader.Valid && authHeader.String != "" { + req.Header.Set(authHeader.String, authValue.String) + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond} + res, probeErr := client.Do(req) + + status := http.StatusBadGateway + message := "" + ok := false + if probeErr != nil { + message = probeErr.Error() + } else { + defer res.Body.Close() + status = res.StatusCode + message = fmt.Sprintf("HTTP %d", res.StatusCode) + ok = res.StatusCode >= 200 && res.StatusCode < 300 + } + + validationStatus := "failed" + if ok { + validationStatus = "healthy" + } + + _, _ = db.ExecContext(ctx, ` + UPDATE api_services + SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW() + WHERE id = $3 + `, validationStatus, message, serviceID) + + if !ok { + _, _ = db.ExecContext(ctx, ` + INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at) + VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW()) + `, serviceID, message, status) + } + + sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ + "validation": map[string]interface{}{ + "ok": ok, + "status": status, + "message": message, + }, + }) +} + // handleAPwhyServicesList returns a list of API services func handleAPwhyServicesList(c *gin.Context) { db := c.MustGet("db").(*database.DB) @@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) { // Query services from database rows, err := db.QueryContext(ctx, ` SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at - FROM api_services + FROM api_services ORDER BY created_at DESC `) if err != nil { @@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) { } defer rows.Close() - var services []map[string]interface{} + services := make([]map[string]interface{}, 0) for rows.Next() { var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string - var enabled bool + var enabled int err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt) if err != nil { continue // Skip malformed rows @@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) { "slug": slug, "upstreamUrl": upstreamURL, "routePrefix": routePrefix, - "enabled": enabled, + "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, }) @@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) { db := c.MustGet("db").(*database.DB) ctx := context.Background() - // Generate slug and ID - id, err := generateServiceID() - if err != nil { - sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", - "Failed to generate service ID", err.Error()) - return - } - slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")) - // Insert service into database - query := ` - INSERT INTO api_services ( - id, name, slug, upstream_url, route_prefix, health_path, - enabled, rpm_limit, monthly_quota, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW()) - ` - var rpmLimit, monthlyQuota int if req.RPMLimit != nil { rpmLimit = *req.RPMLimit @@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) { monthlyQuota = DefaultMonthlyQuota } - _, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL, - req.RoutePrefix, rpmLimit, monthlyQuota) + // id defaults to gen_random_uuid(); enabled defaults to 1. + var id string + err := db.QueryRowContext(ctx, ` + INSERT INTO api_services ( + name, slug, upstream_url, route_prefix, health_path, + rpm_limit, monthly_quota, created_at, updated_at + ) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW()) + RETURNING id + `, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id) if err != nil { + if isUniqueViolation(err) { + sendErrorResponse(c, http.StatusConflict, "CONFLICT", + "A service with this slug or route prefix already exists", err.Error()) + return + } sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", "Failed to create service", err.Error()) return @@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) { db := c.MustGet("db").(*database.DB) if input.Enabled != nil { + enabled := 0 + if *input.Enabled { + enabled = 1 + } _, err := db.ExecContext(context.Background(), "UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", - *input.Enabled, serviceID) + enabled, serviceID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) { } defer rows.Close() - var keys []map[string]interface{} + keys := make([]map[string]interface{}, 0) for rows.Next() { var id, name, keyPrefix, plan, createdAt, updatedAt string - var enabled bool + var enabled int var rpmLimit, monthlyQuota sql.NullInt64 err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt) @@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) { "name": name, "keyPrefix": keyPrefix, "plan": plan, - "enabled": enabled, + "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, } @@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) { return } - // Generate ID and prefix - id, err := generateServiceID() - if err != nil { - sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", - "Failed to generate key ID", err.Error()) - return - } - keyPrefix := apiKey[:8] // Set default limits based on plan - var rpmLimit, monthlyQuota int - switch input.Plan { - case "free": - rpmLimit, monthlyQuota = 60, 1000 - case "pro": - rpmLimit, monthlyQuota = 600, 50000 - case "business": - rpmLimit, monthlyQuota = 3000, 300000 - default: - rpmLimit, monthlyQuota = 60, 1000 + plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan) + if err != nil { + sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", + "Invalid plan", err.Error()) + return } - // Insert key into database - _, err = db.ExecContext(context.Background(), ` + // id defaults to gen_random_uuid(); enabled defaults to 1. + var id string + err = db.QueryRowContext(context.Background(), ` INSERT INTO api_keys ( - id, name, key_hash, key_prefix, plan, allowed_service_ids, - enabled, rpm_limit, monthly_quota, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW()) - `, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota) + name, key_hash, key_prefix, plan, allowed_service_ids, + rpm_limit, monthly_quota, created_at, updated_at + ) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW()) + RETURNING id + `, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) { "data": gin.H{ "id": id, "name": input.Name, - "plan": input.Plan, + "plan": plan, "key": apiKey, // Only return the actual key once "keyPrefix": keyPrefix, "enabled": true, @@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) { db := c.MustGet("db").(*database.DB) if input.Enabled != nil { + enabled := 0 + if *input.Enabled { + enabled = 1 + } _, err := db.ExecContext(context.Background(), "UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", - *input.Enabled, keyID) + enabled, keyID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/app/backend/internal/api/params.go b/app/backend/internal/api/params.go index 0266fde..04505c7 100644 --- a/app/backend/internal/api/params.go +++ b/app/backend/internal/api/params.go @@ -1,6 +1,10 @@ package api -import "github.com/gin-gonic/gin" +import ( + "strings" + + "github.com/gin-gonic/gin" +) // firstPathParam returns the first non-empty route param from the provided names. func firstPathParam(c *gin.Context, names ...string) string { @@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string { } return "" } + +// firstNonEmpty returns the first non-blank string, trimmed. +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 0f19221..72847a6 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -17,8 +17,6 @@ import ( "containr/internal/scaling" "github.com/gin-gonic/gin" - "gorm.io/driver/postgres" - "gorm.io/gorm" ) func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { @@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Initialize scaling handler scalingHandler := NewScalingHandler(autoScaler) - // Initialize GORM for agent system - gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) - if err != nil { - panic("Failed to initialize GORM: " + err.Error()) - } - - // Initialize agent handler - agentHandler := NewNodeAgentHandler(gormDB) + // Initialize agent handler (sqlc-backed) + agentHandler := NewNodeAgentHandler(db) // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) @@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg c.Set("auto_scaler", autoScaler) c.Set("ha_manager", haManager) c.Set("scaling_handler", scalingHandler) - c.Set("gorm_db", gormDB) c.Next() }) @@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Audit Logs routes protected.GET("/audit-logs", handleGetAuditLogs) protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) - } - // APwhy Gateway routes - apwhy := router.Group("/api/v1") - { - // Health check (no auth required) - apwhy.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "ok": true, - "data": gin.H{ - "status": "ok", - "name": "Containr + APwhy", - "database": "postgresql", - "generatedAt": time.Now().UTC().Format(time.RFC3339), - }, - }) - }) - } + // API Gateway routes (merged APwhy) - namespaced to avoid + // colliding with Containr's own /services paths. + gateway := protected.Group("/gateway") + { + gateway.GET("/services", handleAPwhyServicesList) + gateway.POST("/services", handleAPwhyServicesCreate) + gateway.PATCH("/services/:id", handleAPwhyServicesPatch) + gateway.GET("/services/:id/validate", handleAPwhyServiceValidate) - // Protected APwhy routes (authentication required) - protectedAPwhy := router.Group("/api/v1") - protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret)) - { - // Service management - protectedAPwhy.GET("/services", handleAPwhyServicesList) - protectedAPwhy.POST("/services", handleAPwhyServicesCreate) - protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch) + gateway.GET("/keys", handleAPwhyKeysList) + gateway.POST("/keys", handleAPwhyKeysCreate) + gateway.PATCH("/keys/:id", handleAPwhyKeysPatch) - // API Keys - protectedAPwhy.GET("/keys", handleAPwhyKeysList) - protectedAPwhy.POST("/keys", handleAPwhyKeysCreate) - protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch) - - // Analytics - protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps) - protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic) + gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps) + gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic) + } } } } diff --git a/app/backend/internal/api/server.go b/app/backend/internal/api/server.go deleted file mode 100644 index 3a9419e..0000000 --- a/app/backend/internal/api/server.go +++ /dev/null @@ -1,2694 +0,0 @@ -package api - -import ( - "context" - "database/sql" - "embed" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "mime" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - "containr/internal/analytics" - "containr/internal/auth" - "containr/internal/config" - "containr/internal/gateway" - "containr/internal/storage" -) - -//go:embed static/* -var staticFS embed.FS - -type Server struct { - Store *storage.Store - Cfg config.Config - Umami *analytics.Client - proxyClient *http.Client - - rpmMu sync.Mutex - rpmUsage map[string]rpmWindow - - loginMu sync.Mutex - loginAttempts map[string]loginWindow -} - -type rpmWindow struct { - WindowStart time.Time - Count int -} - -type loginWindow struct { - WindowStart time.Time - Count int -} - -type authContext struct { - UserID string - Email string - Enabled bool - ForcePasswordReset bool - Roles []string - Permissions map[string]bool - SessionID string -} - -func NewServer(store *storage.Store, cfg config.Config) *Server { - return &Server{ - Store: store, - Cfg: cfg, - Umami: analytics.NewClient(cfg.UmamiBaseURL, cfg.UmamiAPIKey, cfg.UmamiWebsiteID), - proxyClient: &http.Client{ - Timeout: cfg.DefaultServiceTimeout, - }, - rpmUsage: map[string]rpmWindow{}, - loginAttempts: map[string]loginWindow{}, - } -} - -func (s *Server) Handler() http.Handler { - mux := http.NewServeMux() - - mux.HandleFunc("GET /health", s.handleHealth) - - mux.HandleFunc("GET /api/v1/bootstrap/status", s.handleBootstrapStatus) - mux.HandleFunc("POST /api/v1/bootstrap/register-owner", s.handleBootstrapRegisterOwner) - - mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) - mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) - mux.HandleFunc("POST /api/v1/auth/refresh", s.handleAuthRefresh) - mux.HandleFunc("GET /api/v1/auth/me", s.withAuth("", true, s.handleAuthMe)) - mux.HandleFunc("POST /api/v1/auth/reset-password", s.withAuth("", true, s.handleAuthResetPassword)) - - mux.HandleFunc("GET /api/v1/users", s.withAuth("users.read", false, s.handleUsersList)) - mux.HandleFunc("POST /api/v1/users", s.withAuth("users.write", false, s.handleUsersCreate)) - mux.HandleFunc("PATCH /api/v1/users/{id}", s.withAuth("users.write", false, s.handleUsersPatch)) - - mux.HandleFunc("GET /api/v1/roles", s.withAuth("roles.read", false, s.handleRolesList)) - mux.HandleFunc("POST /api/v1/roles", s.withAuth("roles.write", false, s.handleRolesCreate)) - mux.HandleFunc("PATCH /api/v1/roles/{id}", s.withAuth("roles.write", false, s.handleRolesPatch)) - mux.HandleFunc("GET /api/v1/permissions", s.withAuth("roles.read", false, s.handlePermissionsList)) - - mux.HandleFunc("GET /api/v1/services", s.withAuth("services.read", false, s.handleServicesList)) - mux.HandleFunc("POST /api/v1/services", s.withAuth("services.write", false, s.handleServicesCreate)) - mux.HandleFunc("PATCH /api/v1/services/{id}", s.withAuth("services.write", false, s.handleServicesPatch)) - mux.HandleFunc("POST /api/v1/services/{id}/validate", s.withAuth("services.write", false, s.handleServicesValidate)) - - mux.HandleFunc("GET /api/v1/databases", s.withAuth("databases.read", false, s.handleDatabasesList)) - mux.HandleFunc("POST /api/v1/databases", s.withAuth("databases.write", false, s.handleDatabasesCreate)) - mux.HandleFunc("PATCH /api/v1/databases/{id}", s.withAuth("databases.write", false, s.handleDatabasesPatch)) - mux.HandleFunc("POST /api/v1/databases/{id}/validate", s.withAuth("databases.write", false, s.handleDatabasesValidate)) - - mux.HandleFunc("GET /api/v1/keys", s.withAuth("keys.read", false, s.handleKeysList)) - mux.HandleFunc("POST /api/v1/keys", s.withAuth("keys.write", false, s.handleKeysCreate)) - mux.HandleFunc("PATCH /api/v1/keys/{id}", s.withAuth("keys.write", false, s.handleKeysPatch)) - - mux.HandleFunc("GET /api/v1/analytics/ops", s.withAuth("analytics.read", false, s.handleAnalyticsOps)) - mux.HandleFunc("GET /api/v1/analytics/traffic", s.withAuth("analytics.read", false, s.handleAnalyticsTraffic)) - mux.HandleFunc("POST /api/v1/analytics/events", s.withAuth("", true, s.handleAnalyticsEvents)) - - mux.HandleFunc("/", s.handleGatewayOrUI) - - return s.withSecurityHeaders(s.withLogging(mux)) -} - -func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - 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) - _ = s.logMetric("http_request", 1, map[string]any{ - "method": r.Method, - "path": r.URL.Path, - "ms": time.Since(start).Milliseconds(), - }) - }) -} - -func (s *Server) withAuth(requiredPermission string, allowForceReset bool, handler func(http.ResponseWriter, *http.Request, *authContext)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, err := s.resolveAuth(r) - if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required") - return - } - if !ctx.Enabled { - writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled") - return - } - if ctx.ForcePasswordReset && !allowForceReset { - writeError(w, http.StatusPreconditionRequired, "PASSWORD_RESET_REQUIRED", "Password reset required") - return - } - if requiredPermission != "" && !ctx.Permissions[requiredPermission] { - writeError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions") - return - } - handler(w, r, ctx) - } -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "status": "ok", - "name": "APwhy", - "database": "sqlite", - "generatedAt": time.Now().UTC().Format(time.RFC3339), - }, - }) -} - -func (s *Server) handleBootstrapStatus(w http.ResponseWriter, r *http.Request) { - hasUsers := s.hasUsers(r.Context()) - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "hasUsers": hasUsers, - "registrationOpen": !hasUsers, - }, - }) -} - -func (s *Server) handleBootstrapRegisterOwner(w http.ResponseWriter, r *http.Request) { - if s.hasUsers(r.Context()) { - writeError(w, http.StatusForbidden, "BOOTSTRAP_CLOSED", "Owner registration is closed") - return - } - - var input struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - input.Email = strings.ToLower(strings.TrimSpace(input.Email)) - if input.Email == "" || !strings.Contains(input.Email, "@") { - writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required") - return - } - - hash, err := auth.HashPassword(input.Password) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error()) - return - } - - now := storage.NowISO() - userID, _ := auth.RandomID("usr") - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at) - VALUES (?, ?, ?, 1, 0, ?, ?) - `, userID, input.Email, hash, now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists") - return - } - - ownerRoleID := "" - if err := tx.QueryRowContext(r.Context(), `SELECT id FROM roles WHERE slug = 'owner'`).Scan(&ownerRoleID); err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_MISSING", "Owner role not found") - return - } - - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, ownerRoleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign owner role") - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit owner registration") - return - } - - s.audit(r.Context(), userID, "owner.registered", "user", userID, map[string]any{"email": input.Email}) - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": userID, - "email": input.Email, - "roles": []string{"owner"}, - }, - }) -} - -func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { - var input struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - email := strings.ToLower(strings.TrimSpace(input.Email)) - if email == "" || input.Password == "" { - writeError(w, http.StatusBadRequest, "INVALID_CREDENTIALS", "Email and password are required") - return - } - - if s.isLoginRateLimited(r, email) { - writeError(w, http.StatusTooManyRequests, "LOGIN_RATE_LIMITED", "Too many login attempts") - return - } - - var user struct { - ID string - Email string - PasswordHash string - Enabled int - ForcePasswordReset int - } - err := s.Store.DB.QueryRowContext(r.Context(), ` - SELECT id, email, password_hash, enabled, force_password_reset - FROM users - WHERE email = ? - `, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Enabled, &user.ForcePasswordReset) - if err != nil { - s.markLoginAttempt(r, email) - writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials") - return - } - if user.Enabled != 1 { - writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled") - return - } - - ok, err := auth.VerifyPassword(user.PasswordHash, input.Password) - if err != nil || !ok { - s.markLoginAttempt(r, email) - writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials") - return - } - - s.clearLoginAttempts(r, email) - - sessionID, accessToken, refreshToken, err := s.createSession(r.Context(), user.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "Failed to create login session") - return - } - - now := storage.NowISO() - _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`, now, now, user.ID) - _ = sessionID - - s.setAuthCookies(w, accessToken, refreshToken) - - ctx, _ := s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessToken)) - if ctx == nil { - writeError(w, http.StatusInternalServerError, "SESSION_RESOLVE_FAILED", "Failed to load user profile") - return - } - - s.audit(r.Context(), user.ID, "auth.login", "session", sessionID, map[string]any{"email": user.Email}) - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "user": map[string]any{ - "id": ctx.UserID, - "email": ctx.Email, - "roles": ctx.Roles, - "permissions": keysOfMap(ctx.Permissions), - "forcePasswordReset": ctx.ForcePasswordReset, - }, - }, - }) -} - -func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { - accessCookie, _ := r.Cookie(s.Cfg.SessionAccessCookie) - refreshCookie, _ := r.Cookie(s.Cfg.SessionRefreshCookie) - - now := storage.NowISO() - if accessCookie != nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE access_token_hash = ? - `, now, now, auth.HashToken(accessCookie.Value)) - } - if refreshCookie != nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ? - `, now, now, auth.HashToken(refreshCookie.Value)) - } - - s.clearAuthCookies(w) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(s.Cfg.SessionRefreshCookie) - if err != nil || strings.TrimSpace(refreshCookie.Value) == "" { - writeError(w, http.StatusUnauthorized, "REFRESH_MISSING", "Refresh token is missing") - return - } - - refreshHash := auth.HashToken(refreshCookie.Value) - - var session struct { - ID string - UserID string - ExpiresAt string - RevokedAt sql.NullString - } - err = s.Store.DB.QueryRowContext(r.Context(), ` - SELECT id, user_id, refresh_expires_at, revoked_at - FROM sessions - WHERE refresh_token_hash = ? - `, refreshHash).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) - if err != nil { - writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is invalid") - return - } - if session.RevokedAt.Valid { - writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is revoked") - return - } - expiresAt, _ := time.Parse(time.RFC3339, session.ExpiresAt) - if time.Now().After(expiresAt) { - writeError(w, http.StatusUnauthorized, "REFRESH_EXPIRED", "Refresh token has expired") - return - } - - accessToken, _ := auth.RandomToken(32) - newRefreshToken, _ := auth.RandomToken(48) - accessHash := auth.HashToken(accessToken) - refreshHashNew := auth.HashToken(newRefreshToken) - - accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339) - refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339) - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions - SET access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = ? - WHERE id = ? - `, accessHash, refreshHashNew, accessExp, refreshExp, storage.NowISO(), session.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, "REFRESH_FAILED", "Failed to refresh session") - return - } - - s.setAuthCookies(w, accessToken, newRefreshToken) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request, ctx *authContext) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": ctx.UserID, - "email": ctx.Email, - "roles": ctx.Roles, - "permissions": keysOfMap(ctx.Permissions), - "forcePasswordReset": ctx.ForcePasswordReset, - }, - }) -} - -func (s *Server) handleAuthResetPassword(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - NewPassword string `json:"newPassword"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - hash, err := auth.HashPassword(input.NewPassword) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error()) - return - } - - now := storage.NowISO() - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE users SET password_hash = ?, force_password_reset = 0, updated_at = ? WHERE id = ? - `, hash, now, ctx.UserID) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_UPDATE_FAILED", "Failed to update password") - return - } - - s.audit(r.Context(), ctx.UserID, "auth.reset_password", "user", ctx.UserID, nil) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT u.id, u.email, u.enabled, u.force_password_reset, u.created_at, u.updated_at, - COALESCE(GROUP_CONCAT(r.slug), '') AS role_slugs - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - LEFT JOIN roles r ON r.id = ur.role_id - GROUP BY u.id - ORDER BY u.created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list users") - return - } - defer rows.Close() - - items := make([]map[string]any, 0) - for rows.Next() { - var id, email, createdAt, updatedAt, roleSlugs string - var enabled, forceReset int - if err := rows.Scan(&id, &email, &enabled, &forceReset, &createdAt, &updatedAt, &roleSlugs); err != nil { - continue - } - roles := []string{} - if strings.TrimSpace(roleSlugs) != "" { - roles = strings.Split(roleSlugs, ",") - } - items = append(items, map[string]any{ - "id": id, - "email": email, - "enabled": enabled == 1, - "forcePasswordReset": forceReset == 1, - "roles": roles, - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleUsersCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Email string `json:"email"` - RoleIDs []string `json:"roleIds"` - RoleSlugs []string `json:"roleSlugs"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - email := strings.ToLower(strings.TrimSpace(input.Email)) - if email == "" || !strings.Contains(email, "@") { - writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required") - return - } - - roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error()) - return - } - if len(roleIDs) == 0 { - viewerID, err := s.roleIDBySlug(r.Context(), "viewer") - if err == nil { - roleIDs = []string{viewerID} - } - } - - tempPassword, _ := auth.RandomPassword(16) - passwordHash, err := auth.HashPassword(tempPassword) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate temporary password") - return - } - - inviteToken, _ := auth.RandomToken(32) - inviteHash := auth.HashToken(inviteToken) - now := storage.NowISO() - expiresAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339) - - userID, _ := auth.RandomID("usr") - inviteID, _ := auth.RandomID("inv") - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at) - VALUES (?, ?, ?, 1, 1, ?, ?) - `, userID, email, passwordHash, now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists") - return - } - - for _, roleID := range roleIDs { - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, roleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign roles") - return - } - } - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO invites (id, user_id, email, token_hash, expires_at, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, inviteID, userID, email, inviteHash, expiresAt, ctx.UserID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "INVITE_FAILED", "Failed to create invite") - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user creation") - return - } - - s.audit(r.Context(), ctx.UserID, "users.create", "user", userID, map[string]any{"email": email, "roleCount": len(roleIDs)}) - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": userID, - "email": email, - "inviteToken": inviteToken, - "temporaryPass": tempPassword, - "expiresAt": expiresAt, - }, - }) -} - -func (s *Server) handleUsersPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "User ID is required") - return - } - - var input struct { - Enabled *bool `json:"enabled"` - RoleIDs []string `json:"roleIds"` - RoleSlugs []string `json:"roleSlugs"` - ResetPassword bool `json:"resetPassword"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - now := storage.NowISO() - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - if input.Enabled != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE users SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "Failed to update user") - return - } - } - - if len(input.RoleIDs) > 0 || len(input.RoleSlugs) > 0 { - roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error()) - return - } - _, err = tx.ExecContext(r.Context(), `DELETE FROM user_roles WHERE user_id = ?`, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update user roles") - return - } - for _, roleID := range roleIDs { - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, id, roleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to assign role") - return - } - } - } - - response := map[string]any{} - if input.ResetPassword { - tempPassword, _ := auth.RandomPassword(16) - hash, err := auth.HashPassword(tempPassword) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate password") - return - } - _, err = tx.ExecContext(r.Context(), ` - UPDATE users SET password_hash = ?, force_password_reset = 1, updated_at = ? WHERE id = ? - `, hash, now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_RESET_FAILED", "Failed to reset password") - return - } - response["temporaryPass"] = tempPassword - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user patch") - return - } - - s.audit(r.Context(), ctx.UserID, "users.patch", "user", id, input) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": response}) -} - -func (s *Server) handleRolesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT r.id, r.name, r.slug, r.description, r.is_system, r.enabled, - COALESCE(GROUP_CONCAT(p.code), '') AS permission_codes, - r.created_at, r.updated_at - FROM roles r - LEFT JOIN role_permissions rp ON rp.role_id = r.id - LEFT JOIN permissions p ON p.id = rp.permission_id - GROUP BY r.id - ORDER BY r.created_at ASC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list roles") - return - } - defer rows.Close() - - roles := []map[string]any{} - for rows.Next() { - var id, name, slug, description, permissionCodes, createdAt, updatedAt string - var isSystem, enabled int - if err := rows.Scan(&id, &name, &slug, &description, &isSystem, &enabled, &permissionCodes, &createdAt, &updatedAt); err != nil { - continue - } - codes := []string{} - if strings.TrimSpace(permissionCodes) != "" { - codes = strings.Split(permissionCodes, ",") - sort.Strings(codes) - } - roles = append(roles, map[string]any{ - "id": id, - "name": name, - "slug": slug, - "description": description, - "isSystem": isSystem == 1, - "enabled": enabled == 1, - "permissionCodes": codes, - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": roles}) - _ = ctx -} - -func (s *Server) handleRolesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - PermissionCodes []string `json:"permissionCodes"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - name := strings.TrimSpace(input.Name) - if name == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "Role name is required") - return - } - slug := slugify(firstNonEmpty(input.Slug, input.Name), "role") - id, _ := auth.RandomID("role") - now := storage.NowISO() - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, 0, 1, ?, ?) - `, id, name, slug, strings.TrimSpace(input.Description), now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "ROLE_CREATE_FAILED", "Role slug already exists") - return - } - - if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil { - writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error()) - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role creation") - return - } - - s.audit(r.Context(), ctx.UserID, "roles.create", "role", id, input) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) -} - -func (s *Server) handleRolesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Role ID is required") - return - } - - var input struct { - Name *string `json:"name"` - Description *string `json:"description"` - Enabled *bool `json:"enabled"` - PermissionCodes []string `json:"permissionCodes"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - isSystem := 0 - if err := s.Store.DB.QueryRowContext(r.Context(), `SELECT is_system FROM roles WHERE id = ?`, id).Scan(&isSystem); err != nil { - writeError(w, http.StatusNotFound, "ROLE_NOT_FOUND", "Role not found") - return - } - - now := storage.NowISO() - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - if input.Name != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET name = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Name), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role name") - return - } - } - if input.Description != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET description = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Description), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role description") - return - } - } - if input.Enabled != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role status") - return - } - } - - if input.PermissionCodes != nil { - if isSystem == 1 { - writeError(w, http.StatusBadRequest, "SYSTEM_ROLE_LOCKED", "System role permissions are locked") - return - } - if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil { - writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error()) - return - } - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role patch") - return - } - - s.audit(r.Context(), ctx.UserID, "roles.patch", "role", id, input) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handlePermissionsList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, code, name, description FROM permissions ORDER BY code ASC`) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list permissions") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, code, name, description string - if err := rows.Scan(&id, &code, &name, &description); err != nil { - continue - } - items = append(items, map[string]any{"id": id, "code": code, "name": name, "description": description}) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleServicesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at FROM services ORDER BY created_at DESC`) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list services") - return - } - defer rows.Close() - items := []map[string]any{} - for rows.Next() { - item, err := scanService(rows) - if err == nil { - items = append(items, item) - } - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleServicesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input serviceInput - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - if strings.TrimSpace(input.Name) == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "Service name is required") - return - } - if strings.TrimSpace(input.UpstreamURL) == "" { - writeError(w, http.StatusBadRequest, "INVALID_UPSTREAM", "Service upstream URL is required") - return - } - routePrefix := normalizePathPrefix(input.RoutePrefix, "/"+slugify(input.Name, "service")) - if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix { - writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled") - return - } - if strings.HasPrefix(routePrefix, "/api/") { - writeError(w, http.StatusBadRequest, "ROUTE_CONFLICT", "Route prefix conflicts with internal API") - return - } - - now := storage.NowISO() - id, _ := auth.RandomID("svc") - slug := slugify(firstNonEmpty(input.Slug, input.Name), "service") - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO services ( - id, name, slug, upstream_url, route_prefix, health_path, - upstream_auth_header, upstream_auth_value, internal_token, - enabled, rpm_limit, monthly_quota, request_timeout_ms, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - id, - strings.TrimSpace(input.Name), - slug, - strings.TrimSpace(input.UpstreamURL), - routePrefix, - normalizePathPrefix(input.HealthPath, "/health"), - nullIfEmpty(input.UpstreamAuthHeader), - nullIfEmpty(input.UpstreamAuthValue), - nullIfEmpty(input.InternalToken), - boolToInt(defaultBool(input.Enabled, true)), - nullInt(input.RPMLimit), - nullInt(input.MonthlyQuota), - nullInt(input.RequestTimeoutMS), - now, - now, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "SERVICE_CREATE_FAILED", "Service slug or route already exists") - return - } - - s.audit(r.Context(), ctx.UserID, "services.create", "service", id, input) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) -} - -func (s *Server) handleServicesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Service ID is required") - return - } - - current, err := s.getServiceByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found") - return - } - - var patch serviceInput - if err := decodeJSON(r.Body, &patch); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - name := firstNonEmpty(patch.Name, asString(current["name"])) - routePrefix := normalizePathPrefix(firstNonEmpty(patch.RoutePrefix, asString(current["routePrefix"])), "/") - if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix { - writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled") - return - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE services SET - name = ?, slug = ?, upstream_url = ?, route_prefix = ?, health_path = ?, - upstream_auth_header = ?, upstream_auth_value = ?, internal_token = ?, enabled = ?, - rpm_limit = ?, monthly_quota = ?, request_timeout_ms = ?, updated_at = ? - WHERE id = ? - `, - name, - slugify(firstNonEmpty(patch.Slug, asString(current["slug"])), "service"), - firstNonEmpty(patch.UpstreamURL, asString(current["upstreamUrl"])), - routePrefix, - normalizePathPrefix(firstNonEmpty(patch.HealthPath, asString(current["healthPath"])), "/health"), - nullIfEmpty(firstNonEmpty(patch.UpstreamAuthHeader, asString(current["upstreamAuthHeader"]))), - nullIfEmpty(firstNonEmpty(patch.UpstreamAuthValue, asString(current["upstreamAuthValue"]))), - nullIfEmpty(firstNonEmpty(patch.InternalToken, asString(current["internalToken"]))), - boolToInt(defaultBool(patch.Enabled, asBool(current["enabled"]))), - nullInt(patch.RPMLimitOr(current["rpmLimit"])), - nullInt(patch.MonthlyQuotaOr(current["monthlyQuota"])), - nullInt(patch.TimeoutOr(current["requestTimeoutMs"])), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "SERVICE_UPDATE_FAILED", "Failed to update service") - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleServicesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - service, err := s.getServiceByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found") - return - } - - timeoutMS := intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds())) - client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond} - target := strings.TrimRight(asString(service["upstreamUrl"]), "/") + normalizePathPrefix(asString(service["healthPath"]), "/health") - - req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) - if header := asString(service["upstreamAuthHeader"]); header != "" { - req.Header.Set(header, asString(service["upstreamAuthValue"])) - } - if token := asString(service["internalToken"]); token != "" { - req.Header.Set(s.Cfg.ServiceTokenHeader, token) - } - res, err := client.Do(req) - status := 0 - message := "" - ok := false - if err != nil { - status = http.StatusBadGateway - message = err.Error() - } else { - defer res.Body.Close() - status = res.StatusCode - message = fmt.Sprintf("HTTP %d", res.StatusCode) - ok = res.StatusCode >= 200 && res.StatusCode < 300 - } - - validationStatus := "failed" - if ok { - validationStatus = "healthy" - } - - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE services - SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ? - WHERE id = ? - `, storage.NowISO(), validationStatus, message, storage.NowISO(), id) - - if !ok { - s.logIncident(r.Context(), sql.NullString{String: id, Valid: true}, sql.NullString{}, "SERVICE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true}) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}}) - _ = ctx -} - -func (s *Server) handleDatabasesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM database_connections - ORDER BY created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list database connections") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, name, slug, provider, connURL, createdAt, updatedAt string - var enabled int - var lastAt, lastStatus, lastMessage sql.NullString - if err := rows.Scan(&id, &name, &slug, &provider, &connURL, &enabled, &lastAt, &lastStatus, &lastMessage, &createdAt, &updatedAt); err != nil { - continue - } - items = append(items, map[string]any{ - "id": id, - "name": name, - "slug": slug, - "provider": provider, - "target": extractDatabaseTarget(provider, connURL), - "maskedConnectionUrl": maskDatabaseURL(provider, connURL), - "enabled": enabled == 1, - "lastValidationAt": nullString(lastAt), - "lastValidationStatus": nullString(lastStatus), - "lastValidationMessage": nullString(lastMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleDatabasesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Slug string `json:"slug"` - Provider string `json:"provider"` - ConnectionURL string `json:"connectionUrl"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - provider := normalizeProvider(input.Provider) - if provider == "" { - writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql") - return - } - if strings.TrimSpace(input.ConnectionURL) == "" { - writeError(w, http.StatusBadRequest, "INVALID_CONNECTION", "Connection URL is required") - return - } - - now := storage.NowISO() - id, _ := auth.RandomID("dbc") - slug := slugify(firstNonEmpty(input.Slug, input.Name+"-"+provider), "database") - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO database_connections (id, name, slug, provider, connection_url, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, id, strings.TrimSpace(input.Name), slug, provider, strings.TrimSpace(input.ConnectionURL), boolToInt(defaultBool(input.Enabled, true)), now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "DATABASE_CREATE_FAILED", "Database connection slug already exists") - return - } - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) - _ = ctx -} - -func (s *Server) handleDatabasesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Database connection ID is required") - return - } - - var input struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Provider *string `json:"provider"` - ConnectionURL *string `json:"connectionUrl"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - current, err := s.getDatabaseByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found") - return - } - - provider := asString(current["provider"]) - if input.Provider != nil { - norm := normalizeProvider(*input.Provider) - if norm == "" { - writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql") - return - } - provider = norm - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE database_connections - SET name = ?, slug = ?, provider = ?, connection_url = ?, enabled = ?, updated_at = ? - WHERE id = ? - `, - firstNonEmptyPtr(input.Name, asString(current["name"])), - slugify(firstNonEmptyPtr(input.Slug, asString(current["slug"])), "database"), - provider, - firstNonEmptyPtr(input.ConnectionURL, asString(current["connectionUrl"])), - boolToInt(defaultBool(input.Enabled, asBool(current["enabled"]))), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "DATABASE_UPDATE_FAILED", "Failed to update database connection") - return - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleDatabasesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - database, err := s.getDatabaseByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found") - return - } - - provider := asString(database["provider"]) - connectionURL := asString(database["connectionUrl"]) - ok, status, message := s.validateDatabaseConnection(r.Context(), provider, connectionURL) - state := "failed" - if ok { - state = "healthy" - } - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE database_connections - SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ? - WHERE id = ? - `, storage.NowISO(), state, message, storage.NowISO(), id) - - if !ok { - s.logIncident(r.Context(), sql.NullString{}, sql.NullString{}, "DATABASE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true}) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}}) - _ = ctx -} - -func (s *Server) handleKeysList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at, last_used_at - FROM api_keys - ORDER BY created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list API keys") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, name, keyPrefix, plan, allowed, createdAt, updatedAt string - var enabled int - var rpm, monthly sql.NullInt64 - var lastUsed sql.NullString - if err := rows.Scan(&id, &name, &keyPrefix, &plan, &allowed, &enabled, &rpm, &monthly, &createdAt, &updatedAt, &lastUsed); err != nil { - continue - } - allowedServices := []string{} - _ = json.Unmarshal([]byte(allowed), &allowedServices) - items = append(items, map[string]any{ - "id": id, - "name": name, - "keyPrefix": keyPrefix, - "plan": plan, - "allowedServiceIds": allowedServices, - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpm), - "monthlyQuota": nullIntValue(monthly), - "createdAt": createdAt, - "updatedAt": updatedAt, - "lastUsedAt": nullString(lastUsed), - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleKeysCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Plan string `json:"plan"` - AllowedServiceIDs []string `json:"allowedServiceIds"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - name := strings.TrimSpace(input.Name) - if name == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "API key name is required") - return - } - plan := strings.ToLower(strings.TrimSpace(firstNonEmpty(input.Plan, "pro"))) - rpmLimit, monthlyQuota := s.planDefaults(plan) - if input.RPMLimit != nil { - rpmLimit = input.RPMLimit - } - if input.MonthlyQuota != nil { - monthlyQuota = input.MonthlyQuota - } - - rawKey, _ := auth.RandomToken(28) - rawKey = "apy_" + rawKey - keyHash := auth.HashToken(rawKey) - id, _ := auth.RandomID("key") - now := storage.NowISO() - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO api_keys (id, name, key_hash, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - id, - name, - keyHash, - rawKey[:12], - plan, - mustJSON(input.AllowedServiceIDs), - boolToInt(defaultBool(input.Enabled, true)), - nullInt(rpmLimit), - nullInt(monthlyQuota), - now, - now, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "KEY_CREATE_FAILED", "Failed to create API key") - return - } - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "key": rawKey, - "item": map[string]any{"id": id, "name": name, "plan": plan}, - }, - }) - _ = ctx -} - -func (s *Server) handleKeysPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "API key ID is required") - return - } - var input struct { - Name *string `json:"name"` - Plan *string `json:"plan"` - AllowedServiceIDs []string `json:"allowedServiceIds"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - current := struct { - Name string - Plan string - Allowed string - Enabled int - RPM sql.NullInt64 - Monthly sql.NullInt64 - }{} - err := s.Store.DB.QueryRowContext(r.Context(), ` - SELECT name, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota - FROM api_keys WHERE id = ? - `, id).Scan(¤t.Name, ¤t.Plan, ¤t.Allowed, ¤t.Enabled, ¤t.RPM, ¤t.Monthly) - if err != nil { - writeError(w, http.StatusNotFound, "KEY_NOT_FOUND", "API key not found") - return - } - - plan := current.Plan - if input.Plan != nil { - plan = strings.ToLower(strings.TrimSpace(*input.Plan)) - } - - allowed := current.Allowed - if input.AllowedServiceIDs != nil { - allowed = mustJSON(input.AllowedServiceIDs) - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE api_keys - SET name = ?, plan = ?, allowed_service_ids = ?, enabled = ?, rpm_limit = ?, monthly_quota = ?, updated_at = ? - WHERE id = ? - `, - firstNonEmptyPtr(input.Name, current.Name), - plan, - allowed, - boolToInt(defaultBool(input.Enabled, current.Enabled == 1)), - nullInt(coalesceIntPtr(input.RPMLimit, nullIntValue(current.RPM))), - nullInt(coalesceIntPtr(input.MonthlyQuota, nullIntValue(current.Monthly))), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "KEY_UPDATE_FAILED", "Failed to update API key") - return - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleAnalyticsOps(w http.ResponseWriter, r *http.Request, ctx *authContext) { - counts := map[string]int{} - for _, table := range []string{"users", "services", "database_connections", "api_keys"} { - var count int - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM `+table).Scan(&count) - counts[table] = count - } - - period := time.Now().UTC().Format("2006-01") - var monthlyRequests int - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(request_count), 0) FROM usage_counters WHERE period_month = ?`, period).Scan(&monthlyRequests) - - severityRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT severity, COUNT(*) FROM incident_events - WHERE occurred_at >= datetime('now', '-24 hours') - GROUP BY severity - `) - incidentsBySeverity := map[string]int{} - if severityRows != nil { - defer severityRows.Close() - for severityRows.Next() { - var severity string - var count int - if err := severityRows.Scan(&severity, &count); err == nil { - incidentsBySeverity[severity] = count - } - } - } - - healthRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT COALESCE(last_validation_status, 'unknown') AS status, COUNT(*) - FROM services - GROUP BY status - `) - healthCounts := map[string]int{} - if healthRows != nil { - defer healthRows.Close() - for healthRows.Next() { - var status string - var count int - if err := healthRows.Scan(&status, &count); err == nil { - healthCounts[status] = count - } - } - } - - tsRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT strftime('%Y-%m-%dT%H:00:00Z', occurred_at) AS bucket, COUNT(*) - FROM metrics_timeseries - WHERE metric = 'request_total' AND occurred_at >= datetime('now', '-24 hours') - GROUP BY bucket - ORDER BY bucket ASC - `) - hourly := []map[string]any{} - if tsRows != nil { - defer tsRows.Close() - for tsRows.Next() { - var bucket string - var count int - if err := tsRows.Scan(&bucket, &count); err == nil { - hourly = append(hourly, map[string]any{"bucket": bucket, "value": count}) - } - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "counts": map[string]any{ - "users": counts["users"], - "services": counts["services"], - "databases": counts["database_connections"], - "keys": counts["api_keys"], - }, - "requests": map[string]any{ - "period": period, - "total": monthlyRequests, - "hourly": hourly, - }, - "incidentsBySeverity": incidentsBySeverity, - "serviceHealth": healthCounts, - }, - }) - _ = ctx -} - -func (s *Server) handleAnalyticsTraffic(w http.ResponseWriter, r *http.Request, ctx *authContext) { - from := time.Now().Add(-7 * 24 * time.Hour) - to := time.Now() - if value := strings.TrimSpace(r.URL.Query().Get("from")); value != "" { - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - from = parsed - } - } - if value := strings.TrimSpace(r.URL.Query().Get("to")); value != "" { - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - to = parsed - } - } - - traffic, err := s.Umami.FetchTraffic(r.Context(), from, to) - if err == nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO umami_sync_cache (cache_key, payload_json, updated_at) - VALUES ('traffic', ?, ?) - ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at - `, mustJSON(traffic), storage.NowISO()) - } else { - var payload string - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT payload_json FROM umami_sync_cache WHERE cache_key = 'traffic'`).Scan(&payload) - if payload != "" { - _ = json.Unmarshal([]byte(payload), &traffic) - } - if traffic == nil { - traffic = map[string]any{"enabled": false, "error": err.Error()} - } - } - - eventRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT COALESCE(json_extract(labels_json, '$.path'), 'unknown') AS path, COUNT(*) - FROM metrics_timeseries - WHERE metric = 'client_event' AND occurred_at >= datetime('now', '-7 days') - GROUP BY path - ORDER BY COUNT(*) DESC - LIMIT 20 - `) - events := []map[string]any{} - if eventRows != nil { - defer eventRows.Close() - for eventRows.Next() { - var page string - var count int - if err := eventRows.Scan(&page, &count); err == nil { - events = append(events, map[string]any{"path": page, "count": count}) - } - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "umami": traffic, - "clientEvents": events, - }, - }) - _ = ctx -} - -func (s *Server) handleAnalyticsEvents(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Event string `json:"event"` - Path string `json:"path"` - Meta map[string]any `json:"meta"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - labels := map[string]any{ - "event": firstNonEmpty(input.Event, "event"), - "path": firstNonEmpty(input.Path, "unknown"), - "meta": input.Meta, - } - _ = s.logMetric("client_event", 1, labels) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleGatewayOrUI(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found") - return - } - - uiBasePath := normalizePathPrefix(s.Cfg.DashboardUIBasePath, "/") - uiRequestPath, uiPathMatched := webPathForRequest(r.URL.Path, uiBasePath) - if uiBasePath != "/" && uiPathMatched { - s.serveWeb(w, uiRequestPath) - return - } - - service, ok := s.matchService(r.Context(), r.URL.Path) - if ok { - s.handleProxy(w, r, service) - return - } - - if uiBasePath == "/" { - s.serveWeb(w, uiRequestPath) - return - } - - writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found") -} - -func (s *Server) serveWeb(w http.ResponseWriter, requestPath string) { - sub, err := fs.Sub(staticFS, "static") - if err != nil { - writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available") - return - } - - reqPath := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(requestPath)), "/") - if reqPath == "." { - reqPath = "" - } - if reqPath == "" { - reqPath = "index.html" - } - if _, err := fs.Stat(sub, reqPath); err != nil { - reqPath = "index.html" - } - - data, err := fs.ReadFile(sub, reqPath) - if err != nil { - writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available") - return - } - - if contentType := mime.TypeByExtension(path.Ext(reqPath)); contentType != "" { - w.Header().Set("Content-Type", contentType) - } - _, _ = w.Write(data) -} - -func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request, service map[string]any) { - secret := strings.TrimSpace(r.Header.Get(s.Cfg.APIKeyHeader)) - if secret == "" { - writeError(w, http.StatusUnauthorized, "API_KEY_MISSING", fmt.Sprintf("Missing API key header '%s'", s.Cfg.APIKeyHeader)) - return - } - - apiKey, err := s.getAPIKeyBySecret(r.Context(), secret) - if err != nil { - writeError(w, http.StatusUnauthorized, "API_KEY_INVALID", "Invalid API key") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), sql.NullString{}, "API_KEY_INVALID", "Invalid API key", "medium", sql.NullInt64{Int64: 401, Valid: true}) - return - } - if !asBool(apiKey["enabled"]) { - writeError(w, http.StatusForbidden, "API_KEY_DISABLED", "API key is disabled") - return - } - if !asBool(service["enabled"]) { - writeError(w, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Service is disabled") - return - } - - if !serviceAllowed(apiKey, asString(service["id"]), asString(service["slug"]), asString(service["routePrefix"])) { - writeError(w, http.StatusForbidden, "SERVICE_SCOPE_DENIED", "API key is not allowed for this service") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "SERVICE_SCOPE_DENIED", "API key attempted unauthorized service access", "medium", sql.NullInt64{Int64: 403, Valid: true}) - return - } - - period := time.Now().UTC().Format("2006-01") - serviceLimitRPM := nullIntValueFromAny(service["rpmLimit"]) - keyLimitRPM := nullIntValueFromAny(apiKey["rpmLimit"]) - serviceLimitMonthly := nullIntValueFromAny(service["monthlyQuota"]) - keyLimitMonthly := nullIntValueFromAny(apiKey["monthlyQuota"]) - - effectiveRPM := minPositive(serviceLimitRPM, keyLimitRPM) - effectiveMonthly := minPositive(serviceLimitMonthly, keyLimitMonthly) - - if effectiveMonthly != nil { - used := s.usageCount(r.Context(), asString(apiKey["id"]), asString(service["id"]), period) - if used >= *effectiveMonthly { - writeError(w, http.StatusTooManyRequests, "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded", "high", sql.NullInt64{Int64: 429, Valid: true}) - return - } - } - - if effectiveRPM != nil { - if ok, retryAfter := s.allowRPM(asString(apiKey["id"])+":"+asString(service["id"]), *effectiveRPM); !ok { - w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) - writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "RATE_LIMIT_EXCEEDED", "Rate limit exceeded", "medium", sql.NullInt64{Int64: 429, Valid: true}) - return - } - } - - targetURL, err := gateway.BuildTargetURL(asString(service["upstreamUrl"]), asString(service["routePrefix"]), r.URL.Path, r.URL.RawQuery) - if err != nil { - writeError(w, http.StatusBadGateway, "UPSTREAM_INVALID", "Invalid upstream URL") - return - } - - headers := map[string]string{} - if upstreamHeader := asString(service["upstreamAuthHeader"]); upstreamHeader != "" { - headers[upstreamHeader] = asString(service["upstreamAuthValue"]) - } - if token := asString(service["internalToken"]); token != "" { - headers[s.Cfg.ServiceTokenHeader] = token - } - - r.Header.Del(s.Cfg.APIKeyHeader) - - timeout := time.Duration(intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))) * time.Millisecond - client := &http.Client{Timeout: timeout} - - statusCode, _, proxyErr := gateway.ProxyRequest(client, w, r, targetURL, headers) - if proxyErr != nil { - status := http.StatusBadGateway - if errors.Is(proxyErr, context.DeadlineExceeded) || strings.Contains(strings.ToLower(proxyErr.Error()), "timeout") { - status = http.StatusGatewayTimeout - } - writeError(w, status, "UPSTREAM_ERROR", proxyErr.Error()) - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_ERROR", proxyErr.Error(), "high", sql.NullInt64{Int64: int64(status), Valid: true}) - return - } - - s.incrementUsage(r.Context(), asString(apiKey["id"]), asString(service["id"]), period) - _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`, storage.NowISO(), storage.NowISO(), asString(apiKey["id"])) - _ = s.logMetric("request_total", 1, map[string]any{"service": asString(service["slug"]), "status": statusCode}) - - if statusCode >= 500 { - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_SERVER_ERROR", fmt.Sprintf("Upstream returned %d", statusCode), "high", sql.NullInt64{Int64: int64(statusCode), Valid: true}) - } -} - -func (s *Server) resolveAuth(r *http.Request) (*authContext, error) { - accessCookie, err := r.Cookie(s.Cfg.SessionAccessCookie) - if err != nil || strings.TrimSpace(accessCookie.Value) == "" { - return nil, errors.New("access cookie missing") - } - return s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessCookie.Value)) -} - -func (s *Server) resolveAuthFromTokenHash(ctx context.Context, accessTokenHash string) (*authContext, error) { - var user struct { - ID string - Email string - Enabled int - ForcePasswordReset int - SessionID string - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT u.id, u.email, u.enabled, u.force_password_reset, sess.id - FROM sessions sess - JOIN users u ON u.id = sess.user_id - WHERE sess.access_token_hash = ? - AND sess.revoked_at IS NULL - AND sess.access_expires_at > ? - `, accessTokenHash, storage.NowISO()).Scan(&user.ID, &user.Email, &user.Enabled, &user.ForcePasswordReset, &user.SessionID) - if err != nil { - return nil, err - } - - roles, permissions, err := s.userAccess(ctx, user.ID) - if err != nil { - return nil, err - } - - return &authContext{ - UserID: user.ID, - Email: user.Email, - Enabled: user.Enabled == 1, - ForcePasswordReset: user.ForcePasswordReset == 1, - Roles: roles, - Permissions: permissions, - SessionID: user.SessionID, - }, nil -} - -func (s *Server) userAccess(ctx context.Context, userID string) ([]string, map[string]bool, error) { - roles := []string{} - roleRows, err := s.Store.DB.QueryContext(ctx, ` - SELECT r.slug - FROM user_roles ur - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ? AND r.enabled = 1 - `, userID) - if err != nil { - return nil, nil, err - } - for roleRows.Next() { - var slug string - if err := roleRows.Scan(&slug); err == nil { - roles = append(roles, slug) - } - } - roleRows.Close() - - permissions := map[string]bool{} - permRows, err := s.Store.DB.QueryContext(ctx, ` - SELECT DISTINCT p.code - FROM user_roles ur - JOIN role_permissions rp ON rp.role_id = ur.role_id - JOIN permissions p ON p.id = rp.permission_id - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ? AND r.enabled = 1 - `, userID) - if err != nil { - return roles, permissions, err - } - for permRows.Next() { - var code string - if err := permRows.Scan(&code); err == nil { - permissions[code] = true - } - } - permRows.Close() - - return roles, permissions, nil -} - -func (s *Server) createSession(ctx context.Context, userID string) (string, string, string, error) { - sessionID, _ := auth.RandomID("ses") - accessToken, _ := auth.RandomToken(32) - refreshToken, _ := auth.RandomToken(48) - accessHash := auth.HashToken(accessToken) - refreshHash := auth.HashToken(refreshToken) - - now := storage.NowISO() - accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339) - refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339) - - _, err := s.Store.DB.ExecContext(ctx, ` - INSERT INTO sessions (id, user_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, sessionID, userID, accessHash, refreshHash, accessExp, refreshExp, now, now) - if err != nil { - return "", "", "", err - } - - return sessionID, accessToken, refreshToken, nil -} - -func (s *Server) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) { - accessCookie := &http.Cookie{ - Name: s.Cfg.SessionAccessCookie, - Value: accessToken, - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.Cfg.AccessTokenTTL), - } - if s.Cfg.CookieDomain != "" { - accessCookie.Domain = s.Cfg.CookieDomain - } - - refreshCookie := &http.Cookie{ - Name: s.Cfg.SessionRefreshCookie, - Value: refreshToken, - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.Cfg.RefreshTokenTTL), - } - if s.Cfg.CookieDomain != "" { - refreshCookie.Domain = s.Cfg.CookieDomain - } - - http.SetCookie(w, accessCookie) - http.SetCookie(w, refreshCookie) -} - -func (s *Server) clearAuthCookies(w http.ResponseWriter) { - expired := time.Unix(0, 0) - for _, cookieName := range []string{s.Cfg.SessionAccessCookie, s.Cfg.SessionRefreshCookie} { - cookie := &http.Cookie{ - Name: cookieName, - Value: "", - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: expired, - MaxAge: -1, - } - if s.Cfg.CookieDomain != "" { - cookie.Domain = s.Cfg.CookieDomain - } - http.SetCookie(w, cookie) - } -} - -func (s *Server) hasUsers(ctx context.Context) bool { - var count int - _ = s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) - return count > 0 -} - -func (s *Server) isLoginRateLimited(r *http.Request, email string) bool { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - entry := s.loginAttempts[key] - now := time.Now() - if now.Sub(entry.WindowStart) > 15*time.Minute { - entry = loginWindow{WindowStart: now, Count: 0} - } - return entry.Count >= 10 -} - -func (s *Server) markLoginAttempt(r *http.Request, email string) { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - entry := s.loginAttempts[key] - now := time.Now() - if now.Sub(entry.WindowStart) > 15*time.Minute { - entry = loginWindow{WindowStart: now, Count: 0} - } - entry.Count++ - s.loginAttempts[key] = entry -} - -func (s *Server) clearLoginAttempts(r *http.Request, email string) { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - delete(s.loginAttempts, key) -} - -func (s *Server) resolveRoleIDs(ctx context.Context, roleIDs []string, roleSlugs []string) ([]string, error) { - resolved := []string{} - if len(roleIDs) > 0 { - for _, roleID := range roleIDs { - id := strings.TrimSpace(roleID) - if id == "" { - continue - } - var exists int - if err := s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE id = ?`, id).Scan(&exists); err != nil || exists == 0 { - return nil, fmt.Errorf("role %s not found", id) - } - resolved = append(resolved, id) - } - return uniqueStrings(resolved), nil - } - for _, slug := range roleSlugs { - id, err := s.roleIDBySlug(ctx, slug) - if err != nil { - return nil, err - } - resolved = append(resolved, id) - } - return uniqueStrings(resolved), nil -} - -func (s *Server) roleIDBySlug(ctx context.Context, slug string) (string, error) { - id := "" - err := s.Store.DB.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, strings.ToLower(strings.TrimSpace(slug))).Scan(&id) - if err != nil { - return "", fmt.Errorf("role %s not found", slug) - } - return id, nil -} - -func (s *Server) replaceRolePermissionsTx(ctx context.Context, tx *sql.Tx, roleID string, permissionCodes []string) error { - if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil { - return err - } - now := storage.NowISO() - for _, code := range permissionCodes { - permCode := strings.TrimSpace(code) - if permCode == "" { - continue - } - permID := "" - if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, permCode).Scan(&permID); err != nil { - return fmt.Errorf("permission not found: %s", permCode) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)`, roleID, permID, now); err != nil { - return err - } - } - return nil -} - -func (s *Server) getServiceByID(ctx context.Context, id string) (map[string]any, error) { - row := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM services WHERE id = ? - `, id) - return scanServiceRow(row) -} - -func (s *Server) getDatabaseByID(ctx context.Context, id string) (map[string]any, error) { - var item struct { - ID, Name, Slug, Provider, ConnectionURL, CreatedAt, UpdatedAt string - Enabled int - LastAt, LastStatus, LastMessage sql.NullString - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM database_connections - WHERE id = ? - `, id).Scan(&item.ID, &item.Name, &item.Slug, &item.Provider, &item.ConnectionURL, &item.Enabled, &item.LastAt, &item.LastStatus, &item.LastMessage, &item.CreatedAt, &item.UpdatedAt) - if err != nil { - return nil, err - } - return map[string]any{ - "id": item.ID, - "name": item.Name, - "slug": item.Slug, - "provider": item.Provider, - "connectionUrl": item.ConnectionURL, - "target": extractDatabaseTarget(item.Provider, item.ConnectionURL), - "maskedConnectionUrl": maskDatabaseURL(item.Provider, item.ConnectionURL), - "enabled": item.Enabled == 1, - "lastValidationAt": nullString(item.LastAt), - "lastValidationStatus": nullString(item.LastStatus), - "lastValidationMessage": nullString(item.LastMessage), - "createdAt": item.CreatedAt, - "updatedAt": item.UpdatedAt, - }, nil -} - -func (s *Server) validateDatabaseConnection(ctx context.Context, provider, connectionURL string) (bool, int, string) { - provider = normalizeProvider(provider) - if provider == "" { - return false, http.StatusBadRequest, "Unsupported database provider" - } - - timeoutCtx, cancel := context.WithTimeout(ctx, s.Cfg.DefaultServiceTimeout) - defer cancel() - - sqlDriver := "" - driverConn := connectionURL - switch provider { - case "sqlite": - sqlDriver = "sqlite" - if !strings.HasPrefix(driverConn, "file:") && driverConn != ":memory:" { - if abs, err := pathAbs(driverConn); err == nil { - driverConn = "file:" + abs - } - } - case "postgres": - sqlDriver = "pgx" - case "mysql": - sqlDriver = "mysql" - } - - db, err := sql.Open(sqlDriver, driverConn) - if err != nil { - return false, http.StatusBadGateway, err.Error() - } - defer db.Close() - - db.SetConnMaxLifetime(2 * time.Minute) - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - - if err := db.PingContext(timeoutCtx); err != nil { - return false, http.StatusBadGateway, err.Error() - } - return true, http.StatusOK, fmt.Sprintf("%s connection validated", strings.Title(provider)) -} - -func (s *Server) getAPIKeyBySecret(ctx context.Context, secret string) (map[string]any, error) { - hash := auth.HashToken(secret) - var item struct { - ID, Name, KeyPrefix, Plan, AllowedServiceIDs, CreatedAt, UpdatedAt string - Enabled int - RPMLimit, MonthlyQuota sql.NullInt64 - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at - FROM api_keys - WHERE key_hash = ? - `, hash).Scan(&item.ID, &item.Name, &item.KeyPrefix, &item.Plan, &item.AllowedServiceIDs, &item.Enabled, &item.RPMLimit, &item.MonthlyQuota, &item.CreatedAt, &item.UpdatedAt) - if err != nil { - return nil, err - } - allowed := []string{} - _ = json.Unmarshal([]byte(item.AllowedServiceIDs), &allowed) - return map[string]any{ - "id": item.ID, - "name": item.Name, - "keyPrefix": item.KeyPrefix, - "plan": item.Plan, - "allowedServiceIds": allowed, - "enabled": item.Enabled == 1, - "rpmLimit": nullIntValue(item.RPMLimit), - "monthlyQuota": nullIntValue(item.MonthlyQuota), - }, nil -} - -func (s *Server) planDefaults(plan string) (*int, *int) { - p := strings.ToLower(strings.TrimSpace(plan)) - switch p { - case "free": - return intPtr(s.Cfg.FreeRPM), intPtr(s.Cfg.FreeMonthlyQuota) - case "business": - return intPtr(s.Cfg.BusinessRPM), intPtr(s.Cfg.BusinessMonthlyQuota) - case "enterprise": - return nil, nil - default: - return intPtr(s.Cfg.ProRPM), intPtr(s.Cfg.ProMonthlyQuota) - } -} - -func (s *Server) matchService(ctx context.Context, requestPath string) (map[string]any, bool) { - rows, err := s.Store.DB.QueryContext(ctx, ` - SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM services - WHERE enabled = 1 - ORDER BY LENGTH(route_prefix) DESC, route_prefix DESC - `) - if err != nil { - return nil, false - } - defer rows.Close() - - for rows.Next() { - item, err := scanService(rows) - if err != nil { - continue - } - prefix := asString(item["routePrefix"]) - if prefix == "/" || requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") { - return item, true - } - } - return nil, false -} - -func (s *Server) usageCount(ctx context.Context, apiKeyID, serviceID, period string) int { - var count int - _ = s.Store.DB.QueryRowContext(ctx, ` - SELECT COALESCE(request_count, 0) - FROM usage_counters - WHERE api_key_id = ? AND service_id = ? AND period_month = ? - `, apiKeyID, serviceID, period).Scan(&count) - return count -} - -func (s *Server) incrementUsage(ctx context.Context, apiKeyID, serviceID, period string) { - _, _ = s.Store.DB.ExecContext(ctx, ` - INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at) - VALUES (?, ?, ?, 1, ?) - ON CONFLICT(api_key_id, service_id, period_month) - DO UPDATE SET request_count = request_count + 1, updated_at = excluded.updated_at - `, apiKeyID, serviceID, period, storage.NowISO()) -} - -func (s *Server) allowRPM(bucket string, limit int) (bool, int) { - if limit <= 0 { - return true, 0 - } - now := time.Now().UTC() - windowStart := now.Truncate(time.Minute) - key := bucket + ":" + windowStart.Format(time.RFC3339) - - s.rpmMu.Lock() - defer s.rpmMu.Unlock() - - for k, v := range s.rpmUsage { - if now.Sub(v.WindowStart) > 2*time.Minute { - delete(s.rpmUsage, k) - } - } - - entry := s.rpmUsage[key] - if entry.WindowStart.IsZero() { - entry.WindowStart = windowStart - } - if entry.Count >= limit { - retry := int(windowStart.Add(time.Minute).Sub(now).Seconds()) - if retry < 1 { - retry = 1 - } - return false, retry - } - entry.Count++ - s.rpmUsage[key] = entry - return true, 0 -} - -func (s *Server) logIncident(ctx context.Context, serviceID, apiKeyID sql.NullString, code, message, severity string, httpStatus sql.NullInt64) error { - _, err := s.Store.DB.ExecContext(ctx, ` - INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, count, occurred_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?) - `, serviceID, apiKeyID, code, message, severity, httpStatus, storage.NowISO()) - return err -} - -func (s *Server) logMetric(metric string, value float64, labels map[string]any) error { - _, err := s.Store.DB.Exec(` - INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at) - VALUES (?, ?, ?, ?) - `, metric, value, mustJSON(labels), storage.NowISO()) - return err -} - -func (s *Server) audit(ctx context.Context, actorUserID, action, targetType, targetID string, payload any) { - _, _ = s.Store.DB.ExecContext(ctx, ` - INSERT INTO audit_log (actor_user_id, action, target_type, target_id, payload_json, occurred_at) - VALUES (?, ?, ?, ?, ?, ?) - `, nullIfEmpty(actorUserID), action, nullIfEmpty(targetType), nullIfEmpty(targetID), mustJSON(payload), storage.NowISO()) -} - -func scanService(rows *sql.Rows) (map[string]any, error) { - var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string - var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString - var enabled int - var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64 - if err := rows.Scan( - &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken, - &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS, - &lastValidationAt, &lastValidationStatus, &lastValidationMessage, - &createdAt, &updatedAt, - ); err != nil { - return nil, err - } - return map[string]any{ - "id": id, - "name": name, - "slug": slug, - "upstreamUrl": upstreamURL, - "routePrefix": routePrefix, - "healthPath": healthPath, - "upstreamAuthHeader": nullString(upstreamAuthHeader), - "upstreamAuthValue": nullString(upstreamAuthValue), - "internalToken": nullString(internalToken), - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpmLimit), - "monthlyQuota": nullIntValue(monthlyQuota), - "requestTimeoutMs": nullIntValue(requestTimeoutMS), - "lastValidationAt": nullString(upstreamOr(lastValidationAt, sql.NullString{})), - "lastValidationStatus": nullString(lastValidationStatus), - "lastValidationMessage": nullString(lastValidationMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }, nil -} - -func scanServiceRow(row *sql.Row) (map[string]any, error) { - var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string - var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString - var enabled int - var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64 - if err := row.Scan( - &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken, - &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS, - &lastValidationAt, &lastValidationStatus, &lastValidationMessage, - &createdAt, &updatedAt, - ); err != nil { - return nil, err - } - return map[string]any{ - "id": id, - "name": name, - "slug": slug, - "upstreamUrl": upstreamURL, - "routePrefix": routePrefix, - "healthPath": healthPath, - "upstreamAuthHeader": nullString(upstreamAuthHeader), - "upstreamAuthValue": nullString(upstreamAuthValue), - "internalToken": nullString(internalToken), - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpmLimit), - "monthlyQuota": nullIntValue(monthlyQuota), - "requestTimeoutMs": nullIntValue(requestTimeoutMS), - "lastValidationAt": nullString(lastValidationAt), - "lastValidationStatus": nullString(lastValidationStatus), - "lastValidationMessage": nullString(lastValidationMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }, nil -} - -func serviceAllowed(apiKey map[string]any, serviceID, slug, prefix string) bool { - allowed, ok := apiKey["allowedServiceIds"].([]string) - if !ok || len(allowed) == 0 { - return true - } - for _, candidate := range allowed { - if candidate == serviceID || candidate == slug || candidate == prefix { - return true - } - } - return false -} - -func writeJSON(w http.ResponseWriter, status int, payload map[string]any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) -} - -func writeError(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, map[string]any{ - "ok": false, - "error": map[string]any{ - "code": code, - "message": message, - }, - }) -} - -func decodeJSON(body io.Reader, target any) error { - decoder := json.NewDecoder(io.LimitReader(body, 1<<20)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - return nil -} - -func mustJSON(value any) string { - bytes, _ := json.Marshal(value) - return string(bytes) -} - -func boolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -func intPtr(value int) *int { - v := value - return &v -} - -func nullInt(value *int) any { - if value == nil || *value <= 0 { - return nil - } - return *value -} - -func nullIntValue(value sql.NullInt64) *int { - if !value.Valid { - return nil - } - v := int(value.Int64) - return &v -} - -func nullIntValueFromAny(value any) *int { - if value == nil { - return nil - } - switch typed := value.(type) { - case *int: - return typed - case int: - v := typed - return &v - case int64: - v := int(typed) - return &v - case float64: - v := int(typed) - return &v - default: - return nil - } -} - -func nullString(value sql.NullString) any { - if !value.Valid { - return nil - } - return value.String -} - -func nullStringValue(value any) sql.NullString { - if value == nil { - return sql.NullString{} - } - text := fmt.Sprintf("%v", value) - if strings.TrimSpace(text) == "" { - return sql.NullString{} - } - return sql.NullString{String: text, Valid: true} -} - -func nullIfEmpty(value string) any { - v := strings.TrimSpace(value) - if v == "" { - return nil - } - return v -} - -func coalesceIntPtr(value *int, fallback *int) *int { - if value != nil { - return value - } - return fallback -} - -func normalizePathPrefix(value, fallback string) string { - v := strings.TrimSpace(value) - if v == "" { - v = fallback - } - if !strings.HasPrefix(v, "/") { - v = "/" + v - } - if len(v) > 1 && strings.HasSuffix(v, "/") { - v = strings.TrimSuffix(v, "/") - } - return v -} - -func slugify(value, fallback string) string { - v := strings.ToLower(strings.TrimSpace(value)) - if v == "" { - v = fallback - } - out := strings.Builder{} - lastDash := false - for _, r := range v { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - out.WriteRune(r) - lastDash = false - continue - } - if !lastDash { - out.WriteRune('-') - lastDash = true - } - } - result := strings.Trim(out.String(), "-") - if result == "" { - return fallback - } - return result -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func firstNonEmptyPtr(value *string, fallback string) string { - if value != nil { - if strings.TrimSpace(*value) == "" { - return fallback - } - return strings.TrimSpace(*value) - } - return fallback -} - -func defaultBool(value *bool, fallback bool) bool { - if value == nil { - return fallback - } - return *value -} - -func asString(value any) string { - if value == nil { - return "" - } - if v, ok := value.(string); ok { - return v - } - return fmt.Sprintf("%v", value) -} - -func asBool(value any) bool { - if value == nil { - return false - } - switch v := value.(type) { - case bool: - return v - case int: - return v == 1 - case int64: - return v == 1 - case float64: - return int(v) == 1 - default: - return false - } -} - -func intFromAny(value any, fallback int) int { - if value == nil { - return fallback - } - switch v := value.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case *int: - if v == nil { - return fallback - } - return *v - default: - return fallback - } -} - -func minPositive(a, b *int) *int { - if a == nil && b == nil { - return nil - } - if a == nil { - return b - } - if b == nil { - return a - } - if *a < *b { - return a - } - return b -} - -func keysOfMap(values map[string]bool) []string { - keys := make([]string, 0, len(values)) - for key, enabled := range values { - if enabled { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func uniqueStrings(values []string) []string { - seen := map[string]bool{} - output := []string{} - for _, value := range values { - v := strings.TrimSpace(value) - if v == "" || seen[v] { - continue - } - seen[v] = true - output = append(output, v) - } - return output -} - -func normalizeProvider(value string) string { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "sqlite", "sqlite3", "file": - return "sqlite" - case "postgres", "postgresql", "pg": - return "postgres" - case "mysql", "mariadb": - return "mysql" - default: - return "" - } -} - -func maskDatabaseURL(provider, raw string) string { - if normalizeProvider(provider) == "sqlite" { - return raw - } - u, err := netUrlParse(raw) - if err != nil { - if len(raw) <= 8 { - return "***" - } - return raw[:4] + "***" + raw[len(raw)-2:] - } - if u.User != nil { - username := u.User.Username() - password, hasPassword := u.User.Password() - if username != "" { - if len(username) > 2 { - username = username[:2] + "***" - } else { - username = "***" - } - } - if hasPassword && password != "" { - u.User = url.UserPassword(username, "***") - } else { - u.User = url.User(username) - } - } - q := u.Query() - for _, key := range []string{"password", "pass", "pwd", "token", "secret"} { - if q.Has(key) { - q.Set(key, "***") - } - } - u.RawQuery = q.Encode() - return u.String() -} - -func extractDatabaseTarget(provider, raw string) string { - if normalizeProvider(provider) == "sqlite" { - return raw - } - u, err := netUrlParse(raw) - if err != nil { - return "-" - } - name := strings.TrimPrefix(u.Path, "/") - if name == "" { - name = "-" - } - return name -} - -func pathAbs(value string) (string, error) { - if strings.HasPrefix(value, "file:") { - return strings.TrimPrefix(value, "file:"), nil - } - if filepath.IsAbs(value) { - return value, nil - } - wd, err := os.Getwd() - if err != nil { - return "", err - } - return filepath.Join(wd, value), nil -} - -func netUrlParse(value string) (*url.URL, error) { - return url.Parse(value) -} - -func webPathForRequest(requestPath string, uiBasePath string) (string, bool) { - reqPath := path.Clean("/" + strings.TrimSpace(requestPath)) - if reqPath == "." { - reqPath = "/" - } - - base := normalizePathPrefix(uiBasePath, "/") - if base == "/" { - return strings.TrimPrefix(reqPath, "/"), true - } - - if reqPath == base { - return "", true - } - - prefix := base + "/" - if strings.HasPrefix(reqPath, prefix) { - return strings.TrimPrefix(reqPath, prefix), true - } - - return "", false -} - -func upstreamOr(a, b sql.NullString) sql.NullString { - if a.Valid { - return a - } - return b -} - -type serviceInput struct { - Name string `json:"name"` - Slug string `json:"slug"` - UpstreamURL string `json:"upstreamUrl"` - RoutePrefix string `json:"routePrefix"` - HealthPath string `json:"healthPath"` - UpstreamAuthHeader string `json:"upstreamAuthHeader"` - UpstreamAuthValue string `json:"upstreamAuthValue"` - InternalToken string `json:"internalToken"` - Enabled *bool `json:"enabled"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - RequestTimeoutMS *int `json:"requestTimeoutMs"` -} - -func (s serviceInput) RPMLimitOr(value any) *int { - if s.RPMLimit != nil { - return s.RPMLimit - } - return nullIntValueFromAny(value) -} - -func (s serviceInput) MonthlyQuotaOr(value any) *int { - if s.MonthlyQuota != nil { - return s.MonthlyQuota - } - return nullIntValueFromAny(value) -} - -func (s serviceInput) TimeoutOr(value any) *int { - if s.RequestTimeoutMS != nil { - return s.RequestTimeoutMS - } - return nullIntValueFromAny(value) -} diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 deleted file mode 100644 index 20e72b0..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff deleted file mode 100644 index 1487d93..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff deleted file mode 100644 index eff6edd..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 deleted file mode 100644 index b54e556..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff deleted file mode 100644 index 8c83fdc..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 deleted file mode 100644 index 0804aaf..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 deleted file mode 100644 index 732c64d..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff deleted file mode 100644 index 338fc76..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 deleted file mode 100644 index 21612f4..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff deleted file mode 100644 index dfa68b0..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/index-DRUelTBf.css b/app/backend/internal/api/static/assets/index-DRUelTBf.css deleted file mode 100644 index 63387b5..0000000 --- a/app/backend/internal/api/static/assets/index-DRUelTBf.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2) format("woff2"),url(/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-greek-400-normal-CM4qok81.woff2) format("woff2"),url(/assets/manrope-greek-400-normal-DuX9RsAR.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2) format("woff2"),url(/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2) format("woff2"),url(/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-400-normal-PaqtzbVb.woff2) format("woff2"),url(/assets/manrope-latin-400-normal-8tf8FM3T.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2) format("woff2"),url(/assets/manrope-cyrillic-600-normal-It4mZcQk.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-greek-600-normal-BoRV6lzK.woff2) format("woff2"),url(/assets/manrope-greek-600-normal-CF2i9ZRY.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2) format("woff2"),url(/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2) format("woff2"),url(/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-600-normal-4f0koTD-.woff2) format("woff2"),url(/assets/manrope-latin-600-normal-BqgrALkZ.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2) format("woff2"),url(/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-greek-700-normal-CHUG9PD8.woff2) format("woff2"),url(/assets/manrope-greek-700-normal-DyfsrCpP.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2) format("woff2"),url(/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2) format("woff2"),url(/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-700-normal-BZp_XxE4.woff2) format("woff2"),url(/assets/manrope-latin-700-normal-DGRFkw-m.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2) format("woff2"),url(/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-20{margin-top:5rem}.mt-4{margin-top:1rem}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-2{height:.5rem}.max-h-56{max-height:14rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-\[1500px\]{max-width:1500px}.max-w-\[320px\]{max-width:320px}.max-w-md{max-width:28rem}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border>:not([hidden])~:not([hidden]){border-color:hsl(var(--border))}.overflow-auto{overflow:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.6rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-amber-500\/45{border-color:#f59e0b73}.border-amber-500\/50{border-color:#f59e0b80}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-emerald-500\/50{border-color:#10b98180}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-primary\/40{border-color:hsl(var(--primary) / .4)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-rose-500\/50{border-color:#f43f5e80}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-background{background-color:hsl(var(--background))}.bg-card{background-color:hsl(var(--card))}.bg-card\/90{background-color:hsl(var(--card) / .9)}.bg-destructive{background-color:hsl(var(--destructive))}.bg-emerald-500\/15{background-color:#10b98126}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/15{background-color:hsl(var(--primary) / .15)}.bg-primary\/20{background-color:hsl(var(--primary) / .2)}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-rose-500\/15{background-color:#f43f5e26}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary\/35{background-color:hsl(var(--secondary) / .35)}.bg-secondary\/40{background-color:hsl(var(--secondary) / .4)}.bg-secondary\/60{background-color:hsl(var(--secondary) / .6)}.bg-secondary\/70{background-color:hsl(var(--secondary) / .7)}.bg-transparent{background-color:transparent}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-wide{letter-spacing:.025em}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-200{--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.text-amber-200\/90{color:#fde68ae6}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-200{--tw-text-opacity: 1;color:rgb(167 243 208 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-rose-200{--tw-text-opacity: 1;color:rgb(254 205 211 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.24\)\]{--tw-shadow: 0 8px 24px rgba(0,0,0,.24);--tw-shadow-colored: 0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--background: 147 32% 9%;--foreground: 152 28% 94%;--card: 148 30% 12%;--card-foreground: 152 28% 93%;--primary: 142 73% 45%;--primary-foreground: 144 35% 8%;--secondary: 145 32% 18%;--secondary-foreground: 145 28% 92%;--muted: 146 26% 17%;--muted-foreground: 148 18% 74%;--accent: 136 66% 40%;--accent-foreground: 150 38% 95%;--destructive: 1 74% 56%;--destructive-foreground: 0 0% 98%;--border: 146 24% 24%;--input: 147 25% 20%;--ring: 142 73% 45%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Manrope,ui-sans-serif,system-ui,sans-serif}code,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-secondary:hover{background-color:hsl(var(--secondary))}.hover\:opacity-95:hover{opacity:.95}.focus\:border-primary:focus{border-color:hsl(var(--primary))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:p-8{padding:2rem}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[360px_1fr\]{grid-template-columns:360px 1fr}.xl\:grid-cols-\[420px_1fr\]{grid-template-columns:420px 1fr}} diff --git a/app/backend/internal/api/static/assets/index-DwfYiTMH.js b/app/backend/internal/api/static/assets/index-DwfYiTMH.js deleted file mode 100644 index 8bab5af..0000000 --- a/app/backend/internal/api/static/assets/index-DwfYiTMH.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--)if(e=t[r],e.state===J)z(e);else if(e.state===fe){const n=I;I=null,H(()=>ge(e,t[0]),!1),I=n}}function H(e,t){if(I)return e();let r=!1;t||(I=[]),q?r=!0:q=[],he++;try{const n=e();return st(r),n}catch(n){r||(q=null),I=null,ke(n)}}function st(e){if(I&&(Fe(I),I=null),e)return;const t=q;q=null,t.length&&H(()=>je(t),!1)}function Fe(e){for(let t=0;t=0;t--)re(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)re(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function ue(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function ke(e,t=S){throw ue(e)}function Ce(e){if(typeof e=="function"&&!e.length)return Ce(e());if(Array.isArray(e)){const t=[];for(let r=0;r1?[]:null;return et(()=>Ie(i)),()=>{let h=e()||[],m=h.length,d,l;return h[Xe],j(()=>{let g,b,x,V,U,T,O,k,M;if(m===0)o!==0&&(Ie(i),i=[],n=[],s=[],o=0,a&&(a=[])),r.fallback&&(n=[at],s[0]=ce(G=>(i[0]=G,r.fallback())),o=1);else if(o===0){for(s=new Array(m),l=0;l=T&&k>=T&&n[O]===h[k];O--,k--)x[k]=s[O],V[k]=i[O],a&&(U[k]=a[O]);for(g=new Map,b=new Array(k+1),l=k;l>=T;l--)M=h[l],d=g.get(M),b[l]=d===void 0?-1:d,g.set(M,l);for(d=T;d<=O;d++)M=n[d],l=g.get(M),l!==void 0&&l!==-1?(x[l]=s[d],V[l]=i[d],a&&(U[l]=a[d]),l=b[l],g.set(M,l)):i[d]();for(l=T;le(t||{}))}function ae(){return!0}const ct={get(e,t,r){return t===Se?r:e.get(t)},has(e,t){return t===Se?!0:e.has(t)},set:ae,deleteProperty:ae,getOwnPropertyDescriptor(e,t){return{configurable:!0,enumerable:!0,get(){return e.get(t)},set:ae,deleteProperty:ae}},ownKeys(e){return e.keys()}};function we(e){return(e=typeof e=="function"?e():e)?e:{}}function ut(){for(let e=0,t=this.length;e=0;a--){const h=we(e[a])[o];if(h!==void 0)return h}},has(o){for(let a=e.length-1;a>=0;a--)if(o in we(e[a]))return!0;return!1},keys(){const o=[];for(let a=0;a=0;o--){const a=e[o];if(!a)continue;const h=Object.getOwnPropertyNames(a);for(let m=h.length-1;m>=0;m--){const d=h[m];if(d==="__proto__"||d==="constructor")continue;const l=Object.getOwnPropertyDescriptor(a,d);if(!n[d])n[d]=l.get?{enumerable:!0,configurable:!0,get:ut.bind(r[d]=[l.get.bind(a)])}:l.value!==void 0?l:void 0;else{const f=r[d];f&&(l.get?f.push(l.get.bind(a)):l.value!==void 0&&f.push(()=>l.value))}}}const s={},i=Object.keys(n);for(let o=i.length-1;o>=0;o--){const a=i[o],h=n[a];h&&h.get?Object.defineProperty(s,a,h):s[a]=h?h.value:void 0}return s}const Je=e=>`Stale read from <${e}>.`;function K(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(ot(()=>e.each,e.children,t||void 0))}function ye(e){const t=e.keyed,r=R(()=>e.when,void 0,void 0),n=t?r:R(r,void 0,{equals:(s,i)=>!s==!i});return R(()=>{const s=n();if(s){const i=e.children;return typeof i=="function"&&i.length>0?j(()=>i(t?s:()=>{if(!j(n))throw Je("Show");return r()})):i}return e.fallback},void 0,void 0)}function dt(e){const t=rt(()=>e.children),r=R(()=>{const n=t(),s=Array.isArray(n)?n:[n];let i=()=>{};for(let o=0;om()?void 0:h.when,void 0,void 0),l=h.keyed?d:R(d,void 0,{equals:(f,g)=>!f==!g});i=()=>m()||(l()?[a,d,h]:void 0)}return i});return R(()=>{const n=r()();if(!n)return e.fallback;const[s,i,o]=n,a=o.children;return typeof a=="function"&&a.length>0?j(()=>a(o.keyed?i():()=>{if(j(r)()?.[0]!==s)throw Je("Match");return i()})):a},void 0,void 0)}function W(e){return e}const ft=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],pt=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...ft]),gt=new Set(["innerHTML","textContent","innerText","children"]),ht=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),mt=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});function bt(e,t){const r=mt[e];return typeof r=="object"?r[t]?r.$:void 0:r}const yt=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),Q=e=>R(()=>e());function $t(e,t,r){let n=r.length,s=t.length,i=n,o=0,a=0,h=t[s-1].nextSibling,m=null;for(;od-a){const b=t[o];for(;a{s=i,t===document?e():u(t,e(),t.firstChild?null:void 0,r)},n.owner),()=>{s(),t.textContent=""}}function p(e,t,r,n){let s;const i=()=>{const a=document.createElement("template");return a.innerHTML=e,a.content.firstChild},o=()=>(s||(s=i())).cloneNode(!0);return o.cloneNode=o,o}function Oe(e,t=window.document){const r=t[Ne]||(t[Ne]=new Set);for(let n=0,s=e.length;ns.call(e,r[1],i))}else e.addEventListener(t,r,typeof r!="function"&&r)}function _t(e,t,r={}){const n=Object.keys(t||{}),s=Object.keys(r);let i,o;for(i=0,o=s.length;is.children=ne(e,t.children,s.children)),L(()=>typeof t.ref=="function"&&Pt(t.ref,e)),L(()=>At(e,t,r,!0,s,!0)),s}function Pt(e,t,r){return j(()=>e(t,r))}function u(e,t,r,n){if(r!==void 0&&!n&&(n=[]),typeof t!="function")return ne(e,t,n,r);L(s=>ne(e,t(),s,r),n)}function At(e,t,r,n,s={},i=!1){t||(t={});for(const o in s)if(!(o in t)){if(o==="children")continue;s[o]=Re(e,o,null,s[o],r,i,t)}for(const o in t){if(o==="children")continue;const a=t[o];s[o]=Re(e,o,a,s[o],r,i,t)}}function kt(e){return e.toLowerCase().replace(/-([a-z])/g,(t,r)=>r.toUpperCase())}function Le(e,t,r){const n=t.trim().split(/\s+/);for(let s=0,i=n.length;sObject.defineProperty(e,"target",{configurable:!0,value:h}),o=()=>{const h=t[r];if(h&&!t.disabled){const m=t[`${r}Data`];if(m!==void 0?h.call(t,m,e):h.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},a=()=>{for(;o()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const h=e.composedPath();i(h[0]);for(let m=0;m{let a=t();for(;typeof a=="function";)a=a();r=ne(e,a,r,n)}),()=>r;if(Array.isArray(t)){const a=[],h=r&&Array.isArray(r);if(Ae(a,t,r,s))return L(()=>r=ne(e,a,r,n,!0)),()=>r;if(a.length===0){if(r=Y(e,r,n),o)return r}else h?r.length===0?De(e,a,n):$t(e,r,a):(r&&Y(e),De(e,a));r=a}else if(t.nodeType){if(Array.isArray(r)){if(o)return r=Y(e,r,n,t);Y(e,r,null,t)}else r==null||r===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);r=t}}return r}function Ae(e,t,r,n){let s=!1;for(let i=0,o=t.length;i=0;o--){const a=t[o];if(s!==a){const h=a.parentNode===e;!i&&!o?h?e.replaceChild(s,a):e.insertBefore(s,r):h&&a.remove()}else i=!0}}else e.insertBefore(s,r);return[s]}async function v(e,t){const r=await fetch(e,{credentials:"include",headers:{"Content-Type":"application/json",...t?.headers||{}},...t}),n=await r.json().catch(()=>({}));if(!r.ok)throw new Error(n?.error?.message||`Request failed (${r.status})`);return n}async function Tt(){return(await v("/api/v1/bootstrap/status")).data}async function Et(e,t){return v("/api/v1/bootstrap/register-owner",{method:"POST",body:JSON.stringify({email:e,password:t})})}async function It(e,t){return(await v("/api/v1/auth/login",{method:"POST",body:JSON.stringify({email:e,password:t})})).data.user}async function Ue(){return(await v("/api/v1/auth/me")).data}async function Nt(){return v("/api/v1/auth/logout",{method:"POST"})}async function Lt(e){return v("/api/v1/auth/reset-password",{method:"POST",body:JSON.stringify({newPassword:e})})}async function Rt(){return(await v("/api/v1/services")).data}async function Dt(e){return v("/api/v1/services",{method:"POST",body:JSON.stringify(e)})}async function Ut(e,t){return v(`/api/v1/services/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Mt(e){return v(`/api/v1/services/${e}/validate`,{method:"POST"})}async function jt(){return(await v("/api/v1/databases")).data}async function Vt(e){return v("/api/v1/databases",{method:"POST",body:JSON.stringify(e)})}async function Bt(e,t){return v(`/api/v1/databases/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function qt(e){return v(`/api/v1/databases/${e}/validate`,{method:"POST"})}async function Ft(){return(await v("/api/v1/keys")).data}async function Kt(e){return v("/api/v1/keys",{method:"POST",body:JSON.stringify(e)})}async function Jt(e,t){return v(`/api/v1/keys/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Ht(){return(await v("/api/v1/users")).data}async function Gt(e){return v("/api/v1/users",{method:"POST",body:JSON.stringify(e)})}async function Me(e,t){return v(`/api/v1/users/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function He(){return(await v("/api/v1/roles")).data}async function Wt(e){return v("/api/v1/roles",{method:"POST",body:JSON.stringify(e)})}async function Qt(e,t){return v(`/api/v1/roles/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Xt(){return(await v("/api/v1/permissions")).data}async function Yt(){return v("/api/v1/analytics/ops")}async function zt(){return v("/api/v1/analytics/traffic")}async function Zt(e,t,r){return v("/api/v1/analytics/events",{method:"POST",body:JSON.stringify({event:e,path:t,meta:r})})}var er=p("