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 }