Skip to content
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
Expand Up @@ -89,7 +89,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
OutboxService.PERSISTENT_UNORDERED_NAME);
}

// build malware scanner client, could be null if no service binding is available
// build malware scanner client, could be null if no service binding is
// available
MalwareScanClient scanClient = buildMalwareScanClient(runtime.getEnvironment());

// determine default max size based on malware scanner binding availability
Expand Down Expand Up @@ -118,12 +119,14 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
new AttachmentsReader(new AssociationCascader(), persistenceService);
ThreadLocalDataStorage storage = new ThreadLocalDataStorage();

// register event handlers for application service, if at least one application service is
// register event handlers for application service, if at least one application
// service is
// available
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));
configurer.eventHandler(
new UpdateAttachmentsHandler(
eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize));
Expand All @@ -138,7 +141,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
"No application service is available. Application service event handlers will not be registered.");
}

// register event handlers on draft service, if at least one draft service is available
// register event handlers on draft service, if at least one draft service is
// available
boolean hasDraftServices =
serviceCatalog.getServices(DraftService.class).findFirst().isPresent();
if (hasDraftServices) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
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 +24,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 +43,17 @@ public class CreateAttachmentsHandler implements EventHandler {
private final ModifyAttachmentEventFactory eventFactory;
private final ThreadDataStorageReader storageReader;
private final String defaultMaxSize;
private final CdsRuntime cdsRuntime;

public CreateAttachmentsHandler(
ModifyAttachmentEventFactory eventFactory,
ThreadDataStorageReader storageReader,
String defaultMaxSize) {
String defaultMaxSize,
CdsRuntime cdsRuntime) {
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");
}

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

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

@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,165 @@
/*
* © 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.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import java.net.URLConnection;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class AttachmentValidationHelper {

public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
public static final Map<String, String> EXT_TO_MEDIA_TYPE =
Map.ofEntries(
Map.entry("aac", "audio/aac"),
Map.entry("abw", "application/x-abiword"),
Map.entry("arc", "application/octet-stream"),
Map.entry("avi", "video/x-msvideo"),
Map.entry("azw", "application/vnd.amazon.ebook"),
Map.entry("bin", "application/octet-stream"),
Map.entry("png", "image/png"),
Map.entry("gif", "image/gif"),
Map.entry("bmp", "image/bmp"),
Map.entry("bz", "application/x-bzip"),
Map.entry("bz2", "application/x-bzip2"),
Map.entry("csh", "application/x-csh"),
Map.entry("css", "text/css"),
Map.entry("csv", "text/csv"),
Map.entry("doc", "application/msword"),
Map.entry(
"docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
Map.entry("odp", "application/vnd.oasis.opendocument.presentation"),
Map.entry("ods", "application/vnd.oasis.opendocument.spreadsheet"),
Map.entry("odt", "application/vnd.oasis.opendocument.text"),
Map.entry("epub", "application/epub+zip"),
Map.entry("gz", "application/gzip"),
Map.entry("htm", "text/html"),
Map.entry("html", "text/html"),
Map.entry("ico", "image/x-icon"),
Map.entry("ics", "text/calendar"),
Map.entry("jar", "application/java-archive"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("js", "text/javascript"),
Map.entry("json", "application/json"),
Map.entry("mid", "audio/midi"),
Map.entry("midi", "audio/midi"),
Map.entry("mjs", "text/javascript"),
Map.entry("mov", "video/quicktime"),
Map.entry("mp3", "audio/mpeg"),
Map.entry("mp4", "video/mp4"),
Map.entry("mpeg", "video/mpeg"),
Map.entry("mpkg", "application/vnd.apple.installer+xml"),
Map.entry("otf", "font/otf"),
Map.entry("pdf", "application/pdf"),
Map.entry("ppt", "application/vnd.ms-powerpoint"),
Map.entry(
"pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
Map.entry("rar", "application/x-rar-compressed"),
Map.entry("rtf", "application/rtf"),
Map.entry("svg", "image/svg+xml"),
Map.entry("tar", "application/x-tar"),
Map.entry("tif", "image/tiff"),
Map.entry("tiff", "image/tiff"),
Map.entry("ttf", "font/ttf"),
Map.entry("vsd", "application/vnd.visio"),
Map.entry("wav", "audio/wav"),
Map.entry("woff", "font/woff"),
Map.entry("woff2", "font/woff2"),
Map.entry("xhtml", "application/xhtml+xml"),
Map.entry("xls", "application/vnd.ms-excel"),
Map.entry("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
Map.entry("xml", "application/xml"),
Map.entry("zip", "application/zip"),
Map.entry("txt", "text/plain"),
Map.entry("webp", "image/webp"));

private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class);

/**
* Validates the file name and resolves its media type. Ensures that the detected media type is
* part of the list of acceptable media types.
*
* @param fileName the name of the attachment file
* @param acceptableMediaTypes list of allowed media types (e.g. "image/*", "application/pdf")
* @return the detected media type
* @throws ServiceException if the file name is invalid or the media type is not allowed
*/
public static String validateMediaTypeForAttachment(
String fileName, List<String> acceptableMediaTypes) {
validateFileName(fileName);
String detectedMediaType = resolveMimeType(fileName);
validateAcceptableMediaType(acceptableMediaTypes, detectedMediaType);
return detectedMediaType;
}

private static void validateFileName(String fileName) {
if (fileName == null || fileName.isBlank()) {
throw new ServiceException(
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be null or blank");
}
String clean = fileName.trim();
int lastDotIndex = clean.lastIndexOf('.');
if (lastDotIndex <= 0 || lastDotIndex == clean.length() - 1) {
throw new ServiceException(
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Invalid filename format: " + fileName);
}
}

private static void validateAcceptableMediaType(
List<String> acceptableMediaTypes, String actualMimeType) {
if (!checkMimeTypeMatch(acceptableMediaTypes, actualMimeType)) {
throw new ServiceException(
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE,
"The attachment file type '{}' is not allowed. Allowed types are: {}",
actualMimeType,
String.join(", ", acceptableMediaTypes));
}
}

private static String resolveMimeType(String fileName) {
String actualMimeType = URLConnection.guessContentTypeFromName(fileName);

if (actualMimeType == null) {
String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension);

if (actualMimeType == null) {
logger.warn(
"Could not determine mime type for file: {}. Setting mime type to default: {}",
fileName,
DEFAULT_MEDIA_TYPE);
actualMimeType = DEFAULT_MEDIA_TYPE;
}
}
return actualMimeType;
}

protected static boolean checkMimeTypeMatch(
Collection<String> acceptableMediaTypes, String mimeType) {
if (mimeType == null) {
return false; // forces UNSUPPORTED_MEDIA_TYPE
}
if (acceptableMediaTypes == null
|| acceptableMediaTypes.isEmpty()
|| acceptableMediaTypes.contains("*/*")) return true;

String baseMimeType = mimeType.trim().toLowerCase();

return acceptableMediaTypes.stream()
.anyMatch(
type -> {
String normalizedType = type.trim().toLowerCase();
return normalizedType.endsWith("/*")
? baseMimeType.startsWith(
normalizedType.substring(0, normalizedType.length() - 2) + "/")
: baseMimeType.equals(normalizedType);
});
}
}
Loading
Loading