FileResourceService.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.service;

import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.function.IOSupplier;
import org.apache.commons.io.input.ProxyInputStream;
import org.apache.commons.lang3.StringUtils;
import org.entrystore.Data;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.QuotaException;
import org.entrystore.ResourceType;
import org.entrystore.impl.DataImpl;
import org.entrystore.impl.RepositoryManagerImpl;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.model.exception.EntityTooLargeException;
import org.entrystore.rest.springboot.model.exception.InternalServerErrorException;
import org.entrystore.rest.springboot.model.exception.RedirectSeeOtherException;
import org.entrystore.rest.springboot.util.FileUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;

/**
 * Resource operations on binary (GraphType None) entries: storing uploaded data, locating the data file and its
 * media type for download, and deleting the data.
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class FileResourceService {

	private final RepositoryManagerImpl repositoryManager;

	@Value("${entrystore.http.allow-media-type-javascript:false}")
	@Setter(AccessLevel.PACKAGE)
	private boolean rewriteMediaTypeJavaScript;

	/**
	 * Streams the request body to the entry's data, enforcing the repository's maximum file size up front from
	 * {@code contentLength} (-1 when unknown) and while reading, and records its size, mimetype (the explicit
	 * {@code mimeType} parameter, else the request media type, else octet-stream) and, when one is supplied, the
	 * sanitized filename. The body may be of any size, so callers must check write access first; it is opened only
	 * once the up-front checks pass, as opening it makes Jetty answer Expect: 100-continue. Multipart content is
	 * rejected here; it goes through {@link #setDataMultipart}.
	 */
	public void setData(Entry entry, IOSupplier<InputStream> body, long contentLength, String mediaType,
						String mimeType, String filename) {
		if (MediaType.MULTIPART_FORM_DATA_VALUE.equals(mediaType)) {
			throw new BadRequestException("Content negotiation failure, Multipart file content should be handled by other endpoint.");
		}
		long maxFileSize = repositoryManager.getMaximumFileSize();
		rejectIfAboveMaximum(contentLength, maxFileSize);
		InputStream in;
		try {
			in = body.get();
		} catch (IOException e) {
			throw new BadRequestException("Failed to read the request body", e);
		}
		try {
			Data data = (Data) entry.getResource();
			data.setData(new RequestBodyInputStream(in, maxFileSize));
			entry.setFileSize(data.getDataFile().length());
			if (mimeType == null) {
				mimeType = Objects.requireNonNullElse(mediaType, MediaType.APPLICATION_OCTET_STREAM_VALUE);
			}
			entry.setMimetype(mimeType);
			if (StringUtils.isNotEmpty(filename)) {
				entry.setFilename(FileUtil.sanitizeFilename(filename));
			}
		} catch (QuotaException qe) {
			throw new EntityTooLargeException(qe.getMessage(), qe);
		} catch (SizeLimitExceededException e) {
			throw new EntityTooLargeException("Received file exceeds maximum allowed size of: " + maxFileSize + "b", e);
		} catch (RequestBodyReadException e) {
			throw new BadRequestException("Failed to read the request body", e);
		} catch (DataImpl.EntryRemovedException e) {
			throw new EntityNotFoundException("The entry was removed while its resource was uploaded", e);
		} catch (IOException ioe) {
			if (ioe.getCause() instanceof NullPointerException) {
				throw new BadRequestException("Invalid request data", ioe);
			} else {
				throw new InternalServerErrorException("Failed to process resource data", ioe);
			}
		}
		// TODO: when the `textarea` request parameter of PUT /{context-id}/resource/{entry-id} is set, errors
		// should be answered as TEXT_HTML (<textarea>{"error":"..."}</textarea>) by the exception handler.
	}

	/**
	 * Stores an uploaded file as the entry's data, enforcing the repository's maximum file size. Any graph type
	 * other than None is rejected as a bad request.
	 */
	public void setDataMultipart(Entry entry, MultipartFile file, String mimeType) {

		if (entry.getGraphType() != GraphType.None) {
			throw new BadRequestException("Cannot set resource for entry with GraphType " + entry.getGraphType() + ". Only None GraphType can set a multipart file.");
		}

		rejectIfAboveMaximum(file.getSize(), repositoryManager.getMaximumFileSize());
		try {
			((Data) entry.getResource()).setData(file.getInputStream());
			entry.setFileSize(((Data) entry.getResource()).getDataFile().length());
			String itemMimeType = file.getContentType();
			if (mimeType != null) {
				itemMimeType = mimeType;
			}
			entry.setMimetype(itemMimeType);
			String originalFilename = file.getOriginalFilename();
			if (StringUtils.isNotBlank(originalFilename)) {
				entry.setFilename(FileUtil.sanitizeFilename(originalFilename));
			} else {
				// The part name is client-controlled and carries no filename semantics, so it is not
				// used as a fallback. The entry id is, so that a name left over from an earlier
				// upload cannot end up describing the content stored now. It is sanitized like any
				// other stored filename: ids of imported entries do not go through the REST layer's
				// id validation.
				log.warn("Multipart upload for entry '{}' is missing the original filename, falling back to the entry id",
						entry.getEntryURI());
				entry.setFilename(FileUtil.sanitizeFilename(entry.getId()));
			}
		} catch (DataImpl.EntryRemovedException e) {
			throw new EntityNotFoundException("The entry was removed while its resource was uploaded", e);
		} catch (IOException ioe) {
			throw new InternalServerErrorException("Failed to process multipart resource data", ioe);
		} catch (QuotaException qe) {
			throw new EntityTooLargeException(qe.getMessage(), qe);
		}
	}

	private static void rejectIfAboveMaximum(long size, long maxFileSize) {
		if (maxFileSize != -1 && size > maxFileSize) {
			throw new EntityTooLargeException("Received file size (of " + size + "b) exceeds maximum allowed size of: "
					+ maxFileSize + "b");
		}
	}

	/**
	 * Returns the data file of an information resource, or null when none is available. A named resource has no
	 * data and redirects to its local metadata instead.
	 */
	public File dataFileForDownload(Entry entry) {

		if (entry.getResourceType() == ResourceType.InformationResource) {
			// Local data
			return ((Data) entry.getResource()).getDataFile();
		} else if (entry.getResourceType() == ResourceType.NamedResource) {
			// If there is no resource we redirect to the metadata
			throw new RedirectSeeOtherException(entry.getLocalMetadataURI());
		}
		// NOT USED YET
		//	if (ResourceType.Unknown.equals(entry.getResourceType())) {}
		return null;
	}

	/**
	 * Media type to serve the entry's data with: octet-stream when none is stored or the stored value does not
	 * parse, text/plain for JavaScript when {@code entrystore.http.allow-media-type-javascript} is {@code true}
	 * (the property name is historical: it enables the rewrite).
	 */
	public MediaType mediaTypeForDownload(Entry entry) {
		String medTyp = entry.getMimetype();
		if (medTyp == null) {
			return MediaType.APPLICATION_OCTET_STREAM;
		}
		if (rewriteMediaTypeJavaScript && medTyp.toLowerCase().contains("javascript")) {
			log.info("Rewriting media type {} to text/plain for {}", medTyp, entry.getResourceURI());
			return MediaType.TEXT_PLAIN;
		}
		try {
			return MediaType.parseMediaType(medTyp);
		} catch (InvalidMediaTypeException e) {
			log.warn("Invalid stored media type [{}] for {}, serving as application/octet-stream", medTyp,
					entry.getEntryURI());
			return MediaType.APPLICATION_OCTET_STREAM;
		}
	}

	/**
	 * Deletes the data file of an information resource; other resource types have no data and are left alone. A
	 * failure is answered as a server error carrying file diagnostics for the log.
	 */
	public void deleteData(Entry entry) {
		if (entry.getResourceType() != ResourceType.InformationResource) {
			return;
		}
		Data data = (Data) entry.getResource();
		if (!data.delete()) {
			File dataFile = data.getDataFile();
			String diagnostics;
			if (dataFile == null) {
				diagnostics = "dataFile=null";
			} else {
				boolean exists = dataFile.exists();
				long size = exists ? dataFile.length() : -1;
				diagnostics = "path=" + dataFile.getAbsolutePath() + ", exists=" + exists + ", size=" + size;
			}
			throw new InternalServerErrorException("Unable to delete resource of entry " + entry.getEntryURI()
					+ " (" + diagnostics + ")");
		}
	}

	/** Thrown by {@link RequestBodyInputStream} once more than its limit has been read. */
	private static final class SizeLimitExceededException extends IOException {
	}

	/** Thrown by {@link RequestBodyInputStream} when the body cannot be read, typically as the client went away. */
	private static final class RequestBodyReadException extends IOException {
		RequestBodyReadException(IOException cause) {
			super(cause);
		}
	}

	/**
	 * Tells failures to read the body apart from failures to store it, and fails the read that takes the total
	 * past {@code maxBytes} (-1 for no limit), so a body without Content-Length is capped too.
	 */
	private static final class RequestBodyInputStream extends ProxyInputStream {

		private final long maxBytes;
		private long count;

		RequestBodyInputStream(InputStream in, long maxBytes) {
			super(in);
			this.maxBytes = maxBytes;
		}

		@Override
		protected void afterRead(int n) throws IOException {
			if (maxBytes != -1 && n > 0) {
				count += n;
				if (count > maxBytes) {
					throw new SizeLimitExceededException();
				}
			}
		}

		@Override
		protected void handleIOException(IOException e) throws IOException {
			// Also receives what afterRead throws.
			throw e instanceof SizeLimitExceededException ? e : new RequestBodyReadException(e);
		}
	}
}