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';
}