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
# Copy this file to .env and adjust values as needed
# Copy this file to .env and adjust values as needed.
# Server
# --- Server ---
SERVER_HOST=0.0.0.0
SERVER_PORT=8080
# Public-facing URL (used for download links in local storage mode)
SERVER_BASE_URL=http://localhost:8080
# PostgreSQL Database
DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable
# --- Database ---
# Driver: "sqlite" (default, zero-dependency) or "postgres"
DB_DRIVER=sqlite
# SQLite path (relative to working directory; data/ is auto-created)
DB_PATH=data/shorebird.db
# PostgreSQL URL (only used when DB_DRIVER=postgres)
# DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable
# Object Storage (MinIO / S3-compatible)
STORAGE_ENDPOINT=localhost:9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_RELEASE_BUCKET=shorebird-releases
STORAGE_PATCH_BUCKET=shorebird-patches
# --- Storage ---
# Driver: "local" (default, zero-dependency) or "s3"
STORAGE_DRIVER=local
# Local storage directory (auto-created)
STORAGE_LOCAL_DIR=data/storage
# S3/MinIO settings (only used when STORAGE_DRIVER=s3)
# STORAGE_S3_ENDPOINT=localhost:9000
# STORAGE_S3_ACCESS_KEY=minioadmin
# STORAGE_S3_SECRET_KEY=minioadmin
# STORAGE_S3_USE_SSL=false
# STORAGE_S3_RELEASE_BUCKET=shorebird-releases
# STORAGE_S3_PATCH_BUCKET=shorebird-patches
# JWT Authentication
# --- Authentication ---
JWT_SECRET=change-me-in-production-use-a-long-random-string
# Redis (optional, for caching patch checks)
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
# --- Redis (optional) ---
# REDIS_ADDR=localhost:6379
# REDIS_PASSWORD=
+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
COPY --from=builder /app/shorebird-server .
COPY internal/db/migrations/ ./internal/db/migrations/
COPY --from=builder /app/web ./web
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
all: build
# Default: build and run with zero dependencies (SQLite + local storage)
all: build run
# Build the server binary
build:
go build -o bin/shorebird-server ./cmd/server
# Run the server locally
run:
go run ./cmd/server
# Run tests
test:
go test ./... -v
# Docker Compose
# Docker with full stack (PostgreSQL + MinIO)
# Uses profiles so docker compose up (no args) does nothing.
docker-up:
docker compose up -d
docker compose logs -f
docker compose --profile full up -d
docker-down:
docker compose down
docker compose --profile full down
# Database migrations
migrate-up:
goose -dir internal/db/migrations postgres "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable" up
migrate-down:
goose -dir internal/db/migrations postgres "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable" down
# Tidy dependencies
tidy:
go mod tidy
# Format code
fmt:
go fmt ./...
# Lint
lint:
golangci-lint run ./...
+40 -41
View File
@@ -2,62 +2,61 @@
A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.dev`).
## Quick Start
## Quick Start (Zero Dependencies)
By default the server uses **SQLite** + **local filesystem** — no Docker,
no Postgres, no MinIO needed.
### Prerequisites
- Go 1.23+
- Docker & Docker Compose (for PostgreSQL and MinIO)
### 1. Start infrastructure
```bash
docker compose up -d
```
### 2. Run database migrations
```bash
make migrate-up
```
Or manually:
```bash
goose -dir internal/db/migrations postgres "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable" up
```
### 3. Start the server
### 1. Build and run
```bash
make run
```
The server starts on `http://localhost:8080`.
That's it. The server starts on `http://localhost:8080`. SQLite database
and file storage are auto-created under the `data/` directory.
### 4. Open the Web Dashboard
### 2. Open the Web Dashboard
Navigate to **http://localhost:8080** in your browser. You'll see the login page where you can:
Navigate to **http://localhost:8080** → Register → Start managing apps.
- **Register** a new admin account
- **Login** with your credentials
### 3. (Optional) Use PostgreSQL + MinIO
The dashboard provides:
| Page | Features |
|------|----------|
| **Dashboard** | Overview stats (apps, patches, users, orgs), recent apps list |
| **Apps** | Create, list, and delete applications with App ID copy |
| **Users** | View all registered users and their roles |
| **Settings** | Server info, API base URL, and token display (click to copy) |
### 5. Register via API (or use the Web UI)
For production deployments that need horizontal scaling:
```bash
curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"dev@example.com","password":"securepass","name":"Developer"}'
# Start infrastructure
docker compose --profile full up -d
# Set backend overrides
export DB_DRIVER=postgres
export DATABASE_URL=postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable
export STORAGE_DRIVER=s3
export STORAGE_S3_ENDPOINT=localhost:9000
export STORAGE_S3_ACCESS_KEY=minioadmin
export STORAGE_S3_SECRET_KEY=minioadmin
export STORAGE_S3_USE_SSL=false
# Then run the server
make run
```
Save the returned `token` for subsequent API calls.
### Configuration
All settings via environment variables. See `.env.example` for the full list.
| Variable | Default | Description |
|----------|---------|-------------|
| `DB_DRIVER` | `sqlite` | `sqlite` or `postgres` |
| `DB_PATH` | `data/shorebird.db` | SQLite file path |
| `STORAGE_DRIVER` | `local` | `local` or `s3` |
| `STORAGE_LOCAL_DIR` | `data/storage` | Local storage directory |
| `JWT_SECRET` | *(insecure default)* | Set in production! |
| `SERVER_PORT` | `8080` | HTTP port |
| `SERVER_BASE_URL` | `http://localhost:8080` | Public URL for download links |
### 6. Configure Shorebird CLI
@@ -144,11 +143,11 @@ See `.env.example` for all available configuration options.
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ SQLite / PostgreSQL │ │
│ │ (apps, releases, patches, artifacts, events) │ │
│ └──────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ MinIO (S3-compatible) │ │
│ │ Local FS / MinIO (S3-compatible) │ │
│ │ shorebird-releases (private) │ │
│ │ shorebird-patches (public) │ │
│ └──────────────────────────────────────────────────┘ │
+37 -30
View File
@@ -18,40 +18,48 @@ import (
)
func main() {
// Load configuration
cfg := config.Load()
// Initialize database
ctx := context.Background()
database, err := db.New(ctx, cfg.Database.URL)
// --- Database ---
var dsn string
switch cfg.DB.Driver {
case "postgres", "postgresql", "pg":
dsn = cfg.DB.URL
default:
dsn = cfg.DB.Path
}
database, err := db.NewStore(ctx, cfg.DB.Driver, dsn)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
log.Fatalf("Failed to open database: %v", err)
}
defer database.Close()
log.Println("Connected to PostgreSQL")
log.Printf("Database: %s (%s)", cfg.DB.Driver, cfg.DB.Path)
// Initialize auth service
// --- Auth ---
authService := auth.NewService(cfg.Auth.JWTSecret, cfg.Auth.TokenDuration)
log.Println("Auth service initialized")
// Initialize storage service
store, err := storage.NewService(
cfg.Storage.Endpoint,
cfg.Storage.AccessKeyID,
cfg.Storage.SecretAccessKey,
cfg.Storage.ReleaseBucket,
cfg.Storage.PatchBucket,
cfg.Storage.UseSSL,
)
// --- Storage ---
store, err := storage.NewStore(storage.Config{
Type: cfg.Storage.Driver,
LocalDir: cfg.Storage.LocalDir,
ServerBaseURL: cfg.Server.BaseURL,
S3Endpoint: cfg.Storage.S3Endpoint,
S3AccessKey: cfg.Storage.S3AccessKey,
S3SecretKey: cfg.Storage.S3SecretKey,
S3UseSSL: cfg.Storage.S3UseSSL,
S3ReleaseBucket: cfg.Storage.S3ReleaseBucket,
S3PatchBucket: cfg.Storage.S3PatchBucket,
})
if err != nil {
log.Fatalf("Failed to initialize storage: %v", err)
}
log.Println("Storage service initialized")
log.Printf("Storage: %s", store.BackendName())
// Create router
// --- Router ---
router := handlers.NewRouter(authService, database, store)
// Configure HTTP server
// --- HTTP Server ---
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
srv := &http.Server{
Addr: addr,
@@ -61,29 +69,28 @@ func main() {
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
log.Printf("Shorebird self-hosted server starting on %s", addr)
log.Printf("API: http://%s/api/v1", addr)
log.Printf("Auth: http://%s/auth", addr)
log.Printf("Health: http://%s/health", addr)
log.Printf("Shorebird server starting on %s", addr)
log.Printf(" Dashboard: http://%s", addr)
log.Printf(" API: http://%s/api/v1", addr)
log.Printf(" Auth: http://%s/auth", addr)
log.Printf(" Storage: %s", store.BackendName())
log.Printf(" Database: %s", cfg.DB.Driver)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Wait for interrupt signal
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
log.Println("Shutting down...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
log.Fatalf("Shutdown error: %v", err)
}
log.Println("Server stopped")
}
+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"
services:
postgres:
image: postgres:16-alpine
profiles: ["full"]
environment:
POSTGRES_USER: shorebird
POSTGRES_PASSWORD: shorebird
@@ -19,6 +33,7 @@ services:
minio:
image: minio/minio:latest
profiles: ["full"]
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
@@ -36,6 +51,7 @@ services:
minio-init:
image: minio/mc:latest
profiles: ["full"]
depends_on:
minio:
condition: service_healthy
@@ -50,6 +66,7 @@ services:
redis:
image: redis:7-alpine
profiles: ["full"]
ports:
- "6379:6379"
healthcheck:
+11 -7
View File
@@ -9,29 +9,33 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1
github.com/minio/minio-go/v7 v7.0.80
github.com/pressly/goose/v3 v3.22.1
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/crypto v0.28.0
modernc.org/sqlite v1.33.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sync v0.9.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.20.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+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.
type AdminHandler struct {
DB *db.DB
DB db.Store
}
// AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices
+32 -17
View File
@@ -2,7 +2,7 @@ package handlers
import (
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/shorebird-server/internal/api/middleware"
@@ -22,7 +22,7 @@ func intPtr(i int) *int {
// UserHandler handles user-related API endpoints.
type UserHandler struct {
DB *db.DB
DB db.Store
}
// GetCurrentUser handles GET /api/v1/users/me
@@ -40,10 +40,13 @@ func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"created_at": user.CreatedAt,
"id": user.ID,
"email": user.Email,
"display_name": user.Name,
"jwt_issuer": "http://localhost:8080/auth",
"has_active_subscription": true,
"stripe_customer_id": nil,
"patch_overage_limit": nil,
})
}
@@ -80,7 +83,7 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
// AppHandler handles app-related API endpoints.
type AppHandler struct {
DB *db.DB
DB db.Store
}
// GetApps handles GET /api/v1/apps
@@ -100,12 +103,18 @@ func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) {
var result []models.AppMetadata
for _, a := range apps {
result = append(result, models.AppMetadata{
AppID: a.ID.String(),
DisplayName: a.DisplayName,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
AppID: a.ID.String(),
DisplayName: a.DisplayName,
CreatedAt: a.CreatedAt.Time,
UpdatedAt: a.UpdatedAt.Time,
Platforms: []string{},
LatestReleases: map[string]models.LatestRelease{},
PendingReleases: map[string]models.PendingRelease{},
})
}
if result == nil {
result = []models.AppMetadata{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{"apps": result})
}
@@ -154,7 +163,7 @@ func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, http.StatusCreated, map[string]interface{}{
"app_id": app.ID.String(),
"id": app.ID.String(),
"display_name": app.DisplayName,
"created_at": app.CreatedAt,
"updated_at": app.UpdatedAt,
@@ -180,7 +189,7 @@ func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) {
// ChannelHandler handles channel-related API endpoints.
type ChannelHandler struct {
DB *db.DB
DB db.Store
}
// GetChannels handles GET /api/v1/apps/{appId}/channels
@@ -240,7 +249,7 @@ func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
// OrganizationHandler handles organization-related API endpoints.
type OrganizationHandler struct {
DB *db.DB
DB db.Store
}
// GetOrganizations handles GET /api/v1/organizations
@@ -259,15 +268,21 @@ func (h *OrganizationHandler) GetOrganizations(w http.ResponseWriter, r *http.Re
var result []models.OrganizationMembership
for _, o := range orgs {
now := time.Now().UTC()
result = append(result, models.OrganizationMembership{
Organization: models.Organization{
ID: o.OrgID,
Name: o.OrgName,
Type: o.OrgType,
ID: o.OrgID,
Name: o.OrgName,
OrganizationType: o.OrgType,
CreatedAt: now,
UpdatedAt: now,
},
Role: o.Role,
})
}
if result == nil {
result = []models.OrganizationMembership{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result})
}
+197 -7
View File
@@ -1,7 +1,13 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/auth"
@@ -9,14 +15,146 @@ import (
"github.com/shorebird-server/internal/models"
)
// AuthHandler handles authentication endpoints.
// ---- OAuth auth code store ----
type authCodeStore struct {
mu sync.Mutex
codes map[string]authCodeEntry
}
type authCodeEntry struct {
email string
expiresAt time.Time
}
var globalCodeStore = &authCodeStore{codes: make(map[string]authCodeEntry)}
func (s *authCodeStore) set(email string) string {
s.mu.Lock()
defer s.mu.Unlock()
code := generateCode()
s.codes[code] = authCodeEntry{email: email, expiresAt: time.Now().Add(5 * time.Minute)}
for c, e := range s.codes {
if time.Now().After(e.expiresAt) {
delete(s.codes, c)
}
}
return code
}
func (s *authCodeStore) validate(code string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
entry, ok := s.codes[code]
if !ok || time.Now().After(entry.expiresAt) {
delete(s.codes, code)
return "", false
}
delete(s.codes, code)
return entry.email, true
}
func generateCode() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// ---- OAuth token response ----
type oauthTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// ---- AuthHandler ----
type AuthHandler struct {
DB *db.DB
DB db.Store
AuthService *auth.Service
}
// Login handles POST /auth/token
// LoginPage handles GET /auth/login — OAuth HTML login form.
func (h *AuthHandler) LoginPage(w http.ResponseWriter, r *http.Request) {
continueURL := r.URL.Query().Get("continue")
if continueURL == "" {
respondError(w, http.StatusBadRequest, "Missing continue parameter", nil)
return
}
if r.Method == http.MethodPost {
email := r.FormValue("email")
password := r.FormValue("password")
if email == "" || password == "" {
serveLoginHTML(w, continueURL, "Email and password are required.")
return
}
user, err := h.DB.GetUserByEmail(r.Context(), email)
if err != nil || auth.CheckPassword(user.PasswordHash, password) != nil {
serveLoginHTML(w, continueURL, "Invalid email or password.")
return
}
code := globalCodeStore.set(user.Email)
http.Redirect(w, r, fmt.Sprintf("%s?code=%s", continueURL, code), http.StatusFound)
return
}
serveLoginHTML(w, continueURL, "")
}
// Login handles POST /auth/token — supports OAuth form-encoded + JSON body.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
ct := r.Header.Get("Content-Type")
// --- OAuth 2.0 form-encoded ---
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
if err := r.ParseForm(); err != nil {
respondError(w, http.StatusBadRequest, "Invalid form data", nil)
return
}
grantType := r.FormValue("grant_type")
switch grantType {
case "authorization_code":
code := r.FormValue("code")
if code == "" {
respondError(w, http.StatusBadRequest, "Missing code parameter", nil)
return
}
email, valid := globalCodeStore.validate(code)
if !valid {
respondError(w, http.StatusBadRequest, "Invalid or expired authorization code", strPtr("Request a new code from /auth/login"))
return
}
user, err := h.DB.GetUserByEmail(r.Context(), email)
if err != nil {
respondError(w, http.StatusInternalServerError, "User not found", nil)
return
}
h.writeOAuthTokenResponse(w, user.ID, user.Email)
case "refresh_token":
refreshToken := r.FormValue("refresh_token")
if refreshToken == "" {
respondError(w, http.StatusBadRequest, "Missing refresh_token parameter", nil)
return
}
claims, err := h.AuthService.ValidateToken(strings.TrimPrefix(refreshToken, "sb_api_"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid refresh token", nil)
return
}
h.writeOAuthTokenResponse(w, claims.UserID, claims.Email)
default:
respondError(w, http.StatusBadRequest, "Unsupported grant_type: "+grantType, nil)
}
return
}
// --- JSON body login (Web UI + direct API) ---
var req models.AuthTokenRequest
if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
@@ -55,8 +193,6 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// Refresh handles POST /auth/refresh
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
// For simplicity, re-validate the current token and issue a new one.
// In production, you'd maintain a refresh token table.
claims := middleware.GetClaims(r)
if claims == nil {
respondError(w, http.StatusUnauthorized, "Invalid token", nil)
@@ -82,7 +218,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
})
}
// Register handles POST /auth/register (self-hosted convenience endpoint)
// Register handles POST /auth/register
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
@@ -106,7 +242,6 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
return
}
// Create a default organization for the user
orgID, err := h.DB.CreateOrganization(r.Context(), req.Name+"'s Org", "team")
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create organization", nil)
@@ -129,3 +264,58 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
Email: req.Email,
})
}
func (h *AuthHandler) writeOAuthTokenResponse(w http.ResponseWriter, userID int, email string) {
jwt, _ := h.AuthService.GenerateToken(userID, email)
refresh, _ := auth.GenerateRefreshToken()
respondJSON(w, http.StatusOK, oauthTokenResponse{
AccessToken: jwt,
RefreshToken: "sb_rt_" + refresh,
TokenType: "Bearer",
ExpiresIn: 900,
})
}
// ---- HTML login page ----
func serveLoginHTML(w http.ResponseWriter, continueURL, errorMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
errorHTML := ""
if errorMsg != "" {
errorHTML = fmt.Sprintf(`<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).
type DeviceHandler struct {
DB *db.DB
Storage *storage.Service
DB db.Store
Storage storage.Store
}
// PatchCheck handles POST /api/v1/patches/check
+11 -5
View File
@@ -14,8 +14,8 @@ import (
// PatchHandler handles patch-related API endpoints.
type PatchHandler struct {
DB *db.DB
Storage *storage.Service
DB db.Store
Storage storage.Store
}
// CreatePatch handles POST /api/v1/apps/{appId}/patches
@@ -117,14 +117,20 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques
phl = &podfileLockHash
}
_, err = h.DB.CreatePatchArtifact(r.Context(), patchID, arch, platform, hash, storageKey, size, hs, phl)
artifact, err := h.DB.CreatePatchArtifact(r.Context(), patchID, arch, platform, hash, storageKey, size, hs, phl)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create artifact record", strPtr(err.Error()))
return
}
respondJSON(w, http.StatusCreated, models.CreatePatchArtifactResponse{
URL: uploadURL,
ID: artifact.ID,
PatchID: artifact.PatchID,
Arch: artifact.Arch,
Platform: artifact.Platform,
Hash: artifact.Hash,
Size: artifact.Size,
URL: uploadURL,
})
}
@@ -157,7 +163,7 @@ func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) {
ReleaseID: p.ReleaseID,
PatchID: p.ID,
PatchNumber: p.Number,
CreatedAt: p.PromotedAt,
CreatedAt: p.PromotedAt.Time,
})
}
+25 -11
View File
@@ -8,7 +8,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/db"
"github.com/shorebird-server/internal/models"
"github.com/shorebird-server/internal/storage"
@@ -26,8 +25,8 @@ func parseIntParam(r *http.Request, name string) (int, error) {
// ReleaseHandler handles release-related API endpoints.
type ReleaseHandler struct {
DB *db.DB
Storage *storage.Service
DB db.Store
Storage storage.Store
}
// GetReleases handles GET /api/v1/apps/{appId}/releases
@@ -62,10 +61,13 @@ func (h *ReleaseHandler) GetReleases(w http.ResponseWriter, r *http.Request) {
DisplayName: rel.DisplayName,
Notes: rel.Notes,
PlatformStatuses: statuses,
CreatedAt: rel.CreatedAt,
UpdatedAt: rel.UpdatedAt,
CreatedAt: rel.CreatedAt.Time,
UpdatedAt: rel.UpdatedAt.Time,
})
}
if result == nil {
result = []models.Release{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{"releases": result})
}
@@ -100,8 +102,8 @@ func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) {
DisplayName: release.DisplayName,
Notes: release.Notes,
PlatformStatuses: map[string]string{},
CreatedAt: release.CreatedAt,
UpdatedAt: release.UpdatedAt,
CreatedAt: release.CreatedAt.Time,
UpdatedAt: release.UpdatedAt.Time,
},
})
}
@@ -184,8 +186,16 @@ func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Re
}
respondJSON(w, http.StatusCreated, models.CreateReleaseArtifactResponse{
ID: artifact.ID,
URL: uploadURL,
ID: artifact.ID,
ReleaseID: artifact.ReleaseID,
Arch: artifact.Arch,
Platform: artifact.Platform,
Hash: artifact.Hash,
Size: artifact.Size,
URL: uploadURL,
CreatedAt: artifact.CreatedAt.Time,
CanSideload: artifact.CanSideload,
PodfileLockHash: artifact.PodfileLockHash,
})
}
@@ -214,7 +224,7 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ
var result []models.ReleaseArtifact
for _, a := range artifacts {
// Build a download URL for the artifact
downloadURL := fmt.Sprintf("/storage/releases/%s", a.StorageKey)
downloadURL := fmt.Sprintf("http://localhost:8080/storage/dl/releases/%s", a.StorageKey)
result = append(result, models.ReleaseArtifact{
ID: a.ID,
ReleaseID: a.ReleaseID,
@@ -225,9 +235,13 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ
URL: downloadURL,
CanSideload: a.CanSideload,
PodfileLockHash: a.PodfileLockHash,
CreatedAt: a.CreatedAt,
CreatedAt: a.CreatedAt.Time,
})
}
if result == nil {
result = []models.ReleaseArtifact{}
}
respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result})
}
+26 -2
View File
@@ -15,7 +15,7 @@ import (
)
// NewRouter creates the HTTP router with all API routes.
func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Service) *chi.Mux {
func NewRouter(authService *authpkg.Service, database db.Store, store storage.Store) *chi.Mux {
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
@@ -41,12 +41,21 @@ func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Ser
deviceHandler := &DeviceHandler{DB: database, Storage: store}
diagHandler := &DiagnosticsHandler{}
adminHandler := &AdminHandler{DB: database}
storageHandler := NewStorageHandler(store)
// Auth middleware
authMw := middleware.AuthMiddleware(authService)
// --- Local storage upload/download (used when STORAGE_DRIVER=local) ---
r.Route("/storage", func(r chi.Router) {
r.Post("/upload/{scope}/*", storageHandler.Upload)
r.Get("/dl/{scope}/*", storageHandler.Download)
})
// --- Auth routes (no auth required) ---
r.Route("/auth", func(r chi.Router) {
r.Get("/login", authHandler.LoginPage)
r.Post("/login", authHandler.LoginPage)
r.Post("/token", authHandler.Login)
r.Post("/register", authHandler.Register)
r.With(authMw).Post("/refresh", authHandler.Refresh)
@@ -134,7 +143,22 @@ func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Ser
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
backend := map[string]string{
"storage": "local",
"database": "sqlite",
}
if _, ok := store.(interface{ BackendName() string }); ok {
backend["storage"] = store.BackendName()
}
// Determine DB backend from the store interface
switch database.(type) {
case interface{ DriverName() string }:
// If PgStore has DriverName method
backend["database"] = "postgres"
default:
backend["database"] = "sqlite"
}
w.Write([]byte(`{"status":"ok","backend":{"storage":"` + backend["storage"] + `","database":"` + backend["database"] + `"}}`))
})
return r
+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
}
claims, err := authService.ValidateToken(parts[1])
token := parts[1]
// Strip sb_api_ prefix (self-hosted API key format for Shorebird CLI compatibility)
token = strings.TrimPrefix(token, "sb_api_")
claims, err := authService.ValidateToken(token)
if err != nil {
http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized)
return
+3
View File
@@ -20,6 +20,7 @@ type Claims struct {
jwt.RegisteredClaims
UserID int `json:"user_id"`
Email string `json:"email"`
Aud string `json:"aud"`
}
// NewService creates a new auth service.
@@ -43,9 +44,11 @@ func (s *Service) GenerateToken(userID int, email string) (string, error) {
},
UserID: userID,
Email: email,
Aud: "shorebird",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token.Header["kid"] = "shorebird-self-hosted"
return token.SignedString(s.jwtSecret)
}
+44 -23
View File
@@ -7,34 +7,47 @@ import (
// Config holds all configuration for the Shorebird server.
type Config struct {
Server ServerConfig
Database DatabaseConfig
Storage StorageConfig
Auth AuthConfig
Redis RedisConfig
Server ServerConfig
DB DBConfig
Storage StorageConfig
Auth AuthConfig
Redis RedisConfig
}
// ServerConfig holds HTTP server configuration.
type ServerConfig struct {
Host string
Port string
BaseURL string // public-facing URL, used for download links
ReadTimeout time.Duration
WriteTimeout time.Duration
}
// DatabaseConfig holds PostgreSQL connection configuration.
type DatabaseConfig struct {
// DBConfig holds database configuration.
type DBConfig struct {
// Driver is "postgres" or "sqlite". Empty defaults to "sqlite".
Driver string
// URL is the PostgreSQL connection string (used when driver=postgres).
URL string
// Path is the SQLite database file path (used when driver=sqlite).
Path string
}
// StorageConfig holds object storage (S3/MinIO) configuration.
// StorageConfig holds object storage configuration.
type StorageConfig struct {
Endpoint string
AccessKeyID string
SecretAccessKey string
UseSSL bool
ReleaseBucket string
PatchBucket string
// Driver is "local" or "s3". Empty defaults to "local".
Driver string
// Local settings
LocalDir string
// S3/MinIO settings
S3Endpoint string
S3AccessKey string
S3SecretKey string
S3UseSSL bool
S3ReleaseBucket string
S3PatchBucket string
}
// AuthConfig holds JWT authentication configuration.
@@ -52,23 +65,31 @@ type RedisConfig struct {
// Load reads configuration from environment variables with sensible defaults.
func Load() *Config {
port := envOrDefault("SERVER_PORT", "8080")
baseURL := envOrDefault("SERVER_BASE_URL", "http://localhost:"+port)
return &Config{
Server: ServerConfig{
Host: envOrDefault("SERVER_HOST", "0.0.0.0"),
Port: envOrDefault("SERVER_PORT", "8080"),
Port: port,
BaseURL: baseURL,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
},
Database: DatabaseConfig{
URL: envOrDefault("DATABASE_URL", "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable"),
DB: DBConfig{
Driver: envOrDefault("DB_DRIVER", "sqlite"),
URL: envOrDefault("DATABASE_URL", "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable"),
Path: envOrDefault("DB_PATH", "data/shorebird.db"),
},
Storage: StorageConfig{
Endpoint: envOrDefault("STORAGE_ENDPOINT", "localhost:9000"),
AccessKeyID: envOrDefault("STORAGE_ACCESS_KEY", "minioadmin"),
SecretAccessKey: envOrDefault("STORAGE_SECRET_KEY", "minioadmin"),
UseSSL: false,
ReleaseBucket: envOrDefault("STORAGE_RELEASE_BUCKET", "shorebird-releases"),
PatchBucket: envOrDefault("STORAGE_PATCH_BUCKET", "shorebird-patches"),
Driver: envOrDefault("STORAGE_DRIVER", "local"),
LocalDir: envOrDefault("STORAGE_LOCAL_DIR", "data/storage"),
S3Endpoint: envOrDefault("STORAGE_S3_ENDPOINT", "localhost:9000"),
S3AccessKey: envOrDefault("STORAGE_S3_ACCESS_KEY", "minioadmin"),
S3SecretKey: envOrDefault("STORAGE_S3_SECRET_KEY", "minioadmin"),
S3UseSSL: os.Getenv("STORAGE_S3_USE_SSL") == "true",
S3ReleaseBucket: envOrDefault("STORAGE_S3_RELEASE_BUCKET", "shorebird-releases"),
S3PatchBucket: envOrDefault("STORAGE_S3_PATCH_BUCKET", "shorebird-patches"),
},
Auth: AuthConfig{
JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"),
+87 -200
View File
@@ -3,7 +3,6 @@ package db
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -11,13 +10,13 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// DB wraps the PostgreSQL connection pool with query methods.
type DB struct {
// PgStore implements Store backed by PostgreSQL.
type PgStore struct {
pool *pgxpool.Pool
}
// New creates a new database connection pool.
func New(ctx context.Context, databaseURL string) (*DB, error) {
// NewPostgresStore creates a new PostgreSQL-backed Store.
func NewPostgresStore(ctx context.Context, databaseURL string) (*PgStore, error) {
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to parse database URL: %w", err)
@@ -32,40 +31,37 @@ func New(ctx context.Context, databaseURL string) (*DB, error) {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return &DB{pool: pool}, nil
return &PgStore{pool: pool}, nil
}
// Close closes the database connection pool.
func (db *DB) Close() {
func (db *PgStore) Close() {
db.pool.Close()
}
// Pool returns the underlying connection pool.
func (db *DB) Pool() *pgxpool.Pool {
return db.pool
}
// Exec is a convenience wrapper for pool.Exec.
func (db *DB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
func (db *PgStore) exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
return db.pool.Exec(ctx, sql, args...)
}
// QueryRow is a convenience wrapper for pool.QueryRow.
func (db *DB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
func (db *PgStore) queryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
return db.pool.QueryRow(ctx, sql, args...)
}
// Query is a convenience wrapper for pool.Query.
func (db *DB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
func (db *PgStore) query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
return db.pool.Query(ctx, sql, args...)
}
// --- User queries ---
// CreateUser creates a new user and returns the ID.
func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
func (db *PgStore) CreateUser(ctx context.Context, email, name, passwordHash string) (int, error) {
var id int
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`,
email, name, passwordHash,
).Scan(&id)
@@ -73,9 +69,9 @@ func (db *DB) CreateUser(ctx context.Context, email, name, passwordHash string)
}
// GetUserByEmail retrieves a user by email.
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
func (db *PgStore) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
row := &UserRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, email, name, password_hash, created_at FROM users WHERE email = $1`,
email,
).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt)
@@ -86,9 +82,9 @@ func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error
}
// GetUserByID retrieves a user by ID.
func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
func (db *PgStore) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
row := &UserRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, email, name, password_hash, created_at FROM users WHERE id = $1`,
id,
).Scan(&row.ID, &row.Email, &row.Name, &row.PasswordHash, &row.CreatedAt)
@@ -98,21 +94,12 @@ func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
return row, nil
}
// UserRow represents a user row from the database.
type UserRow struct {
ID int
Email string
Name string
PasswordHash string
CreatedAt time.Time
}
// --- Organization queries ---
// CreateOrganization creates a new organization.
func (db *DB) CreateOrganization(ctx context.Context, name, orgType string) (int, error) {
func (db *PgStore) CreateOrganization(ctx context.Context, name, orgType string) (int, error) {
var id int
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO organizations (name, type) VALUES ($1, $2) RETURNING id`,
name, orgType,
).Scan(&id)
@@ -120,8 +107,8 @@ func (db *DB) CreateOrganization(ctx context.Context, name, orgType string) (int
}
// GetOrganizationsByUserID returns all organizations a user belongs to.
func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMembershipRow, error) {
rows, err := db.query(ctx,
`SELECT o.id, o.name, o.type, om.role
FROM organizations o
JOIN organization_memberships om ON o.id = om.organization_id
@@ -143,17 +130,10 @@ func (db *DB) GetOrganizationsByUserID(ctx context.Context, userID int) ([]OrgMe
return result, nil
}
// OrgMembershipRow represents an organization membership query result.
type OrgMembershipRow struct {
OrgID int
OrgName string
OrgType string
Role string
}
// AddUserToOrganization adds a user to an organization.
func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
_, err := db.Exec(ctx,
func (db *PgStore) AddUserToOrganization(ctx context.Context, userID, orgID int, role string) error {
_, err := db.exec(ctx,
`INSERT INTO organization_memberships (user_id, organization_id, role)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
userID, orgID, role)
@@ -163,9 +143,9 @@ func (db *DB) AddUserToOrganization(ctx context.Context, userID, orgID int, role
// --- App queries ---
// CreateApp creates a new app and returns it.
func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) {
func (db *PgStore) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) {
row := &AppRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO apps (organization_id, display_name) VALUES ($1, $2)
RETURNING id, organization_id, display_name, created_at, updated_at`,
orgID, displayName,
@@ -177,8 +157,8 @@ func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*Ap
}
// GetAppsByOrganization returns all apps for an organization.
func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, error) {
rows, err := db.query(ctx,
`SELECT id, organization_id, display_name, created_at, updated_at
FROM apps WHERE organization_id = $1 ORDER BY display_name`, orgID)
if err != nil {
@@ -198,9 +178,9 @@ func (db *DB) GetAppsByOrganization(ctx context.Context, orgID int) ([]AppRow, e
}
// GetAppByID retrieves a single app by ID.
func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) {
func (db *PgStore) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) {
row := &AppRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, organization_id, display_name, created_at, updated_at FROM apps WHERE id = $1`,
appID,
).Scan(&row.ID, &row.OrganizationID, &row.DisplayName, &row.CreatedAt, &row.UpdatedAt)
@@ -211,7 +191,7 @@ func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error)
}
// GetAppByIDString retrieves a single app by its string ID.
func (db *DB) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
func (db *PgStore) GetAppByIDString(ctx context.Context, appID string) (*AppRow, error) {
id, err := uuid.Parse(appID)
if err != nil {
return nil, fmt.Errorf("invalid app_id: %w", err)
@@ -220,14 +200,14 @@ func (db *DB) GetAppByIDString(ctx context.Context, appID string) (*AppRow, erro
}
// DeleteApp deletes an app by ID.
func (db *DB) DeleteApp(ctx context.Context, appID uuid.UUID) error {
_, err := db.Exec(ctx, `DELETE FROM apps WHERE id = $1`, appID)
func (db *PgStore) DeleteApp(ctx context.Context, appID uuid.UUID) error {
_, err := db.exec(ctx, `DELETE FROM apps WHERE id = $1`, appID)
return err
}
// GetAppsByUserID returns all apps the user has access to via org memberships.
func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error) {
rows, err := db.query(ctx,
`SELECT DISTINCT a.id, a.organization_id, a.display_name, a.created_at, a.updated_at
FROM apps a
JOIN organization_memberships om ON a.organization_id = om.organization_id
@@ -249,21 +229,13 @@ func (db *DB) GetAppsByUserID(ctx context.Context, userID int) ([]AppRow, error)
return result, nil
}
// AppRow represents an app row from the database.
type AppRow struct {
ID uuid.UUID
OrganizationID int
DisplayName string
CreatedAt time.Time
UpdatedAt time.Time
}
// --- Channel queries ---
// CreateChannel creates a new channel for an app.
func (db *DB) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
func (db *PgStore) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
row := &ChannelRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO channels (app_id, name) VALUES ($1, $2)
RETURNING id, app_id, name, created_at`,
appID, name,
@@ -275,8 +247,8 @@ func (db *DB) CreateChannel(ctx context.Context, appID uuid.UUID, name string) (
}
// GetChannelsByAppID returns all channels for an app.
func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]ChannelRow, error) {
rows, err := db.query(ctx,
`SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 ORDER BY name`, appID)
if err != nil {
return nil, err
@@ -295,9 +267,9 @@ func (db *DB) GetChannelsByAppID(ctx context.Context, appID uuid.UUID) ([]Channe
}
// GetChannelByAppIDAndName retrieves a specific channel.
func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
func (db *PgStore) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
row := &ChannelRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, app_id, name, created_at FROM channels WHERE app_id = $1 AND name = $2`,
appID, name,
).Scan(&row.ID, &row.AppID, &row.Name, &row.CreatedAt)
@@ -307,20 +279,13 @@ func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, nam
return row, nil
}
// ChannelRow represents a channel row from the database.
type ChannelRow struct {
ID int
AppID uuid.UUID
Name string
CreatedAt time.Time
}
// --- Release queries ---
// CreateRelease creates a new release.
func (db *DB) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) {
func (db *PgStore) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutterRevision string, flutterVersion, displayName *string) (*ReleaseRow, error) {
row := &ReleaseRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO releases (app_id, version, flutter_revision, flutter_version, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at`,
@@ -333,9 +298,9 @@ func (db *DB) CreateRelease(ctx context.Context, appID uuid.UUID, version, flutt
}
// GetReleaseByID retrieves a release by ID.
func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
func (db *PgStore) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
row := &ReleaseRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
FROM releases WHERE id = $1`, releaseID,
).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt)
@@ -346,9 +311,9 @@ func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, e
}
// GetReleaseByAppIDAndVersion retrieves a release by app and version.
func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) {
func (db *PgStore) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) {
row := &ReleaseRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
FROM releases WHERE app_id = $1 AND version = $2`, appID, version,
).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt)
@@ -359,13 +324,13 @@ func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID,
}
// GetReleasesByAppID returns all releases for an app.
func (db *DB) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
func (db *PgStore) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloadableOnly bool) ([]ReleaseRow, error) {
query := `SELECT id, app_id, version, flutter_revision, flutter_version, display_name, notes, created_at, updated_at
FROM releases WHERE app_id = $1 ORDER BY created_at DESC`
// Note: sideloadable filtering is done via artifact join if needed
_ = sideloadableOnly
rows, err := db.Query(ctx, query, appID)
rows, err := db.query(ctx, query, appID)
if err != nil {
return nil, err
}
@@ -383,8 +348,8 @@ func (db *DB) GetReleasesByAppID(ctx context.Context, appID uuid.UUID, sideloada
}
// UpdateReleasePlatformStatus upserts a platform status for a release.
func (db *DB) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
_, err := db.Exec(ctx,
func (db *PgStore) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, platform, status string, metadata map[string]interface{}) error {
_, err := db.exec(ctx,
`INSERT INTO release_platform_statuses (release_id, platform, status, metadata)
VALUES ($1, $2, $3, $4)
ON CONFLICT (release_id, platform)
@@ -394,8 +359,8 @@ func (db *DB) UpdateReleasePlatformStatus(ctx context.Context, releaseID int, pl
}
// GetReleasePlatformStatuses returns all platform statuses for a release.
func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (map[string]string, error) {
rows, err := db.query(ctx,
`SELECT platform, status FROM release_platform_statuses WHERE release_id = $1`, releaseID)
if err != nil {
return nil, err
@@ -413,25 +378,13 @@ func (db *DB) GetReleasePlatformStatuses(ctx context.Context, releaseID int) (ma
return result, nil
}
// ReleaseRow represents a release row from the database.
type ReleaseRow struct {
ID int
AppID uuid.UUID
Version string
FlutterRevision string
FlutterVersion *string
DisplayName *string
Notes *string
CreatedAt time.Time
UpdatedAt time.Time
}
// --- Release artifact queries ---
// CreateReleaseArtifact creates a release artifact record.
func (db *DB) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) {
func (db *PgStore) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, platform, hash, storageKey string, size int64, canSideload bool, podfileLockHash *string) (*ArtifactRow, error) {
row := &ArtifactRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO release_artifacts (release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at`,
@@ -444,7 +397,7 @@ func (db *DB) CreateReleaseArtifact(ctx context.Context, releaseID int, arch, pl
}
// GetReleaseArtifacts returns artifacts for a release, optionally filtered.
func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) {
func (db *PgStore) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, platform *string) ([]ArtifactRow, error) {
query := `SELECT id, release_id, arch, platform, hash, size, storage_key, can_sideload, podfile_lock_hash, created_at
FROM release_artifacts WHERE release_id = $1`
args := []interface{}{releaseID}
@@ -460,7 +413,7 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat
query += " ORDER BY arch"
rows, err := db.Query(ctx, query, args...)
rows, err := db.query(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -477,35 +430,22 @@ func (db *DB) GetReleaseArtifacts(ctx context.Context, releaseID int, arch, plat
return result, nil
}
// ArtifactRow represents a release artifact row.
type ArtifactRow struct {
ID int
ReleaseID int
Arch string
Platform string
Hash string
Size int64
StorageKey string
CanSideload bool
PodfileLockHash *string
CreatedAt time.Time
}
// --- Patch queries ---
// GetNextPatchNumber returns the next patch number for a release.
func (db *DB) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) {
func (db *PgStore) GetNextPatchNumber(ctx context.Context, releaseID int) (int, error) {
var num int
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT COALESCE(MAX(number), 0) + 1 FROM patches WHERE release_id = $1`, releaseID,
).Scan(&num)
return num, err
}
// CreatePatch creates a new patch.
func (db *DB) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) {
func (db *PgStore) CreatePatch(ctx context.Context, releaseID int, number int, notes *string) (*PatchRow, error) {
row := &PatchRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO patches (release_id, number, notes) VALUES ($1, $2, $3)
RETURNING id, release_id, number, notes, created_at`,
releaseID, number, notes,
@@ -517,9 +457,9 @@ func (db *DB) CreatePatch(ctx context.Context, releaseID int, number int, notes
}
// GetPatchByID retrieves a patch by ID.
func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
func (db *PgStore) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
row := &PatchRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, release_id, number, notes, created_at FROM patches WHERE id = $1`, patchID,
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt)
if err != nil {
@@ -529,9 +469,9 @@ func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error)
}
// GetLatestPatchForRelease returns the latest patch for a release (by number).
func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
func (db *PgStore) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
row := &PatchRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT id, release_id, number, notes, created_at FROM patches
WHERE release_id = $1 ORDER BY number DESC LIMIT 1`, releaseID,
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt)
@@ -542,8 +482,8 @@ func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*Pat
}
// GetPatchesByReleaseID returns all patches for a release.
func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]PatchWithChannelRow, error) {
rows, err := db.query(ctx,
`SELECT p.id, p.release_id, p.number, p.notes, p.created_at,
COALESCE(pc.channel_id, 0) as channel_id, COALESCE(pc.promoted_at, p.created_at) as promoted_at
FROM patches p
@@ -567,17 +507,17 @@ func (db *DB) GetPatchesByReleaseID(ctx context.Context, releaseID int) ([]Patch
}
// PromotePatch promotes a patch to a channel.
func (db *DB) PromotePatch(ctx context.Context, patchID, channelID int) error {
_, err := db.Exec(ctx,
func (db *PgStore) PromotePatch(ctx context.Context, patchID, channelID int) error {
_, err := db.exec(ctx,
`INSERT INTO patch_channels (patch_id, channel_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
patchID, channelID)
return err
}
// GetLatestPromotedPatch returns the latest patch promoted to a specific channel for a release.
func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) {
func (db *PgStore) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID int) (*PatchWithArtifactRow, error) {
row := &PatchWithArtifactRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT p.id, p.release_id, p.number, p.notes, p.created_at,
pa.hash, pa.storage_key, pa.hash_signature
FROM patches p
@@ -594,44 +534,12 @@ func (db *DB) GetLatestPromotedPatch(ctx context.Context, releaseID, channelID i
return row, nil
}
// PatchRow represents a patch row.
type PatchRow struct {
ID int
ReleaseID int
Number int
Notes *string
CreatedAt time.Time
}
// PatchWithChannelRow includes channel promotion info.
type PatchWithChannelRow struct {
ID int
ReleaseID int
Number int
Notes *string
CreatedAt time.Time
ChannelID int
PromotedAt time.Time
}
// PatchWithArtifactRow includes artifact info for patch check.
type PatchWithArtifactRow struct {
ID int
ReleaseID int
Number int
Notes *string
CreatedAt time.Time
Hash string
StorageKey string
HashSignature *string
}
// --- Patch artifact queries ---
// CreatePatchArtifact creates a patch artifact record.
func (db *DB) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) {
func (db *PgStore) CreatePatchArtifact(ctx context.Context, patchID int, arch, platform, hash, storageKey string, size int64, hashSignature, podfileLockHash *string) (*PatchArtifactRow, error) {
row := &PatchArtifactRow{}
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`INSERT INTO patch_artifacts (patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, patch_id, arch, platform, hash, size, storage_key, hash_signature, podfile_lock_hash, created_at`,
@@ -643,25 +551,12 @@ func (db *DB) CreatePatchArtifact(ctx context.Context, patchID int, arch, platfo
return row, nil
}
// PatchArtifactRow represents a patch artifact row.
type PatchArtifactRow struct {
ID int
PatchID int
Arch string
Platform string
Hash string
Size int64
StorageKey string
HashSignature *string
PodfileLockHash *string
CreatedAt time.Time
}
// --- Patch events ---
// InsertPatchEvent records a patch lifecycle event.
func (db *DB) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error {
_, err := db.Exec(ctx,
func (db *PgStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error {
_, err := db.exec(ctx,
`INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
appID, clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message)
@@ -671,16 +566,16 @@ func (db *DB) InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, a
// --- Rolled back patches ---
// RollbackPatch marks a patch as rolled back.
func (db *DB) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
_, err := db.Exec(ctx,
func (db *PgStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
_, err := db.exec(ctx,
`INSERT INTO rolled_back_patches (release_id, patch_number) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
releaseID, patchNumber)
return err
}
// GetRolledBackPatchNumbers returns all rolled-back patch numbers for a release.
func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]int, error) {
rows, err := db.query(ctx,
`SELECT patch_number FROM rolled_back_patches WHERE release_id = $1`, releaseID)
if err != nil {
return nil, err
@@ -701,17 +596,17 @@ func (db *DB) GetRolledBackPatchNumbers(ctx context.Context, releaseID int) ([]i
// --- Targeted device patching ---
// AddPatchTargetDevice adds a device to a patch's target list.
func (db *DB) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error {
_, err := db.Exec(ctx,
func (db *PgStore) AddPatchTargetDevice(ctx context.Context, patchID int, clientID string) error {
_, err := db.exec(ctx,
`INSERT INTO patch_target_devices (patch_id, client_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
patchID, clientID)
return err
}
// IsPatchTargetedToDevice checks if a patch is targeted to a specific device.
func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) {
func (db *PgStore) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID string) (bool, error) {
var count int
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1 AND client_id = $2`,
patchID, clientID,
).Scan(&count)
@@ -719,8 +614,8 @@ func (db *DB) IsPatchTargetedToDevice(ctx context.Context, patchID int, clientID
}
// GetPatchTargetDevices returns all targeted devices for a patch.
func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) {
rows, err := db.Query(ctx,
func (db *PgStore) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string, error) {
rows, err := db.query(ctx,
`SELECT client_id FROM patch_target_devices WHERE patch_id = $1`, patchID)
if err != nil {
return nil, err
@@ -739,9 +634,9 @@ func (db *DB) GetPatchTargetDevices(ctx context.Context, patchID int) ([]string,
}
// HasTargetDevices checks if a patch has any targeted device restrictions.
func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
func (db *PgStore) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
var count int
err := db.QueryRow(ctx,
err := db.queryRow(ctx,
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1`, patchID,
).Scan(&count)
return count > 0, err
@@ -750,8 +645,8 @@ func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
// --- Admin queries ---
// ListAllUsers returns all registered users (admin endpoint).
func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
rows, err := db.Query(ctx,
func (db *PgStore) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
rows, err := db.query(ctx,
`SELECT u.id, u.email, u.name, u.created_at,
COALESCE(string_agg(DISTINCT om.role, ', '), '') as roles
FROM users u
@@ -774,11 +669,3 @@ func (db *DB) ListAllUsers(ctx context.Context) ([]UserListRow, error) {
return result, nil
}
// UserListRow represents a user with role info for admin listing.
type UserListRow struct {
ID int
Email string
Name string
CreatedAt time.Time
Role string
}
+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.
type Organization struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
ID int `json:"id"`
Name string `json:"name"`
OrganizationType string `json:"organization_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// OrganizationMembership represents a user's membership in an org.
@@ -233,10 +235,18 @@ type CreateReleaseArtifactRequest struct {
PodfileLockHash *string `json:"podfile_lock_hash"`
}
// CreateReleaseArtifactResponse returns the presigned upload URL.
// CreateReleaseArtifactResponse returns the artifact metadata.
type CreateReleaseArtifactResponse struct {
ID int `json:"id"`
URL string `json:"url"`
ID int `json:"id"`
ReleaseID int `json:"release_id"`
Arch string `json:"arch"`
Platform string `json:"platform"`
Hash string `json:"hash"`
Size int64 `json:"size"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
CanSideload bool `json:"can_sideload"`
PodfileLockHash *string `json:"podfile_lock_hash"`
}
// CreatePatchRequest is the POST /patches request body.
@@ -260,10 +270,15 @@ type CreatePatchArtifactRequest struct {
PodfileLockHash *string `json:"podfile_lock_hash"`
}
// CreatePatchArtifactResponse returns the presigned upload URL.
// CreatePatchArtifactResponse returns the patch artifact metadata.
type CreatePatchArtifactResponse struct {
ID int `json:"id"`
URL string `json:"url"`
ID int `json:"id"`
PatchID int `json:"patch_id"`
Arch string `json:"arch"`
Platform string `json:"platform"`
Hash string `json:"hash"`
Size int64 `json:"size"`
URL string `json:"url"`
}
// UpdateReleaseRequest is the PATCH /releases/{id} request body.
+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 ----
async function loadSettings() {
// Try to get server info from health endpoint
let backendInfo = { storage: 'Unknown', database: 'Unknown' };
try {
const resp = await fetch('/health');
const data = await resp.json();
if (data.backend) backendInfo = data.backend;
} catch (e) { /* use defaults */ }
const infoDiv = document.getElementById('serverInfo');
infoDiv.innerHTML = `
<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>
<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>
<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>
<div class="info-item">
<div class="info-label">Storage</div>
<div class="info-value">MinIO / S3-compatible</div>
<div class="info-label">Storage Backend</div>
<div class="info-value"><span class="badge badge-info">${backendInfo.storage}</span></div>
</div>
<div class="info-item">
<div class="info-label">Database</div>
<div class="info-value">PostgreSQL</div>
<div class="info-label">Database Backend</div>
<div class="info-value"><span class="badge badge-success">${backendInfo.database}</span></div>
</div>
<div class="info-item">
<div class="info-label">UI Version</div>
@@ -312,7 +320,6 @@ async function loadSettings() {
</div>
`;
// Show current API token
document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated';
}