Initial commit

This commit is contained in:
Tony
2026-06-12 04:22:58 +08:00
commit 246260a8f5
25 changed files with 4446 additions and 0 deletions
+123
View File
@@ -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,
})
}
+273
View File
@@ -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})
}
+131
View File
@@ -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,
})
}
+217
View File
@@ -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",
})
}
+29
View File
@@ -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)
}
+194
View File
@@ -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"})
}
+233
View File
@@ -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})
}
+165
View File
@@ -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 ""
}
+53
View File
@@ -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
}