Ctrl K
ComunidadGuías…

internal/handlers/upload_handler.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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
}