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:
+27
-14
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 ./...
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user