Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user