internal/handlers/upload_handler.go
Rama main · réplica de lectura del repositorio original.
package handlers
import (
"encoding/json"
"errors"
"fmt"
"log"
"mime/multipart"
"net/http"
"regexp"
"xprem/config"
"xprem/internal/bucket"
"xprem/internal/services"
"xprem/internal/types"
"xprem/internal/validation"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
type UploadHandler struct {
deploymentService *services.DeploymentService
}
func NewUploadHandler(deploymentService *services.DeploymentService) *UploadHandler {
return &UploadHandler{
deploymentService: deploymentService,
}
}
type RequestUploadURLsRequest struct {
Files []services.FileUploadItem `json:"files"`
// Sourcemap is the launch asset's source map; absent when the export has
// none or the CLI predates source map uploads.
Sourcemap *services.SourcemapUploadItem `json:"sourcemap,omitempty"`
Message string `json:"message,omitempty"`
}
// manifestKeyPattern is the md5 hex expo-updates uses as its on-device cache
// key. Only its shape can be checked here: the bytes go straight to the bucket,
// so nothing server-side ever sees what the digest was taken over.
var manifestKeyPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
// validateUploadFiles checks every file names a role and carries the manifest
// data its role requires. How many of each a publish needs is the service's
// rule, not the wire's.
func validateUploadFiles(files []services.FileUploadItem) error {
for _, file := range files {
if err := bucket.ValidateUploadFile(file.Path, file.Hash); err != nil {
return fmt.Errorf("%s: %w", file.Path, err)
}
switch file.Role {
case services.FileRoleLaunch, services.FileRoleAsset:
if !manifestKeyPattern.MatchString(file.Key) {
return fmt.Errorf("%s: invalid key: must be md5 hex", file.Path)
}
case services.FileRoleConfig:
default:
return fmt.Errorf("%s: unknown role %q", file.Path, file.Role)
}
}
return nil
}
// parsePublishGroup reads the optional CLI-minted id grouping the per-platform
// rows of one eoas publish run. In stateless mode the parameter is ignored
// entirely: the feature does not exist there, and the missing acknowledgment
// (echo or header) is how the CLI knows. In control-plane mode a malformed
// value is rejected so a buggy CLI cannot silently create ungrouped rows.
func parsePublishGroup(r *http.Request) (*string, error) {
raw := r.URL.Query().Get("publishGroup")
if raw == "" || !config.IsDBMode() {
return nil, nil
}
parsed, err := uuid.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid publishGroup: must be a UUID")
}
normalized := parsed.String()
return &normalized, nil
}
// parsePublishGroupTarget reads the publish group TARGETED by a group-wide
// republish. Unlike the stamp above, a target cannot be ignored in stateless
// mode: it selects what the operation acts on, so without the control plane
// it is rejected outright rather than silently degraded.
func parsePublishGroupTarget(r *http.Request) (*string, error) {
raw := r.URL.Query().Get("publishGroup")
if raw == "" {
return nil, nil
}
if !config.IsDBMode() {
return nil, fmt.Errorf("publish groups require the database control plane")
}
parsed, err := uuid.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid publishGroup: must be a UUID")
}
normalized := parsed.String()
return &normalized, nil
}
type markUpdateAsUploadedResponse struct {
UpdateUUID string `json:"updateUUID,omitempty"`
}
func (h *UploadHandler) MarkUpdateAsUploadedHandler(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
vars := mux.Vars(r)
appId := vars["APP_ID"]
branchName := vars["BRANCH"]
platform, err := types.ParsePlatform(r.URL.Query().Get("platform"))
if err != nil {
log.Printf("[RequestID: %s] Invalid platform: %s", requestID, r.URL.Query().Get("platform"))
http.Error(w, "Invalid platform", http.StatusBadRequest)
return
}
if branchName == "" {
log.Printf("[RequestID: %s] No branch provided", requestID)
http.Error(w, "No branch provided", http.StatusBadRequest)
return
}
runtimeVersion := r.URL.Query().Get("runtimeVersion")
if runtimeVersion == "" {
log.Printf("[RequestID: %s] No runtime version provided", requestID)
http.Error(w, "No runtime version provided", http.StatusBadRequest)
return
}
updateId := r.URL.Query().Get("updateId")
if updateId == "" {
log.Printf("[RequestID: %s] No update id provided", requestID)
http.Error(w, "No update id provided", http.StatusBadRequest)
return
}
params := services.ProcessUpdateParams{
RequestID: requestID,
AppID: appId,
BranchName: branchName,
Platform: platform,
RuntimeVersion: runtimeVersion,
UpdateID: updateId,
}
updateUUID, err := h.deploymentService.ProcessUploadedUpdate(r.Context(), params)
if err != nil {
if errors.Is(err, services.ErrUnauthorized) {
RenderCliAuthError(w, err)
return
}
if errors.Is(err, services.ErrInvalidUpdate) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, services.ErrActiveRolloutBlocksPublish) {
log.Printf("[RequestID: %s] Mark-as-uploaded blocked by active rollout: %v", requestID, err)
http.Error(w, activeRolloutConflictMessage, http.StatusConflict)
return
}
if errors.Is(err, services.ErrRolloutSuperseded) {
log.Printf("[RequestID: %s] Rollout activation superseded by newer update: %v", requestID, err)
http.Error(w, services.ErrRolloutSuperseded.Error(), http.StatusConflict)
return
}
if validation.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Any unexpected runtime/database systems error falls back to standard 500s
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(markUpdateAsUploadedResponse{UpdateUUID: updateUUID}); err != nil {
log.Printf("[RequestID: %s] Error encoding response: %v", requestID, err)
}
}
func (h *UploadHandler) RequestUploadLocalFileHandler(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
appId := mux.Vars(r)["APP_ID"]
token := r.Header.Get(bucket.LocalUploadTokenHeader)
if token == "" {
log.Printf("[RequestID: %s] No token provided", requestID)
http.Error(w, "No token provided", http.StatusBadRequest)
return
}
key, tokenAppId, _, err := bucket.ValidateUploadToken(token)
if err != nil {
log.Printf("[RequestID: %s] Error validating upload token: %v", requestID, err)
http.Error(w, "Error validating upload token", http.StatusBadRequest)
return
}
// No branch check here: the router already judged the branch this token
// claims, and ValidateUploadToken pins the key inside it.
file, err := firstMultipartFile(r)
if err != nil {
log.Printf("[RequestID: %s] Error retrieving file from form: %v", requestID, err)
http.Error(w, "Error retrieving file from form", http.StatusBadRequest)
return
}
defer file.Close()
params := services.RequestLocalFileUploadParams{
RequestID: requestID,
AppID: appId,
Token: token,
Body: file,
Key: key,
TokenAppID: tokenAppId,
}
if err := h.deploymentService.RequestUploadLocalFile(r.Context(), params); err != nil {
if errors.Is(err, services.ErrInvalidStorageMode) {
http.Error(w, "Invalid storage mode", http.StatusInternalServerError)
return
}
if errors.Is(err, services.ErrInvalidToken) {
http.Error(w, "Error validating upload token", http.StatusBadRequest)
return
}
if errors.Is(err, services.ErrTokenAppMismatch) {
http.Error(w, "Upload token does not match this app", http.StatusForbidden)
return
}
if errors.Is(err, services.ErrUploadHashMismatch) {
http.Error(w, "Uploaded file does not match its hash", http.StatusBadRequest)
return
}
if errors.Is(err, services.ErrUploadFailed) {
http.Error(w, "Error handling upload file", http.StatusInternalServerError)
return
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// requestUploadUrlsResponse echoes RolloutPercentage and PublishGroup only when
// the server honoured them: the CLI reads their absence as a server too old to
// know the parameter (or stateless mode for the group) and warns instead of
// publishing to every device or pretending the rows are grouped.
type requestUploadUrlsResponse struct {
UpdateID int64 `json:"updateId"`
UploadRequests []bucket.FileUploadRequest `json:"uploadRequests"`
RolloutPercentage *int `json:"rolloutPercentage,omitempty"`
PublishGroup *string `json:"publishGroup,omitempty"`
}
func (h *UploadHandler) RequestUploadUrlHandler(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New().String()
vars := mux.Vars(r)
appId := vars["APP_ID"]
branchName := vars["BRANCH"]
if branchName == "" {
log.Printf("[RequestID: %s] No branch provided", requestID)
http.Error(w, "No branch provided", http.StatusBadRequest)
return
}
var platform types.Platform
if rawPlatform := r.URL.Query().Get("platform"); rawPlatform != "" {
parsed, err := types.ParsePlatform(rawPlatform)
if err != nil {
log.Printf("[RequestID: %s] Invalid platform: %s", requestID, rawPlatform)
http.Error(w, "Invalid platform", http.StatusBadRequest)
return
}
platform = parsed
}
commitHash := r.URL.Query().Get("commitHash")
runtimeVersion := r.URL.Query().Get("runtimeVersion")
if runtimeVersion == "" {
log.Printf("[RequestID: %s] No runtime version provided", requestID)
http.Error(w, "No runtime version provided", http.StatusBadRequest)
return
}
var rolloutPercentage *int
if rawRolloutPercentage := r.URL.Query().Get("rolloutPercentage"); rawRolloutPercentage != "" {
parsedRolloutPercentage, err := validation.RolloutPercentage("rolloutPercentage", rawRolloutPercentage)
if err != nil {
log.Printf("[RequestID: %s] Invalid rolloutPercentage: %s", requestID, rawRolloutPercentage)
http.Error(w, "Invalid "+err.Error(), http.StatusBadRequest)
return
}
// 100 means every device, i.e. a plain publish: treated as absent.
if parsedRolloutPercentage < 100 {
if !config.IsDBMode() {
log.Printf("[RequestID: %s] rolloutPercentage rejected in stateless mode", requestID)
http.Error(w, "Progressive rollouts require the database control plane", http.StatusBadRequest)
return
}
if platform == "" {
log.Printf("[RequestID: %s] rolloutPercentage requires a platform", requestID)
http.Error(w, "A platform is required when publishing with a rollout percentage", http.StatusBadRequest)
return
}
rolloutPercentage = &parsedRolloutPercentage
}
}
publishGroup, err := parsePublishGroup(r)
if err != nil {
log.Printf("[RequestID: %s] %v", requestID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var bodyReq RequestUploadURLsRequest
if err := json.NewDecoder(r.Body).Decode(&bodyReq); err != nil {
log.Printf("[RequestID: %s] Error decoding JSON body: %v", requestID, err)
http.Error(w, "Invalid JSON body", http.StatusBadRequest)
return
}
defer r.Body.Close()
if len(bodyReq.Files) == 0 {
log.Printf("[RequestID: %s] No file names provided", requestID)
http.Error(w, "No file names provided", http.StatusBadRequest)
return
}
if err := validateUploadFiles(bodyReq.Files); err != nil {
log.Printf("[RequestID: %s] Invalid upload file list: %v", requestID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if bodyReq.Sourcemap != nil {
if err := bucket.ValidateUploadFile(bodyReq.Sourcemap.Path, bodyReq.Sourcemap.Hash); err != nil {
log.Printf("[RequestID: %s] Invalid sourcemap: %v", requestID, err)
http.Error(w, fmt.Sprintf("%s: %v", bodyReq.Sourcemap.Path, err), http.StatusBadRequest)
return
}
}
params := services.RequestUploadURLParams{
RequestID: requestID,
AppID: appId,
BranchName: branchName,
Platform: platform,
CommitHash: commitHash,
RuntimeVersion: runtimeVersion,
Files: bodyReq.Files,
Sourcemap: bodyReq.Sourcemap,
Message: bodyReq.Message,
RolloutPercentage: rolloutPercentage,
PublishGroupID: publishGroup,
}
result, err := h.deploymentService.RequestUploadURLs(r.Context(), params)
if err != nil {
if errors.Is(err, services.ErrActiveRolloutBlocksPublish) {
log.Printf("[RequestID: %s] Publish blocked by active rollout: %v", requestID, err)
http.Error(w, activeRolloutConflictMessage, http.StatusConflict)
return
}
if errors.Is(err, services.ErrLaunchAssetRequired) {
log.Printf("[RequestID: %s] Publish refused: %v", requestID, err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if errors.Is(err, services.ErrNoChangesDetected) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotAcceptable)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "You have already uploaded this update, no changes detected",
})
return
}
if validation.IsValidationError(err) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "Internal server error processing payload URLs", http.StatusInternalServerError)
return
}
response := requestUploadUrlsResponse{
UpdateID: result.UpdateID,
UploadRequests: result.UploadRequests,
RolloutPercentage: rolloutPercentage,
PublishGroup: publishGroup,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("expo-update-id", fmt.Sprintf("%d", result.UpdateID))
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("[RequestID: %s] Error encoding response serialization: %v", requestID, err)
}
}
func firstMultipartFile(r *http.Request) (multipart.File, error) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
return nil, err
}
for _, files := range r.MultipartForm.File {
if len(files) > 0 {
return files[0].Open()
}
}
return nil, http.ErrMissingFile
}