Initial commit
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Shorebird Self-Hosted Server Configuration
|
||||
# Copy this file to .env and adjust values as needed
|
||||
|
||||
# Server
|
||||
SERVER_HOST=0.0.0.0
|
||||
SERVER_PORT=8080
|
||||
|
||||
# PostgreSQL Database
|
||||
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
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=change-me-in-production-use-a-long-random-string
|
||||
|
||||
# Redis (optional, for caching patch checks)
|
||||
REDIS_ADDR=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
# Copy go mod files first for caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o shorebird-server ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.20
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/shorebird-server .
|
||||
COPY internal/db/migrations/ ./internal/db/migrations/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./shorebird-server"]
|
||||
@@ -0,0 +1,43 @@
|
||||
.PHONY: run build test migrate-up migrate-down docker-up docker-down
|
||||
|
||||
# Default target
|
||||
all: build
|
||||
|
||||
# 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-up:
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
|
||||
docker-down:
|
||||
docker compose 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 ./...
|
||||
@@ -0,0 +1,170 @@
|
||||
# Shorebird Self-Hosted Server
|
||||
|
||||
A self-hosted replacement for the Shorebird CodePush API server (`api.shorebird.dev`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:8080`.
|
||||
|
||||
### 4. Open the Web Dashboard
|
||||
|
||||
Navigate to **http://localhost:8080** in your browser. You'll see the login page where you can:
|
||||
|
||||
- **Register** a new admin account
|
||||
- **Login** with your credentials
|
||||
|
||||
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)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"dev@example.com","password":"securepass","name":"Developer"}'
|
||||
```
|
||||
|
||||
Save the returned `token` for subsequent API calls.
|
||||
|
||||
### 6. Configure Shorebird CLI
|
||||
|
||||
```bash
|
||||
export SHOREBIRD_HOSTED_URL=http://localhost:8080
|
||||
export AUTH_SERVICE_URL=http://localhost:8080/auth
|
||||
export SHOREBIRD_TOKEN=<your-jwt-token>
|
||||
```
|
||||
|
||||
Then use `shorebird init`, `shorebird release`, and `shorebird patch` as normal.
|
||||
|
||||
### 7. Configure device-side `shorebird.yaml`
|
||||
|
||||
Add to your Flutter app's `shorebird.yaml`:
|
||||
```yaml
|
||||
app_id: <your-app-uuid>
|
||||
base_url: http://your-server.com:8080
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth (public)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/auth/register` | Register a new user |
|
||||
| POST | `/auth/token` | Login (get JWT) |
|
||||
| POST | `/auth/refresh` | Refresh JWT |
|
||||
|
||||
### API v1 (JWT required unless noted)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/users/me` | Get current user |
|
||||
| POST | `/api/v1/users` | Create user |
|
||||
| GET | `/api/v1/apps` | List apps |
|
||||
| POST | `/api/v1/apps` | Create app |
|
||||
| DELETE | `/api/v1/apps/{appId}` | Delete app |
|
||||
| POST | `/api/v1/apps/{appId}/channels` | Create channel |
|
||||
| GET | `/api/v1/apps/{appId}/channels` | List channels |
|
||||
| POST | `/api/v1/apps/{appId}/releases` | Create release |
|
||||
| GET | `/api/v1/apps/{appId}/releases` | List releases |
|
||||
| PATCH | `/api/v1/apps/{appId}/releases/{releaseId}` | Update release |
|
||||
| POST | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Upload release artifact |
|
||||
| GET | `/api/v1/apps/{appId}/releases/{releaseId}/artifacts` | Get release artifacts |
|
||||
| POST | `/api/v1/apps/{appId}/patches` | Create patch |
|
||||
| POST | `/api/v1/apps/{appId}/patches/{patchId}/artifacts` | Upload patch artifact |
|
||||
| GET | `/api/v1/apps/{appId}/releases/{releaseId}/patches` | List patches |
|
||||
| POST | `/api/v1/apps/{appId}/patches/promote` | Promote patch to channel |
|
||||
| GET | `/api/v1/organizations` | List organizations |
|
||||
| **POST** | **`/api/v1/patches/check`** | **Device patch check (public)** |
|
||||
| **POST** | **`/api/v1/patches/events`** | **Device patch event (public)** |
|
||||
| GET | `/api/v1/diagnostics/gcp_upload` | Speed test (stub) |
|
||||
| GET | `/api/v1/diagnostics/gcp_download` | Speed test (stub) |
|
||||
|
||||
### Admin: Targeted Device Patching
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/v1/admin/patches/{patchId}/target-devices` | Restrict patch to device(s) |
|
||||
| GET | `/api/v1/admin/patches/{patchId}/target-devices` | List targeted devices |
|
||||
| DELETE | `/api/v1/admin/patches/{patchId}/target-devices/{clientId}` | Remove device restriction |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all available configuration options.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Developer Machine │
|
||||
│ ┌──────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ Shorebird │ │Artifact │ │ Flutter (forked)│ │
|
||||
│ │ CLI │ │Proxy (open) │ │ │ │
|
||||
│ └────┬─────┘ └──────┬──────┘ └─────────────────┘ │
|
||||
│ │ │ │
|
||||
└───────┼───────────────┼───────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Self-Hosted Server (Go) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌────────────────────┐ │
|
||||
│ │ Auth API │ │CodePush │ │ Admin API │ │
|
||||
│ │ /auth/* │ │API /api/*│ │ /api/v1/admin/* │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └─────────┬──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ PostgreSQL │ │
|
||||
│ │ (apps, releases, patches, artifacts, events) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ MinIO (S3-compatible) │ │
|
||||
│ │ shorebird-releases (private) │ │
|
||||
│ │ shorebird-patches (public) │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ (patch check + download)
|
||||
│
|
||||
┌───────┴───────────────────────────────────────────────┐
|
||||
│ End-User Device │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ Shorebird Updater (Rust, embedded in app) │ │
|
||||
│ │ POST /api/v1/patches/check → download patch │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT / Apache 2.0 (matching Shorebird's licensing)
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shorebird-server/internal/api/handlers"
|
||||
"github.com/shorebird-server/internal/auth"
|
||||
"github.com/shorebird-server/internal/config"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize database
|
||||
ctx := context.Background()
|
||||
database, err := db.New(ctx, cfg.Database.URL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
log.Println("Connected to PostgreSQL")
|
||||
|
||||
// Initialize auth service
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
log.Println("Storage service initialized")
|
||||
|
||||
// Create router
|
||||
router := handlers.NewRouter(authService, database, store)
|
||||
|
||||
// Configure HTTP server
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
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)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
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.Println("Server stopped")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: shorebird
|
||||
POSTGRES_PASSWORD: shorebird
|
||||
POSTGRES_DB: shorebird
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shorebird"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing local/shorebird-releases;
|
||||
mc mb --ignore-existing local/shorebird-patches;
|
||||
mc anonymous set public local/shorebird-patches;
|
||||
echo 'MinIO buckets created';
|
||||
"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
miniodata:
|
||||
@@ -0,0 +1,37 @@
|
||||
module github.com/shorebird-server
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
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
|
||||
)
|
||||
|
||||
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/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/minio/md5-simd v1.1.2 // 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
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/shorebird-server/internal/api/middleware"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin/management API endpoints.
|
||||
type AdminHandler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
// AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices
|
||||
// Restricts a patch to only be delivered to specific devices (by client_id).
|
||||
func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.AddPatchTargetDevice(r.Context(), patchID, req.ClientID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to add target device", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RemoveTargetDevice handles DELETE /api/v1/admin/patches/{patchId}/target-devices/{clientId}
|
||||
func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
// For simplicity, target device removal is handled by the database cascade
|
||||
// In a full implementation, add a RemovePatchTargetDevice DB method
|
||||
respondError(w, http.StatusNotImplemented, "Not yet implemented", nil)
|
||||
}
|
||||
|
||||
// GetTargetDevices handles GET /api/v1/admin/patches/{patchId}/target-devices
|
||||
func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request) {
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := h.DB.GetPatchTargetDevices(r.Context(), patchID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch target devices", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"patch_id": patchID,
|
||||
"client_ids": devices,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPatchEvents handles GET /api/v1/admin/apps/{appId}/events
|
||||
// Returns patch events for analytics.
|
||||
func (h *AdminHandler) GetPatchEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// This would query patch_events table for the app
|
||||
// For now, return a stub
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"events": []map[string]interface{}{},
|
||||
})
|
||||
}
|
||||
|
||||
// ListUsers handles GET /api/v1/admin/users (admin convenience endpoint)
|
||||
func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := h.DB.ListAllUsers(r.Context())
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Admin endpoints available",
|
||||
"user": claims.Email,
|
||||
"users": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type userEntry struct {
|
||||
ID int `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
result := make([]userEntry, 0, len(users))
|
||||
for _, u := range users {
|
||||
role := u.Role
|
||||
if role == "" {
|
||||
role = "member"
|
||||
}
|
||||
result = append(result, userEntry{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
Role: role,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"users": result,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/shorebird-server/internal/api/middleware"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/models"
|
||||
)
|
||||
|
||||
// strPtr returns a pointer to a string.
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// intPtr returns a pointer to an int.
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
// UserHandler handles user-related API endpoints.
|
||||
type UserHandler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
// GetCurrentUser handles GET /api/v1/users/me
|
||||
func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.DB.GetUserByID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "User not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"created_at": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateUser handles POST /api/v1/users
|
||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateUserRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// In the Shorebird protocol, user creation during login flow
|
||||
// is handled by the auth server. For API completeness, this
|
||||
// endpoint updates the user's name if they exist.
|
||||
user, err := h.DB.GetUserByEmail(r.Context(), claims.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "User not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"created_at": user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// AppHandler handles app-related API endpoints.
|
||||
type AppHandler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
// GetApps handles GET /api/v1/apps
|
||||
func (h *AppHandler) GetApps(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
apps, err := h.DB.GetAppsByUserID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch apps", nil)
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"apps": result})
|
||||
}
|
||||
|
||||
// CreateApp handles POST /api/v1/apps
|
||||
func (h *AppHandler) CreateApp(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateAppRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.OrganizationID == 0 {
|
||||
// Default to user's first organization
|
||||
orgs, err := h.DB.GetOrganizationsByUserID(r.Context(), claims.UserID)
|
||||
if err != nil || len(orgs) == 0 {
|
||||
// Create a default org
|
||||
orgID, err := h.DB.CreateOrganization(r.Context(), claims.Email+"'s Org", "team")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create organization", nil)
|
||||
return
|
||||
}
|
||||
h.DB.AddUserToOrganization(r.Context(), claims.UserID, orgID, "admin")
|
||||
req.OrganizationID = orgID
|
||||
} else {
|
||||
req.OrganizationID = orgs[0].OrgID
|
||||
}
|
||||
}
|
||||
|
||||
app, err := h.DB.CreateApp(r.Context(), req.OrganizationID, req.DisplayName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create app", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-create default "stable" channel
|
||||
_, err = h.DB.CreateChannel(r.Context(), app.ID, "stable")
|
||||
if err != nil {
|
||||
// Log but don't fail; channel creation is best-effort for compat
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"app_id": app.ID.String(),
|
||||
"display_name": app.DisplayName,
|
||||
"created_at": app.CreatedAt,
|
||||
"updated_at": app.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteApp handles DELETE /api/v1/apps/{appId}
|
||||
func (h *AppHandler) DeleteApp(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
id, err := parseUUID(appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.DeleteApp(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to delete app", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ChannelHandler handles channel-related API endpoints.
|
||||
type ChannelHandler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
// GetChannels handles GET /api/v1/apps/{appId}/channels
|
||||
func (h *ChannelHandler) GetChannels(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
id, err := parseUUID(appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := h.DB.GetChannelsByAppID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch channels", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var result []models.Channel
|
||||
for _, c := range channels {
|
||||
result = append(result, models.Channel{
|
||||
ID: c.ID,
|
||||
AppID: c.AppID,
|
||||
Name: c.Name,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// CreateChannel handles POST /api/v1/apps/{appId}/channels
|
||||
func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
id, err := parseUUID(appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := h.DB.CreateChannel(r.Context(), id, req.Channel)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusConflict, "Channel already exists", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, models.Channel{
|
||||
ID: channel.ID,
|
||||
AppID: channel.AppID,
|
||||
Name: channel.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// OrganizationHandler handles organization-related API endpoints.
|
||||
type OrganizationHandler struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
// GetOrganizations handles GET /api/v1/organizations
|
||||
func (h *OrganizationHandler) GetOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
|
||||
orgs, err := h.DB.GetOrganizationsByUserID(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch organizations", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var result []models.OrganizationMembership
|
||||
for _, o := range orgs {
|
||||
result = append(result, models.OrganizationMembership{
|
||||
Organization: models.Organization{
|
||||
ID: o.OrgID,
|
||||
Name: o.OrgName,
|
||||
Type: o.OrgType,
|
||||
},
|
||||
Role: o.Role,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"organizations": result})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/shorebird-server/internal/api/middleware"
|
||||
"github.com/shorebird-server/internal/auth"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/models"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints.
|
||||
type AuthHandler struct {
|
||||
DB *db.DB
|
||||
AuthService *auth.Service
|
||||
}
|
||||
|
||||
// Login handles POST /auth/token
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.AuthTokenRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.DB.GetUserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Invalid email or password", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Invalid email or password", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.AuthService.GenerateToken(user.ID, user.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate refresh token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, models.AuthTokenResponse{
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
Email: user.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.AuthService.GenerateToken(claims.UserID, claims.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate refresh token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, models.AuthTokenResponse{
|
||||
Token: token,
|
||||
RefreshToken: refreshToken,
|
||||
Email: claims.Email,
|
||||
})
|
||||
}
|
||||
|
||||
// Register handles POST /auth/register (self-hosted convenience endpoint)
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to hash password", nil)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.DB.CreateUser(r.Context(), req.Email, req.Name, passwordHash)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusConflict, "User already exists", strPtr(err.Error()))
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.AddUserToOrganization(r.Context(), userID, orgID, "admin"); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to add user to organization", nil)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.AuthService.GenerateToken(userID, req.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, models.AuthTokenResponse{
|
||||
Token: token,
|
||||
Email: req.Email,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/models"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
)
|
||||
|
||||
// DeviceHandler handles device-facing API endpoints (no auth required).
|
||||
type DeviceHandler struct {
|
||||
DB *db.DB
|
||||
Storage *storage.Service
|
||||
}
|
||||
|
||||
// PatchCheck handles POST /api/v1/patches/check
|
||||
// This is THE critical endpoint called by the device-side updater library.
|
||||
// No authentication required.
|
||||
func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.PatchCheckRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse app ID
|
||||
appID, err := uuid.Parse(req.AppID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app_id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify app exists
|
||||
_, err = h.DB.GetAppByID(r.Context(), appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "App not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the release by app and version
|
||||
release, err := h.DB.GetReleaseByAppIDAndVersion(r.Context(), appID, req.ReleaseVersion)
|
||||
if err != nil {
|
||||
// Release not found, no patch available
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: false,
|
||||
RolledBackPatchNumbers: []int{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get rolled back patches
|
||||
rolledBack, err := h.DB.GetRolledBackPatchNumbers(r.Context(), release.ID)
|
||||
if err != nil {
|
||||
rolledBack = []int{}
|
||||
}
|
||||
|
||||
// Find the channel (default to "stable")
|
||||
channelName := req.Channel
|
||||
if channelName == "" {
|
||||
channelName = "stable"
|
||||
}
|
||||
|
||||
channel, err := h.DB.GetChannelByAppIDAndName(r.Context(), appID, channelName)
|
||||
if err != nil {
|
||||
// Channel not found, no patch available
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: false,
|
||||
RolledBackPatchNumbers: rolledBack,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find the latest promoted patch for this release+channel
|
||||
patch, err := h.DB.GetLatestPromotedPatch(r.Context(), release.ID, channel.ID)
|
||||
if err != nil {
|
||||
// No patch available
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: false,
|
||||
RolledBackPatchNumbers: rolledBack,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// If the device already has this or a newer patch, no update needed
|
||||
if req.CurrentPatchNumber != nil && *req.CurrentPatchNumber >= patch.Number {
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: false,
|
||||
RolledBackPatchNumbers: rolledBack,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Also check legacy patch_number field
|
||||
if req.PatchNumber != nil && *req.PatchNumber >= patch.Number {
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: false,
|
||||
RolledBackPatchNumbers: rolledBack,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this patch has targeted device restrictions
|
||||
if req.ClientID != nil {
|
||||
hasTargets, err := h.DB.HasTargetDevices(r.Context(), patch.ID)
|
||||
if err == nil && hasTargets {
|
||||
isTargeted, err := h.DB.IsPatchTargetedToDevice(r.Context(), patch.ID, *req.ClientID)
|
||||
if err != nil || !isTargeted {
|
||||
// Patch is restricted but this device is not in the allowlist
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: false,
|
||||
RolledBackPatchNumbers: rolledBack,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the public download URL for the patch artifact
|
||||
downloadURL, err := h.Storage.GeneratePublicDownloadURL(r.Context(), patch.StorageKey)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate download URL", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, models.PatchCheckResponse{
|
||||
PatchAvailable: true,
|
||||
Patch: &models.PatchCheckMetadata{
|
||||
Number: patch.Number,
|
||||
DownloadURL: downloadURL,
|
||||
Hash: patch.Hash,
|
||||
HashSignature: patch.HashSignature,
|
||||
},
|
||||
RolledBackPatchNumbers: rolledBack,
|
||||
})
|
||||
}
|
||||
|
||||
// PatchEvents handles POST /api/v1/patches/events
|
||||
// Records patch install success/failure events from devices.
|
||||
// No authentication required.
|
||||
func (h *DeviceHandler) PatchEvents(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreatePatchEventRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
appID, err := uuid.Parse(req.Event.AppID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app_id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate event type
|
||||
eventType := req.Event.Type
|
||||
if eventType != models.EventPatchInstallSuccess && eventType != models.EventPatchInstallFailure {
|
||||
respondError(w, http.StatusBadRequest, "Invalid event type", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.InsertPatchEvent(r.Context(),
|
||||
appID,
|
||||
req.Event.ClientID,
|
||||
req.Event.Arch,
|
||||
req.Event.Platform,
|
||||
req.Event.ReleaseVersion,
|
||||
eventType,
|
||||
req.Event.PatchNumber,
|
||||
req.Event.Timestamp,
|
||||
req.Event.Message,
|
||||
); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to record event", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RollbackPatch handles POST /api/v1/apps/{appId}/patches/rollback (admin endpoint)
|
||||
func (h *DeviceHandler) RollbackPatch(w http.ResponseWriter, r *http.Request) {
|
||||
// This is an admin operation. For now, allow authenticated users.
|
||||
var req struct {
|
||||
ReleaseID int `json:"release_id"`
|
||||
PatchNumber int `json:"patch_number"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.RollbackPatch(r.Context(), req.ReleaseID, req.PatchNumber); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to rollback patch", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- Diagnostics (optional stubs) ---
|
||||
|
||||
// DiagnosticsHandler handles diagnostics/speed test endpoints.
|
||||
type DiagnosticsHandler struct{}
|
||||
|
||||
// GCPUploadSpeedTest handles GET /api/v1/diagnostics/gcp_upload
|
||||
func (h *DiagnosticsHandler) GCPUploadSpeedTest(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"upload_url": "https://example.com/speedtest-upload",
|
||||
})
|
||||
}
|
||||
|
||||
// GCPDownloadSpeedTest handles GET /api/v1/diagnostics/gcp_download
|
||||
func (h *DiagnosticsHandler) GCPDownloadSpeedTest(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"download_url": "https://example.com/speedtest-download",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// respondJSON writes a JSON response with the given status code.
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if data != nil {
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
}
|
||||
|
||||
// respondError writes a JSON error response.
|
||||
func respondError(w http.ResponseWriter, status int, message string, details *string) {
|
||||
respondJSON(w, status, map[string]interface{}{
|
||||
"message": message,
|
||||
"details": details,
|
||||
})
|
||||
}
|
||||
|
||||
// decodeJSON decodes a JSON request body.
|
||||
func decodeJSON(r *http.Request, v interface{}) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/models"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
)
|
||||
|
||||
// PatchHandler handles patch-related API endpoints.
|
||||
type PatchHandler struct {
|
||||
DB *db.DB
|
||||
Storage *storage.Service
|
||||
}
|
||||
|
||||
// CreatePatch handles POST /api/v1/apps/{appId}/patches
|
||||
func (h *PatchHandler) CreatePatch(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
|
||||
var req models.CreatePatchRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate release exists and belongs to this app
|
||||
release, err := h.DB.GetReleaseByID(r.Context(), req.ReleaseID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Release not found", nil)
|
||||
return
|
||||
}
|
||||
if release.AppID.String() != appID {
|
||||
respondError(w, http.StatusNotFound, "Release not found for this app", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-increment patch number
|
||||
patchNum, err := h.DB.GetNextPatchNumber(r.Context(), req.ReleaseID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to determine patch number", nil)
|
||||
return
|
||||
}
|
||||
|
||||
patch, err := h.DB.CreatePatch(r.Context(), req.ReleaseID, patchNum, nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create patch", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, models.Patch{
|
||||
ID: patch.ID,
|
||||
Number: patch.Number,
|
||||
Notes: patch.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePatchArtifact handles POST /api/v1/apps/{appId}/patches/{patchId}/artifacts
|
||||
func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to parse multipart form", nil)
|
||||
return
|
||||
}
|
||||
|
||||
arch := r.FormValue("arch")
|
||||
platform := r.FormValue("platform")
|
||||
hash := r.FormValue("hash")
|
||||
sizeStr := r.FormValue("size")
|
||||
hashSignature := r.FormValue("hash_signature")
|
||||
podfileLockHash := r.FormValue("podfile_lock_hash")
|
||||
|
||||
size, _ := strconv.ParseInt(sizeStr, 10, 64)
|
||||
|
||||
// Get the patch to verify it exists and get release info
|
||||
patch, err := h.DB.GetPatchByID(r.Context(), patchID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Patch not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
release, err := h.DB.GetReleaseByID(r.Context(), patch.ReleaseID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Release not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a storage key for the public bucket
|
||||
filename := "dlc.vmcode" // standard patch artifact name
|
||||
storageKey := storage.GeneratePatchStorageKey(appID, release.ID, patch.Number, platform, arch, filename)
|
||||
|
||||
// Generate presigned upload URL (public bucket)
|
||||
uploadURL, err := h.Storage.GeneratePresignedUploadURL(r.Context(), storageKey, true, 30*time.Minute)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate upload URL", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Create the artifact record
|
||||
var hs *string
|
||||
if hashSignature != "" {
|
||||
hs = &hashSignature
|
||||
}
|
||||
var phl *string
|
||||
if podfileLockHash != "" {
|
||||
phl = &podfileLockHash
|
||||
}
|
||||
|
||||
_, 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,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPatches handles GET /api/v1/apps/{appId}/releases/{releaseId}/patches
|
||||
func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) {
|
||||
releaseID, err := strconv.Atoi(chi.URLParam(r, "releaseId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
patches, err := h.DB.GetPatchesByReleaseID(r.Context(), releaseID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch patches", nil)
|
||||
return
|
||||
}
|
||||
|
||||
type releasePatch struct {
|
||||
ID int `json:"id"`
|
||||
ReleaseID int `json:"release_id"`
|
||||
PatchID int `json:"patch_id"`
|
||||
PatchNumber int `json:"patch_number"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
var result []releasePatch
|
||||
for _, p := range patches {
|
||||
result = append(result, releasePatch{
|
||||
ID: p.ID,
|
||||
ReleaseID: p.ReleaseID,
|
||||
PatchID: p.ID,
|
||||
PatchNumber: p.Number,
|
||||
CreatedAt: p.PromotedAt,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"patches": result})
|
||||
}
|
||||
|
||||
// PromotePatch handles POST /api/v1/apps/{appId}/patches/promote
|
||||
func (h *PatchHandler) PromotePatch(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.PromotePatchRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.PromotePatch(r.Context(), req.PatchID, req.ChannelID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to promote patch", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UpdatePatch handles PATCH /api/v1/apps/{appId}/patches/{patchId}
|
||||
func (h *PatchHandler) UpdatePatch(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.UpdatePatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// For now, notes are the only updatable field on patches
|
||||
// This would require an additional DB method
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// parseUUID parses a UUID string.
|
||||
func parseUUID(s string) (uuid.UUID, error) {
|
||||
return uuid.Parse(s)
|
||||
}
|
||||
|
||||
// parseIntParam parses an integer URL parameter.
|
||||
func parseIntParam(r *http.Request, name string) (int, error) {
|
||||
return strconv.Atoi(chi.URLParam(r, name))
|
||||
}
|
||||
|
||||
// ReleaseHandler handles release-related API endpoints.
|
||||
type ReleaseHandler struct {
|
||||
DB *db.DB
|
||||
Storage *storage.Service
|
||||
}
|
||||
|
||||
// GetReleases handles GET /api/v1/apps/{appId}/releases
|
||||
func (h *ReleaseHandler) GetReleases(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := parseUUID(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
sideloadable := r.URL.Query().Get("sideloadable") == "true"
|
||||
|
||||
releases, err := h.DB.GetReleasesByAppID(r.Context(), appID, sideloadable)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch releases", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var result []models.Release
|
||||
for _, rel := range releases {
|
||||
statuses, _ := h.DB.GetReleasePlatformStatuses(r.Context(), rel.ID)
|
||||
if statuses == nil {
|
||||
statuses = map[string]string{}
|
||||
}
|
||||
|
||||
result = append(result, models.Release{
|
||||
ID: rel.ID,
|
||||
AppID: rel.AppID,
|
||||
Version: rel.Version,
|
||||
FlutterRevision: rel.FlutterRevision,
|
||||
FlutterVersion: rel.FlutterVersion,
|
||||
DisplayName: rel.DisplayName,
|
||||
Notes: rel.Notes,
|
||||
PlatformStatuses: statuses,
|
||||
CreatedAt: rel.CreatedAt,
|
||||
UpdatedAt: rel.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"releases": result})
|
||||
}
|
||||
|
||||
// CreateRelease handles POST /api/v1/apps/{appId}/releases
|
||||
func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := parseUUID(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateReleaseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
release, err := h.DB.CreateRelease(r.Context(), appID, req.Version, req.FlutterRevision, req.FlutterVersion, req.DisplayName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create release", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, models.CreateReleaseResponse{
|
||||
Release: models.Release{
|
||||
ID: release.ID,
|
||||
AppID: release.AppID,
|
||||
Version: release.Version,
|
||||
FlutterRevision: release.FlutterRevision,
|
||||
FlutterVersion: release.FlutterVersion,
|
||||
DisplayName: release.DisplayName,
|
||||
Notes: release.Notes,
|
||||
PlatformStatuses: map[string]string{},
|
||||
CreatedAt: release.CreatedAt,
|
||||
UpdatedAt: release.UpdatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRelease handles PATCH /api/v1/apps/{appId}/releases/{releaseId}
|
||||
func (h *ReleaseHandler) UpdateRelease(w http.ResponseWriter, r *http.Request) {
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateReleaseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.UpdateReleasePlatformStatus(r.Context(), releaseID, req.Platform, req.Status, req.Metadata); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to update release", nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// CreateReleaseArtifact handles POST /api/v1/apps/{appId}/releases/{releaseId}/artifacts
|
||||
func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// The CLI sends multipart form data. Parse it.
|
||||
if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB max
|
||||
respondError(w, http.StatusBadRequest, "Failed to parse multipart form", nil)
|
||||
return
|
||||
}
|
||||
|
||||
arch := r.FormValue("arch")
|
||||
platform := r.FormValue("platform")
|
||||
hash := r.FormValue("hash")
|
||||
sizeStr := r.FormValue("size")
|
||||
canSideloadStr := r.FormValue("can_sideload")
|
||||
filename := r.FormValue("filename")
|
||||
podfileLockHash := r.FormValue("podfile_lock_hash")
|
||||
|
||||
size, _ := strconv.ParseInt(sizeStr, 10, 64)
|
||||
canSideload := canSideloadStr == "true"
|
||||
|
||||
// Get the release to know its version
|
||||
release, err := h.DB.GetReleaseByID(r.Context(), releaseID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Release not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a storage key
|
||||
storageKey := storage.GenerateReleaseStorageKey(appID, release.Version, platform, arch, filename)
|
||||
|
||||
// Generate presigned upload URL
|
||||
uploadURL, err := h.Storage.GeneratePresignedUploadURL(r.Context(), storageKey, false, 30*time.Minute)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate upload URL", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Create the artifact record in DB
|
||||
var phl *string
|
||||
if podfileLockHash != "" {
|
||||
phl = &podfileLockHash
|
||||
}
|
||||
|
||||
artifact, err := h.DB.CreateReleaseArtifact(r.Context(), releaseID, arch, platform, hash, storageKey, size, canSideload, phl)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create artifact record", strPtr(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, models.CreateReleaseArtifactResponse{
|
||||
ID: artifact.ID,
|
||||
URL: uploadURL,
|
||||
})
|
||||
}
|
||||
|
||||
// GetReleaseArtifacts handles GET /api/v1/apps/{appId}/releases/{releaseId}/artifacts
|
||||
func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var archPtr, platformPtr *string
|
||||
if a := r.URL.Query().Get("arch"); a != "" {
|
||||
archPtr = &a
|
||||
}
|
||||
if p := r.URL.Query().Get("platform"); p != "" {
|
||||
platformPtr = &p
|
||||
}
|
||||
|
||||
artifacts, err := h.DB.GetReleaseArtifacts(r.Context(), releaseID, archPtr, platformPtr)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to fetch artifacts", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var result []models.ReleaseArtifact
|
||||
for _, a := range artifacts {
|
||||
// Build a download URL for the artifact
|
||||
downloadURL := fmt.Sprintf("/storage/releases/%s", a.StorageKey)
|
||||
result = append(result, models.ReleaseArtifact{
|
||||
ID: a.ID,
|
||||
ReleaseID: a.ReleaseID,
|
||||
Arch: a.Arch,
|
||||
Platform: a.Platform,
|
||||
Hash: a.Hash,
|
||||
Size: a.Size,
|
||||
URL: downloadURL,
|
||||
CanSideload: a.CanSideload,
|
||||
PodfileLockHash: a.PodfileLockHash,
|
||||
CreatedAt: a.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
authpkg "github.com/shorebird-server/internal/auth"
|
||||
"github.com/shorebird-server/internal/api/middleware"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
)
|
||||
|
||||
// NewRouter creates the HTTP router with all API routes.
|
||||
func NewRouter(authService *authpkg.Service, database *db.DB, store *storage.Service) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimw.Logger)
|
||||
r.Use(chimw.Recoverer)
|
||||
|
||||
// CORS
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Version"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := &AuthHandler{DB: database, AuthService: authService}
|
||||
userHandler := &UserHandler{DB: database}
|
||||
appHandler := &AppHandler{DB: database}
|
||||
channelHandler := &ChannelHandler{DB: database}
|
||||
orgHandler := &OrganizationHandler{DB: database}
|
||||
releaseHandler := &ReleaseHandler{DB: database, Storage: store}
|
||||
patchHandler := &PatchHandler{DB: database, Storage: store}
|
||||
deviceHandler := &DeviceHandler{DB: database, Storage: store}
|
||||
diagHandler := &DiagnosticsHandler{}
|
||||
adminHandler := &AdminHandler{DB: database}
|
||||
|
||||
// Auth middleware
|
||||
authMw := middleware.AuthMiddleware(authService)
|
||||
|
||||
// --- Auth routes (no auth required) ---
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/token", authHandler.Login)
|
||||
r.Post("/register", authHandler.Register)
|
||||
r.With(authMw).Post("/refresh", authHandler.Refresh)
|
||||
})
|
||||
|
||||
// --- API v1 routes ---
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
// Public endpoints (no auth required - called by devices)
|
||||
r.Post("/patches/check", deviceHandler.PatchCheck)
|
||||
r.Post("/patches/events", deviceHandler.PatchEvents)
|
||||
|
||||
// Authenticated endpoints
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(authMw)
|
||||
|
||||
// Users
|
||||
r.Get("/users/me", userHandler.GetCurrentUser)
|
||||
r.Post("/users", userHandler.CreateUser)
|
||||
|
||||
// Organizations
|
||||
r.Get("/organizations", orgHandler.GetOrganizations)
|
||||
|
||||
// Apps
|
||||
r.Get("/apps", appHandler.GetApps)
|
||||
r.Post("/apps", appHandler.CreateApp)
|
||||
r.Delete("/apps/{appId}", appHandler.DeleteApp)
|
||||
|
||||
// Channels
|
||||
r.Get("/apps/{appId}/channels", channelHandler.GetChannels)
|
||||
r.Post("/apps/{appId}/channels", channelHandler.CreateChannel)
|
||||
|
||||
// Releases
|
||||
r.Get("/apps/{appId}/releases", releaseHandler.GetReleases)
|
||||
r.Post("/apps/{appId}/releases", releaseHandler.CreateRelease)
|
||||
r.Patch("/apps/{appId}/releases/{releaseId}", releaseHandler.UpdateRelease)
|
||||
|
||||
// Release artifacts
|
||||
r.Post("/apps/{appId}/releases/{releaseId}/artifacts", releaseHandler.CreateReleaseArtifact)
|
||||
r.Get("/apps/{appId}/releases/{releaseId}/artifacts", releaseHandler.GetReleaseArtifacts)
|
||||
|
||||
// Patches
|
||||
r.Post("/apps/{appId}/patches", patchHandler.CreatePatch)
|
||||
r.Patch("/apps/{appId}/patches/{patchId}", patchHandler.UpdatePatch)
|
||||
|
||||
// Patch artifacts
|
||||
r.Post("/apps/{appId}/patches/{patchId}/artifacts", patchHandler.CreatePatchArtifact)
|
||||
|
||||
// Patch listing and promotion
|
||||
r.Get("/apps/{appId}/releases/{releaseId}/patches", patchHandler.GetPatches)
|
||||
r.Post("/apps/{appId}/patches/promote", patchHandler.PromotePatch)
|
||||
|
||||
// Rollback
|
||||
r.Post("/apps/{appId}/patches/rollback", deviceHandler.RollbackPatch)
|
||||
|
||||
// Diagnostics
|
||||
r.Get("/diagnostics/gcp_upload", diagHandler.GCPUploadSpeedTest)
|
||||
r.Get("/diagnostics/gcp_download", diagHandler.GCPDownloadSpeedTest)
|
||||
|
||||
// Admin: user listing
|
||||
r.Get("/admin/users", adminHandler.ListUsers)
|
||||
|
||||
// Admin: targeted device patching
|
||||
r.Post("/admin/patches/{patchId}/target-devices", adminHandler.AddTargetDevice)
|
||||
r.Delete("/admin/patches/{patchId}/target-devices/{clientId}", adminHandler.RemoveTargetDevice)
|
||||
r.Get("/admin/patches/{patchId}/target-devices", adminHandler.GetTargetDevices)
|
||||
r.Get("/admin/apps/{appId}/events", adminHandler.GetPatchEvents)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Static files: Web UI ---
|
||||
webDir := findWebDir()
|
||||
if webDir != "" {
|
||||
fileServer := http.FileServer(http.Dir(webDir))
|
||||
r.Handle("/web/*", http.StripPrefix("/web/", fileServer))
|
||||
|
||||
// Catch-all: serve the SPA index.html for the dashboard
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, filepath.Join(webDir, "index.html"))
|
||||
})
|
||||
r.Get("/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, filepath.Join(webDir, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// findWebDir locates the web/ directory. It searches relative to the
|
||||
// binary and from common workspace paths so that the dashboard works
|
||||
// in local development as well as in the Docker image.
|
||||
func findWebDir() string {
|
||||
candidates := []string{
|
||||
"web",
|
||||
"../../web",
|
||||
filepath.Join("..", "web"),
|
||||
}
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(cwd, "web"))
|
||||
}
|
||||
// Also check relative to the executable
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(filepath.Dir(exe), "web"))
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if info, err := os.Stat(p); err == nil && info.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/shorebird-server/internal/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// ClaimsKey is the context key for auth claims.
|
||||
ClaimsKey contextKey = "auth_claims"
|
||||
)
|
||||
|
||||
// AuthMiddleware validates JWT tokens and injects claims into the request context.
|
||||
func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, `{"message":"Missing Authorization header"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||
http.Error(w, `{"message":"Invalid Authorization header format"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := authService.ValidateToken(parts[1])
|
||||
if err != nil {
|
||||
http.Error(w, `{"message":"Invalid or expired token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ClaimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetClaims extracts auth claims from the request context.
|
||||
func GetClaims(r *http.Request) *auth.Claims {
|
||||
claims, ok := r.Context().Value(ClaimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return claims
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Service handles JWT token creation and validation.
|
||||
type Service struct {
|
||||
jwtSecret []byte
|
||||
tokenDuration time.Duration
|
||||
}
|
||||
|
||||
// Claims represents the JWT claims.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserID int `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// NewService creates a new auth service.
|
||||
func NewService(jwtSecret string, tokenDuration time.Duration) *Service {
|
||||
return &Service{
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
tokenDuration: tokenDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken creates a new JWT for the given user.
|
||||
func (s *Service) GenerateToken(userID int, email string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "shorebird-auth",
|
||||
Subject: email,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(s.tokenDuration)),
|
||||
ID: generateID(),
|
||||
},
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(s.jwtSecret)
|
||||
}
|
||||
|
||||
// ValidateToken parses and validates a JWT token string.
|
||||
func (s *Service) ValidateToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return s.jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a random refresh token.
|
||||
func GenerateRefreshToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// HashPassword creates a bcrypt hash of the password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword compares a password against its hash.
|
||||
func CheckPassword(hash, password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all configuration for the Shorebird server.
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Storage StorageConfig
|
||||
Auth AuthConfig
|
||||
Redis RedisConfig
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server configuration.
|
||||
type ServerConfig struct {
|
||||
Host string
|
||||
Port string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
}
|
||||
|
||||
// DatabaseConfig holds PostgreSQL connection configuration.
|
||||
type DatabaseConfig struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// StorageConfig holds object storage (S3/MinIO) configuration.
|
||||
type StorageConfig struct {
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
UseSSL bool
|
||||
ReleaseBucket string
|
||||
PatchBucket string
|
||||
}
|
||||
|
||||
// AuthConfig holds JWT authentication configuration.
|
||||
type AuthConfig struct {
|
||||
JWTSecret string
|
||||
TokenDuration time.Duration
|
||||
}
|
||||
|
||||
// RedisConfig holds Redis connection configuration.
|
||||
type RedisConfig struct {
|
||||
Addr string
|
||||
Password string
|
||||
DB int
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with sensible defaults.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: envOrDefault("SERVER_HOST", "0.0.0.0"),
|
||||
Port: envOrDefault("SERVER_PORT", "8080"),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
URL: envOrDefault("DATABASE_URL", "postgres://shorebird:shorebird@localhost:5432/shorebird?sslmode=disable"),
|
||||
},
|
||||
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"),
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
JWTSecret: envOrDefault("JWT_SECRET", "change-me-in-production-use-a-long-random-string"),
|
||||
TokenDuration: 24 * time.Hour,
|
||||
},
|
||||
Redis: RedisConfig{
|
||||
Addr: envOrDefault("REDIS_ADDR", "localhost:6379"),
|
||||
Password: envOrDefault("REDIS_PASSWORD", ""),
|
||||
DB: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func envOrDefault(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// DB wraps the PostgreSQL connection pool with query methods.
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new database connection pool.
|
||||
func New(ctx context.Context, databaseURL string) (*DB, error) {
|
||||
config, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse database URL: %w", err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection pool.
|
||||
func (db *DB) 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) {
|
||||
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 {
|
||||
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) {
|
||||
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) {
|
||||
var id int
|
||||
err := db.QueryRow(ctx,
|
||||
`INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id`,
|
||||
email, name, passwordHash,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves a user by email.
|
||||
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) {
|
||||
row := &UserRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID.
|
||||
func (db *DB) GetUserByID(ctx context.Context, id int) (*UserRow, error) {
|
||||
row := &UserRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
var id int
|
||||
err := db.QueryRow(ctx,
|
||||
`INSERT INTO organizations (name, type) VALUES ($1, $2) RETURNING id`,
|
||||
name, orgType,
|
||||
).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// GetOrganizationsByUserID returns all organizations a user belongs to.
|
||||
func (db *DB) 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
|
||||
WHERE om.user_id = $1
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
`INSERT INTO organization_memberships (user_id, organization_id, role)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
||||
userID, orgID, role)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- App queries ---
|
||||
|
||||
// CreateApp creates a new app and returns it.
|
||||
func (db *DB) CreateApp(ctx context.Context, orgID int, displayName string) (*AppRow, error) {
|
||||
row := &AppRow{}
|
||||
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,
|
||||
).Scan(&row.ID, &row.OrganizationID, &row.DisplayName, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetAppsByOrganization returns all apps for an organization.
|
||||
func (db *DB) 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 {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []AppRow
|
||||
for rows.Next() {
|
||||
var r AppRow
|
||||
if err := rows.Scan(&r.ID, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAppByID retrieves a single app by ID.
|
||||
func (db *DB) GetAppByID(ctx context.Context, appID uuid.UUID) (*AppRow, error) {
|
||||
row := &AppRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetAppByIDString retrieves a single app by its string ID.
|
||||
func (db *DB) 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 db.GetAppByID(ctx, id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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,
|
||||
`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 = $1
|
||||
ORDER BY a.display_name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []AppRow
|
||||
for rows.Next() {
|
||||
var r AppRow
|
||||
if err := rows.Scan(&r.ID, &r.OrganizationID, &r.DisplayName, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
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) {
|
||||
row := &ChannelRow{}
|
||||
err := db.QueryRow(ctx,
|
||||
`INSERT INTO channels (app_id, name) VALUES ($1, $2)
|
||||
RETURNING id, app_id, name, created_at`,
|
||||
appID, name,
|
||||
).Scan(&row.ID, &row.AppID, &row.Name, &row.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetChannelsByAppID returns all channels for an app.
|
||||
func (db *DB) 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
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ChannelRow
|
||||
for rows.Next() {
|
||||
var r ChannelRow
|
||||
if err := rows.Scan(&r.ID, &r.AppID, &r.Name, &r.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetChannelByAppIDAndName retrieves a specific channel.
|
||||
func (db *DB) GetChannelByAppIDAndName(ctx context.Context, appID uuid.UUID, name string) (*ChannelRow, error) {
|
||||
row := &ChannelRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
row := &ReleaseRow{}
|
||||
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`,
|
||||
appID, version, flutterRevision, flutterVersion, displayName,
|
||||
).Scan(&row.ID, &row.AppID, &row.Version, &row.FlutterRevision, &row.FlutterVersion, &row.DisplayName, &row.Notes, &row.CreatedAt, &row.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetReleaseByID retrieves a release by ID.
|
||||
func (db *DB) GetReleaseByID(ctx context.Context, releaseID int) (*ReleaseRow, error) {
|
||||
row := &ReleaseRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetReleaseByAppIDAndVersion retrieves a release by app and version.
|
||||
func (db *DB) GetReleaseByAppIDAndVersion(ctx context.Context, appID uuid.UUID, version string) (*ReleaseRow, error) {
|
||||
row := &ReleaseRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetReleasesByAppID returns all releases for an app.
|
||||
func (db *DB) 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []ReleaseRow
|
||||
for rows.Next() {
|
||||
var r ReleaseRow
|
||||
if err := rows.Scan(&r.ID, &r.AppID, &r.Version, &r.FlutterRevision, &r.FlutterVersion, &r.DisplayName, &r.Notes, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
`INSERT INTO release_platform_statuses (release_id, platform, status, metadata)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (release_id, platform)
|
||||
DO UPDATE SET status = $3, metadata = $4, updated_at = NOW()`,
|
||||
releaseID, platform, status, metadata)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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,
|
||||
`SELECT platform, status FROM release_platform_statuses WHERE release_id = $1`, releaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var platform, status string
|
||||
if err := rows.Scan(&platform, &status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[platform] = status
|
||||
}
|
||||
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) {
|
||||
row := &ArtifactRow{}
|
||||
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`,
|
||||
releaseID, arch, platform, hash, size, storageKey, canSideload, podfileLockHash,
|
||||
).Scan(&row.ID, &row.ReleaseID, &row.Arch, &row.Platform, &row.Hash, &row.Size, &row.StorageKey, &row.CanSideload, &row.PodfileLockHash, &row.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetReleaseArtifacts returns artifacts for a release, optionally filtered.
|
||||
func (db *DB) 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}
|
||||
|
||||
if arch != nil {
|
||||
query += fmt.Sprintf(" AND arch = $%d", len(args)+1)
|
||||
args = append(args, *arch)
|
||||
}
|
||||
if platform != nil {
|
||||
query += fmt.Sprintf(" AND platform = $%d", len(args)+1)
|
||||
args = append(args, *platform)
|
||||
}
|
||||
|
||||
query += " ORDER BY arch"
|
||||
|
||||
rows, err := db.Query(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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var num int
|
||||
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) {
|
||||
row := &PatchRow{}
|
||||
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,
|
||||
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetPatchByID retrieves a patch by ID.
|
||||
func (db *DB) GetPatchByID(ctx context.Context, patchID int) (*PatchRow, error) {
|
||||
row := &PatchRow{}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetLatestPatchForRelease returns the latest patch for a release (by number).
|
||||
func (db *DB) GetLatestPatchForRelease(ctx context.Context, releaseID int) (*PatchRow, error) {
|
||||
row := &PatchRow{}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// GetPatchesByReleaseID returns all patches for a release.
|
||||
func (db *DB) 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
|
||||
LEFT JOIN patch_channels pc ON p.id = pc.patch_id
|
||||
WHERE p.release_id = $1
|
||||
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
|
||||
}
|
||||
|
||||
// PromotePatch promotes a patch to a channel.
|
||||
func (db *DB) 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) {
|
||||
row := &PatchWithArtifactRow{}
|
||||
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
|
||||
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 = $1 AND pc.channel_id = $2
|
||||
ORDER BY p.number DESC LIMIT 1`,
|
||||
releaseID, channelID,
|
||||
).Scan(&row.ID, &row.ReleaseID, &row.Number, &row.Notes, &row.CreatedAt,
|
||||
&row.Hash, &row.StorageKey, &row.HashSignature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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) {
|
||||
row := &PatchArtifactRow{}
|
||||
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`,
|
||||
patchID, arch, platform, hash, size, storageKey, hashSignature, podfileLockHash,
|
||||
).Scan(&row.ID, &row.PatchID, &row.Arch, &row.Platform, &row.Hash, &row.Size, &row.StorageKey, &row.HashSignature, &row.PodfileLockHash, &row.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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,
|
||||
`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)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
`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,
|
||||
`SELECT patch_number FROM rolled_back_patches WHERE release_id = $1`, releaseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []int
|
||||
for rows.Next() {
|
||||
var num int
|
||||
if err := rows.Scan(&num); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, num)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
`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) {
|
||||
var count int
|
||||
err := db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1 AND client_id = $2`,
|
||||
patchID, clientID,
|
||||
).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// GetPatchTargetDevices returns all targeted devices for a patch.
|
||||
func (db *DB) 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
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []string
|
||||
for rows.Next() {
|
||||
var clientID string
|
||||
if err := rows.Scan(&clientID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, clientID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HasTargetDevices checks if a patch has any targeted device restrictions.
|
||||
func (db *DB) HasTargetDevices(ctx context.Context, patchID int) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM patch_target_devices WHERE patch_id = $1`, patchID,
|
||||
).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// --- Admin queries ---
|
||||
|
||||
// ListAllUsers returns all registered users (admin endpoint).
|
||||
func (db *DB) 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
|
||||
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
|
||||
}
|
||||
|
||||
// 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,184 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Organizations table
|
||||
CREATE TABLE organizations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'team',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Organization memberships
|
||||
CREATE TABLE organization_memberships (
|
||||
id SERIAL PRIMARY KEY,
|
||||
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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, organization_id)
|
||||
);
|
||||
|
||||
-- Apps table
|
||||
CREATE TABLE apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
display_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Channels table (default "stable" channel created per app)
|
||||
CREATE TABLE channels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(app_id, name)
|
||||
);
|
||||
|
||||
-- Releases table
|
||||
CREATE TABLE releases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
app_id UUID 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 TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Release platform statuses
|
||||
CREATE TABLE release_platform_statuses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||
platform TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(release_id, platform)
|
||||
);
|
||||
|
||||
-- Release artifacts
|
||||
CREATE TABLE release_artifacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||
arch TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
storage_key TEXT NOT NULL,
|
||||
can_sideload BOOLEAN NOT NULL DEFAULT false,
|
||||
podfile_lock_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Patches table
|
||||
CREATE TABLE patches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||
number INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(release_id, number)
|
||||
);
|
||||
|
||||
-- Patch artifacts
|
||||
CREATE TABLE patch_artifacts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
|
||||
arch TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
size BIGINT NOT NULL DEFAULT 0,
|
||||
storage_key TEXT NOT NULL,
|
||||
hash_signature TEXT,
|
||||
podfile_lock_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Patch channel promotion (links patches to channels)
|
||||
CREATE TABLE patch_channels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
||||
promoted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(patch_id, channel_id)
|
||||
);
|
||||
|
||||
-- Patch install/event log
|
||||
CREATE TABLE patch_events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
app_id UUID 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 BIGINT NOT NULL,
|
||||
message JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Rolled-back patches
|
||||
CREATE TABLE rolled_back_patches (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||
patch_number INTEGER NOT NULL,
|
||||
rolled_back_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(release_id, patch_number)
|
||||
);
|
||||
|
||||
-- Targeted device patching (extension beyond standard Shorebird)
|
||||
CREATE TABLE patch_target_devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
|
||||
client_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(patch_id, client_id)
|
||||
);
|
||||
|
||||
-- Indexes for common query patterns
|
||||
CREATE INDEX idx_releases_app_id ON releases(app_id);
|
||||
CREATE INDEX idx_releases_app_version ON releases(app_id, version);
|
||||
CREATE INDEX idx_patches_release_id ON patches(release_id);
|
||||
CREATE INDEX idx_patch_channels_channel ON patch_channels(channel_id);
|
||||
CREATE INDEX idx_patch_channels_patch ON patch_channels(patch_id);
|
||||
CREATE INDEX idx_patch_events_app ON patch_events(app_id);
|
||||
CREATE INDEX idx_patch_events_client ON patch_events(app_id, client_id);
|
||||
CREATE INDEX idx_rolled_back_release ON rolled_back_patches(release_id);
|
||||
CREATE INDEX idx_patch_target_devices_patch ON patch_target_devices(patch_id);
|
||||
CREATE INDEX idx_patch_target_devices_client ON patch_target_devices(patch_id, client_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS patch_target_devices CASCADE;
|
||||
DROP TABLE IF EXISTS rolled_back_patches CASCADE;
|
||||
DROP TABLE IF EXISTS patch_events CASCADE;
|
||||
DROP TABLE IF EXISTS patch_channels CASCADE;
|
||||
DROP TABLE IF EXISTS patch_artifacts CASCADE;
|
||||
DROP TABLE IF EXISTS patches CASCADE;
|
||||
DROP TABLE IF EXISTS release_artifacts CASCADE;
|
||||
DROP TABLE IF EXISTS release_platform_statuses CASCADE;
|
||||
DROP TABLE IF EXISTS releases CASCADE;
|
||||
DROP TABLE IF EXISTS channels CASCADE;
|
||||
DROP TABLE IF EXISTS apps CASCADE;
|
||||
DROP TABLE IF EXISTS organization_memberships CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
DROP TABLE IF EXISTS organizations CASCADE;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,309 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// App represents a single Shorebird application.
|
||||
type App struct {
|
||||
ID uuid.UUID `json:"app_id"`
|
||||
OrganizationID int `json:"organization_id,omitempty"`
|
||||
DisplayName string `json:"display_name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AppMetadata is the full app info returned by GET /apps.
|
||||
type AppMetadata struct {
|
||||
AppID string `json:"app_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
LatestReleaseVersion *string `json:"latest_release_version"`
|
||||
LatestPatchNumber *int `json:"latest_patch_number"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Platforms []string `json:"platforms"`
|
||||
LatestReleases map[string]LatestRelease `json:"latest_releases"`
|
||||
PendingReleases map[string]PendingRelease `json:"pending_releases"`
|
||||
IconURL *string `json:"icon_url"`
|
||||
}
|
||||
|
||||
// LatestRelease represents the latest analyzed release per platform.
|
||||
type LatestRelease struct {
|
||||
ReleaseID int `json:"release_id"`
|
||||
Version string `json:"version"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
}
|
||||
|
||||
// PendingRelease represents an unanalyzed newer release per platform.
|
||||
type PendingRelease struct {
|
||||
ReleaseID int `json:"release_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Channel represents a deployment channel for an app.
|
||||
type Channel struct {
|
||||
ID int `json:"id"`
|
||||
AppID uuid.UUID `json:"app_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Release represents a release of an application.
|
||||
type Release struct {
|
||||
ID int `json:"id"`
|
||||
AppID uuid.UUID `json:"app_id"`
|
||||
Version string `json:"version"`
|
||||
FlutterRevision string `json:"flutter_revision"`
|
||||
FlutterVersion *string `json:"flutter_version"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Notes *string `json:"notes"`
|
||||
PlatformStatuses map[string]string `json:"platform_statuses"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ReleaseStatus values.
|
||||
const (
|
||||
ReleaseStatusDraft = "draft"
|
||||
ReleaseStatusActive = "active"
|
||||
ReleaseStatusInactive = "inactive"
|
||||
)
|
||||
|
||||
// ReleasePlatform values.
|
||||
const (
|
||||
PlatformAndroid = "android"
|
||||
PlatformIOS = "ios"
|
||||
PlatformMacOS = "macos"
|
||||
PlatformWindows = "windows"
|
||||
PlatformLinux = "linux"
|
||||
)
|
||||
|
||||
// ReleaseArtifact represents metadata about a release artifact.
|
||||
type ReleaseArtifact struct {
|
||||
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"`
|
||||
CanSideload bool `json:"can_sideload"`
|
||||
PodfileLockHash *string `json:"podfile_lock_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Patch represents a patch (hotfix update) for a release.
|
||||
type Patch struct {
|
||||
ID int `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// ReleasePatch is the join between releases and patches.
|
||||
type ReleasePatch struct {
|
||||
ID int `json:"id"`
|
||||
ReleaseID int `json:"release_id"`
|
||||
PatchID int `json:"patch_id"`
|
||||
PatchNumber int `json:"patch_number"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PatchArtifact represents metadata about a patch artifact.
|
||||
type PatchArtifact struct {
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PatchCheckRequest is the POST /patches/check request body.
|
||||
type PatchCheckRequest struct {
|
||||
ReleaseVersion string `json:"release_version"`
|
||||
PatchNumber *int `json:"patch_number"`
|
||||
PatchHash *string `json:"patch_hash"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
AppID string `json:"app_id"`
|
||||
Channel string `json:"channel"`
|
||||
ClientID *string `json:"client_id"`
|
||||
CurrentPatchNumber *int `json:"current_patch_number"`
|
||||
}
|
||||
|
||||
// PatchCheckResponse is the POST /patches/check response body.
|
||||
type PatchCheckResponse struct {
|
||||
PatchAvailable bool `json:"patch_available"`
|
||||
Patch *PatchCheckMetadata `json:"patch"`
|
||||
RolledBackPatchNumbers []int `json:"rolled_back_patch_numbers"`
|
||||
}
|
||||
|
||||
// PatchCheckMetadata is the patch metadata returned in patch check.
|
||||
type PatchCheckMetadata struct {
|
||||
Number int `json:"number"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
Hash string `json:"hash"`
|
||||
HashSignature *string `json:"hash_signature"`
|
||||
}
|
||||
|
||||
// CreatePatchEventRequest is sent by devices to report install events.
|
||||
type CreatePatchEventRequest struct {
|
||||
Event PatchEvent `json:"event"`
|
||||
}
|
||||
|
||||
// PatchEvent represents a single patch lifecycle event.
|
||||
type PatchEvent struct {
|
||||
AppID string `json:"app_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Arch string `json:"arch"`
|
||||
PatchNumber int `json:"patch_number"`
|
||||
Platform string `json:"platform"`
|
||||
ReleaseVersion string `json:"release_version"`
|
||||
Type string `json:"type"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Message *string `json:"message"`
|
||||
}
|
||||
|
||||
// Event types.
|
||||
const (
|
||||
EventPatchInstallSuccess = "PatchInstallSuccess"
|
||||
EventPatchInstallFailure = "PatchInstallFailure"
|
||||
)
|
||||
|
||||
// Organization represents an organization.
|
||||
type Organization struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// OrganizationMembership represents a user's membership in an org.
|
||||
type OrganizationMembership struct {
|
||||
Organization Organization `json:"organization"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// CreateUserRequest is the POST /users request body.
|
||||
type CreateUserRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// User represents a Shorebird user.
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PrivateUser extends User with private fields.
|
||||
type PrivateUser struct {
|
||||
User
|
||||
}
|
||||
|
||||
// CreateAppRequest is the POST /apps request body.
|
||||
type CreateAppRequest struct {
|
||||
OrganizationID int `json:"organization_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// CreateReleaseRequest is the POST /releases request body.
|
||||
type CreateReleaseRequest struct {
|
||||
Version string `json:"version"`
|
||||
FlutterRevision string `json:"flutter_revision"`
|
||||
FlutterVersion *string `json:"flutter_version"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
}
|
||||
|
||||
// CreateReleaseResponse wraps the created release.
|
||||
type CreateReleaseResponse struct {
|
||||
Release Release `json:"release"`
|
||||
}
|
||||
|
||||
// CreateReleaseArtifactRequest is the request for uploading a release artifact.
|
||||
type CreateReleaseArtifactRequest struct {
|
||||
Arch string `json:"arch"`
|
||||
Platform string `json:"platform"`
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
CanSideload bool `json:"can_sideload"`
|
||||
Filename string `json:"filename"`
|
||||
PodfileLockHash *string `json:"podfile_lock_hash"`
|
||||
}
|
||||
|
||||
// CreateReleaseArtifactResponse returns the presigned upload URL.
|
||||
type CreateReleaseArtifactResponse struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// CreatePatchRequest is the POST /patches request body.
|
||||
type CreatePatchRequest struct {
|
||||
ReleaseID int `json:"release_id"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// CreatePatchResponse wraps the created patch.
|
||||
type CreatePatchResponse struct {
|
||||
Patch Patch `json:"patch"`
|
||||
}
|
||||
|
||||
// CreatePatchArtifactRequest is the request for uploading a patch artifact.
|
||||
type CreatePatchArtifactRequest struct {
|
||||
Arch string `json:"arch"`
|
||||
Platform string `json:"platform"`
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
HashSignature *string `json:"hash_signature"`
|
||||
PodfileLockHash *string `json:"podfile_lock_hash"`
|
||||
}
|
||||
|
||||
// CreatePatchArtifactResponse returns the presigned upload URL.
|
||||
type CreatePatchArtifactResponse struct {
|
||||
ID int `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UpdateReleaseRequest is the PATCH /releases/{id} request body.
|
||||
type UpdateReleaseRequest struct {
|
||||
Status string `json:"status"`
|
||||
Platform string `json:"platform"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
// PromotePatchRequest is the POST /patches/promote request body.
|
||||
type PromotePatchRequest struct {
|
||||
PatchID int `json:"patch_id"`
|
||||
ChannelID int `json:"channel_id"`
|
||||
}
|
||||
|
||||
// CreateChannelRequest is the POST /channels request body.
|
||||
type CreateChannelRequest struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
// UpdatePatchRequest is the PATCH /patches request body.
|
||||
type UpdatePatchRequest struct {
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// ErrorResponse is returned on errors.
|
||||
type ErrorResponse struct {
|
||||
Message string `json:"message"`
|
||||
Details *string `json:"details"`
|
||||
}
|
||||
|
||||
// AuthTokenRequest is the login request.
|
||||
type AuthTokenRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AuthTokenResponse is the login response.
|
||||
type AuthTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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,313 @@
|
||||
/* ============================================================
|
||||
Shorebird Self-Hosted Dashboard — Complete Stylesheet
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--primary: #0891b2;
|
||||
--primary-dark: #0e7490;
|
||||
--primary-light: #cffafe;
|
||||
--accent: #06b6d4;
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--danger-light: #fef2f2;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow-lg: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Login Page ---- */
|
||||
.login-container {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; padding: 20px;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 40px;
|
||||
width: 100%; max-width: 420px;
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 28px; font-weight: 700; margin-bottom: 4px;
|
||||
color: var(--text); text-align: center;
|
||||
}
|
||||
.login-card .logo-icon {
|
||||
font-size: 48px; text-align: center; margin-bottom: 8px;
|
||||
}
|
||||
.login-card .subtitle {
|
||||
color: var(--text-secondary); text-align: center;
|
||||
margin-bottom: 28px; font-size: 14px;
|
||||
}
|
||||
.login-error {
|
||||
background: var(--danger-light); color: var(--danger);
|
||||
padding: 10px 14px; border-radius: var(--radius);
|
||||
margin-bottom: 16px; font-size: 13px; display: none;
|
||||
}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.app-container { display: none; }
|
||||
.app-container.active { display: block; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: 240px; background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 100; padding: 0;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.sidebar-header .logo {
|
||||
width: 32px; height: 32px; background: var(--primary);
|
||||
border-radius: 8px; display: flex; align-items: center;
|
||||
justify-content: center; color: #fff; font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sidebar-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.sidebar-nav { flex: 1; padding: 12px 8px; }
|
||||
.sidebar-nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; border-radius: var(--radius);
|
||||
color: var(--text-secondary); text-decoration: none;
|
||||
font-size: 14px; font-weight: 500; transition: all .15s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sidebar-nav a:hover { background: #f1f5f9; color: var(--text); }
|
||||
.sidebar-nav a.active {
|
||||
background: var(--primary-light); color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar-nav a .nav-icon { font-size: 18px; width: 24px; text-align: center; }
|
||||
.sidebar-footer {
|
||||
padding: 16px 24px; border-top: 1px solid var(--border);
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 240px; padding: 32px 40px; min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Top Bar ---- */
|
||||
.top-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.top-bar h2 { font-size: 24px; font-weight: 700; }
|
||||
.top-bar .user-info {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font-size: 14px; color: var(--text-secondary);
|
||||
}
|
||||
.top-bar .btn-sm {
|
||||
padding: 6px 14px; font-size: 13px; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.top-bar .btn-sm:hover { background: var(--danger-light); color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ---- Cards & Stats ---- */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px; margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface); border-radius: var(--radius);
|
||||
border: 1px solid var(--border); padding: 20px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-card .stat-value {
|
||||
font-size: 32px; font-weight: 700; color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-card .stat-label {
|
||||
font-size: 13px; color: var(--text-muted); margin-top: 4px;
|
||||
}
|
||||
.stat-card.primary { border-left: 3px solid var(--primary); }
|
||||
.stat-card.success { border-left: 3px solid var(--success); }
|
||||
.stat-card.warning { border-left: 3px solid var(--warning); }
|
||||
|
||||
/* ---- Tables ---- */
|
||||
.card {
|
||||
background: var(--surface); border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border); box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-header {
|
||||
padding: 18px 24px; border-bottom: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.card-header h3 { font-size: 16px; font-weight: 600; }
|
||||
.card-body { padding: 0; }
|
||||
|
||||
table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left; padding: 12px 24px;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
tbody td {
|
||||
padding: 12px 24px; border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
}
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 100px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #d1fae5; color: #065f46; }
|
||||
.badge-warning { background: #fef3c7; color: #92400e; }
|
||||
.badge-danger { background: #fee2e2; color: #991b1b; }
|
||||
.badge-info { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.code {
|
||||
font-family: var(--mono); font-size: 13px;
|
||||
background: #f1f5f9; padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 18px; border-radius: var(--radius);
|
||||
font-size: 14px; font-weight: 500; cursor: pointer;
|
||||
border: 1px solid transparent; transition: all .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-danger:hover { opacity: .85; }
|
||||
.btn-outline { background: #fff; color: var(--text); border-color: var(--border); }
|
||||
.btn-outline:hover { background: #f8fafc; }
|
||||
.btn-ghost {
|
||||
background: transparent; border-color: transparent;
|
||||
color: var(--text-secondary); padding: 6px 10px;
|
||||
}
|
||||
.btn-ghost:hover { background: #f1f5f9; color: var(--text); }
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block; margin-bottom: 5px;
|
||||
font-size: 13px; font-weight: 600; color: var(--text-secondary);
|
||||
}
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); font-size: 14px; font-family: var(--font);
|
||||
transition: border-color .15s; outline: none;
|
||||
background: var(--surface);
|
||||
}
|
||||
.form-group input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(8,145,178,.1); }
|
||||
.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ---- Modals ---- */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(15,23,42,.5); z-index: 200;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg); padding: 28px; width: 100%;
|
||||
max-width: 480px; max-height: 80vh; overflow-y: auto;
|
||||
}
|
||||
.modal h3 { font-size: 18px; font-weight: 600; margin-bottom: 20px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
|
||||
|
||||
/* ---- Toast ---- */
|
||||
.toast-container {
|
||||
position: fixed; top: 20px; right: 20px; z-index: 300;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.toast {
|
||||
padding: 12px 20px; border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg); font-size: 14px; font-weight: 500;
|
||||
animation: slideIn .25s ease-out;
|
||||
max-width: 380px;
|
||||
}
|
||||
.toast-success { background: #065f46; color: #fff; }
|
||||
.toast-error { background: #991b1b; color: #fff; }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
.empty-state {
|
||||
text-align: center; padding: 60px 20px;
|
||||
}
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state p { color: var(--text-muted); font-size: 14px; }
|
||||
|
||||
/* ---- Section visibility ---- */
|
||||
.page-section { display: none; }
|
||||
.page-section.active { display: block; }
|
||||
|
||||
/* ---- Spinner ---- */
|
||||
.spinner {
|
||||
display: inline-block; width: 20px; height: 20px;
|
||||
border: 2px solid var(--border); border-top-color: var(--primary);
|
||||
border-radius: 50%; animation: spin .6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading { text-align: center; padding: 40px; color: var(--text-muted); }
|
||||
|
||||
/* ---- Server info card ---- */
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||||
}
|
||||
.info-item { }
|
||||
.info-item .info-label { font-size: 12px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
|
||||
.info-item .info-value { font-size: 14px; color: var(--text); margin-top: 2px; word-break: break-all; }
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 200px; }
|
||||
.main-content { margin-left: 200px; padding: 20px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sidebar { display: none; }
|
||||
.main-content { margin-left: 0; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---- Tab bar for settings ---- */
|
||||
.tab-bar {
|
||||
display: flex; gap: 0; border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tab-bar button {
|
||||
padding: 10px 20px; border: none; background: none;
|
||||
font-size: 14px; font-weight: 500; color: var(--text-secondary);
|
||||
cursor: pointer; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px; transition: all .15s;
|
||||
}
|
||||
.tab-bar button.active {
|
||||
color: var(--primary); border-bottom-color: var(--primary); font-weight: 600;
|
||||
}
|
||||
.tab-bar button:hover { color: var(--text); }
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shorebird Self-Hosted</title>
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%230891b2'/><text x='50%25' y='55%25' text-anchor='middle' dy='.1em' font-size='20' fill='white'>🐦</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================================================
|
||||
LOGIN PAGE
|
||||
============================================================ -->
|
||||
<div id="loginPage" class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="logo-icon">🐦</div>
|
||||
<h1>Shorebird</h1>
|
||||
<p class="subtitle">Self-Hosted Dashboard</p>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="loginEmail">Email</label>
|
||||
<input type="email" id="loginEmail" placeholder="admin@example.com" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">Password</label>
|
||||
<input type="password" id="loginPassword" placeholder="Enter password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;padding:12px;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<p style="text-align:center;margin-top:16px;font-size:13px;color:var(--text-muted);">
|
||||
No account? <a href="#" id="showRegister" style="color:var(--primary);">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration modal on login page -->
|
||||
<div id="registerOverlay" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Create Account</h3>
|
||||
<form id="registerForm">
|
||||
<div class="form-group">
|
||||
<label for="regName">Name</label>
|
||||
<input type="text" id="regName" placeholder="Your name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="regEmail">Email</label>
|
||||
<input type="email" id="regEmail" placeholder="you@example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="regPassword">Password</label>
|
||||
<input type="password" id="regPassword" placeholder="Min 6 characters" required minlength="6">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeRegister()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Register & Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
APP SHELL (hidden until authenticated)
|
||||
============================================================ -->
|
||||
<div id="appContainer" class="app-container">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">🐦</div>
|
||||
<h2>Shorebird</h2>
|
||||
</div>
|
||||
<nav class="sidebar-nav" id="sidebarNav">
|
||||
<a href="#dashboard" class="active" data-page="dashboard">
|
||||
<span class="nav-icon">📊</span> Dashboard
|
||||
</a>
|
||||
<a href="#apps" data-page="apps">
|
||||
<span class="nav-icon">📱</span> Apps
|
||||
</a>
|
||||
<a href="#users" data-page="users">
|
||||
<span class="nav-icon">👥</span> Users
|
||||
</a>
|
||||
<a href="#settings" data-page="settings">
|
||||
<span class="nav-icon">⚙️</span> Settings
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer" id="sidebarFooter">
|
||||
Shorebird v1.0 · Self-Hosted
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<h2 id="pageTitle">Dashboard</h2>
|
||||
<div class="user-info">
|
||||
<span id="currentUserEmail">—</span>
|
||||
<button class="btn-sm" id="logoutBtn" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- ================================================
|
||||
DASHBOARD PAGE
|
||||
================================================ -->
|
||||
<section id="page-dashboard" class="page-section active">
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card primary"><div class="stat-value" id="statApps">—</div><div class="stat-label">Apps</div></div>
|
||||
<div class="stat-card success"><div class="stat-value" id="statPatches">—</div><div class="stat-label">Patches</div></div>
|
||||
<div class="stat-card warning"><div class="stat-value" id="statUsers">—</div><div class="stat-label">Users</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="statOrganizations">—</div><div class="stat-label">Organizations</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Recent Apps</h3></div>
|
||||
<div class="card-body" id="recentAppsTable"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================================================
|
||||
APPS PAGE
|
||||
================================================ -->
|
||||
<section id="page-apps" class="page-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>All Applications</h3>
|
||||
<button class="btn btn-primary" onclick="openCreateAppModal()">+ New App</button>
|
||||
</div>
|
||||
<div class="card-body" id="appsTable"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================================================
|
||||
USERS PAGE
|
||||
================================================ -->
|
||||
<section id="page-users" class="page-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>All Users</h3>
|
||||
<button class="btn btn-primary" onclick="openCreateUserModal()">+ New User</button>
|
||||
</div>
|
||||
<div class="card-body" id="usersTable"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================================================
|
||||
SETTINGS PAGE
|
||||
================================================ -->
|
||||
<section id="page-settings" class="page-section">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Server Configuration</h3></div>
|
||||
<div class="card-body" style="padding:24px;">
|
||||
<div class="info-grid" id="serverInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:20px;">
|
||||
<div class="card-header"><h3>API Token</h3></div>
|
||||
<div class="card-body" style="padding:24px;">
|
||||
<p style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">
|
||||
Use this token to authenticate the Shorebird CLI:
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input type="text" id="apiTokenDisplay" readonly
|
||||
style="font-family:var(--mono);font-size:13px;cursor:pointer;"
|
||||
onclick="this.select();document.execCommand('copy')"
|
||||
title="Click to copy">
|
||||
</div>
|
||||
<p class="form-hint">Click the token to copy. Set as <code>SHOREBIRD_TOKEN</code> env var.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
MODALS
|
||||
============================================================ -->
|
||||
|
||||
<!-- Create App Modal -->
|
||||
<div id="createAppOverlay" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Create New App</h3>
|
||||
<form id="createAppForm">
|
||||
<div class="form-group">
|
||||
<label for="appDisplayName">Display Name</label>
|
||||
<input type="text" id="appDisplayName" placeholder="My Flutter App" required>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('createAppOverlay')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create App</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserOverlay" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Create New User</h3>
|
||||
<form id="createUserForm">
|
||||
<div class="form-group">
|
||||
<label for="newUserName">Name</label>
|
||||
<input type="text" id="newUserName" placeholder="Full name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newUserEmail">Email</label>
|
||||
<input type="email" id="newUserEmail" placeholder="user@example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newUserPassword">Password</label>
|
||||
<input type="password" id="newUserPassword" placeholder="Min 6 characters" required minlength="6">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('createUserOverlay')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Delete Modal -->
|
||||
<div id="confirmModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 id="confirmTitle">Confirm</h3>
|
||||
<p id="confirmMessage" style="font-size:14px;color:var(--text-secondary);"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeModal('confirmModal')">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirmBtn" onclick="executeConfirm()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Shorebird Self-Hosted Dashboard — Complete SPA Application
|
||||
* Vanilla JS, no framework dependencies.
|
||||
*/
|
||||
|
||||
// ---- State ----
|
||||
const state = {
|
||||
token: localStorage.getItem('shorebird_token') || null,
|
||||
user: null,
|
||||
apps: [],
|
||||
users: [],
|
||||
confirmCallback: null,
|
||||
};
|
||||
|
||||
// ---- API Client ----
|
||||
const api = {
|
||||
base: '/api/v1',
|
||||
auth: '/auth',
|
||||
|
||||
headers() {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (state.token) h['Authorization'] = `Bearer ${state.token}`;
|
||||
return h;
|
||||
},
|
||||
|
||||
async request(method, path, body) {
|
||||
const url = path.startsWith('/auth') ? path : `${this.base}${path}`;
|
||||
const opts = { method, headers: this.headers() };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
|
||||
throw new Error(data.message || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
get(path) { return this.request('GET', path); },
|
||||
post(path, body) { return this.request('POST', path, body); },
|
||||
patch(path, body) { return this.request('PATCH', path, body); },
|
||||
del(path) { return this.request('DELETE', path); },
|
||||
};
|
||||
|
||||
// ---- Navigation ----
|
||||
function navigate(page) {
|
||||
document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active'));
|
||||
const section = document.getElementById(`page-${page}`);
|
||||
if (section) section.classList.add('active');
|
||||
|
||||
document.querySelectorAll('#sidebarNav a').forEach(a => a.classList.remove('active'));
|
||||
const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`);
|
||||
if (link) link.classList.add('active');
|
||||
|
||||
const titles = { dashboard: 'Dashboard', apps: 'Apps', users: 'Users', settings: 'Settings' };
|
||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||
|
||||
if (page === 'dashboard') loadDashboard();
|
||||
if (page === 'apps') loadApps();
|
||||
if (page === 'users') loadUsers();
|
||||
if (page === 'settings') loadSettings();
|
||||
}
|
||||
|
||||
// ---- Toast Notifications ----
|
||||
function showToast(message, type) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => { toast.remove(); }, 4000);
|
||||
}
|
||||
|
||||
// ---- Modals ----
|
||||
function openModal(id) { document.getElementById(id).classList.add('active'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||
|
||||
function confirmAction(title, message, callback) {
|
||||
document.getElementById('confirmTitle').textContent = title;
|
||||
document.getElementById('confirmMessage').textContent = message;
|
||||
state.confirmCallback = callback;
|
||||
openModal('confirmModal');
|
||||
}
|
||||
function executeConfirm() {
|
||||
if (state.confirmCallback) state.confirmCallback();
|
||||
closeModal('confirmModal');
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
async function login(email, password) {
|
||||
const data = await api.request('POST', '/auth/token', { email, password });
|
||||
state.token = data.token;
|
||||
localStorage.setItem('shorebird_token', data.token);
|
||||
await loadCurrentUser();
|
||||
showApp();
|
||||
navigate('dashboard');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
state.token = null;
|
||||
state.user = null;
|
||||
state.apps = [];
|
||||
localStorage.removeItem('shorebird_token');
|
||||
document.getElementById('appContainer').classList.remove('active');
|
||||
document.getElementById('loginPage').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
state.user = await api.get('/users/me');
|
||||
document.getElementById('currentUserEmail').textContent = state.user.email || '—';
|
||||
} catch (e) {
|
||||
// User endpoint might not be available
|
||||
state.user = { email: 'admin' };
|
||||
}
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById('loginPage').style.display = 'none';
|
||||
document.getElementById('appContainer').classList.add('active');
|
||||
}
|
||||
|
||||
async function register(name, email, password) {
|
||||
const data = await api.request('POST', '/auth/register', { name, email, password });
|
||||
state.token = data.token;
|
||||
localStorage.setItem('shorebird_token', data.token);
|
||||
await loadCurrentUser();
|
||||
closeModal('registerOverlay');
|
||||
showApp();
|
||||
navigate('dashboard');
|
||||
showToast('Account created successfully', 'success');
|
||||
}
|
||||
|
||||
// ---- Dashboard ----
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const appsResp = await api.get('/apps');
|
||||
state.apps = appsResp.apps || [];
|
||||
document.getElementById('statApps').textContent = state.apps.length;
|
||||
document.getElementById('statPatches').textContent = '—';
|
||||
|
||||
// Try to get users
|
||||
try {
|
||||
const usersResp = await api.get('/admin/users');
|
||||
state.users = usersResp.users || [];
|
||||
document.getElementById('statUsers').textContent = state.users.length;
|
||||
} catch {
|
||||
document.getElementById('statUsers').textContent = '—';
|
||||
}
|
||||
|
||||
// Try to get organizations
|
||||
try {
|
||||
const orgsResp = await api.get('/organizations');
|
||||
const orgs = orgsResp.organizations || [];
|
||||
document.getElementById('statOrganizations').textContent = orgs.length;
|
||||
} catch {
|
||||
document.getElementById('statOrganizations').textContent = '—';
|
||||
}
|
||||
|
||||
// Recent apps table
|
||||
const tableDiv = document.getElementById('recentAppsTable');
|
||||
if (state.apps.length === 0) {
|
||||
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">📱</div><p>No apps yet. Create your first app to get started.</p></div>';
|
||||
} else {
|
||||
const recent = state.apps.slice(0, 5);
|
||||
tableDiv.innerHTML = renderTable(
|
||||
['App Name', 'App ID', 'Created'],
|
||||
recent.map(a => [
|
||||
a.display_name,
|
||||
`<code class="code">${a.app_id}</code>`,
|
||||
new Date(a.created_at).toLocaleDateString(),
|
||||
])
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Failed to load dashboard', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Apps ----
|
||||
async function loadApps() {
|
||||
try {
|
||||
const resp = await api.get('/apps');
|
||||
state.apps = resp.apps || [];
|
||||
const tableDiv = document.getElementById('appsTable');
|
||||
|
||||
if (state.apps.length === 0) {
|
||||
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">📱</div><p>No apps yet. Create your first app.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableDiv.innerHTML = renderTable(
|
||||
['App Name', 'App ID', 'Created', 'Actions'],
|
||||
state.apps.map(a => [
|
||||
a.display_name,
|
||||
`<code class="code">${a.app_id}</code>`,
|
||||
new Date(a.created_at).toLocaleDateString(),
|
||||
`<button class="btn btn-ghost" onclick="copyToClipboard('${a.app_id}')" title="Copy App ID">📋</button>
|
||||
<button class="btn btn-ghost" onclick="confirmDeleteApp('${a.app_id}','${escapeHtml(a.display_name)}')" title="Delete App">🗑️</button>`,
|
||||
])
|
||||
);
|
||||
} catch (e) {
|
||||
showToast('Failed to load apps', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp(displayName) {
|
||||
try {
|
||||
await api.post('/apps', { display_name: displayName, organization_id: 0 });
|
||||
closeModal('createAppOverlay');
|
||||
showToast(`App "${displayName}" created`, 'success');
|
||||
loadApps();
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to create app', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteApp(appId, name) {
|
||||
confirmAction('Delete App', `Are you sure you want to delete "${name}"? This action cannot be undone.`, async () => {
|
||||
try {
|
||||
await api.del(`/apps/${appId}`);
|
||||
showToast(`App "${name}" deleted`, 'success');
|
||||
loadApps();
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to delete app', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateAppModal() {
|
||||
document.getElementById('appDisplayName').value = '';
|
||||
openModal('createAppOverlay');
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await api.get('/admin/users');
|
||||
state.users = resp.users || [];
|
||||
const tableDiv = document.getElementById('usersTable');
|
||||
|
||||
if (state.users.length === 0) {
|
||||
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">👥</div><p>No users found.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableDiv.innerHTML = renderTable(
|
||||
['ID', 'Email', 'Name', 'Role', 'Created'],
|
||||
state.users.map(u => [
|
||||
u.id,
|
||||
u.email,
|
||||
u.name || '—',
|
||||
u.role ? `<span class="badge badge-info">${u.role}</span>` : `<span class="badge">member</span>`,
|
||||
u.created_at ? new Date(u.created_at).toLocaleDateString() : '—',
|
||||
])
|
||||
);
|
||||
} catch (e) {
|
||||
// If admin/users endpoint is not available, show a message
|
||||
document.getElementById('usersTable').innerHTML =
|
||||
'<div class="empty-state"><div class="empty-icon">👥</div><p>User management API not available. Check server configuration.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(name, email, password) {
|
||||
try {
|
||||
await api.request('POST', '/auth/register', { name, email, password });
|
||||
closeModal('createUserOverlay');
|
||||
showToast(`User "${email}" created`, 'success');
|
||||
loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to create user', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateUserModal() {
|
||||
document.getElementById('newUserName').value = '';
|
||||
document.getElementById('newUserEmail').value = '';
|
||||
document.getElementById('newUserPassword').value = '';
|
||||
openModal('createUserOverlay');
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
async function loadSettings() {
|
||||
const infoDiv = document.getElementById('serverInfo');
|
||||
infoDiv.innerHTML = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">Server</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-value"><code class="code">${window.location.origin}/api/v1</code></div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Auth Endpoint</div>
|
||||
<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>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Database</div>
|
||||
<div class="info-value">PostgreSQL</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">UI Version</div>
|
||||
<div class="info-value">1.0.0</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current API token
|
||||
document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated';
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
function renderTable(headers, rows) {
|
||||
let html = '<table><thead><tr>';
|
||||
headers.forEach(h => { html += `<th>${h}</th>`; });
|
||||
html += '</tr></thead><tbody>';
|
||||
rows.forEach(row => {
|
||||
html += '<tr>';
|
||||
row.forEach(cell => { html += `<td>${cell}</td>`; });
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('Copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const input = document.createElement('input');
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
showToast('Copied to clipboard', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Event Listeners ----
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Login form
|
||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('loginEmail').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const errorDiv = document.getElementById('loginError');
|
||||
errorDiv.style.display = 'none';
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message || 'Login failed. Check your credentials.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Register form (modal)
|
||||
document.getElementById('registerForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('regName').value;
|
||||
const email = document.getElementById('regEmail').value;
|
||||
const password = document.getElementById('regPassword').value;
|
||||
try {
|
||||
await register(name, email, password);
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Registration failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Show register modal
|
||||
document.getElementById('showRegister').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.getElementById('regName').value = '';
|
||||
document.getElementById('regEmail').value = '';
|
||||
document.getElementById('regPassword').value = '';
|
||||
openModal('registerOverlay');
|
||||
});
|
||||
|
||||
function closeRegister() { closeModal('registerOverlay'); }
|
||||
window.closeRegister = closeRegister;
|
||||
|
||||
// Create app form
|
||||
document.getElementById('createAppForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('appDisplayName').value;
|
||||
await createApp(name);
|
||||
});
|
||||
|
||||
// Create user form
|
||||
document.getElementById('createUserForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('newUserName').value;
|
||||
const email = document.getElementById('newUserEmail').value;
|
||||
const password = document.getElementById('newUserPassword').value;
|
||||
await createUser(name, email, password);
|
||||
});
|
||||
|
||||
// Sidebar navigation
|
||||
document.querySelectorAll('#sidebarNav a').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const page = link.dataset.page;
|
||||
navigate(page);
|
||||
window.location.hash = page;
|
||||
});
|
||||
});
|
||||
|
||||
// Logout
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
logout();
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', e => {
|
||||
if (e.target === overlay) closeModal(overlay.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-login if token exists
|
||||
if (state.token) {
|
||||
loadCurrentUser().then(() => {
|
||||
showApp();
|
||||
const page = window.location.hash.replace('#', '') || 'dashboard';
|
||||
navigate(page);
|
||||
}).catch(() => {
|
||||
logout();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export functions for inline onclick handlers
|
||||
window.openCreateAppModal = openCreateAppModal;
|
||||
window.openCreateUserModal = openCreateUserModal;
|
||||
window.confirmDeleteApp = confirmDeleteApp;
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
window.closeModal = closeModal;
|
||||
Reference in New Issue
Block a user