MultipartUtil.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.util;

import lombok.NoArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartRequest;

import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;

@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public class MultipartUtil {

	private static final String CONVENTIONAL_PART_NAME = "file";

	private static final Pattern LIMIT_MESSAGE = Pattern.compile("^max (file size|length) exceeded");

	/**
	 * Whether parsing a multipart body failed on a {@code spring.servlet.multipart.*} limit. Jetty reports some
	 * limits, such as "max length exceeded: 4096", as an {@code IllegalStateException} inside its generic
	 * "bad multipart" failure, which Spring does not turn into {@link MaxUploadSizeExceededException}.
	 */
	public static boolean isSizeLimitBreach(MultipartException ex) {
		if (ex instanceof MaxUploadSizeExceededException) {
			return true;
		}
		for (Throwable cause = ex; cause != null; cause = cause.getCause()) {
			if (cause instanceof IllegalStateException && cause.getMessage() != null
					&& LIMIT_MESSAGE.matcher(cause.getMessage()).find()) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Returns the file part of a multipart request that carries a single upload, regardless of the
	 * name the client gave it.
	 * <p>
	 * Endpoints accepting a single upload do not use the part name for anything and no specification
	 * reserves the name "file" for it, so clients naming their part something else are accepted too.
	 * A part named "file" is preferred when it carries a filename, so that the conventional name wins
	 * over body order when a request carries several genuine file parts.
	 * <p>
	 * Parts whose filename is empty are considered last: an unfilled {@code <input type="file">} is
	 * still submitted, as a zero-byte part with {@code filename=""} that the servlet layer reports as
	 * a file part like any other, and such a placeholder must never win over a real upload.
	 * <p>
	 * Every part is considered, including several sharing one name, in the order Spring reports them,
	 * grouped by part name: {@link MultipartRequest#getMultiFileMap()} is keyed by name, so a body of
	 * a, b, a is seen as a, a, b. {@link MultipartRequest#getFileMap()} is deliberately not used: it
	 * is a single-value view that keeps only the first part of each name, which would hide the real
	 * upload behind an unfilled input of the same name.
	 *
	 * @param request the multipart request to read the file part from
	 * @return the first part named "file" carrying a filename, otherwise the first part carrying a
	 * filename, otherwise the first part carrying content; empty if the request has no file part or
	 * all of its file parts are empty placeholders
	 */
	public static Optional<MultipartFile> firstFilePart(MultipartRequest request) {
		List<MultipartFile> parts = request.getMultiFileMap().values().stream()
				.flatMap(List::stream)
				.toList();

		return parts.stream()
				.filter(part -> CONVENTIONAL_PART_NAME.equals(part.getName()) && carriesFilename(part))
				.findFirst()
				.or(() -> parts.stream().filter(MultipartUtil::carriesFilename).findFirst())
				.or(() -> parts.stream().filter(part -> !part.isEmpty()).findFirst());
	}

	private static boolean carriesFilename(MultipartFile part) {
		return StringUtils.isNotBlank(part.getOriginalFilename());
	}
}