feat: implement database and storage abstractions

- Added `internal/db/store.go` to define the Store interface for database operations, including user, organization, app, channel, release, patch, and artifact management.
- Introduced `internal/models/models.go` changes to reflect updated organization and artifact response structures.
- Created `internal/storage/factory.go`, `local.go`, and `s3.go` to implement storage backends for local filesystem and S3-compatible services.
- Removed deprecated `internal/storage/storage.go` and refactored storage interface into `internal/storage/store.go`.
- Updated frontend JavaScript in `web/js/app.js` to display server health information, including storage and database backend details.
This commit is contained in:
Tony
2026-06-12 08:45:01 +08:00
parent 246260a8f5
commit 3bb4ab494e
31 changed files with 1698 additions and 509 deletions
+27 -14
View File
@@ -1,23 +1,36 @@
# Shorebird Self-Hosted Server Configuration # 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_HOST=0.0.0.0
SERVER_PORT=8080 SERVER_PORT=8080
# Public-facing URL (used for download links in local storage mode)
SERVER_BASE_URL=http://localhost:8080
# PostgreSQL Database # --- Database ---
DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable # 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 ---
STORAGE_ENDPOINT=localhost:9000 # Driver: "local" (default, zero-dependency) or "s3"
STORAGE_ACCESS_KEY=minioadmin STORAGE_DRIVER=local
STORAGE_SECRET_KEY=minioadmin # Local storage directory (auto-created)
STORAGE_RELEASE_BUCKET=shorebird-releases STORAGE_LOCAL_DIR=data/storage
STORAGE_PATCH_BUCKET=shorebird-patches # 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 JWT_SECRET=change-me-in-production-use-a-long-random-string
# Redis (optional, for caching patch checks) # --- Redis (optional) ---
REDIS_ADDR=localhost:6379 # REDIS_ADDR=localhost:6379
REDIS_PASSWORD= # REDIS_PASSWORD=
+31
View File
@@ -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
+1 -1
View File
@@ -23,7 +23,7 @@ RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app WORKDIR /app
COPY --from=builder /app/shorebird-server . COPY --from=builder /app/shorebird-server .
COPY internal/db/migrations/ ./internal/db/migrations/ COPY --from=builder /app/web ./web
EXPOSE 8080 EXPOSE 8080
+7 -23
View File
@@ -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 # Default: build and run with zero dependencies (SQLite + local storage)
all: build all: build run
# Build the server binary
build: build:
go build -o bin/shorebird-server ./cmd/server go build -o bin/shorebird-server ./cmd/server
# Run the server locally
run: run:
go run ./cmd/server go run ./cmd/server
# Run tests
test: test:
go test ./... -v go test ./... -v
# Docker Compose # Docker with full stack (PostgreSQL + MinIO)
# Uses profiles so docker compose up (no args) does nothing.
docker-up: docker-up:
docker compose up -d docker compose --profile full up -d
docker compose logs -f
docker-down: 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: tidy:
go mod tidy go mod tidy
# Format code
fmt: fmt:
go fmt ./... go fmt ./...
# Lint
lint:
golangci-lint run ./...
+40 -41
View File
@@ -2,62 +2,61 @@
A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.dev`). 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 ### Prerequisites
- Go 1.23+ - Go 1.23+
- Docker & Docker Compose (for PostgreSQL and MinIO)
### 1. Start infrastructure ### 1. Build and run
```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
```bash ```bash
make run 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 ### 3. (Optional) Use PostgreSQL + MinIO
- **Login** with your credentials
The dashboard provides: For production deployments that need horizontal scaling:
| 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)
```bash ```bash
curl -X POST http://localhost:8080/auth/register \ # Start infrastructure
-H "Content-Type: application/json" \ docker compose --profile full up -d
-d '{"email":"dev@example.com","password":"securepass","name":"Developer"}'
# 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 ### 6. Configure Shorebird CLI
@@ -144,11 +143,11 @@ See `.env.example` for all available configuration options.
│ │ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────┐ │
│ │ PostgreSQL │ │ │ │ SQLite / PostgreSQL │ │
│ │ (apps, releases, patches, artifacts, events) │ │ │ │ (apps, releases, patches, artifacts, events) │ │
│ └──────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────┐ │
│ │ MinIO (S3-compatible) │ │ │ │ Local FS / MinIO (S3-compatible) │ │
│ │ shorebird-releases (private) │ │ │ │ shorebird-releases (private) │ │
│ │ shorebird-patches (public) │ │ │ │ shorebird-patches (public) │ │
│ └──────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────┘ │
+37 -30
View File
@@ -18,40 +18,48 @@ import (
) )
func main() { func main() {
// Load configuration
cfg := config.Load() cfg := config.Load()
// Initialize database
ctx := context.Background() 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 { if err != nil {
log.Fatalf("Failed to connect to database: %v", err) log.Fatalf("Failed to open database: %v", err)
} }
defer database.Close() 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) authService := auth.NewService(cfg.Auth.JWTSecret, cfg.Auth.TokenDuration)
log.Println("Auth service initialized")
// Initialize storage service // --- Storage ---
store, err := storage.NewService( store, err := storage.NewStore(storage.Config{
cfg.Storage.Endpoint, Type: cfg.Storage.Driver,
cfg.Storage.AccessKeyID, LocalDir: cfg.Storage.LocalDir,
cfg.Storage.SecretAccessKey, ServerBaseURL: cfg.Server.BaseURL,
cfg.Storage.ReleaseBucket, S3Endpoint: cfg.Storage.S3Endpoint,
cfg.Storage.PatchBucket, S3AccessKey: cfg.Storage.S3AccessKey,
cfg.Storage.UseSSL, S3SecretKey: cfg.Storage.S3SecretKey,
) S3UseSSL: cfg.Storage.S3UseSSL,
S3ReleaseBucket: cfg.Storage.S3ReleaseBucket,
S3PatchBucket: cfg.Storage.S3PatchBucket,
})
if err != nil { if err != nil {
log.Fatalf("Failed to initialize storage: %v", err) 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) router := handlers.NewRouter(authService, database, store)
// Configure HTTP server // --- HTTP Server ---
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
@@ -61,29 +69,28 @@ func main() {
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
// Start server in goroutine
go func() { go func() {
log.Printf("Shorebird self-hosted server starting on %s", addr) log.Printf("Shorebird server starting on %s", addr)
log.Printf("API: http://%s/api/v1", addr) log.Printf(" Dashboard: http://%s", addr)
log.Printf("Auth: http://%s/auth", addr) log.Printf(" API: http://%s/api/v1", addr)
log.Printf("Health: http://%s/health", 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 { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err) log.Fatalf("Server failed: %v", err)
} }
}() }()
// Wait for interrupt signal // Graceful shutdown
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
log.Println("Shutting down server...") log.Println("Shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { 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") log.Println("Server stopped")
} }
+17
View File
@@ -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" version: "3.9"
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
profiles: ["full"]
environment: environment:
POSTGRES_USER: shorebird POSTGRES_USER: shorebird
POSTGRES_PASSWORD: shorebird POSTGRES_PASSWORD: shorebird
@@ -19,6 +33,7 @@ services:
minio: minio:
image: minio/minio:latest image: minio/minio:latest
profiles: ["full"]
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: minioadmin
@@ -36,6 +51,7 @@ services:
minio-init: minio-init:
image: minio/mc:latest image: minio/mc:latest
profiles: ["full"]
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
@@ -50,6 +66,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
profiles: ["full"]
ports: ports:
- "6379:6379" - "6379:6379"
healthcheck: healthcheck:
+11 -7
View File
@@ -9,29 +9,33 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1 github.com/jackc/pgx/v5 v5.7.1
github.com/minio/minio-go/v7 v7.0.80 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 golang.org/x/crypto v0.28.0
modernc.org/sqlite v1.33.1
) )
require ( 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/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.4 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // 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/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/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/net v0.30.0 // indirect
golang.org/x/sync v0.9.0 // indirect golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.20.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
) )
+98
View File
@@ -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=
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// AdminHandler handles admin/management API endpoints. // AdminHandler handles admin/management API endpoints.
type AdminHandler struct { type AdminHandler struct {
DB *db.DB DB db.Store
} }
// AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices // AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices
+32 -17
View File
@@ -2,7 +2,7 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/shorebird-server/internal/api/middleware" "github.com/shorebird-server/internal/api/middleware"
@@ -22,7 +22,7 @@ func intPtr(i int) *int {
// UserHandler handles user-related API endpoints. // UserHandler handles user-related API endpoints.
type UserHandler struct { type UserHandler struct {
DB *db.DB DB db.Store
} }
// GetCurrentUser handles GET /api/v1/users/me // 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{}{ respondJSON(w, http.StatusOK, map[string]interface{}{
"id": user.ID, "id": user.ID,
"email": user.Email, "email": user.Email,
"name": user.Name, "display_name": user.Name,
"created_at": user.CreatedAt, "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. // AppHandler handles app-related API endpoints.
type AppHandler struct { type AppHandler struct {
DB *db.DB DB db.Store
} }
// GetApps handles GET /api/v1/apps // GetApps handles GET /api/v1/apps
@@ -100,12 +103,18 @@ func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) {
var result []models.AppMetadata var result []models.AppMetadata
for _, a := range apps { for _, a := range apps {
result = append(result, models.AppMetadata{ result = append(result, models.AppMetadata{
AppID: a.ID.String(), AppID: a.ID.String(),
DisplayName: a.DisplayName, DisplayName: a.DisplayName,
CreatedAt: a.CreatedAt, CreatedAt: a.CreatedAt.Time,
UpdatedAt: a.UpdatedAt, 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}) 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{}{ respondJSON(w, http.StatusCreated, map[string]interface{}{
"app_id": app.ID.String(), "id": app.ID.String(),
"display_name": app.DisplayName, "display_name": app.DisplayName,
"created_at": app.CreatedAt, "created_at": app.CreatedAt,
"updated_at": app.UpdatedAt, "updated_at": app.UpdatedAt,
@@ -180,7 +189,7 @@ func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) {
// ChannelHandler handles channel-related API endpoints. // ChannelHandler handles channel-related API endpoints.
type ChannelHandler struct { type ChannelHandler struct {
DB *db.DB DB db.Store
} }
// GetChannels handles GET /api/v1/apps/{appId}/channels // 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. // OrganizationHandler handles organization-related API endpoints.
type OrganizationHandler struct { type OrganizationHandler struct {
DB *db.DB DB db.Store
} }
// GetOrganizations handles GET /api/v1/organizations // GetOrganizations handles GET /api/v1/organizations
@@ -259,15 +268,21 @@ func (h *OrganizationHandler) GetOrganizations(w http.ResponseWriter, r *http.Re
var result []models.OrganizationMembership var result []models.OrganizationMembership
for _, o := range orgs { for _, o := range orgs {
now := time.Now().UTC()
result = append(result, models.OrganizationMembership{ result = append(result, models.OrganizationMembership{
Organization: models.Organization{ Organization: models.Organization{
ID: o.OrgID, ID: o.OrgID,
Name: o.OrgName, Name: o.OrgName,
Type: o.OrgType, OrganizationType: o.OrgType,
CreatedAt: now,
UpdatedAt: now,
}, },
Role: o.Role, Role: o.Role,
}) })
} }
if result == nil {
result = []models.OrganizationMembership{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result}) respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result})
} }
+197 -7
View File
@@ -1,7 +1,13 @@
package handlers package handlers
import ( import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http" "net/http"
"strings"
"sync"
"time"
"github.com/shorebird-server/internal/api/middleware" "github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/auth" "github.com/shorebird-server/internal/auth"
@@ -9,14 +15,146 @@ import (
"github.com/shorebird-server/internal/models" "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 { type AuthHandler struct {
DB *db.DB DB db.Store
AuthService *auth.Service 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) { 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 var req models.AuthTokenRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", 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 // Refresh handles POST /auth/refresh
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { 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) claims := middleware.GetClaims(r)
if claims == nil { if claims == nil {
respondError(w, http.StatusUnauthorized, "Invalid token", 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) { func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
@@ -106,7 +242,6 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
// Create a default organization for the user
orgID, err := h.DB.CreateOrganization(r.Context(), req.Name+"'s Org", "team") orgID, err := h.DB.CreateOrganization(r.Context(), req.Name+"'s Org", "team")
if err != nil { if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create organization", 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, 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(`<div class="error">%s</div>`, errorMsg)
}
fmt.Fprintf(w, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shorebird Login</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f8fafc;display:flex;align-items:center;justify-content:center;min-height:100vh}
.card{background:#fff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,.07),0 2px 4px rgba(0,0,0,.06);padding:40px;width:100%%;max-width:400px}
.card h1{font-size:24px;font-weight:700;text-align:center;margin-bottom:4px}
.card .st{color:#64748b;text-align:center;font-size:14px;margin-bottom:24px}
.fg{margin-bottom:16px}
.fg label{display:block;font-size:13px;font-weight:600;color:#64748b;margin-bottom:5px}
.fg input{width:100%%;padding:10px 14px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;outline:none;transition:border-color .15s}
.fg input:focus{border-color:#0891b2;box-shadow:0 0 0 3px rgba(8,145,178,.1)}
.btn{width:100%%;padding:12px;background:#0891b2;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer}
.btn:hover{background:#0e7490}
.error{background:#fef2f2;color:#ef4444;padding:10px 14px;border-radius:8px;margin-bottom:16px;font-size:13px}
</style>
</head>
<body>
<div class="card">
<h1>🐦 Shorebird</h1>
<p class="st">Self-Hosted · Sign in to continue</p>
%s
<form method="POST" action="">
<div class="fg"><label for="email">Email</label><input type="email" id="email" name="email" placeholder="you@example.com" required autofocus></div>
<div class="fg"><label for="password">Password</label><input type="password" id="password" name="password" placeholder="Enter password" required></div>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</body>
</html>`, errorHTML)
}
+2 -2
View File
@@ -11,8 +11,8 @@ import (
// DeviceHandler handles device-facing API endpoints (no auth required). // DeviceHandler handles device-facing API endpoints (no auth required).
type DeviceHandler struct { type DeviceHandler struct {
DB *db.DB DB db.Store
Storage *storage.Service Storage storage.Store
} }
// PatchCheck handles POST /api/v1/patches/check // PatchCheck handles POST /api/v1/patches/check
+11 -5
View File
@@ -14,8 +14,8 @@ import (
// PatchHandler handles patch-related API endpoints. // PatchHandler handles patch-related API endpoints.
type PatchHandler struct { type PatchHandler struct {
DB *db.DB DB db.Store
Storage *storage.Service Storage storage.Store
} }
// CreatePatch handles POST /api/v1/apps/{appId}/patches // CreatePatch handles POST /api/v1/apps/{appId}/patches
@@ -117,14 +117,20 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques
phl = &podfileLockHash 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create artifact record", strPtr(err.Error())) respondError(w, http.StatusInternalServerError, "Failed to create artifact record", strPtr(err.Error()))
return return
} }
respondJSON(w, http.StatusCreated, models.CreatePatchArtifactResponse{ 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, ReleaseID: p.ReleaseID,
PatchID: p.ID, PatchID: p.ID,
PatchNumber: p.Number, PatchNumber: p.Number,
CreatedAt: p.PromotedAt, CreatedAt: p.PromotedAt.Time,
}) })
} }
+25 -11
View File
@@ -8,7 +8,6 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/db" "github.com/shorebird-server/internal/db"
"github.com/shorebird-server/internal/models" "github.com/shorebird-server/internal/models"
"github.com/shorebird-server/internal/storage" "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. // ReleaseHandler handles release-related API endpoints.
type ReleaseHandler struct { type ReleaseHandler struct {
DB *db.DB DB db.Store
Storage *storage.Service Storage storage.Store
} }
// GetReleases handles GET /api/v1/apps/{appId}/releases // 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, DisplayName: rel.DisplayName,
Notes: rel.Notes, Notes: rel.Notes,
PlatformStatuses: statuses, PlatformStatuses: statuses,
CreatedAt: rel.CreatedAt, CreatedAt: rel.CreatedAt.Time,
UpdatedAt: rel.UpdatedAt, UpdatedAt: rel.UpdatedAt.Time,
}) })
} }
if result == nil {
result = []models.Release{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{"releases": result}) 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, DisplayName: release.DisplayName,
Notes: release.Notes, Notes: release.Notes,
PlatformStatuses: map[string]string{}, PlatformStatuses: map[string]string{},
CreatedAt: release.CreatedAt, CreatedAt: release.CreatedAt.Time,
UpdatedAt: release.UpdatedAt, UpdatedAt: release.UpdatedAt.Time,
}, },
}) })
} }
@@ -184,8 +186,16 @@ func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Re
} }
respondJSON(w, http.StatusCreated, models.CreateReleaseArtifactResponse{ respondJSON(w, http.StatusCreated, models.CreateReleaseArtifactResponse{
ID: artifact.ID, ID: artifact.ID,
URL: uploadURL, 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 var result []models.ReleaseArtifact
for _, a := range artifacts { for _, a := range artifacts {
// Build a download URL for the artifact // 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{ result = append(result, models.ReleaseArtifact{
ID: a.ID, ID: a.ID,
ReleaseID: a.ReleaseID, ReleaseID: a.ReleaseID,
@@ -225,9 +235,13 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ
URL: downloadURL, URL: downloadURL,
CanSideload: a.CanSideload, CanSideload: a.CanSideload,
PodfileLockHash: a.PodfileLockHash, 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}) respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result})
} }
+26 -2
View File
@@ -15,7 +15,7 @@ import (
) )
// NewRouter creates the HTTP router with all API routes. // 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 := chi.NewRouter()
r.Use(chimw.Logger) r.Use(chimw.Logger)
r.Use(chimw.Recoverer) 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} deviceHandler := &DeviceHandler{DB: database, Storage: store}
diagHandler := &DiagnosticsHandler{} diagHandler := &DiagnosticsHandler{}
adminHandler := &AdminHandler{DB: database} adminHandler := &AdminHandler{DB: database}
storageHandler := NewStorageHandler(store)
// Auth middleware // Auth middleware
authMw := middleware.AuthMiddleware(authService) 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) --- // --- Auth routes (no auth required) ---
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Get("/login", authHandler.LoginPage)
r.Post("/login", authHandler.LoginPage)
r.Post("/token", authHandler.Login) r.Post("/token", authHandler.Login)
r.Post("/register", authHandler.Register) r.Post("/register", authHandler.Register)
r.With(authMw).Post("/refresh", authHandler.Refresh) r.With(authMw).Post("/refresh", authHandler.Refresh)
@@ -134,7 +143,22 @@ func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Ser
// Health check // Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") 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 return r
+56
View File
@@ -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)
}
+5 -1
View File
@@ -31,7 +31,11 @@ func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return 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 { if err != nil {
http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized) http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized)
return return
+3
View File
@@ -20,6 +20,7 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
UserID int `json:"user_id"` UserID int `json:"user_id"`
Email string `json:"email"` Email string `json:"email"`
Aud string `json:"aud"`
} }
// NewService creates a new auth service. // NewService creates a new auth service.
@@ -43,9 +44,11 @@ func (s *Service) GenerateToken(userID int, email string) (string, error) {
}, },
UserID: userID, UserID: userID,
Email: email, Email: email,
Aud: "shorebird",
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token.Header["kid"] = "shorebird-self-hosted"
return token.SignedString(s.jwtSecret) return token.SignedString(s.jwtSecret)
} }
+44 -23
View File
@@ -7,34 +7,47 @@ import (
// Config holds all configuration for the Shorebird server. // Config holds all configuration for the Shorebird server.
type Config struct { type Config struct {
Server ServerConfig Server ServerConfig
Database DatabaseConfig DB DBConfig
Storage StorageConfig Storage StorageConfig
Auth AuthConfig Auth AuthConfig
Redis RedisConfig Redis RedisConfig
} }
// ServerConfig holds HTTP server configuration. // ServerConfig holds HTTP server configuration.
type ServerConfig struct { type ServerConfig struct {
Host string Host string
Port string Port string
BaseURL string // public-facing URL, used for download links
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration
} }
// DatabaseConfig holds PostgreSQL connection configuration. // DBConfig holds database configuration.
type DatabaseConfig struct { 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 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 { type StorageConfig struct {
Endpoint string // Driver is "local" or "s3". Empty defaults to "local".
AccessKeyID string Driver string
SecretAccessKey string
UseSSL bool // Local settings
ReleaseBucket string LocalDir string
PatchBucket string
// S3/MinIO settings
S3Endpoint string
S3AccessKey string
S3SecretKey string
S3UseSSL bool
S3ReleaseBucket string
S3PatchBucket string
} }
// AuthConfig holds JWT authentication configuration. // AuthConfig holds JWT authentication configuration.
@@ -52,23 +65,31 @@ type RedisConfig struct {
// Load reads configuration from environment variables with sensible defaults. // Load reads configuration from environment variables with sensible defaults.
func Load() *Config { func Load() *Config {
port := envOrDefault("SERVER_PORT", "8080")
baseURL := envOrDefault("SERVER_BASE_URL", "http://localhost:"+port)
return &Config{ return &Config{
Server: ServerConfig{ Server: ServerConfig{
Host: envOrDefault("SERVER_HOST", "0.0.0.0"), Host: envOrDefault("SERVER_HOST", "0.0.0.0"),
Port: envOrDefault("SERVER_PORT", "8080"), Port: port,
BaseURL: baseURL,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
}, },
Database: DatabaseConfig{ DB: DBConfig{
URL: envOrDefault("DATABASE_URL", "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable"), 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{ Storage: StorageConfig{
Endpoint: envOrDefault("STORAGE_ENDPOINT", "localhost:9000"), Driver: envOrDefault("STORAGE_DRIVER", "local"),
AccessKeyID: envOrDefault("STORAGE_ACCESS_KEY", "minioadmin"), LocalDir: envOrDefault("STORAGE_LOCAL_DIR", "data/storage"),
SecretAccessKey: envOrDefault("STORAGE_SECRET_KEY", "minioadmin"), S3Endpoint: envOrDefault("STORAGE_S3_ENDPOINT", "localhost:9000"),
UseSSL: false, S3AccessKey: envOrDefault("STORAGE_S3_ACCESS_KEY", "minioadmin"),
ReleaseBucket: envOrDefault("STORAGE_RELEASE_BUCKET", "shorebird-releases"), S3SecretKey: envOrDefault("STORAGE_S3_SECRET_KEY", "minioadmin"),
PatchBucket: envOrDefault("STORAGE_PATCH_BUCKET", "shorebird-patches"), 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{ Auth: AuthConfig{
JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"), JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"),
+87 -200
View File
@@ -3,7 +3,6 @@ package db
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -11,13 +10,13 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
// DB wraps the PostgreSQL connection pool with query methods. // PgStore implements Store backed by PostgreSQL.
type DB struct { type PgStore struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
// New creates a new database connection pool. // NewPostgresStore creates a new PostgreSQL-backed Store.
func New(ctx context.Context, databaseURL string) (*DB, error) { func NewPostgresStore(ctx context.Context, databaseURL string) (*PgStore, error) {
config, err := pgxpool.ParseConfig(databaseURL) config, err := pgxpool.ParseConfig(databaseURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse database URL: %w", err) 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 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. // Close closes the database connection pool.
func (db *DB) Close() { func (db *PgStore) Close() {
db.pool.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. // 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...) return db.pool.Exec(ctx, sql, args...)
} }
// QueryRow is a convenience wrapper for pool.QueryRow. // 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...) return db.pool.QueryRow(ctx, sql, args...)
} }
// Query is a convenience wrapper for pool.Query. // 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...) return db.pool.Query(ctx, sql, args...)
} }
// --- User queries --- // --- User queries ---
// CreateUser creates a new user and returns the ID. // 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 var id int
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`, `INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`,
email, name, passwordHash, email, name, passwordHash,
).Scan(&id) ).Scan(&id)
@@ -73,9 +69,9 @@ func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string)
} }
// GetUserByEmail retrieves a user by email. // 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{} row := &UserRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`, `SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`,
email, email,
).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt) ).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. // 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{} row := &UserRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`, `SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`,
id, id,
).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt) ).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 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 --- // --- Organization queries ---
// CreateOrganization creates a new organization. // 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 var id int
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`INSERT INTO organizations (name, type) VALUES ($1, $2) RETURNING id`, `INSERT INTO organizations (name, type) VALUES ($1, $2) RETURNING id`,
name, orgType, name, orgType,
).Scan(&id) ).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. // GetOrganizationsByUserID returns all organizations a user belongs to.
func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) { func (db *PgStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT o.id, o.name, o.type, om.role `SELECT o.id, o.name, o.type, om.role
FROM organizations o FROM organizations o
JOIN organization_memberships om ON o.id = om.organization_id 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 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. // AddUserToOrganization adds a user to an organization.
func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error { func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
_, err := db.Exec(ctx, _, err := db.exec(ctx,
`INSERT INTO organization_memberships (user_id, organization_id, role) `INSERT INTO organization_memberships (user_id, organization_id, role)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
userID, orgID, role) userID, orgID, role)
@@ -163,9 +143,9 @@ func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role
// --- App queries --- // --- App queries ---
// CreateApp creates a new app and returns it. // 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{} row := &AppRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`INSERT INTO apps (organization_id, display_name) VALUES ($1, $2) `INSERT INTO apps (organization_id, display_name) VALUES ($1, $2)
RETURNING id, organization_id, display_name, created_at, updated_at`, RETURNING id, organization_id, display_name, created_at, updated_at`,
orgID, displayName, 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. // GetAppsByOrganization returns all apps for an organization.
func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) { func (db *PgStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT id, organization_id, display_name, created_at, updated_at `SELECT id, organization_id, display_name, created_at, updated_at
FROM apps WHERE organization_id = $1 ORDER BY display_name`, orgID) FROM apps WHERE organization_id = $1 ORDER BY display_name`, orgID)
if err != nil { 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. // 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{} 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`, `SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = $1`,
appID, appID,
).Scan(&row.ID, &row.OrganizationID, &row.DisplayName, &row.CreatedAt, &row.UpdatedAt) ).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. // 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) id, err := uuid.Parse(appID)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid app_id: %w", err) 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. // DeleteApp deletes an app by ID.
func (db *DB) DeleteApp(ctx context.Context, appID uuid.UUID) error { func (db *PgStore) DeleteApp(ctx context.Context, appID uuid.UUID) error {
_, err := db.Exec(ctx, `DELETE FROM apps WHERE id = $1`, appID) _, err := db.exec(ctx, `DELETE FROM apps WHERE id = $1`, appID)
return err return err
} }
// GetAppsByUserID returns all apps the user has access to via org memberships. // GetAppsByUserID returns all apps the user has access to via org memberships.
func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) { func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at `SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at
FROM apps a FROM apps a
JOIN organization_memberships om ON a.organization_id = om.organization_id 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 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 --- // --- Channel queries ---
// CreateChannel creates a new channel for an app. // 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{} row := &ChannelRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`INSERT INTO channels (app_id, name) VALUES ($1, $2) `INSERT INTO channels (app_id, name) VALUES ($1, $2)
RETURNING id, app_id, name, created_at`, RETURNING id, app_id, name, created_at`,
appID, name, 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. // GetChannelsByAppID returns all channels for an app.
func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) { func (db *PgStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 ORDER BY name`, appID) `SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 ORDER BY name`, appID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -295,9 +267,9 @@ func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]Channe
} }
// GetChannelByAppIDAndName retrieves a specific channel. // 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{} 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`, `SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 AND name = $2`,
appID, name, appID, name,
).Scan(&row.ID, &row.AppID, &row.Name, &row.CreatedAt) ).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 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 --- // --- Release queries ---
// CreateRelease creates a new release. // 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{} row := &ReleaseRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name) `INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at`, 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. // 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{} 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 `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
FROM releases WHERE id = $1`, releaseID, 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) ).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. // 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{} 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 `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, 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) ).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. // 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 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` FROM releases WHERE app_id = $1 ORDER BY created_at DESC`
// Note: sideloadable filtering is done via artifact join if needed // Note: sideloadable filtering is done via artifact join if needed
_ = sideloadableOnly _ = sideloadableOnly
rows, err := db.Query(ctx, query, appID) rows, err := db.query(ctx, query, appID)
if err != nil { if err != nil {
return nil, err 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. // 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 { func (db *PgStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
_, err := db.Exec(ctx, _, err := db.exec(ctx,
`INSERT INTO release_platform_statuses (release_id, platform, status, metadata) `INSERT INTO release_platform_statuses (release_id, platform, status, metadata)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
ON CONFLICT (release_id, platform) 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. // GetReleasePlatformStatuses returns all platform statuses for a release.
func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) { func (db *PgStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT platform, status FROM release_platform_statuses WHERE release_id = $1`, releaseID) `SELECT platform, status FROM release_platform_statuses WHERE release_id = $1`, releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -413,25 +378,13 @@ func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (ma
return result, nil 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 --- // --- Release artifact queries ---
// CreateReleaseArtifact creates a release artifact record. // 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{} 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) `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) 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`, 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. // 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 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` FROM release_artifacts WHERE release_id = $1`
args := []interface{}{releaseID} args := []interface{}{releaseID}
@@ -460,7 +413,7 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat
query += " ORDER BY arch" query += " ORDER BY arch"
rows, err := db.Query(ctx, query, args...) rows, err := db.query(ctx, query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -477,35 +430,22 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat
return result, nil 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 --- // --- Patch queries ---
// GetNextPatchNumber returns the next patch number for a release. // 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 var num int
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = $1`, releaseID, `SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = $1`, releaseID,
).Scan(&num) ).Scan(&num)
return num, err return num, err
} }
// CreatePatch creates a new patch. // 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{} row := &PatchRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`INSERT INTO patches (release_id, number, notes) VALUES ($1, $2, $3) `INSERT INTO patches (release_id, number, notes) VALUES ($1, $2, $3)
RETURNING id, release_id, number, notes, created_at`, RETURNING id, release_id, number, notes, created_at`,
releaseID, number, notes, 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. // 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{} row := &PatchRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT id, release_id, number, notes, created_at FROM patches WHERE id = $1`, patchID, `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) ).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt)
if err != nil { 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). // 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{} row := &PatchRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT id, release_id, number, notes, created_at FROM patches `SELECT id, release_id, number, notes, created_at FROM patches
WHERE release_id = $1 ORDER BY number DESC LIMIT 1`, releaseID, WHERE release_id = $1 ORDER BY number DESC LIMIT 1`, releaseID,
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt) ).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. // GetPatchesByReleaseID returns all patches for a release.
func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) { func (db *PgStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT p.id, p.release_id, p.number, p.notes, p.created_at, `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 COALESCE(pc.channel_id, 0) as channel_id, COALESCE(pc.promoted_at, p.created_at) as promoted_at
FROM patches p FROM patches p
@@ -567,17 +507,17 @@ func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]Patch
} }
// PromotePatch promotes a patch to a channel. // PromotePatch promotes a patch to a channel.
func (db *DB) PromotePatch(ctx context.Context, patchID, channelID int) error { func (db *PgStore) PromotePatch(ctx context.Context, patchID, channelID int) error {
_, err := db.Exec(ctx, _, err := db.exec(ctx,
`INSERT INTO patch_channels (patch_id, channel_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, `INSERT INTO patch_channels (patch_id, channel_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
patchID, channelID) patchID, channelID)
return err return err
} }
// GetLatestPromotedPatch returns the latest patch promoted to a specific channel for a release. // 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{} row := &PatchWithArtifactRow{}
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT p.id, p.release_id, p.number, p.notes, p.created_at, `SELECT p.id, p.release_id, p.number, p.notes, p.created_at,
pa.hash, pa.storage_key, pa.hash_signature pa.hash, pa.storage_key, pa.hash_signature
FROM patches p FROM patches p
@@ -594,44 +534,12 @@ func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID i
return row, nil 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 --- // --- Patch artifact queries ---
// CreatePatchArtifact creates a patch artifact record. // 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{} 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) `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) 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`, 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 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 --- // --- Patch events ---
// InsertPatchEvent records a patch lifecycle event. // 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 { 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, _, err := db.exec(ctx,
`INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message) `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)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
appID, clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message) 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 --- // --- Rolled back patches ---
// RollbackPatch marks a patch as rolled back. // RollbackPatch marks a patch as rolled back.
func (db *DB) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error { func (db *PgStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
_, err := db.Exec(ctx, _, err := db.exec(ctx,
`INSERT INTO rolled_back_patches (release_id, patch_number) VALUES ($1, $2) ON CONFLICT DO NOTHING`, `INSERT INTO rolled_back_patches (release_id, patch_number) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
releaseID, patchNumber) releaseID, patchNumber)
return err return err
} }
// GetRolledBackPatchNumbers returns all rolled-back patch numbers for a release. // GetRolledBackPatchNumbers returns all rolled-back patch numbers for a release.
func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) { func (db *PgStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT patch_number FROM rolled_back_patches WHERE release_id = $1`, releaseID) `SELECT patch_number FROM rolled_back_patches WHERE release_id = $1`, releaseID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -701,17 +596,17 @@ func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]i
// --- Targeted device patching --- // --- Targeted device patching ---
// AddPatchTargetDevice adds a device to a patch's target list. // AddPatchTargetDevice adds a device to a patch's target list.
func (db *DB) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error { func (db *PgStore) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error {
_, err := db.Exec(ctx, _, err := db.exec(ctx,
`INSERT INTO patch_target_devices (patch_id, client_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, `INSERT INTO patch_target_devices (patch_id, client_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
patchID, clientID) patchID, clientID)
return err return err
} }
// IsPatchTargetedToDevice checks if a patch is targeted to a specific device. // 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 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`, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1 AND client_id = $2`,
patchID, clientID, patchID, clientID,
).Scan(&count) ).Scan(&count)
@@ -719,8 +614,8 @@ func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID
} }
// GetPatchTargetDevices returns all targeted devices for a patch. // GetPatchTargetDevices returns all targeted devices for a patch.
func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) { func (db *PgStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT client_id FROM patch_target_devices WHERE patch_id = $1`, patchID) `SELECT client_id FROM patch_target_devices WHERE patch_id = $1`, patchID)
if err != nil { if err != nil {
return nil, err 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. // 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 var count int
err := db.QueryRow(ctx, err := db.queryRow(ctx,
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1`, patchID, `SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1`, patchID,
).Scan(&count) ).Scan(&count)
return count > 0, err return count > 0, err
@@ -750,8 +645,8 @@ func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
// --- Admin queries --- // --- Admin queries ---
// ListAllUsers returns all registered users (admin endpoint). // ListAllUsers returns all registered users (admin endpoint).
func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) { func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
rows, err := db.Query(ctx, rows, err := db.query(ctx,
`SELECT u.id, u.email, u.name, u.created_at, `SELECT u.id, u.email, u.name, u.created_at,
COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles
FROM users u FROM users u
@@ -774,11 +669,3 @@ func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
return result, nil 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
}
+20
View File
@@ -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)
}
}
+394
View File
@@ -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
}
+254
View File
@@ -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
}
+24 -9
View File
@@ -174,9 +174,11 @@ const (
// Organization represents an organization. // Organization represents an organization.
type Organization struct { type Organization struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` 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. // OrganizationMembership represents a user's membership in an org.
@@ -233,10 +235,18 @@ type CreateReleaseArtifactRequest struct {
PodfileLockHash *string `json:"podfile_lock_hash"` PodfileLockHash *string `json:"podfile_lock_hash"`
} }
// CreateReleaseArtifactResponse returns the presigned upload URL. // CreateReleaseArtifactResponse returns the artifact metadata.
type CreateReleaseArtifactResponse struct { type CreateReleaseArtifactResponse struct {
ID int `json:"id"` ID int `json:"id"`
URL string `json:"url"` 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. // CreatePatchRequest is the POST /patches request body.
@@ -260,10 +270,15 @@ type CreatePatchArtifactRequest struct {
PodfileLockHash *string `json:"podfile_lock_hash"` PodfileLockHash *string `json:"podfile_lock_hash"`
} }
// CreatePatchArtifactResponse returns the presigned upload URL. // CreatePatchArtifactResponse returns the patch artifact metadata.
type CreatePatchArtifactResponse struct { type CreatePatchArtifactResponse struct {
ID int `json:"id"` ID int `json:"id"`
URL string `json:"url"` 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. // UpdateReleaseRequest is the PATCH /releases/{id} request body.
+37
View File
@@ -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)
}
}
+91
View File
@@ -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 <baseDir>/releases/; patch artifacts
// under <baseDir>/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 }
+79
View File
@@ -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)
}
-108
View File
@@ -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)
}
+27
View File
@@ -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)
}
+14 -7
View File
@@ -284,14 +284,22 @@ function openCreateUserModal() {
// ---- Settings ---- // ---- Settings ----
async function loadSettings() { 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'); const infoDiv = document.getElementById('serverInfo');
infoDiv.innerHTML = ` infoDiv.innerHTML = `
<div class="info-item"> <div class="info-item">
<div class="info-label">Server</div> <div class="info-label">Server Version</div>
<div class="info-value">Shorebird Self-Hosted v1.0</div> <div class="info-value">Shorebird Self-Hosted v1.0</div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">API Base</div> <div class="info-label">API Base URL</div>
<div class="info-value"><code class="code">${window.location.origin}/api/v1</code></div> <div class="info-value"><code class="code">${window.location.origin}/api/v1</code></div>
</div> </div>
<div class="info-item"> <div class="info-item">
@@ -299,12 +307,12 @@ async function loadSettings() {
<div class="info-value"><code class="code">${window.location.origin}/auth</code></div> <div class="info-value"><code class="code">${window.location.origin}/auth</code></div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">Storage</div> <div class="info-label">Storage Backend</div>
<div class="info-value">MinIO / S3-compatible</div> <div class="info-value"><span class="badge badge-info">${backendInfo.storage}</span></div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">Database</div> <div class="info-label">Database Backend</div>
<div class="info-value">PostgreSQL</div> <div class="info-value"><span class="badge badge-success">${backendInfo.database}</span></div>
</div> </div>
<div class="info-item"> <div class="info-item">
<div class="info-label">UI Version</div> <div class="info-label">UI Version</div>
@@ -312,7 +320,6 @@ async function loadSettings() {
</div> </div>
`; `;
// Show current API token
document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated'; document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated';
} }