feat: implement app access control and upload token validation in storage

This commit is contained in:
Tony
2026-06-15 15:17:02 +08:00
parent d391ac678f
commit 09a22900cd
11 changed files with 297 additions and 22 deletions
+1
View File
@@ -64,6 +64,7 @@ func main() {
Type: cfg.Storage.Driver, Type: cfg.Storage.Driver,
LocalDir: cfg.Storage.LocalDir, LocalDir: cfg.Storage.LocalDir,
ServerBaseURL: cfg.Server.BaseURL, ServerBaseURL: cfg.Server.BaseURL,
UploadSecret: cfg.Auth.JWTSecret,
S3Endpoint: cfg.Storage.S3Endpoint, S3Endpoint: cfg.Storage.S3Endpoint,
S3AccessKey: cfg.Storage.S3AccessKey, S3AccessKey: cfg.Storage.S3AccessKey,
S3SecretKey: cfg.Storage.S3SecretKey, S3SecretKey: cfg.Storage.S3SecretKey,
+46
View File
@@ -0,0 +1,46 @@
package handlers
import (
"net/http"
"github.com/google/uuid"
"github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/db"
)
func requireAppAccess(w http.ResponseWriter, r *http.Request, store db.Store, appID uuid.UUID, manage bool) (*db.AppRow, bool) {
claims := middleware.GetClaims(r)
if claims == nil {
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
return nil, false
}
app, err := store.GetAppByID(r.Context(), appID)
if err != nil {
respondError(w, http.StatusNotFound, "App not found", nil)
return nil, false
}
if canAccessOrganization(r, store, claims.UserID, app.OrganizationID, manage) {
return app, true
}
if manage {
respondError(w, http.StatusForbidden, "Organization admin access required", nil)
} else {
respondError(w, http.StatusForbidden, "Organization access required", nil)
}
return nil, false
}
func canAccessOrganization(r *http.Request, store db.Store, userID, orgID int, manage bool) bool {
user, _ := store.GetUserByID(r.Context(), userID)
if user != nil && user.IsAdmin {
return true
}
role, err := store.GetOrganizationRole(r.Context(), userID, orgID)
if err != nil {
return false
}
if manage {
return role == "admin"
}
return role != ""
}
+12
View File
@@ -17,6 +17,9 @@ type AdminHandler struct {
// AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices // AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices
// Restricts a patch to only be delivered to specific devices (by client_id). // Restricts a patch to only be delivered to specific devices (by client_id).
func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId")) patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil) respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
@@ -41,6 +44,9 @@ func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) {
// RemoveTargetDevice handles DELETE /api/v1/admin/patches/{patchId}/target-devices/{clientId} // RemoveTargetDevice handles DELETE /api/v1/admin/patches/{patchId}/target-devices/{clientId}
func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
// For simplicity, target device removal is handled by the database cascade // For simplicity, target device removal is handled by the database cascade
// In a full implementation, add a RemovePatchTargetDevice DB method // In a full implementation, add a RemovePatchTargetDevice DB method
respondError(w, http.StatusNotImplemented, "Not yet implemented", nil) respondError(w, http.StatusNotImplemented, "Not yet implemented", nil)
@@ -48,6 +54,9 @@ func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request
// GetTargetDevices handles GET /api/v1/admin/patches/{patchId}/target-devices // GetTargetDevices handles GET /api/v1/admin/patches/{patchId}/target-devices
func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId")) patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil) respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
@@ -69,6 +78,9 @@ func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request)
// GetPatchEvents handles GET /api/v1/admin/apps/{appId}/events // GetPatchEvents handles GET /api/v1/admin/apps/{appId}/events
// Returns patch events for analytics. // Returns patch events for analytics.
func (h *AdminHandler) GetPatchEvents(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) GetPatchEvents(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
// This would query patch_events table for the app // This would query patch_events table for the app
// For now, return a stub // For now, return a stub
respondJSON(w, http.StatusOK, map[string]interface{}{ respondJSON(w, http.StatusOK, map[string]interface{}{
+7 -6
View File
@@ -285,12 +285,7 @@ func (h *AppHandler) TransferApp(w http.ResponseWriter, r *http.Request) {
} }
func (h *AppHandler) canManageOrganization(r *http.Request, userID, orgID int) bool { func (h *AppHandler) canManageOrganization(r *http.Request, userID, orgID int) bool {
user, _ := h.DB.GetUserByID(r.Context(), userID) return canAccessOrganization(r, h.DB, userID, orgID, true)
if user != nil && user.IsAdmin {
return true
}
role, err := h.DB.GetOrganizationRole(r.Context(), userID, orgID)
return err == nil && role == "admin"
} }
// ChannelHandler handles channel-related API endpoints. // ChannelHandler handles channel-related API endpoints.
@@ -306,6 +301,9 @@ func (h *ChannelHandler) GetChannels(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil) respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return return
} }
if _, ok := requireAppAccess(w, r, h.DB, id, false); !ok {
return
}
channels, err := h.DB.GetChannelsByAppID(r.Context(), id) channels, err := h.DB.GetChannelsByAppID(r.Context(), id)
if err != nil { if err != nil {
@@ -333,6 +331,9 @@ func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil) respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return return
} }
if _, ok := requireAppAccess(w, r, h.DB, id, true); !ok {
return
}
var req models.CreateChannelRequest var req models.CreateChannelRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
+12 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/shorebird-server/internal/api/middleware"
"github.com/shorebird-server/internal/db" "github.com/shorebird-server/internal/db"
"github.com/shorebird-server/internal/models" "github.com/shorebird-server/internal/models"
"github.com/shorebird-server/internal/storage" "github.com/shorebird-server/internal/storage"
@@ -179,7 +180,17 @@ func (h *DeviceHandler) PatchEvents(w http.ResponseWriter, r *http.Request) {
// RollbackPatch handles POST /api/v1/apps/{appId}/patches/rollback (admin endpoint) // RollbackPatch handles POST /api/v1/apps/{appId}/patches/rollback (admin endpoint)
func (h *DeviceHandler) RollbackPatch(w http.ResponseWriter, r *http.Request) { func (h *DeviceHandler) RollbackPatch(w http.ResponseWriter, r *http.Request) {
// This is an admin operation. For now, allow authenticated users. 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 || !user.IsAdmin {
respondError(w, http.StatusForbidden, "Admin access required", nil)
return
}
var req struct { var req struct {
ReleaseID int `json:"release_id"` ReleaseID int `json:"release_id"`
PatchNumber int `json:"patch_number"` PatchNumber int `json:"patch_number"`
+75
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/shorebird-server/internal/db" "github.com/shorebird-server/internal/db"
"github.com/shorebird-server/internal/models" "github.com/shorebird-server/internal/models"
"github.com/shorebird-server/internal/storage" "github.com/shorebird-server/internal/storage"
@@ -21,6 +22,14 @@ type PatchHandler struct {
// CreatePatch handles POST /api/v1/apps/{appId}/patches // CreatePatch handles POST /api/v1/apps/{appId}/patches
func (h *PatchHandler) CreatePatch(w http.ResponseWriter, r *http.Request) { func (h *PatchHandler) CreatePatch(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appId") appID := chi.URLParam(r, "appId")
parsedAppID, err := uuid.Parse(appID)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, parsedAppID, true); !ok {
return
}
var req models.CreatePatchRequest var req models.CreatePatchRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -62,6 +71,14 @@ func (h *PatchHandler) CreatePatch(w http.ResponseWriter, r *http.Request) {
// CreatePatchArtifact handles POST /api/v1/apps/{appId}/patches/{patchId}/artifacts // CreatePatchArtifact handles POST /api/v1/apps/{appId}/patches/{patchId}/artifacts
func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Request) { func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appId") appID := chi.URLParam(r, "appId")
parsedAppID, err := uuid.Parse(appID)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, parsedAppID, true); !ok {
return
}
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId")) patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil) respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
@@ -95,6 +112,10 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques
respondError(w, http.StatusNotFound, "Release not found", nil) respondError(w, http.StatusNotFound, "Release not found", nil)
return return
} }
if release.AppID != parsedAppID {
respondError(w, http.StatusNotFound, "Patch not found for this app", nil)
return
}
// Generate a storage key for the public bucket // Generate a storage key for the public bucket
filename := "dlc.vmcode" // standard patch artifact name filename := "dlc.vmcode" // standard patch artifact name
@@ -136,11 +157,24 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques
// GetPatches handles GET /api/v1/apps/{appId}/releases/{releaseId}/patches // GetPatches handles GET /api/v1/apps/{appId}/releases/{releaseId}/patches
func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) { func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
return
}
releaseID, err := strconv.Atoi(chi.URLParam(r, "releaseId")) releaseID, err := strconv.Atoi(chi.URLParam(r, "releaseId"))
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid release ID", nil) respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
return return
} }
release, err := h.DB.GetReleaseByID(r.Context(), releaseID)
if err != nil || release.AppID != appID {
respondError(w, http.StatusNotFound, "Release not found for this app", nil)
return
}
patches, err := h.DB.GetPatchesByReleaseID(r.Context(), releaseID) patches, err := h.DB.GetPatchesByReleaseID(r.Context(), releaseID)
if err != nil { if err != nil {
@@ -172,11 +206,22 @@ func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) {
// PromotePatch handles POST /api/v1/apps/{appId}/patches/promote // PromotePatch handles POST /api/v1/apps/{appId}/patches/promote
func (h *PatchHandler) PromotePatch(w http.ResponseWriter, r *http.Request) { func (h *PatchHandler) PromotePatch(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
return
}
var req models.PromotePatchRequest var req models.PromotePatchRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", nil) respondError(w, http.StatusBadRequest, "Invalid request body", nil)
return return
} }
if !h.patchBelongsToApp(w, r, req.PatchID, appID) {
return
}
if err := h.DB.PromotePatch(r.Context(), req.PatchID, req.ChannelID); err != nil { if err := h.DB.PromotePatch(r.Context(), req.PatchID, req.ChannelID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to promote patch", nil) respondError(w, http.StatusInternalServerError, "Failed to promote patch", nil)
@@ -188,6 +233,22 @@ func (h *PatchHandler) PromotePatch(w http.ResponseWriter, r *http.Request) {
// UpdatePatch handles PATCH /api/v1/apps/{appId}/patches/{patchId} // UpdatePatch handles PATCH /api/v1/apps/{appId}/patches/{patchId}
func (h *PatchHandler) UpdatePatch(w http.ResponseWriter, r *http.Request) { func (h *PatchHandler) UpdatePatch(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
return
}
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
return
}
if !h.patchBelongsToApp(w, r, patchID, appID) {
return
}
var req models.UpdatePatchRequest var req models.UpdatePatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", nil) respondError(w, http.StatusBadRequest, "Invalid request body", nil)
@@ -198,3 +259,17 @@ func (h *PatchHandler) UpdatePatch(w http.ResponseWriter, r *http.Request) {
// This would require an additional DB method // This would require an additional DB method
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"}) respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
func (h *PatchHandler) patchBelongsToApp(w http.ResponseWriter, r *http.Request, patchID int, appID uuid.UUID) bool {
patch, err := h.DB.GetPatchByID(r.Context(), patchID)
if err != nil {
respondError(w, http.StatusNotFound, "Patch not found", nil)
return false
}
release, err := h.DB.GetReleaseByID(r.Context(), patch.ReleaseID)
if err != nil || release.AppID != appID {
respondError(w, http.StatusNotFound, "Patch not found for this app", nil)
return false
}
return true
}
+59
View File
@@ -38,6 +38,9 @@ func (h *ReleaseHandler) GetReleases(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil) respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return return
} }
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
return
}
sideloadable := r.URL.Query().Get("sideloadable") == "true" sideloadable := r.URL.Query().Get("sideloadable") == "true"
@@ -81,6 +84,9 @@ func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil) respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return return
} }
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
return
}
var req models.CreateReleaseRequest var req models.CreateReleaseRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -112,11 +118,22 @@ func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) {
// UpdateRelease handles PATCH /api/v1/apps/{appId}/releases/{releaseId} // UpdateRelease handles PATCH /api/v1/apps/{appId}/releases/{releaseId}
func (h *ReleaseHandler) UpdateRelease(w http.ResponseWriter, r *http.Request) { func (h *ReleaseHandler) UpdateRelease(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
}
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
return
}
releaseID, err := parseIntParam(r, "releaseId") releaseID, err := parseIntParam(r, "releaseId")
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid release ID", nil) respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
return return
} }
if !h.releaseBelongsToApp(w, r, releaseID, appID) {
return
}
var req models.UpdateReleaseRequest var req models.UpdateReleaseRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
@@ -133,11 +150,22 @@ func (h *ReleaseHandler) UpdateRelease(w http.ResponseWriter, r *http.Request) {
} }
func (h *ReleaseHandler) DeleteRelease(w http.ResponseWriter, r *http.Request) { func (h *ReleaseHandler) DeleteRelease(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
}
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
return
}
releaseID, err := parseIntParam(r, "releaseId") releaseID, err := parseIntParam(r, "releaseId")
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid release ID", nil) respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
return return
} }
if !h.releaseBelongsToApp(w, r, releaseID, appID) {
return
}
if err := h.DB.DeleteRelease(r.Context(), releaseID); err != nil { if err := h.DB.DeleteRelease(r.Context(), releaseID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to delete release", strPtr(err.Error())) respondError(w, http.StatusInternalServerError, "Failed to delete release", strPtr(err.Error()))
return return
@@ -148,11 +176,22 @@ func (h *ReleaseHandler) DeleteRelease(w http.ResponseWriter, r *http.Request) {
// CreateReleaseArtifact handles POST /api/v1/apps/{appId}/releases/{releaseId}/artifacts // CreateReleaseArtifact handles POST /api/v1/apps/{appId}/releases/{releaseId}/artifacts
func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) { func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appId") appID := chi.URLParam(r, "appId")
parsedAppID, err := parseUUID(appID)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, parsedAppID, true); !ok {
return
}
releaseID, err := parseIntParam(r, "releaseId") releaseID, err := parseIntParam(r, "releaseId")
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid release ID", nil) respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
return return
} }
if !h.releaseBelongsToApp(w, r, releaseID, parsedAppID) {
return
}
// The CLI sends multipart form data. Parse it. // The CLI sends multipart form data. Parse it.
if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB max if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB max
@@ -216,11 +255,22 @@ func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Re
// GetReleaseArtifacts handles GET /api/v1/apps/{appId}/releases/{releaseId}/artifacts // GetReleaseArtifacts handles GET /api/v1/apps/{appId}/releases/{releaseId}/artifacts
func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Request) { func (h *ReleaseHandler) GetReleaseArtifacts(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
}
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
return
}
releaseID, err := parseIntParam(r, "releaseId") releaseID, err := parseIntParam(r, "releaseId")
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Invalid release ID", nil) respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
return return
} }
if !h.releaseBelongsToApp(w, r, releaseID, appID) {
return
}
var archPtr, platformPtr *string var archPtr, platformPtr *string
if a := r.URL.Query().Get("arch"); a != "" { if a := r.URL.Query().Get("arch"); a != "" {
@@ -260,3 +310,12 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ
respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result}) respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result})
} }
func (h *ReleaseHandler) releaseBelongsToApp(w http.ResponseWriter, r *http.Request, releaseID int, appID uuid.UUID) bool {
release, err := h.DB.GetReleaseByID(r.Context(), releaseID)
if err != nil || release.AppID != appID {
respondError(w, http.StatusNotFound, "Release not found for this app", nil)
return false
}
return true
}
+1 -1
View File
@@ -27,7 +27,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Version"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Version"},
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: true, AllowCredentials: false,
MaxAge: 300, MaxAge: 300,
})) }))
+13
View File
@@ -3,12 +3,15 @@ package handlers
import ( import (
"io" "io"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/shorebird-server/internal/storage" "github.com/shorebird-server/internal/storage"
) )
const maxStorageUploadBytes = 2 << 30
// StorageHandler provides upload/download endpoints for local storage. // StorageHandler provides upload/download endpoints for local storage.
type StorageHandler struct { type StorageHandler struct {
store storage.Store store storage.Store
@@ -21,8 +24,18 @@ func NewStorageHandler(store storage.Store) *StorageHandler {
// Upload handles POST /storage/upload/{scope}/{key} // Upload handles POST /storage/upload/{scope}/{key}
func (h *StorageHandler) Upload(w http.ResponseWriter, r *http.Request) { func (h *StorageHandler) Upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxStorageUploadBytes)
scope := chi.URLParam(r, "scope") scope := chi.URLParam(r, "scope")
key := chi.URLParam(r, "*") key := chi.URLParam(r, "*")
if v, ok := h.store.(interface {
ValidateUploadToken(scope, objectKey, token string, expires int64) bool
}); ok {
expires, _ := strconv.ParseInt(r.URL.Query().Get("expires"), 10, 64)
if !v.ValidateUploadToken(scope, key, r.URL.Query().Get("token"), expires) {
respondError(w, http.StatusForbidden, "Invalid or expired upload URL", nil)
return
}
}
isPublic := scope == "patches" isPublic := scope == "patches"
ct := r.Header.Get("Content-Type") ct := r.Header.Get("Content-Type")
+10 -9
View File
@@ -8,16 +8,17 @@ type Config struct {
Type string Type string
// Local settings // Local settings
LocalDir string LocalDir string
ServerBaseURL string ServerBaseURL string
UploadSecret string
// S3 settings // S3 settings
S3Endpoint string S3Endpoint string
S3AccessKey string S3AccessKey string
S3SecretKey string S3SecretKey string
S3UseSSL bool S3UseSSL bool
S3ReleaseBucket string S3ReleaseBucket string
S3PatchBucket string S3PatchBucket string
} }
// NewStore creates the appropriate Store backend based on Config.Type. // NewStore creates the appropriate Store backend based on Config.Type.
@@ -32,6 +33,6 @@ func NewStore(cfg Config) (Store, error) {
if cfg.Type != "" && cfg.Type != "local" { if cfg.Type != "" && cfg.Type != "local" {
fmt.Printf("storage: unknown type %q, falling back to local\n", cfg.Type) fmt.Printf("storage: unknown type %q, falling back to local\n", cfg.Type)
} }
return NewLocalStore(cfg.LocalDir, cfg.ServerBaseURL) return NewLocalStore(cfg.LocalDir, cfg.ServerBaseURL, cfg.UploadSecret)
} }
} }
+61 -5
View File
@@ -2,10 +2,15 @@ package storage
import ( import (
"context" "context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"time" "time"
) )
@@ -18,11 +23,12 @@ type LocalStore struct {
// serverBaseURL is the external URL of this server (e.g. http://localhost:8080). // serverBaseURL is the external URL of this server (e.g. http://localhost:8080).
// Used when generating download URLs for device-side patch checks. // Used when generating download URLs for device-side patch checks.
serverBaseURL string serverBaseURL string
uploadSecret string
} }
// NewLocalStore creates a local-filesystem storage backend. // NewLocalStore creates a local-filesystem storage backend.
// baseDir is created automatically if it does not exist. // baseDir is created automatically if it does not exist.
func NewLocalStore(baseDir, serverBaseURL string) (*LocalStore, error) { func NewLocalStore(baseDir, serverBaseURL, uploadSecret string) (*LocalStore, error) {
abs, err := filepath.Abs(baseDir) abs, err := filepath.Abs(baseDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("local storage: %w", err) return nil, fmt.Errorf("local storage: %w", err)
@@ -36,7 +42,7 @@ func NewLocalStore(baseDir, serverBaseURL string) (*LocalStore, error) {
if serverBaseURL == "" { if serverBaseURL == "" {
serverBaseURL = "http://localhost:8080" serverBaseURL = "http://localhost:8080"
} }
return &LocalStore{baseDir: abs, serverBaseURL: serverBaseURL}, nil return &LocalStore{baseDir: abs, serverBaseURL: serverBaseURL, uploadSecret: uploadSecret}, nil
} }
func (s *LocalStore) BackendName() string { return "Local filesystem" } func (s *LocalStore) BackendName() string { return "Local filesystem" }
@@ -49,7 +55,12 @@ func (s *LocalStore) GeneratePresignedUploadURL(_ context.Context, objectKey str
if isPublic { if isPublic {
scope = "patches" scope = "patches"
} }
return fmt.Sprintf("%s/storage/upload/%s/%s", s.serverBaseURL, scope, objectKey), nil base := fmt.Sprintf("%s/storage/upload/%s/%s", s.serverBaseURL, scope, objectKey)
if s.uploadSecret == "" {
return base, nil
}
expires := time.Now().Add(30 * time.Minute).Unix()
return fmt.Sprintf("%s?expires=%d&token=%s", base, expires, s.signUpload(scope, objectKey, expires)), nil
} }
// GeneratePublicDownloadURL returns a public download URL for a patch. // GeneratePublicDownloadURL returns a public download URL for a patch.
@@ -63,7 +74,10 @@ func (s *LocalStore) UploadObject(_ context.Context, objectKey string, reader io
if isPublic { if isPublic {
scope = "patches" scope = "patches"
} }
target := filepath.Join(s.baseDir, scope, objectKey) target, err := s.objectPath(scope, objectKey)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("local storage: %w", err) return fmt.Errorf("local storage: %w", err)
} }
@@ -84,8 +98,50 @@ func (s *LocalStore) GetObject(_ context.Context, objectKey string, isPublic boo
if isPublic { if isPublic {
scope = "patches" scope = "patches"
} }
return os.Open(filepath.Join(s.baseDir, scope, objectKey)) target, err := s.objectPath(scope, objectKey)
if err != nil {
return nil, err
}
return os.Open(target)
} }
// BaseDir returns the absolute base directory. // BaseDir returns the absolute base directory.
func (s *LocalStore) BaseDir() string { return s.baseDir } func (s *LocalStore) BaseDir() string { return s.baseDir }
func (s *LocalStore) ValidateUploadToken(scope, objectKey, token string, expires int64) bool {
if s.uploadSecret == "" {
return true
}
if token == "" || expires <= time.Now().Unix() {
return false
}
expected := s.signUpload(scope, objectKey, expires)
return hmac.Equal([]byte(token), []byte(expected))
}
func (s *LocalStore) signUpload(scope, objectKey string, expires int64) string {
mac := hmac.New(sha256.New, []byte(s.uploadSecret))
io.WriteString(mac, scope)
io.WriteString(mac, "\n")
io.WriteString(mac, objectKey)
io.WriteString(mac, "\n")
io.WriteString(mac, strconv.FormatInt(expires, 10))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (s *LocalStore) objectPath(scope, objectKey string) (string, error) {
if objectKey == "" || filepath.IsAbs(objectKey) {
return "", fmt.Errorf("local storage: invalid object key")
}
cleanKey := filepath.Clean(filepath.FromSlash(objectKey))
if cleanKey == "." || cleanKey == ".." || strings.HasPrefix(cleanKey, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("local storage: invalid object key")
}
root := filepath.Join(s.baseDir, scope)
target := filepath.Join(root, cleanKey)
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("local storage: invalid object key")
}
return target, nil
}