diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java index c5c2c863a..9a3e5b1d7 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java @@ -5,6 +5,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; @@ -69,6 +70,10 @@ void processBeforeForDraft(CdsCreateEventContext context, List data) { @HandlerOrder(HandlerOrder.BEFORE) void processBeforeForMetadata(EventContext context, List data) { CdsEntity target = context.getTarget(); + // Normalize file name / MIME type derived from request headers (Content-Disposition, slug, + // Content-Type) into the data first, so acceptable-media-type validation runs over the exact + // values that storage will persist and the read model will serve. + HeaderMediaMetadataResolver.applyHeaderFallback(target, data, context); AttachmentValidationHelper.validateMediaAttachments(target, data, cdsRuntime); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java new file mode 100644 index 000000000..c6c41a257 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolver.java @@ -0,0 +1,117 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import com.sap.cds.CdsData; +import com.sap.cds.CdsDataProcessor; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.EventContext; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Resolves attachment metadata (file name, MIME type) from HTTP headers and applies it as a + * fallback to the request data. + * + *

For direct media uploads the file name is typically provided via the {@code + * Content-Disposition} or {@code slug} header and the MIME type via the {@code Content-Type} header + * rather than in the request payload. This resolver normalizes those header values into the + * attachment data before acceptable-media-type validation runs, so that validation and + * storage operate on the same file name / MIME type that is ultimately persisted and served. + */ +public final class HeaderMediaMetadataResolver { + + private static final Pattern RFC5987_FILENAME_PATTERN = + Pattern.compile("filename\\*=UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern PLAIN_FILENAME_PATTERN = + Pattern.compile("(? data, EventContext eventContext) { + if (entity == null + || data == null + || data.isEmpty() + || eventContext.getParameterInfo() == null) { + return; + } + + CdsDataProcessor.create() + .addValidator( + ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, + (path, element, value) -> { + Map values = path.target().values(); + if (values.get(MediaData.FILE_NAME) == null) { + extractFileNameFromHeader(eventContext) + .ifPresent(fn -> values.put(MediaData.FILE_NAME, fn)); + } + if (values.get(MediaData.MIME_TYPE) == null) { + extractMimeTypeFromHeader(eventContext) + .ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt)); + } + }) + .process(data, entity); + } + + /** + * Extracts the file name from the {@code Content-Disposition} header or falls back to the {@code + * slug} header. Supports RFC 5987 encoded file names ({@code filename*=UTF-8''...}) and plain + * file names. + */ + public static Optional extractFileNameFromHeader(EventContext eventContext) { + String header = eventContext.getParameterInfo().getHeader("Content-Disposition"); + if (header != null) { + // Try RFC 5987 encoded filename first (filename*=UTF-8''...) + Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header); + if (utf8Matcher.find()) { + return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8)); + } + // Fall back to plain filename= + Matcher plainMatcher = PLAIN_FILENAME_PATTERN.matcher(header); + if (plainMatcher.find()) { + return Optional.of(plainMatcher.group(1).trim()); + } + } + // Fiori Elements may use the slug header instead + String slug = eventContext.getParameterInfo().getHeader("slug"); + if (slug != null) { + return Optional.of(URLDecoder.decode(slug, StandardCharsets.UTF_8)); + } + return Optional.empty(); + } + + /** + * Extracts the MIME type from the {@code Content-Type} header, stripping charset and other + * parameters. Returns empty if the {@code Content-Type} is null or empty. + */ + public static Optional extractMimeTypeFromHeader(EventContext eventContext) { + String contentType = eventContext.getParameterInfo().getHeader("Content-Type"); + if (contentType == null) { + return Optional.empty(); + } + String mimeType = contentType.split(";")[0].trim(); + if (mimeType.isEmpty()) { + return Optional.empty(); + } + return Optional.of(mimeType); + } + + private HeaderMediaMetadataResolver() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index 530dddbe1..cf0082ef5 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -7,6 +7,7 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.service.AttachmentService; @@ -16,12 +17,8 @@ import com.sap.cds.services.EventContext; import com.sap.cds.services.changeset.ChangeSetListener; import java.io.InputStream; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,10 +30,6 @@ public class CreateAttachmentEvent implements ModifyAttachmentEvent { private static final Logger logger = LoggerFactory.getLogger(CreateAttachmentEvent.class); - private static final Pattern RFC5987_FILENAME_PATTERN = - Pattern.compile("filename\\*=UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE); - private static final Pattern PLAIN_FILENAME_PATTERN = - Pattern.compile("(? values.put(MediaData.FILE_NAME, fn)); } if (mimeTypeOptional.isEmpty()) { - mimeTypeOptional = extractMimeTypeFromHeader(eventContext); + mimeTypeOptional = HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext); mimeTypeOptional.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt)); } } @@ -96,46 +89,4 @@ private static Optional getFieldValue( Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); } - - /** - * Extracts the filename from the Content-Disposition header or falls back to the slug header. - * Supports RFC 5987 encoded filenames (filename*=UTF-8''...) and plain filenames. - */ - private static Optional extractFileNameFromHeader(EventContext eventContext) { - String header = eventContext.getParameterInfo().getHeader("Content-Disposition"); - if (header != null) { - // Try RFC 5987 encoded filename first (filename*=UTF-8''...) - Matcher utf8Matcher = RFC5987_FILENAME_PATTERN.matcher(header); - if (utf8Matcher.find()) { - return Optional.of(URLDecoder.decode(utf8Matcher.group(1), StandardCharsets.UTF_8)); - } - // Fall back to plain filename= - Matcher plainMatcher = PLAIN_FILENAME_PATTERN.matcher(header); - if (plainMatcher.find()) { - return Optional.of(plainMatcher.group(1).trim()); - } - } - // Fiori Elements may use the slug header instead - String slug = eventContext.getParameterInfo().getHeader("slug"); - if (slug != null) { - return Optional.of(URLDecoder.decode(slug, StandardCharsets.UTF_8)); - } - return Optional.empty(); - } - - /** - * Extracts the MIME type from the Content-Type header, stripping charset and other parameters. - * Returns empty if the Content-Type is null or empty. - */ - private static Optional extractMimeTypeFromHeader(EventContext eventContext) { - String contentType = eventContext.getParameterInfo().getHeader("Content-Type"); - if (contentType == null) { - return Optional.empty(); - } - String mimeType = contentType.split(";")[0].trim(); - if (mimeType.isEmpty()) { - return Optional.empty(); - } - return Optional.of(mimeType); - } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index 37ed28c8a..bc7d183ad 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -26,6 +26,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.HeaderMediaMetadataResolver; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; import com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation.AttachmentValidationHelper; @@ -371,6 +372,28 @@ void processBeforeForMetadata_executesValidation() { } } + @Test + void processBeforeForMetadata_appliesHeaderFallbackBeforeValidation() { + EventContext context = mock(EventContext.class); + CdsEntity entity = mock(CdsEntity.class); + List data = List.of(mock(CdsData.class)); + when(context.getTarget()).thenReturn(entity); + + try (MockedStatic resolver = + mockStatic(HeaderMediaMetadataResolver.class); + MockedStatic helper = + mockStatic(AttachmentValidationHelper.class)) { + // when + new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime) + .processBeforeForMetadata(context, data); + + // then header-derived metadata is normalized into the data and validation runs over it + resolver.verify(() -> HeaderMediaMetadataResolver.applyHeaderFallback(entity, data, context)); + helper.verify( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime)); + } + } + private void getEntityAndMockContext(String cdsName) { var serviceEntity = runtime.getCdsModel().findEntity(cdsName); mockTargetInCreateContext(serviceEntity.orElseThrow()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java new file mode 100644 index 000000000..3248516ba --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/HeaderMediaMetadataResolverTest.java @@ -0,0 +1,119 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.request.ParameterInfo; +import com.sap.cds.services.runtime.CdsRuntime; +import java.io.InputStream; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HeaderMediaMetadataResolverTest { + + private static final String MEDIA_ENTITY = "unit.test.Attachment"; + + private static CdsRuntime runtime; + + private EventContext eventContext; + private ParameterInfo parameterInfo; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + eventContext = mock(EventContext.class); + parameterInfo = mock(ParameterInfo.class); + when(eventContext.getParameterInfo()).thenReturn(parameterInfo); + } + + @Test + void extractsFileNameFromContentDispositionHeader() { + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"report.pdf\""); + + assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)) + .contains("report.pdf"); + } + + @Test + void extractsFileNameFromSlugHeader() { + when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null); + when(parameterInfo.getHeader("slug")).thenReturn("document.docx"); + + assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)) + .contains("document.docx"); + } + + @Test + void extractsMimeTypeFromContentTypeHeaderStrippingParameters() { + when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html; charset=utf-8"); + + assertThat(HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext)) + .contains("text/html"); + } + + @Test + void returnsEmptyWhenNoHeadersPresent() { + assertThat(HeaderMediaMetadataResolver.extractFileNameFromHeader(eventContext)).isEmpty(); + assertThat(HeaderMediaMetadataResolver.extractMimeTypeFromHeader(eventContext)).isEmpty(); + } + + @Test + void fillsFileNameAndMimeTypeFromHeadersWhenAbsentInData() { + CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY); + Attachments data = Attachments.create(); + data.setContent(mock(InputStream.class)); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"notes.txt\""); + when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html"); + + HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext); + + assertThat(data.getFileName()).isEqualTo("notes.txt"); + assertThat(data.getMimeType()).isEqualTo("text/html"); + } + + @Test + void doesNotOverridePayloadValuesWithHeaderValues() { + CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY); + Attachments data = Attachments.create(); + data.setContent(mock(InputStream.class)); + data.setFileName("payload.png"); + data.setMimeType("image/png"); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"notes.txt\""); + when(parameterInfo.getHeader("Content-Type")).thenReturn("text/html"); + + HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext); + + assertThat(data.getFileName()).isEqualTo("payload.png"); + assertThat(data.getMimeType()).isEqualTo("image/png"); + } + + @Test + void doesNothingWhenDataHasNoContentElement() { + CdsEntity entity = runtime.getCdsModel().getEntity(MEDIA_ENTITY); + Attachments data = Attachments.create(); + when(parameterInfo.getHeader("Content-Disposition")) + .thenReturn("attachment; filename=\"notes.txt\""); + + HeaderMediaMetadataResolver.applyHeaderFallback(entity, List.of(data), eventContext); + + assertThat(data.getFileName()).isNull(); + assertThat(data.getMimeType()).isNull(); + } +}