3bb4ab494e
- Added `internal/db/store.go` to define the Store interface for database operations, including user, organization, app, channel, release, patch, and artifact management. - Introduced `internal/models/models.go` changes to reflect updated organization and artifact response structures. - Created `internal/storage/factory.go`, `local.go`, and `s3.go` to implement storage backends for local filesystem and S3-compatible services. - Removed deprecated `internal/storage/storage.go` and refactored storage interface into `internal/storage/store.go`. - Updated frontend JavaScript in `web/js/app.js` to display server health information, including storage and database backend details.
218 lines
6.3 KiB
Go
218 lines
6.3 KiB
Go
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.Store
|
|
Storage storage.Store
|
|
}
|
|
|
|
// 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",
|
|
})
|
|
}
|