diff --git a/.env.example b/.env.example index a425b35..82ce242 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,36 @@ # Shorebird Self-Hosted Server Configuration -# Copy this file to .env and adjust values as needed +# Copy this file to .env and adjust values as needed. -# Server +# --- Server --- SERVER_HOST=0.0.0.0 SERVER_PORT=8080 +# Public-facing URL (used for download links in local storage mode) +SERVER_BASE_URL=http://localhost:8080 -# PostgreSQL Database -DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable +# --- Database --- +# Driver: "sqlite" (default, zero-dependency) or "postgres" +DB_DRIVER=sqlite +# SQLite path (relative to working directory; data/ is auto-created) +DB_PATH=data/shorebird.db +# PostgreSQL URL (only used when DB_DRIVER=postgres) +# DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable -# Object Storage (MinIO / S3-compatible) -STORAGE_ENDPOINT=localhost:9000 -STORAGE_ACCESS_KEY=minioadmin -STORAGE_SECRET_KEY=minioadmin -STORAGE_RELEASE_BUCKET=shorebird-releases -STORAGE_PATCH_BUCKET=shorebird-patches +# --- Storage --- +# Driver: "local" (default, zero-dependency) or "s3" +STORAGE_DRIVER=local +# Local storage directory (auto-created) +STORAGE_LOCAL_DIR=data/storage +# S3/MinIO settings (only used when STORAGE_DRIVER=s3) +# STORAGE_S3_ENDPOINT=localhost:9000 +# STORAGE_S3_ACCESS_KEY=minioadmin +# STORAGE_S3_SECRET_KEY=minioadmin +# STORAGE_S3_USE_SSL=false +# STORAGE_S3_RELEASE_BUCKET=shorebird-releases +# STORAGE_S3_PATCH_BUCKET=shorebird-patches -# JWT Authentication +# --- Authentication --- JWT_SECRET=change-me-in-production-use-a-long-random-string -# Redis (optional, for caching patch checks) -REDIS_ADDR=localhost:6379 -REDIS_PASSWORD= +# --- Redis (optional) --- +# REDIS_ADDR=localhost:6379 +# REDIS_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f01ed4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Binaries +bin/ +*.exe +*.test +*.out + +# Local data (SQLite DB + file storage) +data/ +*.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment files (may contain secrets) +.env +!.env.example + +# Go +vendor/ + +# Test coverage +coverage/ +*.coverprofile diff --git a/Dockerfile b/Dockerfile index 8feebca..30cbcba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=builder /app/shorebird-server . -COPY internal/db/migrations/ ./internal/db/migrations/ +COPY --from=builder /app/web ./web EXPOSE 8080 diff --git a/Makefile b/Makefile index e8c25cd..e7cd6f1 100644 --- a/Makefile +++ b/Makefile @@ -1,43 +1,27 @@ -.PHONY: run build test migrate-up migrate-down docker-up docker-down +.PHONY: run build test docker-up docker-down tidy fmt -# Default target -all: build +# Default: build and run with zero dependencies (SQLite + local storage) +all: build run -# Build the server binary build: go build -o bin/shorebird-server ./cmd/server -# Run the server locally run: go run ./cmd/server -# Run tests test: go test ./... -v -# Docker Compose +# Docker with full stack (PostgreSQL + MinIO) +# Uses profiles so docker compose up (no args) does nothing. docker-up: - docker compose up -d - docker compose logs -f + docker compose --profile full up -d docker-down: - docker compose down + docker compose --profile full down -# Database migrations -migrate-up: - goose -dir internal/db/migrations postgres "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable" up - -migrate-down: - goose -dir internal/db/migrations postgres "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable" down - -# Tidy dependencies tidy: go mod tidy -# Format code fmt: go fmt ./... - -# Lint -lint: - golangci-lint run ./... diff --git a/README.md b/README.md index c7a0aa2..f990fd8 100644 --- a/README.md +++ b/README.md @@ -2,62 +2,61 @@ A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.dev`). -## Quick Start +## Quick Start (Zero Dependencies) + +By default the server uses **SQLite** + **local filesystem** — no Docker, +no Postgres, no MinIO needed. ### Prerequisites - Go 1.23+ -- Docker & Docker Compose (for PostgreSQL and MinIO) -### 1. Start infrastructure - -```bash -docker compose up -d -``` - -### 2. Run database migrations - -```bash -make migrate-up -``` - -Or manually: -```bash -goose -dir internal/db/migrations postgres "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable" up -``` - -### 3. Start the server +### 1. Build and run ```bash make run ``` -The server starts on `http://localhost:8080`. +That's it. The server starts on `http://localhost:8080`. SQLite database +and file storage are auto-created under the `data/` directory. -### 4. Open the Web Dashboard +### 2. Open the Web Dashboard -Navigate to **http://localhost:8080** in your browser. You'll see the login page where you can: +Navigate to **http://localhost:8080** → Register → Start managing apps. -- **Register** a new admin account -- **Login** with your credentials +### 3. (Optional) Use PostgreSQL + MinIO -The dashboard provides: - -| Page | Features | -|------|----------| -| **Dashboard** | Overview stats (apps, patches, users, orgs), recent apps list | -| **Apps** | Create, list, and delete applications with App ID copy | -| **Users** | View all registered users and their roles | -| **Settings** | Server info, API base URL, and token display (click to copy) | - -### 5. Register via API (or use the Web UI) +For production deployments that need horizontal scaling: ```bash -curl -X POST http://localhost:8080/auth/register \ - -H "Content-Type: application/json" \ - -d '{"email":"dev@example.com","password":"securepass","name":"Developer"}' +# Start infrastructure +docker compose --profile full up -d + +# Set backend overrides +export DB_DRIVER=postgres +export DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable +export STORAGE_DRIVER=s3 +export STORAGE_S3_ENDPOINT=localhost:9000 +export STORAGE_S3_ACCESS_KEY=minioadmin +export STORAGE_S3_SECRET_KEY=minioadmin +export STORAGE_S3_USE_SSL=false + +# Then run the server +make run ``` -Save the returned `token` for subsequent API calls. +### Configuration + +All settings via environment variables. See `.env.example` for the full list. + +| Variable | Default | Description | +|----------|---------|-------------| +| `DB_DRIVER` | `sqlite` | `sqlite` or `postgres` | +| `DB_PATH` | `data/shorebird.db` | SQLite file path | +| `STORAGE_DRIVER` | `local` | `local` or `s3` | +| `STORAGE_LOCAL_DIR` | `data/storage` | Local storage directory | +| `JWT_SECRET` | *(insecure default)* | Set in production! | +| `SERVER_PORT` | `8080` | HTTP port | +| `SERVER_BASE_URL` | `http://localhost:8080` | Public URL for download links | ### 6. Configure Shorebird CLI @@ -144,11 +143,11 @@ See `.env.example` for all available configuration options. │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────────────────────────────────────┐ │ -│ │ PostgreSQL │ │ +│ │ SQLite / PostgreSQL │ │ │ │ (apps, releases, patches, artifacts, events) │ │ │ └──────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────┐ │ -│ │ MinIO (S3-compatible) │ │ +│ │ Local FS / MinIO (S3-compatible) │ │ │ │ shorebird-releases (private) │ │ │ │ shorebird-patches (public) │ │ │ └──────────────────────────────────────────────────┘ │ diff --git a/cmd/server/main.go b/cmd/server/main.go index 2ec2a82..134bf78 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -18,40 +18,48 @@ import ( ) func main() { - // Load configuration cfg := config.Load() - - // Initialize database ctx := context.Background() - database, err := db.New(ctx, cfg.Database.URL) + + // --- Database --- + var dsn string + switch cfg.DB.Driver { + case "postgres", "postgresql", "pg": + dsn = cfg.DB.URL + default: + dsn = cfg.DB.Path + } + database, err := db.NewStore(ctx, cfg.DB.Driver, dsn) if err != nil { - log.Fatalf("Failed to connect to database: %v", err) + log.Fatalf("Failed to open database: %v", err) } defer database.Close() - log.Println("Connected to PostgreSQL") + log.Printf("Database: %s (%s)", cfg.DB.Driver, cfg.DB.Path) - // Initialize auth service + // --- Auth --- authService := auth.NewService(cfg.Auth.JWTSecret, cfg.Auth.TokenDuration) - log.Println("Auth service initialized") - // Initialize storage service - store, err := storage.NewService( - cfg.Storage.Endpoint, - cfg.Storage.AccessKeyID, - cfg.Storage.SecretAccessKey, - cfg.Storage.ReleaseBucket, - cfg.Storage.PatchBucket, - cfg.Storage.UseSSL, - ) + // --- Storage --- + store, err := storage.NewStore(storage.Config{ + Type: cfg.Storage.Driver, + LocalDir: cfg.Storage.LocalDir, + ServerBaseURL: cfg.Server.BaseURL, + S3Endpoint: cfg.Storage.S3Endpoint, + S3AccessKey: cfg.Storage.S3AccessKey, + S3SecretKey: cfg.Storage.S3SecretKey, + S3UseSSL: cfg.Storage.S3UseSSL, + S3ReleaseBucket: cfg.Storage.S3ReleaseBucket, + S3PatchBucket: cfg.Storage.S3PatchBucket, + }) if err != nil { log.Fatalf("Failed to initialize storage: %v", err) } - log.Println("Storage service initialized") + log.Printf("Storage: %s", store.BackendName()) - // Create router + // --- Router --- router := handlers.NewRouter(authService, database, store) - // Configure HTTP server + // --- HTTP Server --- addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) srv := &http.Server{ Addr: addr, @@ -61,29 +69,28 @@ func main() { IdleTimeout: 60 * time.Second, } - // Start server in goroutine go func() { - log.Printf("Shorebird self-hosted server starting on %s", addr) - log.Printf("API: http://%s/api/v1", addr) - log.Printf("Auth: http://%s/auth", addr) - log.Printf("Health: http://%s/health", addr) + log.Printf("Shorebird server starting on %s", addr) + log.Printf(" Dashboard: http://%s", addr) + log.Printf(" API: http://%s/api/v1", addr) + log.Printf(" Auth: http://%s/auth", addr) + log.Printf(" Storage: %s", store.BackendName()) + log.Printf(" Database: %s", cfg.DB.Driver) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } }() - // Wait for interrupt signal + // Graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - log.Println("Shutting down server...") + log.Println("Shutting down...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Fatalf("Server forced to shutdown: %v", err) + log.Fatalf("Shutdown error: %v", err) } - log.Println("Server stopped") } diff --git a/docker-compose.yml b/docker-compose.yml index 4a7376d..00dd05f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,22 @@ +# Docker Compose file for optional PostgreSQL + MinIO backends. +# The server runs without Docker by default (SQLite + local filesystem). +# To use the full stack: docker compose --profile full up -d +# +# Then set: +# DB_DRIVER=postgres +# DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable +# STORAGE_DRIVER=s3 +# STORAGE_S3_ENDPOINT=localhost:9000 +# STORAGE_S3_ACCESS_KEY=minioadmin +# STORAGE_S3_SECRET_KEY=minioadmin +# STORAGE_S3_USE_SSL=false + version: "3.9" services: postgres: image: postgres:16-alpine + profiles: ["full"] environment: POSTGRES_USER: shorebird POSTGRES_PASSWORD: shorebird @@ -19,6 +33,7 @@ services: minio: image: minio/minio:latest + profiles: ["full"] command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin @@ -36,6 +51,7 @@ services: minio-init: image: minio/mc:latest + profiles: ["full"] depends_on: minio: condition: service_healthy @@ -50,6 +66,7 @@ services: redis: image: redis:7-alpine + profiles: ["full"] ports: - "6379:6379" healthcheck: diff --git a/go.mod b/go.mod index 78a4e25..e95405e 100644 --- a/go.mod +++ b/go.mod @@ -9,29 +9,33 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.1 github.com/minio/minio-go/v7 v7.0.80 - github.com/pressly/goose/v3 v3.22.1 - github.com/redis/go-redis/v9 v9.7.0 golang.org/x/crypto v0.28.0 + modernc.org/sqlite v1.33.1 ) require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/goccy/go-json v0.10.4 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect - github.com/mfridman/interpolate v0.0.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/md5-simd v1.1.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.6.0 // indirect - github.com/sethvargo/go-retry v0.3.0 // indirect - go.uber.org/multierr v1.11.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.20.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba9ff04 --- /dev/null +++ b/go.sum @@ -0,0 +1,98 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +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/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.0.80 h1:2mdUHXEykRdY/BigLt3Iuu1otL0JTogT0Nmltg0wujk= +github.com/minio/minio-go/v7 v7.0.80/go.mod h1:84gmIilaX4zcvAWWzJ5Z1WI5axN+hAbM5w25xf8xvC0= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 4243d96..cdaa2ee 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -11,7 +11,7 @@ import ( // AdminHandler handles admin/management API endpoints. type AdminHandler struct { - DB *db.DB + DB db.Store } // AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices diff --git a/internal/api/handlers/apps.go b/internal/api/handlers/apps.go index 70f74d0..68f48d3 100644 --- a/internal/api/handlers/apps.go +++ b/internal/api/handlers/apps.go @@ -2,7 +2,7 @@ package handlers import ( "net/http" - "strconv" + "time" "github.com/go-chi/chi/v5" "github.com/shorebird-server/internal/api/middleware" @@ -22,7 +22,7 @@ func intPtr(i int) *int { // UserHandler handles user-related API endpoints. type UserHandler struct { - DB *db.DB + DB db.Store } // GetCurrentUser handles GET /api/v1/users/me @@ -40,10 +40,13 @@ func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusOK, map[string]interface{}{ - "id": user.ID, - "email": user.Email, - "name": user.Name, - "created_at": user.CreatedAt, + "id": user.ID, + "email": user.Email, + "display_name": user.Name, + "jwt_issuer": "http://localhost:8080/auth", + "has_active_subscription": true, + "stripe_customer_id": nil, + "patch_overage_limit": nil, }) } @@ -80,7 +83,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { // AppHandler handles app-related API endpoints. type AppHandler struct { - DB *db.DB + DB db.Store } // GetApps handles GET /api/v1/apps @@ -100,12 +103,18 @@ func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) { var result []models.AppMetadata for _, a := range apps { result = append(result, models.AppMetadata{ - AppID: a.ID.String(), - DisplayName: a.DisplayName, - CreatedAt: a.CreatedAt, - UpdatedAt: a.UpdatedAt, + AppID: a.ID.String(), + DisplayName: a.DisplayName, + CreatedAt: a.CreatedAt.Time, + UpdatedAt: a.UpdatedAt.Time, + Platforms: []string{}, + LatestReleases: map[string]models.LatestRelease{}, + PendingReleases: map[string]models.PendingRelease{}, }) } + if result == nil { + result = []models.AppMetadata{} + } respondJSON(w, http.StatusOK, map[string]interface{}{"apps": result}) } @@ -154,7 +163,7 @@ func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) { } respondJSON(w, http.StatusCreated, map[string]interface{}{ - "app_id": app.ID.String(), + "id": app.ID.String(), "display_name": app.DisplayName, "created_at": app.CreatedAt, "updated_at": app.UpdatedAt, @@ -180,7 +189,7 @@ func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) { // ChannelHandler handles channel-related API endpoints. type ChannelHandler struct { - DB *db.DB + DB db.Store } // GetChannels handles GET /api/v1/apps/{appId}/channels @@ -240,7 +249,7 @@ func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) { // OrganizationHandler handles organization-related API endpoints. type OrganizationHandler struct { - DB *db.DB + DB db.Store } // GetOrganizations handles GET /api/v1/organizations @@ -259,15 +268,21 @@ func (h *OrganizationHandler) GetOrganizations(w http.ResponseWriter, r *http.Re var result []models.OrganizationMembership for _, o := range orgs { + now := time.Now().UTC() result = append(result, models.OrganizationMembership{ Organization: models.Organization{ - ID: o.OrgID, - Name: o.OrgName, - Type: o.OrgType, + ID: o.OrgID, + Name: o.OrgName, + OrganizationType: o.OrgType, + CreatedAt: now, + UpdatedAt: now, }, Role: o.Role, }) } + if result == nil { + result = []models.OrganizationMembership{} + } respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result}) } diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 305a3c2..181bd57 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -1,7 +1,13 @@ package handlers import ( + "crypto/rand" + "encoding/hex" + "fmt" "net/http" + "strings" + "sync" + "time" "github.com/shorebird-server/internal/api/middleware" "github.com/shorebird-server/internal/auth" @@ -9,14 +15,146 @@ import ( "github.com/shorebird-server/internal/models" ) -// AuthHandler handles authentication endpoints. +// ---- OAuth auth code store ---- + +type authCodeStore struct { + mu sync.Mutex + codes map[string]authCodeEntry +} + +type authCodeEntry struct { + email string + expiresAt time.Time +} + +var globalCodeStore = &authCodeStore{codes: make(map[string]authCodeEntry)} + +func (s *authCodeStore) set(email string) string { + s.mu.Lock() + defer s.mu.Unlock() + code := generateCode() + s.codes[code] = authCodeEntry{email: email, expiresAt: time.Now().Add(5 * time.Minute)} + for c, e := range s.codes { + if time.Now().After(e.expiresAt) { + delete(s.codes, c) + } + } + return code +} + +func (s *authCodeStore) validate(code string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + entry, ok := s.codes[code] + if !ok || time.Now().After(entry.expiresAt) { + delete(s.codes, code) + return "", false + } + delete(s.codes, code) + return entry.email, true +} + +func generateCode() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// ---- OAuth token response ---- + +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` +} + +// ---- AuthHandler ---- + type AuthHandler struct { - DB *db.DB + DB db.Store AuthService *auth.Service } -// Login handles POST /auth/token +// LoginPage handles GET /auth/login — OAuth HTML login form. +func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) { + continueURL := r.URL.Query().Get("continue") + if continueURL == "" { + respondError(w, http.StatusBadRequest, "Missing continue parameter", nil) + return + } + + if r.Method == http.MethodPost { + email := r.FormValue("email") + password := r.FormValue("password") + if email == "" || password == "" { + serveLoginHTML(w, continueURL, "Email and password are required.") + return + } + user, err := h.DB.GetUserByEmail(r.Context(), email) + if err != nil || auth.CheckPassword(user.PasswordHash, password) != nil { + serveLoginHTML(w, continueURL, "Invalid email or password.") + return + } + code := globalCodeStore.set(user.Email) + http.Redirect(w, r, fmt.Sprintf("%s?code=%s", continueURL, code), http.StatusFound) + return + } + + serveLoginHTML(w, continueURL, "") +} + +// Login handles POST /auth/token — supports OAuth form-encoded + JSON body. func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + ct := r.Header.Get("Content-Type") + + // --- OAuth 2.0 form-encoded --- + if strings.HasPrefix(ct, "application/x-www-form-urlencoded") { + if err := r.ParseForm(); err != nil { + respondError(w, http.StatusBadRequest, "Invalid form data", nil) + return + } + grantType := r.FormValue("grant_type") + + switch grantType { + case "authorization_code": + code := r.FormValue("code") + if code == "" { + respondError(w, http.StatusBadRequest, "Missing code parameter", nil) + return + } + email, valid := globalCodeStore.validate(code) + if !valid { + respondError(w, http.StatusBadRequest, "Invalid or expired authorization code", strPtr("Request a new code from /auth/login")) + return + } + user, err := h.DB.GetUserByEmail(r.Context(), email) + if err != nil { + respondError(w, http.StatusInternalServerError, "User not found", nil) + return + } + h.writeOAuthTokenResponse(w, user.ID, user.Email) + + case "refresh_token": + refreshToken := r.FormValue("refresh_token") + if refreshToken == "" { + respondError(w, http.StatusBadRequest, "Missing refresh_token parameter", nil) + return + } + claims, err := h.AuthService.ValidateToken(strings.TrimPrefix(refreshToken, "sb_api_")) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid refresh token", nil) + return + } + h.writeOAuthTokenResponse(w, claims.UserID, claims.Email) + + default: + respondError(w, http.StatusBadRequest, "Unsupported grant_type: "+grantType, nil) + } + return + } + + // --- JSON body login (Web UI + direct API) --- var req models.AuthTokenRequest if err := decodeJSON(r, &req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request body", nil) @@ -55,8 +193,6 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { // Refresh handles POST /auth/refresh func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { - // For simplicity, re-validate the current token and issue a new one. - // In production, you'd maintain a refresh token table. claims := middleware.GetClaims(r) if claims == nil { respondError(w, http.StatusUnauthorized, "Invalid token", nil) @@ -82,7 +218,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { }) } -// Register handles POST /auth/register (self-hosted convenience endpoint) +// Register handles POST /auth/register func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` @@ -106,7 +242,6 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { return } - // Create a default organization for the user orgID, err := h.DB.CreateOrganization(r.Context(), req.Name+"'s Org", "team") if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create organization", nil) @@ -129,3 +264,58 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { Email: req.Email, }) } + +func (h *AuthHandler) writeOAuthTokenResponse(w http.ResponseWriter, userID int, email string) { + jwt, _ := h.AuthService.GenerateToken(userID, email) + refresh, _ := auth.GenerateRefreshToken() + respondJSON(w, http.StatusOK, oauthTokenResponse{ + AccessToken: jwt, + RefreshToken: "sb_rt_" + refresh, + TokenType: "Bearer", + ExpiresIn: 900, + }) +} + +// ---- HTML login page ---- + +func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + errorHTML := "" + if errorMsg != "" { + errorHTML = fmt.Sprintf(`
%s
`, errorMsg) + } + fmt.Fprintf(w, ` + + + + +Shorebird Login + + + +
+

🐦 Shorebird

+

Self-Hosted · Sign in to continue

+ %s +
+
+
+ +
+
+ +`, errorHTML) +} diff --git a/internal/api/handlers/device.go b/internal/api/handlers/device.go index 18db190..75946f7 100644 --- a/internal/api/handlers/device.go +++ b/internal/api/handlers/device.go @@ -11,8 +11,8 @@ import ( // DeviceHandler handles device-facing API endpoints (no auth required). type DeviceHandler struct { - DB *db.DB - Storage *storage.Service + DB db.Store + Storage storage.Store } // PatchCheck handles POST /api/v1/patches/check diff --git a/internal/api/handlers/patches.go b/internal/api/handlers/patches.go index c1a4bd9..f3272dd 100644 --- a/internal/api/handlers/patches.go +++ b/internal/api/handlers/patches.go @@ -14,8 +14,8 @@ import ( // PatchHandler handles patch-related API endpoints. type PatchHandler struct { - DB *db.DB - Storage *storage.Service + DB db.Store + Storage storage.Store } // CreatePatch handles POST /api/v1/apps/{appId}/patches @@ -117,14 +117,20 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques phl = &podfileLockHash } - _, err = h.DB.CreatePatchArtifact(r.Context(), patchID, arch, platform, hash, storageKey, size, hs, phl) + artifact, err := h.DB.CreatePatchArtifact(r.Context(), patchID, arch, platform, hash, storageKey, size, hs, phl) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to create artifact record", strPtr(err.Error())) return } respondJSON(w, http.StatusCreated, models.CreatePatchArtifactResponse{ - URL: uploadURL, + ID: artifact.ID, + PatchID: artifact.PatchID, + Arch: artifact.Arch, + Platform: artifact.Platform, + Hash: artifact.Hash, + Size: artifact.Size, + URL: uploadURL, }) } @@ -157,7 +163,7 @@ func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) { ReleaseID: p.ReleaseID, PatchID: p.ID, PatchNumber: p.Number, - CreatedAt: p.PromotedAt, + CreatedAt: p.PromotedAt.Time, }) } diff --git a/internal/api/handlers/releases.go b/internal/api/handlers/releases.go index 7b21ba1..ec41ce7 100644 --- a/internal/api/handlers/releases.go +++ b/internal/api/handlers/releases.go @@ -8,7 +8,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "github.com/shorebird-server/internal/api/middleware" "github.com/shorebird-server/internal/db" "github.com/shorebird-server/internal/models" "github.com/shorebird-server/internal/storage" @@ -26,8 +25,8 @@ func parseIntParam(r *http.Request, name string) (int, error) { // ReleaseHandler handles release-related API endpoints. type ReleaseHandler struct { - DB *db.DB - Storage *storage.Service + DB db.Store + Storage storage.Store } // GetReleases handles GET /api/v1/apps/{appId}/releases @@ -62,10 +61,13 @@ func (h *ReleaseHandler) GetReleases(w http.ResponseWriter, r *http.Request) { DisplayName: rel.DisplayName, Notes: rel.Notes, PlatformStatuses: statuses, - CreatedAt: rel.CreatedAt, - UpdatedAt: rel.UpdatedAt, + CreatedAt: rel.CreatedAt.Time, + UpdatedAt: rel.UpdatedAt.Time, }) } + if result == nil { + result = []models.Release{} + } respondJSON(w, http.StatusOK, map[string]interface{}{"releases": result}) } @@ -100,8 +102,8 @@ func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) { DisplayName: release.DisplayName, Notes: release.Notes, PlatformStatuses: map[string]string{}, - CreatedAt: release.CreatedAt, - UpdatedAt: release.UpdatedAt, + CreatedAt: release.CreatedAt.Time, + UpdatedAt: release.UpdatedAt.Time, }, }) } @@ -184,8 +186,16 @@ func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Re } respondJSON(w, http.StatusCreated, models.CreateReleaseArtifactResponse{ - ID: artifact.ID, - URL: uploadURL, + ID: artifact.ID, + ReleaseID: artifact.ReleaseID, + Arch: artifact.Arch, + Platform: artifact.Platform, + Hash: artifact.Hash, + Size: artifact.Size, + URL: uploadURL, + CreatedAt: artifact.CreatedAt.Time, + CanSideload: artifact.CanSideload, + PodfileLockHash: artifact.PodfileLockHash, }) } @@ -214,7 +224,7 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ var result []models.ReleaseArtifact for _, a := range artifacts { // Build a download URL for the artifact - downloadURL := fmt.Sprintf("/storage/releases/%s", a.StorageKey) + downloadURL := fmt.Sprintf("http://localhost:8080/storage/dl/releases/%s", a.StorageKey) result = append(result, models.ReleaseArtifact{ ID: a.ID, ReleaseID: a.ReleaseID, @@ -225,9 +235,13 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ URL: downloadURL, CanSideload: a.CanSideload, PodfileLockHash: a.PodfileLockHash, - CreatedAt: a.CreatedAt, + CreatedAt: a.CreatedAt.Time, }) } + if result == nil { + result = []models.ReleaseArtifact{} + } + respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result}) } diff --git a/internal/api/handlers/router.go b/internal/api/handlers/router.go index c3f058f..fb042f4 100644 --- a/internal/api/handlers/router.go +++ b/internal/api/handlers/router.go @@ -15,7 +15,7 @@ import ( ) // NewRouter creates the HTTP router with all API routes. -func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Service) *chi.Mux { +func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store) *chi.Mux { r := chi.NewRouter() r.Use(chimw.Logger) r.Use(chimw.Recoverer) @@ -41,12 +41,21 @@ func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Ser deviceHandler := &DeviceHandler{DB: database, Storage: store} diagHandler := &DiagnosticsHandler{} adminHandler := &AdminHandler{DB: database} + storageHandler := NewStorageHandler(store) // Auth middleware authMw := middleware.AuthMiddleware(authService) + // --- Local storage upload/download (used when STORAGE_DRIVER=local) --- + r.Route("/storage", func(r chi.Router) { + r.Post("/upload/{scope}/*", storageHandler.Upload) + r.Get("/dl/{scope}/*", storageHandler.Download) + }) + // --- Auth routes (no auth required) --- r.Route("/auth", func(r chi.Router) { + r.Get("/login", authHandler.LoginPage) + r.Post("/login", authHandler.LoginPage) r.Post("/token", authHandler.Login) r.Post("/register", authHandler.Register) r.With(authMw).Post("/refresh", authHandler.Refresh) @@ -134,7 +143,22 @@ func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Ser // Health check r.Get("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) + backend := map[string]string{ + "storage": "local", + "database": "sqlite", + } + if _, ok := store.(interface{ BackendName() string }); ok { + backend["storage"] = store.BackendName() + } + // Determine DB backend from the store interface + switch database.(type) { + case interface{ DriverName() string }: + // If PgStore has DriverName method + backend["database"] = "postgres" + default: + backend["database"] = "sqlite" + } + w.Write([]byte(`{"status":"ok","backend":{"storage":"` + backend["storage"] + `","database":"` + backend["database"] + `"}}`)) }) return r diff --git a/internal/api/handlers/storage_handler.go b/internal/api/handlers/storage_handler.go new file mode 100644 index 0000000..ce14f54 --- /dev/null +++ b/internal/api/handlers/storage_handler.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "io" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/shorebird-server/internal/storage" +) + +// StorageHandler provides upload/download endpoints for local storage. +type StorageHandler struct { + store storage.Store +} + +// NewStorageHandler creates a new storage HTTP handler. +func NewStorageHandler(store storage.Store) *StorageHandler { + return &StorageHandler{store: store} +} + +// Upload handles POST /storage/upload/{scope}/{key} +func (h *StorageHandler) Upload(w http.ResponseWriter, r *http.Request) { + scope := chi.URLParam(r, "scope") + key := chi.URLParam(r, "*") + + isPublic := scope == "patches" + ct := r.Header.Get("Content-Type") + if ct == "" { + ct = "application/octet-stream" + } + + if err := h.store.UploadObject(r.Context(), key, r.Body, r.ContentLength, ct, isPublic); err != nil { + respondError(w, http.StatusInternalServerError, "Upload failed: "+err.Error(), nil) + return + } + w.WriteHeader(http.StatusOK) +} + +// Download handles GET /storage/dl/{scope}/{key} +func (h *StorageHandler) Download(w http.ResponseWriter, r *http.Request) { + scope := chi.URLParam(r, "scope") + key := chi.URLParam(r, "*") + isPublic := scope == "patches" + + reader, err := h.store.GetObject(r.Context(), key, isPublic) + if err != nil { + respondError(w, http.StatusNotFound, "File not found", nil) + return + } + defer reader.Close() + + contentType := "application/octet-stream" + w.Header().Set("Content-Type", contentType) + w.Header().Set("Accept-Ranges", "bytes") + io.Copy(w, reader) +} diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index b972ad3..503a2bd 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -31,7 +31,11 @@ func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler { return } - claims, err := authService.ValidateToken(parts[1]) + token := parts[1] + // Strip sb_api_ prefix (self-hosted API key format for Shorebird CLI compatibility) + token = strings.TrimPrefix(token, "sb_api_") + + claims, err := authService.ValidateToken(token) if err != nil { http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized) return diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 803873a..331ddfb 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -20,6 +20,7 @@ type Claims struct { jwt.RegisteredClaims UserID int `json:"user_id"` Email string `json:"email"` + Aud string `json:"aud"` } // NewService creates a new auth service. @@ -43,9 +44,11 @@ func (s *Service) GenerateToken(userID int, email string) (string, error) { }, UserID: userID, Email: email, + Aud: "shorebird", } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + token.Header["kid"] = "shorebird-self-hosted" return token.SignedString(s.jwtSecret) } diff --git a/internal/config/config.go b/internal/config/config.go index 2e7e73c..36d72a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,34 +7,47 @@ import ( // Config holds all configuration for the Shorebird server. type Config struct { - Server ServerConfig - Database DatabaseConfig - Storage StorageConfig - Auth AuthConfig - Redis RedisConfig + Server ServerConfig + DB DBConfig + Storage StorageConfig + Auth AuthConfig + Redis RedisConfig } // ServerConfig holds HTTP server configuration. type ServerConfig struct { Host string Port string + BaseURL string // public-facing URL, used for download links ReadTimeout time.Duration WriteTimeout time.Duration } -// DatabaseConfig holds PostgreSQL connection configuration. -type DatabaseConfig struct { +// DBConfig holds database configuration. +type DBConfig struct { + // Driver is "postgres" or "sqlite". Empty defaults to "sqlite". + Driver string + // URL is the PostgreSQL connection string (used when driver=postgres). URL string + // Path is the SQLite database file path (used when driver=sqlite). + Path string } -// StorageConfig holds object storage (S3/MinIO) configuration. +// StorageConfig holds object storage configuration. type StorageConfig struct { - Endpoint string - AccessKeyID string - SecretAccessKey string - UseSSL bool - ReleaseBucket string - PatchBucket string + // Driver is "local" or "s3". Empty defaults to "local". + Driver string + + // Local settings + LocalDir string + + // S3/MinIO settings + S3Endpoint string + S3AccessKey string + S3SecretKey string + S3UseSSL bool + S3ReleaseBucket string + S3PatchBucket string } // AuthConfig holds JWT authentication configuration. @@ -52,23 +65,31 @@ type RedisConfig struct { // Load reads configuration from environment variables with sensible defaults. func Load() *Config { + port := envOrDefault("SERVER_PORT", "8080") + baseURL := envOrDefault("SERVER_BASE_URL", "http://localhost:"+port) + return &Config{ Server: ServerConfig{ Host: envOrDefault("SERVER_HOST", "0.0.0.0"), - Port: envOrDefault("SERVER_PORT", "8080"), + Port: port, + BaseURL: baseURL, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, }, - Database: DatabaseConfig{ - URL: envOrDefault("DATABASE_URL", "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable"), + DB: DBConfig{ + Driver: envOrDefault("DB_DRIVER", "sqlite"), + URL: envOrDefault("DATABASE_URL", "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable"), + Path: envOrDefault("DB_PATH", "data/shorebird.db"), }, Storage: StorageConfig{ - Endpoint: envOrDefault("STORAGE_ENDPOINT", "localhost:9000"), - AccessKeyID: envOrDefault("STORAGE_ACCESS_KEY", "minioadmin"), - SecretAccessKey: envOrDefault("STORAGE_SECRET_KEY", "minioadmin"), - UseSSL: false, - ReleaseBucket: envOrDefault("STORAGE_RELEASE_BUCKET", "shorebird-releases"), - PatchBucket: envOrDefault("STORAGE_PATCH_BUCKET", "shorebird-patches"), + Driver: envOrDefault("STORAGE_DRIVER", "local"), + LocalDir: envOrDefault("STORAGE_LOCAL_DIR", "data/storage"), + S3Endpoint: envOrDefault("STORAGE_S3_ENDPOINT", "localhost:9000"), + S3AccessKey: envOrDefault("STORAGE_S3_ACCESS_KEY", "minioadmin"), + S3SecretKey: envOrDefault("STORAGE_S3_SECRET_KEY", "minioadmin"), + S3UseSSL: os.Getenv("STORAGE_S3_USE_SSL") == "true", + S3ReleaseBucket: envOrDefault("STORAGE_S3_RELEASE_BUCKET", "shorebird-releases"), + S3PatchBucket: envOrDefault("STORAGE_S3_PATCH_BUCKET", "shorebird-patches"), }, Auth: AuthConfig{ JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"), diff --git a/internal/db/db.go b/internal/db/db.go index ece1c58..baa16d5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -3,7 +3,6 @@ package db import ( "context" "fmt" - "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -11,13 +10,13 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// DB wraps the PostgreSQL connection pool with query methods. -type DB struct { +// PgStore implements Store backed by PostgreSQL. +type PgStore struct { pool *pgxpool.Pool } -// New creates a new database connection pool. -func New(ctx context.Context, databaseURL string) (*DB, error) { +// NewPostgresStore creates a new PostgreSQL-backed Store. +func NewPostgresStore(ctx context.Context, databaseURL string) (*PgStore, error) { config, err := pgxpool.ParseConfig(databaseURL) if err != nil { return nil, fmt.Errorf("failed to parse database URL: %w", err) @@ -32,40 +31,37 @@ func New(ctx context.Context, databaseURL string) (*DB, error) { return nil, fmt.Errorf("failed to ping database: %w", err) } - return &DB{pool: pool}, nil + return &PgStore{pool: pool}, nil } // Close closes the database connection pool. -func (db *DB) Close() { +func (db *PgStore) Close() { db.pool.Close() } -// Pool returns the underlying connection pool. -func (db *DB) Pool() *pgxpool.Pool { - return db.pool -} + // Exec is a convenience wrapper for pool.Exec. -func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { +func (db *PgStore) exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) { return db.pool.Exec(ctx, sql, args...) } // QueryRow is a convenience wrapper for pool.QueryRow. -func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row { +func (db *PgStore) queryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row { return db.pool.QueryRow(ctx, sql, args...) } // Query is a convenience wrapper for pool.Query. -func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) { +func (db *PgStore) query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) { return db.pool.Query(ctx, sql, args...) } // --- User queries --- // CreateUser creates a new user and returns the ID. -func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) { +func (db *PgStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) { var id int - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`, email, name, passwordHash, ).Scan(&id) @@ -73,9 +69,9 @@ func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string) } // GetUserByEmail retrieves a user by email. -func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { +func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { row := &UserRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`, email, ).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt) @@ -86,9 +82,9 @@ func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error } // GetUserByID retrieves a user by ID. -func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) { +func (db *PgStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) { row := &UserRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`, id, ).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt) @@ -98,21 +94,12 @@ func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) { return row, nil } -// UserRow represents a user row from the database. -type UserRow struct { - ID int - Email string - Name string - PasswordHash string - CreatedAt time.Time -} - // --- Organization queries --- // CreateOrganization creates a new organization. -func (db *DB) CreateOrganization(ctx context.Context, name, orgType string) (int, error) { +func (db *PgStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) { var id int - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO organizations (name, type) VALUES ($1, $2) RETURNING id`, name, orgType, ).Scan(&id) @@ -120,8 +107,8 @@ func (db *DB) CreateOrganization(ctx context.Context, name, orgType string) (int } // GetOrganizationsByUserID returns all organizations a user belongs to. -func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) { + rows, err := db.query(ctx, `SELECT o.id, o.name, o.type, om.role FROM organizations o JOIN organization_memberships om ON o.id = om.organization_id @@ -143,17 +130,10 @@ func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMe return result, nil } -// OrgMembershipRow represents an organization membership query result. -type OrgMembershipRow struct { - OrgID int - OrgName string - OrgType string - Role string -} // AddUserToOrganization adds a user to an organization. -func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error { - _, err := db.Exec(ctx, +func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error { + _, err := db.exec(ctx, `INSERT INTO organization_memberships (user_id, organization_id, role) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, userID, orgID, role) @@ -163,9 +143,9 @@ func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role // --- App queries --- // CreateApp creates a new app and returns it. -func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) { +func (db *PgStore) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) { row := &AppRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO apps (organization_id, display_name) VALUES ($1, $2) RETURNING id, organization_id, display_name, created_at, updated_at`, orgID, displayName, @@ -177,8 +157,8 @@ func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*Ap } // GetAppsByOrganization returns all apps for an organization. -func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) { + rows, err := db.query(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE organization_id = $1 ORDER BY display_name`, orgID) if err != nil { @@ -198,9 +178,9 @@ func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, e } // GetAppByID retrieves a single app by ID. -func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) { +func (db *PgStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) { row := &AppRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = $1`, appID, ).Scan(&row.ID, &row.OrganizationID, &row.DisplayName, &row.CreatedAt, &row.UpdatedAt) @@ -211,7 +191,7 @@ func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) } // GetAppByIDString retrieves a single app by its string ID. -func (db *DB) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) { +func (db *PgStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) { id, err := uuid.Parse(appID) if err != nil { return nil, fmt.Errorf("invalid app_id: %w", err) @@ -220,14 +200,14 @@ func (db *DB) GetAppByIDString(ctx context.Context, appID string) (*AppRow, erro } // DeleteApp deletes an app by ID. -func (db *DB) DeleteApp(ctx context.Context, appID uuid.UUID) error { - _, err := db.Exec(ctx, `DELETE FROM apps WHERE id = $1`, appID) +func (db *PgStore) DeleteApp(ctx context.Context, appID uuid.UUID) error { + _, err := db.exec(ctx, `DELETE FROM apps WHERE id = $1`, appID) return err } // GetAppsByUserID returns all apps the user has access to via org memberships. -func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) { + rows, err := db.query(ctx, `SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at FROM apps a JOIN organization_memberships om ON a.organization_id = om.organization_id @@ -249,21 +229,13 @@ func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) return result, nil } -// AppRow represents an app row from the database. -type AppRow struct { - ID uuid.UUID - OrganizationID int - DisplayName string - CreatedAt time.Time - UpdatedAt time.Time -} // --- Channel queries --- // CreateChannel creates a new channel for an app. -func (db *DB) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { +func (db *PgStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { row := &ChannelRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO channels (app_id, name) VALUES ($1, $2) RETURNING id, app_id, name, created_at`, appID, name, @@ -275,8 +247,8 @@ func (db *DB) CreateChannel(ctx context.Context, appID uuid.UUID, name string) ( } // GetChannelsByAppID returns all channels for an app. -func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) { + rows, err := db.query(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 ORDER BY name`, appID) if err != nil { return nil, err @@ -295,9 +267,9 @@ func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]Channe } // GetChannelByAppIDAndName retrieves a specific channel. -func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { +func (db *PgStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { row := &ChannelRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 AND name = $2`, appID, name, ).Scan(&row.ID, &row.AppID, &row.Name, &row.CreatedAt) @@ -307,20 +279,13 @@ func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, nam return row, nil } -// ChannelRow represents a channel row from the database. -type ChannelRow struct { - ID int - AppID uuid.UUID - Name string - CreatedAt time.Time -} // --- Release queries --- // CreateRelease creates a new release. -func (db *DB) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) { +func (db *PgStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) { row := &ReleaseRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name) VALUES ($1, $2, $3, $4, $5) RETURNING id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at`, @@ -333,9 +298,9 @@ func (db *DB) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutt } // GetReleaseByID retrieves a release by ID. -func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) { +func (db *PgStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) { row := &ReleaseRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE id = $1`, releaseID, ).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt) @@ -346,9 +311,9 @@ func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, e } // GetReleaseByAppIDAndVersion retrieves a release by app and version. -func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) { +func (db *PgStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) { row := &ReleaseRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = $1 AND version = $2`, appID, version, ).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt) @@ -359,13 +324,13 @@ func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, } // GetReleasesByAppID returns all releases for an app. -func (db *DB) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) { +func (db *PgStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) { query := `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = $1 ORDER BY created_at DESC` // Note: sideloadable filtering is done via artifact join if needed _ = sideloadableOnly - rows, err := db.Query(ctx, query, appID) + rows, err := db.query(ctx, query, appID) if err != nil { return nil, err } @@ -383,8 +348,8 @@ func (db *DB) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloada } // UpdateReleasePlatformStatus upserts a platform status for a release. -func (db *DB) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error { - _, err := db.Exec(ctx, +func (db *PgStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error { + _, err := db.exec(ctx, `INSERT INTO release_platform_statuses (release_id, platform, status, metadata) VALUES ($1, $2, $3, $4) ON CONFLICT (release_id, platform) @@ -394,8 +359,8 @@ func (db *DB) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, pl } // GetReleasePlatformStatuses returns all platform statuses for a release. -func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) { + rows, err := db.query(ctx, `SELECT platform, status FROM release_platform_statuses WHERE release_id = $1`, releaseID) if err != nil { return nil, err @@ -413,25 +378,13 @@ func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (ma return result, nil } -// ReleaseRow represents a release row from the database. -type ReleaseRow struct { - ID int - AppID uuid.UUID - Version string - FlutterRevision string - FlutterVersion *string - DisplayName *string - Notes *string - CreatedAt time.Time - UpdatedAt time.Time -} // --- Release artifact queries --- // CreateReleaseArtifact creates a release artifact record. -func (db *DB) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) { +func (db *PgStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) { row := &ArtifactRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO release_artifacts (release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at`, @@ -444,7 +397,7 @@ func (db *DB) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, pl } // GetReleaseArtifacts returns artifacts for a release, optionally filtered. -func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) { +func (db *PgStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) { query := `SELECT id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at FROM release_artifacts WHERE release_id = $1` args := []interface{}{releaseID} @@ -460,7 +413,7 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat query += " ORDER BY arch" - rows, err := db.Query(ctx, query, args...) + rows, err := db.query(ctx, query, args...) if err != nil { return nil, err } @@ -477,35 +430,22 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat return result, nil } -// ArtifactRow represents a release artifact row. -type ArtifactRow struct { - ID int - ReleaseID int - Arch string - Platform string - Hash string - Size int64 - StorageKey string - CanSideload bool - PodfileLockHash *string - CreatedAt time.Time -} // --- Patch queries --- // GetNextPatchNumber returns the next patch number for a release. -func (db *DB) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) { +func (db *PgStore) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) { var num int - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = $1`, releaseID, ).Scan(&num) return num, err } // CreatePatch creates a new patch. -func (db *DB) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) { +func (db *PgStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) { row := &PatchRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO patches (release_id, number, notes) VALUES ($1, $2, $3) RETURNING id, release_id, number, notes, created_at`, releaseID, number, notes, @@ -517,9 +457,9 @@ func (db *DB) CreatePatch(ctx context.Context, releaseID int, number int, notes } // GetPatchByID retrieves a patch by ID. -func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) { +func (db *PgStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) { row := &PatchRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE id = $1`, patchID, ).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt) if err != nil { @@ -529,9 +469,9 @@ func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) } // GetLatestPatchForRelease returns the latest patch for a release (by number). -func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) { +func (db *PgStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) { row := &PatchRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE release_id = $1 ORDER BY number DESC LIMIT 1`, releaseID, ).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt) @@ -542,8 +482,8 @@ func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*Pat } // GetPatchesByReleaseID returns all patches for a release. -func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) { + rows, err := db.query(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, COALESCE(pc.channel_id, 0) as channel_id, COALESCE(pc.promoted_at, p.created_at) as promoted_at FROM patches p @@ -567,17 +507,17 @@ func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]Patch } // PromotePatch promotes a patch to a channel. -func (db *DB) PromotePatch(ctx context.Context, patchID, channelID int) error { - _, err := db.Exec(ctx, +func (db *PgStore) PromotePatch(ctx context.Context, patchID, channelID int) error { + _, err := db.exec(ctx, `INSERT INTO patch_channels (patch_id, channel_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, patchID, channelID) return err } // GetLatestPromotedPatch returns the latest patch promoted to a specific channel for a release. -func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) { +func (db *PgStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) { row := &PatchWithArtifactRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature FROM patches p @@ -594,44 +534,12 @@ func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID i return row, nil } -// PatchRow represents a patch row. -type PatchRow struct { - ID int - ReleaseID int - Number int - Notes *string - CreatedAt time.Time -} - -// PatchWithChannelRow includes channel promotion info. -type PatchWithChannelRow struct { - ID int - ReleaseID int - Number int - Notes *string - CreatedAt time.Time - ChannelID int - PromotedAt time.Time -} - -// PatchWithArtifactRow includes artifact info for patch check. -type PatchWithArtifactRow struct { - ID int - ReleaseID int - Number int - Notes *string - CreatedAt time.Time - Hash string - StorageKey string - HashSignature *string -} - // --- Patch artifact queries --- // CreatePatchArtifact creates a patch artifact record. -func (db *DB) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) { +func (db *PgStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) { row := &PatchArtifactRow{} - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, created_at`, @@ -643,25 +551,12 @@ func (db *DB) CreatePatchArtifact(ctx context.Context, patchID int, arch, platfo return row, nil } -// PatchArtifactRow represents a patch artifact row. -type PatchArtifactRow struct { - ID int - PatchID int - Arch string - Platform string - Hash string - Size int64 - StorageKey string - HashSignature *string - PodfileLockHash *string - CreatedAt time.Time -} // --- Patch events --- // InsertPatchEvent records a patch lifecycle event. -func (db *DB) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error { - _, err := db.Exec(ctx, +func (db *PgStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error { + _, err := db.exec(ctx, `INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, appID, clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message) @@ -671,16 +566,16 @@ func (db *DB) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, a // --- Rolled back patches --- // RollbackPatch marks a patch as rolled back. -func (db *DB) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error { - _, err := db.Exec(ctx, +func (db *PgStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error { + _, err := db.exec(ctx, `INSERT INTO rolled_back_patches (release_id, patch_number) VALUES ($1, $2) ON CONFLICT DO NOTHING`, releaseID, patchNumber) return err } // GetRolledBackPatchNumbers returns all rolled-back patch numbers for a release. -func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) { + rows, err := db.query(ctx, `SELECT patch_number FROM rolled_back_patches WHERE release_id = $1`, releaseID) if err != nil { return nil, err @@ -701,17 +596,17 @@ func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]i // --- Targeted device patching --- // AddPatchTargetDevice adds a device to a patch's target list. -func (db *DB) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error { - _, err := db.Exec(ctx, +func (db *PgStore) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error { + _, err := db.exec(ctx, `INSERT INTO patch_target_devices (patch_id, client_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, patchID, clientID) return err } // IsPatchTargetedToDevice checks if a patch is targeted to a specific device. -func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) { +func (db *PgStore) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) { var count int - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1 AND client_id = $2`, patchID, clientID, ).Scan(&count) @@ -719,8 +614,8 @@ func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID } // GetPatchTargetDevices returns all targeted devices for a patch. -func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) { - rows, err := db.Query(ctx, +func (db *PgStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) { + rows, err := db.query(ctx, `SELECT client_id FROM patch_target_devices WHERE patch_id = $1`, patchID) if err != nil { return nil, err @@ -739,9 +634,9 @@ func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, } // HasTargetDevices checks if a patch has any targeted device restrictions. -func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) { +func (db *PgStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) { var count int - err := db.QueryRow(ctx, + err := db.queryRow(ctx, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1`, patchID, ).Scan(&count) return count > 0, err @@ -750,8 +645,8 @@ func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) { // --- Admin queries --- // ListAllUsers returns all registered users (admin endpoint). -func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) { - rows, err := db.Query(ctx, +func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) { + rows, err := db.query(ctx, `SELECT u.id, u.email, u.name, u.created_at, COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles FROM users u @@ -774,11 +669,3 @@ func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) { return result, nil } -// UserListRow represents a user with role info for admin listing. -type UserListRow struct { - ID int - Email string - Name string - CreatedAt time.Time - Role string -} diff --git a/internal/db/factory.go b/internal/db/factory.go new file mode 100644 index 0000000..306ec96 --- /dev/null +++ b/internal/db/factory.go @@ -0,0 +1,20 @@ +package db + +import ( + "context" + "fmt" +) + +// NewStore creates the appropriate database backend based on the driver name. +// Supported drivers: "sqlite" (default), "postgres". +func NewStore(ctx context.Context, driver, dsn string) (Store, error) { + switch driver { + case "postgres", "postgresql", "pg": + return NewPostgresStore(ctx, dsn) + default: + if driver != "" && driver != "sqlite" { + fmt.Printf("db: unknown driver %q, falling back to sqlite\n", driver) + } + return NewSqliteStore(dsn) + } +} diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go new file mode 100644 index 0000000..40a1a64 --- /dev/null +++ b/internal/db/sqlite.go @@ -0,0 +1,394 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +// SqliteStore implements Store backed by a local SQLite database. +type SqliteStore struct { + db *sql.DB +} + +// NewSqliteStore opens (or creates) a SQLite database at the given path. +func NewSqliteStore(path string) (*SqliteStore, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("sqlite: %w", err) + } + + db, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("sqlite: open: %w", err) + } + db.SetMaxOpenConns(1) + + store := &SqliteStore{db: db} + if err := store.migrate(context.Background()); err != nil { + db.Close() + return nil, fmt.Errorf("sqlite: migrate: %w", err) + } + return store, nil +} + +func (s *SqliteStore) Close() { s.db.Close() } + +// --- Users --- +func (s *SqliteStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) { + var id int + err := s.db.QueryRowContext(ctx, + `INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING id`, + email, name, passwordHash).Scan(&id) + return id, err +} +func (s *SqliteStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { + r := &UserRow{} + err := s.db.QueryRowContext(ctx, + `SELECT id, email, name, password_hash, created_at FROM users WHERE email = ?`, email). + Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) { + r := &UserRow{} + err := s.db.QueryRowContext(ctx, + `SELECT id, email, name, password_hash, created_at FROM users WHERE id = ?`, id). + Scan(&r.ID, &r.Email, &r.Name, &r.PasswordHash, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT u.id, u.email, u.name, u.created_at, COALESCE(GROUP_CONCAT(DISTINCT om.role), '') FROM users u LEFT JOIN organization_memberships om ON u.id = om.user_id GROUP BY u.id ORDER BY u.created_at DESC`) + if err != nil { return nil, err } + defer rows.Close() + var result []UserListRow + for rows.Next() { + var r UserListRow + if err := rows.Scan(&r.ID, &r.Email, &r.Name, &r.CreatedAt, &r.Role); err != nil { return nil, err } + result = append(result, r) + } + return result, nil +} + +// --- Organizations --- +func (s *SqliteStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) { + var id int + err := s.db.QueryRowContext(ctx, `INSERT INTO organizations (name, type) VALUES (?, ?) RETURNING id`, name, orgType).Scan(&id) + return id, err +} +func (s *SqliteStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT o.id, o.name, o.type, om.role FROM organizations o JOIN organization_memberships om ON o.id = om.organization_id WHERE om.user_id = ? ORDER BY o.name`, userID) + if err != nil { return nil, err } + defer rows.Close() + var result []OrgMembershipRow + for rows.Next() { + var r OrgMembershipRow + if err := rows.Scan(&r.OrgID, &r.OrgName, &r.OrgType, &r.Role); err != nil { return nil, err } + result = append(result, r) + } + return result, nil +} +func (s *SqliteStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error { + _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO organization_memberships (user_id, organization_id, role) VALUES (?, ?, ?)`, userID, orgID, role) + return err +} + +// --- Apps --- +// rfcNow returns the current UTC time in RFC3339Nano format for SQLite storage. +func rfcNow() string { return time.Now().UTC().Format(time.RFC3339Nano) } + +func (s *SqliteStore) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) { + id := uuid.New().String() + now := rfcNow() + _, err := s.db.ExecContext(ctx, `INSERT INTO apps (id, organization_id, display_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, id, orgID, displayName, now, now) + if err != nil { return nil, err } + uid, _ := uuid.Parse(id) + t := time.Now().UTC() + return &AppRow{ID: uid, OrganizationID: orgID, DisplayName: displayName, CreatedAt: ShorebirdTime{Time: t}, UpdatedAt: ShorebirdTime{Time: t}}, nil +} +func (s *SqliteStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE organization_id = ? ORDER BY display_name`, orgID) + if err != nil { return nil, err } + defer rows.Close() + return scanApps(rows) +} +func (s *SqliteStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) { + r := &AppRow{} + var idStr string + err := s.db.QueryRowContext(ctx, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = ?`, appID.String()). + Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt) + if err != nil { return nil, err } + r.ID, _ = uuid.Parse(idStr) + return r, nil +} +func (s *SqliteStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) { + id, err := uuid.Parse(appID) + if err != nil { return nil, fmt.Errorf("invalid app_id: %w", err) } + return s.GetAppByID(ctx, id) +} +func (s *SqliteStore) DeleteApp(ctx context.Context, appID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM apps WHERE id = ?`, appID.String()) + return err +} +func (s *SqliteStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at FROM apps a JOIN organization_memberships om ON a.organization_id = om.organization_id WHERE om.user_id = ? ORDER BY a.display_name`, userID) + if err != nil { return nil, err } + defer rows.Close() + return scanApps(rows) +} +func scanApps(rows *sql.Rows) ([]AppRow, error) { + var result []AppRow + for rows.Next() { + var r AppRow + var idStr string + if err := rows.Scan(&idStr, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } + r.ID, _ = uuid.Parse(idStr) + result = append(result, r) + } + return result, nil +} + +// --- Channels --- +func (s *SqliteStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { + r := &ChannelRow{AppID: appID, Name: name} + err := s.db.QueryRowContext(ctx, `INSERT INTO channels (app_id, name) VALUES (?, ?) RETURNING id, created_at`, appID.String(), name).Scan(&r.ID, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = ? ORDER BY name`, appID.String()) + if err != nil { return nil, err } + defer rows.Close() + var result []ChannelRow + for rows.Next() { + var r ChannelRow; var aid string + if err := rows.Scan(&r.ID, &aid, &r.Name, &r.CreatedAt); err != nil { return nil, err } + r.AppID, _ = uuid.Parse(aid); result = append(result, r) + } + return result, nil +} +func (s *SqliteStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) { + r := &ChannelRow{} + var aid string + err := s.db.QueryRowContext(ctx, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = ? AND name = ?`, appID.String(), name).Scan(&r.ID, &aid, &r.Name, &r.CreatedAt) + if err != nil { return nil, err } + r.AppID, _ = uuid.Parse(aid) + return r, nil +} + +// --- Releases --- +func (s *SqliteStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) { + r := &ReleaseRow{AppID: appID, Version: version, FlutterRevision: flutterRevision, FlutterVersion: flutterVersion, DisplayName: displayName} + var fv, dn interface{}; if flutterVersion != nil { fv = *flutterVersion }; if displayName != nil { dn = *displayName } + err := s.db.QueryRowContext(ctx, `INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name) VALUES (?, ?, ?, ?, ?) RETURNING id, notes, created_at, updated_at`, appID.String(), version, flutterRevision, fv, dn).Scan(&r.ID, &r.Notes, &r.CreatedAt, &r.UpdatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) { + r := &ReleaseRow{} + var aid string + err := s.db.QueryRowContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE id = ?`, releaseID).Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt) + if err != nil { return nil, err } + r.AppID, _ = uuid.Parse(aid) + return r, nil +} +func (s *SqliteStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) { + r := &ReleaseRow{} + var aid string + err := s.db.QueryRowContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = ? AND version = ?`, appID.String(), version).Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt) + if err != nil { return nil, err } + r.AppID, _ = uuid.Parse(aid) + return r, nil +} +func (s *SqliteStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) { + _ = sideloadableOnly + rows, err := s.db.QueryContext(ctx, `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at FROM releases WHERE app_id = ? ORDER BY created_at DESC`, appID.String()) + if err != nil { return nil, err } + defer rows.Close() + var result []ReleaseRow + for rows.Next() { + var r ReleaseRow; var aid string + if err := rows.Scan(&r.ID, &aid, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, err } + r.AppID, _ = uuid.Parse(aid); result = append(result, r) + } + return result, nil +} +func (s *SqliteStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error { + metaJSON := "{}" + if metadata != nil { b, _ := json.Marshal(metadata); metaJSON = string(b) } + _, err := s.db.ExecContext(ctx, `INSERT INTO release_platform_statuses (release_id, platform, status, metadata, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(release_id, platform) DO UPDATE SET status=excluded.status, metadata=excluded.metadata, updated_at=excluded.updated_at`, releaseID, platform, status, metaJSON, rfcNow()) + return err +} +func (s *SqliteStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT platform, status FROM release_platform_statuses WHERE release_id = ?`, releaseID) + if err != nil { return nil, err } + defer rows.Close() + result := make(map[string]string) + for rows.Next() { var p, st string; if err := rows.Scan(&p, &st); err != nil { return nil, err }; result[p] = st } + return result, nil +} + +// --- Release Artifacts --- +func (s *SqliteStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) { + r := &ArtifactRow{ReleaseID: releaseID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, CanSideload: canSideload, PodfileLockHash: podfileLockHash} + err := s.db.QueryRowContext(ctx, `INSERT INTO release_artifacts (release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, releaseID, arch, platform, hash, size, storageKey, canSideload, podfileLockHash).Scan(&r.ID, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) { + query := `SELECT id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at FROM release_artifacts WHERE release_id = ?` + args := []interface{}{releaseID} + if arch != nil { query += " AND arch = ?"; args = append(args, *arch) } + if platform != nil { query += " AND platform = ?"; args = append(args, *platform) } + query += " ORDER BY arch" + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { return nil, err } + defer rows.Close() + var result []ArtifactRow + for rows.Next() { + var r ArtifactRow + if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Arch, &r.Platform, &r.Hash, &r.Size, &r.StorageKey, &r.CanSideload, &r.PodfileLockHash, &r.CreatedAt); err != nil { return nil, err } + result = append(result, r) + } + return result, nil +} + +// --- Patches --- +func (s *SqliteStore) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) { + var num int + err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = ?`, releaseID).Scan(&num) + return num, err +} +func (s *SqliteStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) { + r := &PatchRow{ReleaseID: releaseID, Number: number, Notes: notes} + err := s.db.QueryRowContext(ctx, `INSERT INTO patches (release_id, number, notes) VALUES (?, ?, ?) RETURNING id, created_at`, releaseID, number, notes).Scan(&r.ID, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) { + r := &PatchRow{} + err := s.db.QueryRowContext(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE id = ?`, patchID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) { + r := &PatchRow{} + err := s.db.QueryRowContext(ctx, `SELECT id, release_id, number, notes, created_at FROM patches WHERE release_id = ? ORDER BY number DESC LIMIT 1`, releaseID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} +func (s *SqliteStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) { + rows, err := s.db.QueryContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, COALESCE(pc.channel_id, 0), COALESCE(pc.promoted_at, p.created_at) FROM patches p LEFT JOIN patch_channels pc ON p.id = pc.patch_id WHERE p.release_id = ? ORDER BY p.number DESC`, releaseID) + if err != nil { return nil, err } + defer rows.Close() + var result []PatchWithChannelRow + for rows.Next() { + var r PatchWithChannelRow + if err := rows.Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.ChannelID, &r.PromotedAt); err != nil { return nil, err } + result = append(result, r) + } + return result, nil +} +func (s *SqliteStore) PromotePatch(ctx context.Context, patchID, channelID int) error { + _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO patch_channels (patch_id, channel_id) VALUES (?, ?)`, patchID, channelID) + return err +} +func (s *SqliteStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) { + r := &PatchWithArtifactRow{} + err := s.db.QueryRowContext(ctx, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at, pa.hash, pa.storage_key, pa.hash_signature FROM patches p JOIN patch_channels pc ON p.id = pc.patch_id LEFT JOIN patch_artifacts pa ON p.id = pa.patch_id WHERE p.release_id = ? AND pc.channel_id = ? ORDER BY p.number DESC LIMIT 1`, releaseID, channelID).Scan(&r.ID, &r.ReleaseID, &r.Number, &r.Notes, &r.CreatedAt, &r.Hash, &r.StorageKey, &r.HashSignature) + if err != nil { return nil, err } + return r, nil +} + +// --- Patch Artifacts --- +func (s *SqliteStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) { + r := &PatchArtifactRow{PatchID: patchID, Arch: arch, Platform: platform, Hash: hash, Size: size, StorageKey: storageKey, HashSignature: hashSignature, PodfileLockHash: podfileLockHash} + err := s.db.QueryRowContext(ctx, `INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, created_at`, patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash).Scan(&r.ID, &r.CreatedAt) + if err != nil { return nil, err } + return r, nil +} + +// --- Patch Events --- +func (s *SqliteStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appID.String(), clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message) + return err +} + +// --- Rollbacks --- +func (s *SqliteStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error { + _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO rolled_back_patches (release_id, patch_number) VALUES (?, ?)`, releaseID, patchNumber) + return err +} +func (s *SqliteStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) { + rows, err := s.db.QueryContext(ctx, `SELECT patch_number FROM rolled_back_patches WHERE release_id = ?`, releaseID) + if err != nil { return nil, err } + defer rows.Close() + var result []int + for rows.Next() { var n int; if err := rows.Scan(&n); err != nil { return nil, err }; result = append(result, n) } + return result, nil +} + +// --- Targeted Devices --- +func (s *SqliteStore) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error { + _, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO patch_target_devices (patch_id, client_id) VALUES (?, ?)`, patchID, clientID) + return err +} +func (s *SqliteStore) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) { + var c int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = ? AND client_id = ?`, patchID, clientID).Scan(&c) + return c > 0, err +} +func (s *SqliteStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT client_id FROM patch_target_devices WHERE patch_id = ?`, patchID) + if err != nil { return nil, err } + defer rows.Close() + var result []string + for rows.Next() { var cid string; if err := rows.Scan(&cid); err != nil { return nil, err }; result = append(result, cid) } + return result, nil +} +func (s *SqliteStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) { + var c int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = ?`, patchID).Scan(&c) + return c > 0, err +} + +// --- Migration --- +func (s *SqliteStore) migrate(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS organizations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'team', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS organization_memberships (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(user_id, organization_id)); + CREATE TABLE IF NOT EXISTS apps (id TEXT PRIMARY KEY, organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, display_name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS channels (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(app_id, name)); + CREATE TABLE IF NOT EXISTS releases (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL REFERENCES apps(id) ON DELETE CASCADE, version TEXT NOT NULL, flutter_revision TEXT NOT NULL, flutter_version TEXT, display_name TEXT, notes TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS release_platform_statuses (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, platform TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, platform)); + CREATE TABLE IF NOT EXISTS release_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, can_sideload INTEGER NOT NULL DEFAULT 0, podfile_lock_hash TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS patches (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, number INTEGER NOT NULL, notes TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, number)); + CREATE TABLE IF NOT EXISTS patch_artifacts (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, arch TEXT NOT NULL, platform TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL DEFAULT 0, storage_key TEXT NOT NULL, hash_signature TEXT, podfile_lock_hash TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS patch_channels (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, promoted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(patch_id, channel_id)); + CREATE TABLE IF NOT EXISTS patch_events (id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, client_id TEXT NOT NULL, arch TEXT NOT NULL, patch_number INTEGER NOT NULL, platform TEXT NOT NULL, release_version TEXT NOT NULL, event_type TEXT NOT NULL, timestamp INTEGER NOT NULL, message TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))); + CREATE TABLE IF NOT EXISTS rolled_back_patches (id INTEGER PRIMARY KEY AUTOINCREMENT, release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, patch_number INTEGER NOT NULL, rolled_back_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(release_id, patch_number)); + CREATE TABLE IF NOT EXISTS patch_target_devices (id INTEGER PRIMARY KEY AUTOINCREMENT, patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE, client_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), UNIQUE(patch_id, client_id)); + CREATE INDEX IF NOT EXISTS idx_releases_app_id ON releases(app_id); + CREATE INDEX IF NOT EXISTS idx_releases_app_version ON releases(app_id, version); + CREATE INDEX IF NOT EXISTS idx_patches_release_id ON patches(release_id); + CREATE INDEX IF NOT EXISTS idx_patch_channels_channel ON patch_channels(channel_id); + CREATE INDEX IF NOT EXISTS idx_patch_channels_patch ON patch_channels(patch_id); + CREATE INDEX IF NOT EXISTS idx_patch_events_app ON patch_events(app_id); + CREATE INDEX IF NOT EXISTS idx_patch_events_client ON patch_events(app_id, client_id); + CREATE INDEX IF NOT EXISTS idx_rolled_back_release ON rolled_back_patches(release_id); + CREATE INDEX IF NOT EXISTS idx_patch_target_devices_patch ON patch_target_devices(patch_id); + CREATE INDEX IF NOT EXISTS idx_patch_target_devices_client ON patch_target_devices(patch_id, client_id); + `) + return err +} \ No newline at end of file diff --git a/internal/db/store.go b/internal/db/store.go new file mode 100644 index 0000000..a0d4914 --- /dev/null +++ b/internal/db/store.go @@ -0,0 +1,254 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// ShorebirdTime wraps time.Time with sql.Scanner support for both +// PostgreSQL (native time.Time) and SQLite (RFC3339Nano TEXT). +type ShorebirdTime struct{ time.Time } + +// Scan implements sql.Scanner. +func (st *ShorebirdTime) Scan(v interface{}) error { + if v == nil { + st.Time = time.Time{} + return nil + } + switch val := v.(type) { + case time.Time: + st.Time = val + return nil + case string: + t, err := time.Parse(time.RFC3339Nano, val) + if err != nil { + t, err = time.Parse("2006-01-02 15:04:05", val) + if err != nil { + return fmt.Errorf("ShorebirdTime: cannot parse %q", val) + } + } + st.Time = t + return nil + case []byte: + return st.Scan(string(val)) + default: + return fmt.Errorf("ShorebirdTime: unsupported type %T", v) + } +} + +// Value implements driver.Valuer for SQLite INSERTs. +func (st ShorebirdTime) Value() (interface{}, error) { + if st.Time.IsZero() { + return nil, nil + } + return st.Time.Format(time.RFC3339Nano), nil +} + +// NullShorebirdTime is a nullable variant. +type NullShorebirdTime struct { + Time ShorebirdTime + Valid bool +} + +// Scan implements sql.Scanner for nullable time. +func (nst *NullShorebirdTime) Scan(v interface{}) error { + if v == nil { + nst.Time = ShorebirdTime{} + nst.Valid = false + return nil + } + nst.Valid = true + return nst.Time.Scan(v) +} + +// Ensure unused import warnings are suppressed. +var _ sql.Scanner = (*ShorebirdTime)(nil) + +// Store is the database abstraction. Both PostgreSQL and SQLite +// backends implement this interface. +type Store interface { + Close() + + // --- Users --- + CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) + GetUserByEmail(ctx context.Context, email string) (*UserRow, error) + GetUserByID(ctx context.Context, id int) (*UserRow, error) + ListAllUsers(ctx context.Context) ([]UserListRow, error) + + // --- Organizations --- + CreateOrganization(ctx context.Context, name, orgType string) (int, error) + GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) + AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error + + // --- Apps --- + CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) + GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) + GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) + GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) + DeleteApp(ctx context.Context, appID uuid.UUID) error + GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) + + // --- Channels --- + CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) + GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) + GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) + + // --- Releases --- + CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) + GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) + GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) + GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) + UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error + GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) + + // --- Release Artifacts --- + CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) + GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) + + // --- Patches --- + GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) + CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) + GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) + GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) + GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) + PromotePatch(ctx context.Context, patchID, channelID int) error + GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) + + // --- Patch Artifacts --- + CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) + + // --- Patch Events --- + InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error + + // --- Rollbacks --- + RollbackPatch(ctx context.Context, releaseID, patchNumber int) error + GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) + + // --- Targeted Devices --- + AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error + IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) + GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) + HasTargetDevices(ctx context.Context, patchID int) (bool, error) +} + +// --- Shared row types (used by both backends) --- + +// UserRow represents a user row from the database. +type UserRow struct { + ID int + Email string + Name string + PasswordHash string + CreatedAt ShorebirdTime +} + +// OrgMembershipRow represents an organization membership query result. +type OrgMembershipRow struct { + OrgID int + OrgName string + OrgType string + Role string +} + +// AppRow represents an app row. +type AppRow struct { + ID uuid.UUID + OrganizationID int + DisplayName string + CreatedAt ShorebirdTime + UpdatedAt ShorebirdTime +} + +// ChannelRow represents a channel row. +type ChannelRow struct { + ID int + AppID uuid.UUID + Name string + CreatedAt ShorebirdTime +} + +// ReleaseRow represents a release row. +type ReleaseRow struct { + ID int + AppID uuid.UUID + Version string + FlutterRevision string + FlutterVersion *string + DisplayName *string + Notes *string + CreatedAt ShorebirdTime + UpdatedAt ShorebirdTime +} + +// ArtifactRow represents a release artifact row. +type ArtifactRow struct { + ID int + ReleaseID int + Arch string + Platform string + Hash string + Size int64 + StorageKey string + CanSideload bool + PodfileLockHash *string + CreatedAt ShorebirdTime +} + +// PatchRow represents a patch row. +type PatchRow struct { + ID int + ReleaseID int + Number int + Notes *string + CreatedAt ShorebirdTime +} + +// PatchWithChannelRow includes channel promotion info. +type PatchWithChannelRow struct { + ID int + ReleaseID int + Number int + Notes *string + CreatedAt ShorebirdTime + ChannelID int + PromotedAt ShorebirdTime +} + +// PatchWithArtifactRow includes artifact info for patch check. +type PatchWithArtifactRow struct { + ID int + ReleaseID int + Number int + Notes *string + CreatedAt ShorebirdTime + Hash string + StorageKey string + HashSignature *string +} + +// PatchArtifactRow represents a patch artifact row. +type PatchArtifactRow struct { + ID int + PatchID int + Arch string + Platform string + Hash string + Size int64 + StorageKey string + HashSignature *string + PodfileLockHash *string + CreatedAt ShorebirdTime +} + +// UserListRow is used by the admin user listing endpoint. +type UserListRow struct { + ID int + Email string + Name string + CreatedAt ShorebirdTime + Role string +} diff --git a/internal/models/models.go b/internal/models/models.go index 9d2b08c..5b29462 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -174,9 +174,11 @@ const ( // Organization represents an organization. type Organization struct { - ID int `json:"id"` - Name string `json:"name"` - Type string `json:"type"` + ID int `json:"id"` + Name string `json:"name"` + OrganizationType string `json:"organization_type"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // OrganizationMembership represents a user's membership in an org. @@ -233,10 +235,18 @@ type CreateReleaseArtifactRequest struct { PodfileLockHash *string `json:"podfile_lock_hash"` } -// CreateReleaseArtifactResponse returns the presigned upload URL. +// CreateReleaseArtifactResponse returns the artifact metadata. type CreateReleaseArtifactResponse struct { - ID int `json:"id"` - URL string `json:"url"` + ID int `json:"id"` + ReleaseID int `json:"release_id"` + Arch string `json:"arch"` + Platform string `json:"platform"` + Hash string `json:"hash"` + Size int64 `json:"size"` + URL string `json:"url"` + CreatedAt time.Time `json:"created_at"` + CanSideload bool `json:"can_sideload"` + PodfileLockHash *string `json:"podfile_lock_hash"` } // CreatePatchRequest is the POST /patches request body. @@ -260,10 +270,15 @@ type CreatePatchArtifactRequest struct { PodfileLockHash *string `json:"podfile_lock_hash"` } -// CreatePatchArtifactResponse returns the presigned upload URL. +// CreatePatchArtifactResponse returns the patch artifact metadata. type CreatePatchArtifactResponse struct { - ID int `json:"id"` - URL string `json:"url"` + ID int `json:"id"` + PatchID int `json:"patch_id"` + Arch string `json:"arch"` + Platform string `json:"platform"` + Hash string `json:"hash"` + Size int64 `json:"size"` + URL string `json:"url"` } // UpdateReleaseRequest is the PATCH /releases/{id} request body. diff --git a/internal/storage/factory.go b/internal/storage/factory.go new file mode 100644 index 0000000..ab0ab88 --- /dev/null +++ b/internal/storage/factory.go @@ -0,0 +1,37 @@ +package storage + +import "fmt" + +// Config holds common storage configuration used by the factory. +type Config struct { + // Type is "local" or "s3". Empty defaults to "local". + Type string + + // Local settings + LocalDir string + ServerBaseURL string + + // S3 settings + S3Endpoint string + S3AccessKey string + S3SecretKey string + S3UseSSL bool + S3ReleaseBucket string + S3PatchBucket string +} + +// NewStore creates the appropriate Store backend based on Config.Type. +func NewStore(cfg Config) (Store, error) { + switch cfg.Type { + case "s3", "minio": + return NewS3Store( + cfg.S3Endpoint, cfg.S3AccessKey, cfg.S3SecretKey, + cfg.S3ReleaseBucket, cfg.S3PatchBucket, cfg.S3UseSSL, + ) + default: + if cfg.Type != "" && cfg.Type != "local" { + fmt.Printf("storage: unknown type %q, falling back to local\n", cfg.Type) + } + return NewLocalStore(cfg.LocalDir, cfg.ServerBaseURL) + } +} diff --git a/internal/storage/local.go b/internal/storage/local.go new file mode 100644 index 0000000..ccf5fbc --- /dev/null +++ b/internal/storage/local.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// LocalStore implements Store using the local filesystem. +// Release artifacts go under /releases/; patch artifacts +// under /patches/ (publicly served by the built-in upload +// handler — no presigned URLs needed). +type LocalStore struct { + baseDir string + // serverBaseURL is the external URL of this server (e.g. http://localhost:8080). + // Used when generating download URLs for device-side patch checks. + serverBaseURL string +} + +// NewLocalStore creates a local-filesystem storage backend. +// baseDir is created automatically if it does not exist. +func NewLocalStore(baseDir, serverBaseURL string) (*LocalStore, error) { + abs, err := filepath.Abs(baseDir) + if err != nil { + return nil, fmt.Errorf("local storage: %w", err) + } + if err := os.MkdirAll(filepath.Join(abs, "releases"), 0o755); err != nil { + return nil, fmt.Errorf("local storage: create releases dir: %w", err) + } + if err := os.MkdirAll(filepath.Join(abs, "patches"), 0o755); err != nil { + return nil, fmt.Errorf("local storage: create patches dir: %w", err) + } + if serverBaseURL == "" { + serverBaseURL = "http://localhost:8080" + } + return &LocalStore{baseDir: abs, serverBaseURL: serverBaseURL}, nil +} + +func (s *LocalStore) BackendName() string { return "Local filesystem" } + +// GeneratePresignedUploadURL returns a server endpoint URL that the +// CLI can POST the artifact to. The upload handler (registered on +// the router) writes the file to disk. +func (s *LocalStore) GeneratePresignedUploadURL(_ context.Context, objectKey string, isPublic bool, _ time.Duration) (string, error) { + scope := "releases" + if isPublic { + scope = "patches" + } + return fmt.Sprintf("%s/storage/upload/%s/%s", s.serverBaseURL, scope, objectKey), nil +} + +// GeneratePublicDownloadURL returns a public download URL for a patch. +func (s *LocalStore) GeneratePublicDownloadURL(_ context.Context, objectKey string) (string, error) { + return fmt.Sprintf("%s/storage/dl/patches/%s", s.serverBaseURL, objectKey), nil +} + +// UploadObject writes data directly to the local filesystem. +func (s *LocalStore) UploadObject(_ context.Context, objectKey string, reader io.Reader, _ int64, _ string, isPublic bool) error { + scope := "releases" + if isPublic { + scope = "patches" + } + target := filepath.Join(s.baseDir, scope, objectKey) + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("local storage: %w", err) + } + f, err := os.Create(target) + if err != nil { + return fmt.Errorf("local storage: %w", err) + } + defer f.Close() + if _, err := io.Copy(f, reader); err != nil { + return fmt.Errorf("local storage: write: %w", err) + } + return nil +} + +// GetObject reads an object from the local filesystem. +func (s *LocalStore) GetObject(_ context.Context, objectKey string, isPublic bool) (io.ReadCloser, error) { + scope := "releases" + if isPublic { + scope = "patches" + } + return os.Open(filepath.Join(s.baseDir, scope, objectKey)) +} + +// BaseDir returns the absolute base directory. +func (s *LocalStore) BaseDir() string { return s.baseDir } diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..5954a9a --- /dev/null +++ b/internal/storage/s3.go @@ -0,0 +1,79 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/url" + "time" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +// S3Store implements Store backed by MinIO or any S3-compatible service. +type S3Store struct { + client *minio.Client + releaseBucket string + patchBucket string +} + +// NewS3Store creates a new S3-compatible storage backend. +func NewS3Store(endpoint, accessKeyID, secretAccessKey, releaseBucket, patchBucket string, useSSL bool) (*S3Store, error) { + client, err := minio.New(endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), + Secure: useSSL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create minio client: %w", err) + } + return &S3Store{client: client, releaseBucket: releaseBucket, patchBucket: patchBucket}, nil +} + +func (s *S3Store) BackendName() string { return "S3/MinIO" } + +func (s *S3Store) GeneratePresignedUploadURL(ctx context.Context, objectKey string, isPublic bool, expiry time.Duration) (string, error) { + bucket := s.releaseBucket + if isPublic { + bucket = s.patchBucket + } + u, err := s.client.PresignedPutObject(ctx, bucket, objectKey, expiry) + if err != nil { + return "", fmt.Errorf("failed to generate presigned URL: %w", err) + } + return u.String(), nil +} + +func (s *S3Store) GeneratePublicDownloadURL(ctx context.Context, objectKey string) (string, error) { + endpoint := s.client.EndpointURL() + return fmt.Sprintf("%s/%s/%s", endpoint.String(), s.patchBucket, objectKey), nil +} + +func (s *S3Store) UploadObject(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string, isPublic bool) error { + bucket := s.releaseBucket + if isPublic { + bucket = s.patchBucket + } + _, err := s.client.PutObject(ctx, bucket, objectKey, reader, size, minio.PutObjectOptions{ContentType: contentType}) + if err != nil { + return fmt.Errorf("failed to upload object: %w", err) + } + return nil +} + +func (s *S3Store) GetObject(ctx context.Context, objectKey string, isPublic bool) (io.ReadCloser, error) { + bucket := s.releaseBucket + if isPublic { + bucket = s.patchBucket + } + obj, err := s.client.GetObject(ctx, bucket, objectKey, minio.GetObjectOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get object: %w", err) + } + return obj, nil +} + +// PresignedURL parses a presigned URL string. +func PresignedURL(raw string) (*url.URL, error) { + return url.Parse(raw) +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go deleted file mode 100644 index 20ebfc5..0000000 --- a/internal/storage/storage.go +++ /dev/null @@ -1,108 +0,0 @@ -package storage - -import ( - "context" - "fmt" - "io" - "net/url" - "time" - - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" -) - -// Service handles object storage operations (MinIO / S3 compatible). -type Service struct { - client *minio.Client - releaseBucket string - patchBucket string -} - -// NewService creates a new storage service. -func NewService(endpoint, accessKeyID, secretAccessKey, releaseBucket, patchBucket string, useSSL bool) (*Service, error) { - client, err := minio.New(endpoint, &minio.Options{ - Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), - Secure: useSSL, - }) - if err != nil { - return nil, fmt.Errorf("failed to create minio client: %w", err) - } - - return &Service{ - client: client, - releaseBucket: releaseBucket, - patchBucket: patchBucket, - }, nil -} - -// GeneratePresignedUploadURL creates a presigned URL for uploading an object. -func (s *Service) GeneratePresignedUploadURL(ctx context.Context, objectKey string, isPublic bool, expiry time.Duration) (string, error) { - bucket := s.releaseBucket - if isPublic { - bucket = s.patchBucket - } - - u, err := s.client.PresignedPutObject(ctx, bucket, objectKey, expiry) - if err != nil { - return "", fmt.Errorf("failed to generate presigned URL: %w", err) - } - return u.String(), nil -} - -// GeneratePublicDownloadURL creates a public download URL for a patch artifact. -func (s *Service) GeneratePublicDownloadURL(ctx context.Context, objectKey string) (string, error) { - // For MinIO, construct the public URL directly - // In production with a CDN, this would be a CDN URL - endpoint := s.client.EndpointURL() - return fmt.Sprintf("%s/%s/%s", endpoint.String(), s.patchBucket, objectKey), nil -} - -// UploadObject directly uploads data to storage. -func (s *Service) UploadObject(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string, isPublic bool) error { - bucket := s.releaseBucket - if isPublic { - bucket = s.patchBucket - } - - opts := minio.PutObjectOptions{ - ContentType: contentType, - } - if size > 0 { - // Let minio handle unknown size - } - - _, err := s.client.PutObject(ctx, bucket, objectKey, reader, size, opts) - if err != nil { - return fmt.Errorf("failed to upload object: %w", err) - } - return nil -} - -// GetObject retrieves an object from storage. -func (s *Service) GetObject(ctx context.Context, objectKey string, isPublic bool) (io.ReadCloser, error) { - bucket := s.releaseBucket - if isPublic { - bucket = s.patchBucket - } - - obj, err := s.client.GetObject(ctx, bucket, objectKey, minio.GetObjectOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get object: %w", err) - } - return obj, nil -} - -// GenerateReleaseStorageKey creates a standardized storage key for releases. -func GenerateReleaseStorageKey(appID, version, platform, arch, filename string) string { - return fmt.Sprintf("apps/%s/releases/%s/%s/%s/%s", appID, version, platform, arch, filename) -} - -// GeneratePatchStorageKey creates a standardized storage key for patches. -func GeneratePatchStorageKey(appID string, releaseID int, patchNumber int, platform, arch, filename string) string { - return fmt.Sprintf("apps/%s/releases/%d/patches/%d/%s/%s/%s", appID, releaseID, patchNumber, platform, arch, filename) -} - -// PresignedURL returns a parsed presigned URL. -func PresignedURL(raw string) (*url.URL, error) { - return url.Parse(raw) -} diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..679c43b --- /dev/null +++ b/internal/storage/store.go @@ -0,0 +1,27 @@ +package storage + +import ( + "context" + "fmt" + "io" + "time" +) + +// Store is the storage backend interface. +type Store interface { + GeneratePresignedUploadURL(ctx context.Context, objectKey string, isPublic bool, expiry time.Duration) (string, error) + GeneratePublicDownloadURL(ctx context.Context, objectKey string) (string, error) + UploadObject(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string, isPublic bool) error + GetObject(ctx context.Context, objectKey string, isPublic bool) (io.ReadCloser, error) + BackendName() string +} + +// GenerateReleaseStorageKey creates a standardized storage key for releases. +func GenerateReleaseStorageKey(appID, version, platform, arch, filename string) string { + return fmt.Sprintf("apps/%s/releases/%s/%s/%s/%s", appID, version, platform, arch, filename) +} + +// GeneratePatchStorageKey creates a standardized storage key for patches. +func GeneratePatchStorageKey(appID string, releaseID int, patchNumber int, platform, arch, filename string) string { + return fmt.Sprintf("apps/%s/releases/%d/patches/%d/%s/%s/%s", appID, releaseID, patchNumber, platform, arch, filename) +} diff --git a/web/js/app.js b/web/js/app.js index 20f296e..cdaabd0 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -284,14 +284,22 @@ function openCreateUserModal() { // ---- Settings ---- async function loadSettings() { + // Try to get server info from health endpoint + let backendInfo = { storage: 'Unknown', database: 'Unknown' }; + try { + const resp = await fetch('/health'); + const data = await resp.json(); + if (data.backend) backendInfo = data.backend; + } catch (e) { /* use defaults */ } + const infoDiv = document.getElementById('serverInfo'); infoDiv.innerHTML = `
-
Server
+
Server Version
Shorebird Self-Hosted v1.0
-
API Base
+
API Base URL
${window.location.origin}/api/v1
@@ -299,12 +307,12 @@ async function loadSettings() {
${window.location.origin}/auth
-
Storage
-
MinIO / S3-compatible
+
Storage Backend
+
${backendInfo.storage}
-
Database
-
PostgreSQL
+
Database Backend
+
${backendInfo.database}
UI Version
@@ -312,7 +320,6 @@ async function loadSettings() {
`; - // Show current API token document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated'; }