Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu
* [Storage Targets](#storage-targets)
* [Malware Scanner](#malware-scanner)
* [Specify the maximum file size](#specify-the-maximum-file-size)
* [Restrict allowed MIME types](#restrict-allowed-mime-types)
* [Outbox](#outbox)
* [Restore Endpoint](#restore-endpoint)
* [Motivation](#motivation)
Expand Down Expand Up @@ -214,6 +215,38 @@ The @Validation.Maximum value is a size string consisting of a number followed b

The default is 400MB

### Restrict allowed MIME types

You can restrict which MIME types are allowed for attachments by annotating the content property with @Core.AcceptableMediaTypes. This validation is performed during file upload.

```cds
entity Books {
...
attachments: Composition of many Attachments;
}
annotate Books.attachments with {
content @Core.AcceptableMediaTypes : ['image/jpeg', 'image/png', 'application/pdf'];
}
```

Wildcard patterns are supported:

```cds
annotate Books.attachments with {
content @Core.AcceptableMediaTypes : ['image/*', 'application/pdf'];
}
```

To allow all MIME types (default behavior), either omit the annotation or use:

```cds
annotate Books.attachments with {
content @Core.AcceptableMediaTypes : ['*/*'];
}
```


### Outbox

In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
* © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.configuration;

Expand Down Expand Up @@ -123,7 +123,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
boolean hasApplicationServices =
serviceCatalog.getServices(ApplicationService.class).findFirst().isPresent();
if (hasApplicationServices) {
configurer.eventHandler(new CreateAttachmentsHandler(eventFactory, storage, defaultMaxSize));
configurer.eventHandler(
new CreateAttachmentsHandler(
eventFactory, storage, defaultMaxSize, runtime, new AssociationCascader()));
configurer.eventHandler(
new UpdateAttachmentsHandler(
eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
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;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation.MediaTypeValidator;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.common.AssociationCascader;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.cds.ApplicationService;
Expand All @@ -23,6 +26,7 @@
import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.On;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.utils.OrderConstants;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -41,14 +45,20 @@ public class CreateAttachmentsHandler implements EventHandler {
private final ModifyAttachmentEventFactory eventFactory;
private final ThreadDataStorageReader storageReader;
private final String defaultMaxSize;
private final CdsRuntime cdsRuntime;
private final AssociationCascader cascader;

public CreateAttachmentsHandler(
ModifyAttachmentEventFactory eventFactory,
ThreadDataStorageReader storageReader,
String defaultMaxSize) {
String defaultMaxSize,
CdsRuntime cdsRuntime,
AssociationCascader cascader) {
this.eventFactory = requireNonNull(eventFactory, "eventFactory must not be null");
this.storageReader = requireNonNull(storageReader, "storageReader must not be null");
this.defaultMaxSize = requireNonNull(defaultMaxSize, "defaultMaxSize must not be null");
this.cdsRuntime = requireNonNull(cdsRuntime, "cdsRuntime must not be null");
this.cascader = requireNonNull(cascader, "cascader must not be null");
}

@Before
Expand All @@ -61,6 +71,19 @@ void processBeforeForDraft(CdsCreateEventContext context, List<CdsData> data) {
context.getTarget(), data, storageReader.get());
}

@Before(
event = {
CqnService.EVENT_CREATE,
CqnService.EVENT_UPDATE,
DraftService.EVENT_DRAFT_NEW,
DraftService.EVENT_DRAFT_PATCH
})
@HandlerOrder(HandlerOrder.BEFORE)
void processBeforeForMetadata(EventContext context, List<CdsData> data) {
CdsEntity target = context.getTarget();
MediaTypeValidator.validateMediaAttachments(target, data, cdsRuntime, cascader);
}

@Before
@HandlerOrder(HandlerOrder.LATE)
void processBefore(CdsCreateEventContext context, List<CdsData> data) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation;

import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Filter;
import com.sap.cds.CdsDataProcessor.Validator;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.reflect.CdsAssociationType;
import com.sap.cds.reflect.CdsElement;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public final class AttachmentDataExtractor {
private static final String FILE_NAME_FIELD = "fileName";
public static final Filter FILE_NAME_FILTER =
(path, element, type) -> element.getName().contentEquals(FILE_NAME_FIELD);

/**
* Extracts and validates file names of attachments from the given entity data.
*
* @param entity the CDS entity definition
* @param data the incoming data containing attachment values
* @return a map of element names to sets of associated file names
*/
public static Map<String, Set<String>> extractAndValidateFileNamesByElement(
CdsEntity entity, List<? extends CdsData> data) {
// Collects file names from attachment-related elements in the entity
Map<String, Set<String>> fileNamesByElementName = collectFileNamesByElementName(entity, data);
// Ensures that all attachments have valid (non-null, non-empty) file names.
ensureAttachmentsHaveFileNames(entity, data, fileNamesByElementName);
return fileNamesByElementName;
}

private static Map<String, Set<String>> collectFileNamesByElementName(
CdsEntity entity, List<? extends CdsData> data) {
// Use CdsProcessor to traverse the data and collect file names for elements
// named "fileName"
Map<String, Set<String>> fileNamesByElementName = new HashMap<>();
CdsDataProcessor processor = CdsDataProcessor.create();
Validator fileNameValidator = generateFileNameFieldValidator(fileNamesByElementName);
processor.addValidator(FILE_NAME_FILTER, fileNameValidator).process(data, entity);
return fileNamesByElementName;
}

private static Validator generateFileNameFieldValidator(Map<String, Set<String>> result) {
Validator validator =
(path, element, value) -> {
String fileName = requireString(value);
String normalizedFileName = validateAndNormalize(fileName);
String key = element.getDeclaringType().getQualifiedName();
result.computeIfAbsent(key, k -> new HashSet<>()).add(normalizedFileName);
};
return validator;
}

private static String validateAndNormalize(String fileName) {
String trimmedFileName = fileName.trim();
if (trimmedFileName.isEmpty()) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must not be blank");
}
return trimmedFileName;
}

private static void ensureAttachmentsHaveFileNames(
CdsEntity entity, List<? extends CdsData> data, Map<String, Set<String>> result) {
// Collect attachment-related elements/fields from the entity
List<CdsElement> attachmentElements =
entity
.elements()
.filter(
e -> {
// Only consider associations
if (!e.getType().isAssociation()) {
return false;
}
// Keep only associations targeting media entities
// that define acceptable media types
CdsAssociationType association = e.getType().as(CdsAssociationType.class);
return ApplicationHandlerHelper.isMediaEntity(association.getTarget())
&& MediaTypeValidator.getAcceptableMediaTypesAnnotation(
association.getTarget())
.isPresent();
})
.toList();

// Validate that required attachments have file names
ensureFilenamesPresent(data, result, attachmentElements);
}

private static void ensureFilenamesPresent(
List<? extends CdsData> data,
Map<String, Set<String>> result,
List<CdsElement> attachmentElements) {

// Extract keys of fields that actually contain data
Set<String> dataKeys = collectValidDataKeys(data);

// Check if any required attachment is missing a filename
boolean hasMissingFileName = hasMissingFileNames(result, attachmentElements, dataKeys);

// If any attachment is missing a filename, throw and exception
if (hasMissingFileName) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing");
}
}

private static boolean hasMissingFileNames(
Map<String, Set<String>> result,
List<CdsElement> availableAttachmentElements,
Set<String> dataKeys) {

return availableAttachmentElements.stream()
.filter(e -> dataKeys.contains(e.getName()))
.anyMatch(
element -> {
CdsAssociationType assoc = element.getType().as(CdsAssociationType.class);
String target = assoc.getTarget().getQualifiedName();
Set<String> fileNames = result.get(target);
return fileNames == null || fileNames.isEmpty();
});
}

private static Set<String> collectValidDataKeys(List<? extends CdsData> data) {
return data.stream()
.flatMap(d -> d.entrySet().stream())
.filter(entry -> !isEmpty(entry.getValue()))
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
}

private static boolean isEmpty(Object value) {
return value == null
|| (value instanceof String s && s.isBlank())
|| (value instanceof Iterable<?> i && !i.iterator().hasNext());
}

private static String requireString(Object value) {
if (value == null) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing");
}
if (!(value instanceof String s)) {
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must be a string");
}
return s;
}

private AttachmentDataExtractor() {
// Private constructor to prevent instantiation
}
}
Loading
Loading