Skip to content

Commit e98f840

Browse files
single commit
1 parent f1413c9 commit e98f840

File tree

15 files changed

+1177
-29
lines changed

15 files changed

+1177
-29
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu
2222
* [Storage Targets](#storage-targets)
2323
* [Malware Scanner](#malware-scanner)
2424
* [Specify the maximum file size](#specify-the-maximum-file-size)
25+
* [Restrict allowed MIME types](#restrict-allowed-mime-types)
2526
* [Outbox](#outbox)
2627
* [Restore Endpoint](#restore-endpoint)
2728
* [Motivation](#motivation)
@@ -214,6 +215,38 @@ The @Validation.Maximum value is a size string consisting of a number followed b
214215

215216
The default is 400MB
216217

218+
### Restrict allowed MIME types
219+
220+
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.
221+
222+
```cds
223+
entity Books {
224+
...
225+
attachments: Composition of many Attachments;
226+
}
227+
228+
annotate Books.attachments with {
229+
content @Core.AcceptableMediaTypes : ['image/jpeg', 'image/png', 'application/pdf'];
230+
}
231+
```
232+
233+
Wildcard patterns are supported:
234+
235+
```cds
236+
annotate Books.attachments with {
237+
content @Core.AcceptableMediaTypes : ['image/*', 'application/pdf'];
238+
}
239+
```
240+
241+
To allow all MIME types (default behavior), either omit the annotation or use:
242+
243+
```cds
244+
annotate Books.attachments with {
245+
content @Core.AcceptableMediaTypes : ['*/*'];
246+
}
247+
```
248+
249+
217250
### Outbox
218251

219252
In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
2+
* © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
33
*/
44
package com.sap.cds.feature.attachments.configuration;
55

@@ -89,7 +89,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
8989
OutboxService.PERSISTENT_UNORDERED_NAME);
9090
}
9191

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

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

121-
// register event handlers for application service, if at least one application service is
122+
// register event handlers for application service, if at least one application
123+
// service is
122124
// available
123125
boolean hasApplicationServices =
124126
serviceCatalog.getServices(ApplicationService.class).findFirst().isPresent();
125127
if (hasApplicationServices) {
126-
configurer.eventHandler(new CreateAttachmentsHandler(eventFactory, storage, defaultMaxSize));
128+
configurer.eventHandler(
129+
new CreateAttachmentsHandler(eventFactory, storage, defaultMaxSize, runtime));
127130
configurer.eventHandler(
128131
new UpdateAttachmentsHandler(
129132
eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize));
@@ -138,7 +141,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
138141
"No application service is available. Application service event handlers will not be registered.");
139142
}
140143

141-
// register event handlers on draft service, if at least one draft service is available
144+
// register event handlers on draft service, if at least one draft service is
145+
// available
142146
boolean hasDraftServices =
143147
serviceCatalog.getServices(DraftService.class).findFirst().isPresent();
144148
if (hasDraftServices) {

cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import static java.util.Objects.requireNonNull;
77

88
import com.sap.cds.CdsData;
9+
import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentValidationHelper;
910
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses;
1011
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
1112
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
1213
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
1314
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
1415
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
16+
import com.sap.cds.reflect.CdsEntity;
1517
import com.sap.cds.services.EventContext;
1618
import com.sap.cds.services.ServiceException;
1719
import com.sap.cds.services.cds.ApplicationService;
@@ -23,6 +25,7 @@
2325
import com.sap.cds.services.handler.annotations.HandlerOrder;
2426
import com.sap.cds.services.handler.annotations.On;
2527
import com.sap.cds.services.handler.annotations.ServiceName;
28+
import com.sap.cds.services.runtime.CdsRuntime;
2629
import com.sap.cds.services.utils.OrderConstants;
2730
import java.util.ArrayList;
2831
import java.util.List;
@@ -41,14 +44,17 @@ public class CreateAttachmentsHandler implements EventHandler {
4144
private final ModifyAttachmentEventFactory eventFactory;
4245
private final ThreadDataStorageReader storageReader;
4346
private final String defaultMaxSize;
47+
private final CdsRuntime cdsRuntime;
4448

4549
public CreateAttachmentsHandler(
4650
ModifyAttachmentEventFactory eventFactory,
4751
ThreadDataStorageReader storageReader,
48-
String defaultMaxSize) {
52+
String defaultMaxSize,
53+
CdsRuntime cdsRuntime) {
4954
this.eventFactory = requireNonNull(eventFactory, "eventFactory must not be null");
5055
this.storageReader = requireNonNull(storageReader, "storageReader must not be null");
5156
this.defaultMaxSize = requireNonNull(defaultMaxSize, "defaultMaxSize must not be null");
57+
this.cdsRuntime = requireNonNull(cdsRuntime, "cdsRuntime must not be null");
5258
}
5359

5460
@Before
@@ -61,6 +67,13 @@ void processBeforeForDraft(CdsCreateEventContext context, List<CdsData> data) {
6167
context.getTarget(), data, storageReader.get());
6268
}
6369

70+
@Before(event = {CqnService.EVENT_CREATE, DraftService.EVENT_DRAFT_NEW})
71+
@HandlerOrder(HandlerOrder.BEFORE)
72+
void processBeforeForMetadata(EventContext context, List<CdsData> data) {
73+
CdsEntity target = context.getTarget();
74+
AttachmentValidationHelper.validateAcceptableMediaTypes(target, data, cdsRuntime);
75+
}
76+
6477
@Before
6578
@HandlerOrder(HandlerOrder.LATE)
6679
void processBefore(CdsCreateEventContext context, List<CdsData> data) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
3+
*/
4+
package com.sap.cds.feature.attachments.handler.applicationservice.helper;
5+
6+
import com.fasterxml.jackson.core.type.TypeReference;
7+
import com.fasterxml.jackson.databind.ObjectMapper;
8+
import com.sap.cds.CdsData;
9+
import com.sap.cds.CdsDataProcessor;
10+
import com.sap.cds.CdsDataProcessor.Filter;
11+
import com.sap.cds.CdsDataProcessor.Validator;
12+
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
13+
import com.sap.cds.reflect.CdsAnnotation;
14+
import com.sap.cds.reflect.CdsEntity;
15+
import com.sap.cds.reflect.CdsModel;
16+
import com.sap.cds.services.ErrorStatuses;
17+
import com.sap.cds.services.ServiceException;
18+
import com.sap.cds.services.runtime.CdsRuntime;
19+
import java.util.Collection;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Optional;
23+
import java.util.concurrent.atomic.AtomicReference;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
27+
public final class AttachmentValidationHelper {
28+
29+
public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream";
30+
public static final Map<String, String> EXT_TO_MEDIA_TYPE =
31+
Map.ofEntries(
32+
Map.entry("aac", "audio/aac"),
33+
Map.entry("abw", "application/x-abiword"),
34+
Map.entry("arc", "application/octet-stream"),
35+
Map.entry("avi", "video/x-msvideo"),
36+
Map.entry("azw", "application/vnd.amazon.ebook"),
37+
Map.entry("bin", "application/octet-stream"),
38+
Map.entry("png", "image/png"),
39+
Map.entry("gif", "image/gif"),
40+
Map.entry("bmp", "image/bmp"),
41+
Map.entry("bz", "application/x-bzip"),
42+
Map.entry("bz2", "application/x-bzip2"),
43+
Map.entry("csh", "application/x-csh"),
44+
Map.entry("css", "text/css"),
45+
Map.entry("csv", "text/csv"),
46+
Map.entry("doc", "application/msword"),
47+
Map.entry(
48+
"docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
49+
Map.entry("odp", "application/vnd.oasis.opendocument.presentation"),
50+
Map.entry("ods", "application/vnd.oasis.opendocument.spreadsheet"),
51+
Map.entry("odt", "application/vnd.oasis.opendocument.text"),
52+
Map.entry("epub", "application/epub+zip"),
53+
Map.entry("gz", "application/gzip"),
54+
Map.entry("htm", "text/html"),
55+
Map.entry("html", "text/html"),
56+
Map.entry("ico", "image/x-icon"),
57+
Map.entry("ics", "text/calendar"),
58+
Map.entry("jar", "application/java-archive"),
59+
Map.entry("jpg", "image/jpeg"),
60+
Map.entry("jpeg", "image/jpeg"),
61+
Map.entry("js", "text/javascript"),
62+
Map.entry("json", "application/json"),
63+
Map.entry("mid", "audio/midi"),
64+
Map.entry("midi", "audio/midi"),
65+
Map.entry("mjs", "text/javascript"),
66+
Map.entry("mov", "video/quicktime"),
67+
Map.entry("mp3", "audio/mpeg"),
68+
Map.entry("mp4", "video/mp4"),
69+
Map.entry("mpeg", "video/mpeg"),
70+
Map.entry("mpkg", "application/vnd.apple.installer+xml"),
71+
Map.entry("otf", "font/otf"),
72+
Map.entry("pdf", "application/pdf"),
73+
Map.entry("ppt", "application/vnd.ms-powerpoint"),
74+
Map.entry(
75+
"pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
76+
Map.entry("rar", "application/x-rar-compressed"),
77+
Map.entry("rtf", "application/rtf"),
78+
Map.entry("svg", "image/svg+xml"),
79+
Map.entry("tar", "application/x-tar"),
80+
Map.entry("tif", "image/tiff"),
81+
Map.entry("tiff", "image/tiff"),
82+
Map.entry("ttf", "font/ttf"),
83+
Map.entry("vsd", "application/vnd.visio"),
84+
Map.entry("wav", "audio/wav"),
85+
Map.entry("woff", "font/woff"),
86+
Map.entry("woff2", "font/woff2"),
87+
Map.entry("xhtml", "application/xhtml+xml"),
88+
Map.entry("xls", "application/vnd.ms-excel"),
89+
Map.entry("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
90+
Map.entry("xml", "application/xml"),
91+
Map.entry("zip", "application/zip"),
92+
Map.entry("txt", "text/plain"),
93+
Map.entry("webp", "image/webp"));
94+
95+
private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class);
96+
private static final ObjectMapper objectMapper = new ObjectMapper();
97+
private static final TypeReference<List<String>> STRING_LIST_TYPE_REF = new TypeReference<>() {};
98+
99+
/** Filter to support extraction of file name for attachment validation */
100+
public static final Filter FILE_NAME_FILTER =
101+
(path, element, type) -> element.getName().contentEquals("fileName");
102+
103+
/**
104+
* Validates if the media type of the attachment in the given fileName is acceptable
105+
*
106+
* @param entity the {@link CdsEntity entity} type of the given data
107+
* @param data the list of {@link CdsData} to process
108+
* @throws ServiceException if the media type of the attachment is not acceptable
109+
*/
110+
public static void validateAcceptableMediaTypes(
111+
CdsEntity entity, List<CdsData> data, CdsRuntime cdsRuntime) {
112+
if (entity == null) {
113+
return;
114+
}
115+
CdsModel cdsModel = cdsRuntime.getCdsModel();
116+
CdsEntity serviceEntity = cdsModel.findEntity(entity.getQualifiedName()).orElse(null);
117+
if (serviceEntity == null || !ApplicationHandlerHelper.isMediaEntity(serviceEntity)) {
118+
return;
119+
}
120+
List<String> allowedTypes = getEntityAcceptableMediaTypes(serviceEntity);
121+
String fileName = extractFileName(entity, data);
122+
validateMediaTypeForAttachment(fileName, allowedTypes);
123+
}
124+
125+
protected static List<String> getEntityAcceptableMediaTypes(CdsEntity entity) {
126+
Optional<CdsAnnotation<Object>> flatMap =
127+
entity.getElement("content").findAnnotation("Core.AcceptableMediaTypes");
128+
List<String> result =
129+
flatMap
130+
.map(
131+
annotation ->
132+
objectMapper.convertValue(annotation.getValue(), STRING_LIST_TYPE_REF))
133+
.orElse(List.of("*/*"));
134+
return result;
135+
}
136+
137+
protected static String extractFileName(CdsEntity entity, List<? extends CdsData> data) {
138+
CdsDataProcessor processor = CdsDataProcessor.create();
139+
AtomicReference<String> fileNameRef = new AtomicReference<>();
140+
Validator validator =
141+
(path, element, value) -> {
142+
if (element.getName().contentEquals("fileName") && value instanceof String) {
143+
fileNameRef.set((String) value);
144+
}
145+
};
146+
147+
processor.addValidator(FILE_NAME_FILTER, validator).process(data, entity);
148+
149+
if (fileNameRef.get() == null || fileNameRef.get().isBlank()) {
150+
throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing");
151+
}
152+
return fileNameRef.get();
153+
}
154+
155+
public static String validateMediaTypeForAttachment(
156+
String fileName, List<String> acceptableMediaTypes) {
157+
validateFileName(fileName);
158+
String detectedMediaType = resolveMimeType(fileName);
159+
validateAcceptableMediaType(acceptableMediaTypes, detectedMediaType);
160+
return detectedMediaType;
161+
}
162+
163+
private static void validateFileName(String fileName) {
164+
if (fileName == null || fileName.isBlank()) {
165+
throw new ServiceException(
166+
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be null or blank");
167+
}
168+
String clean = fileName.trim();
169+
int lastDotIndex = clean.lastIndexOf('.');
170+
if (lastDotIndex <= 0 || lastDotIndex == clean.length() - 1) {
171+
throw new ServiceException(
172+
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Invalid filename format: " + fileName);
173+
}
174+
}
175+
176+
private static void validateAcceptableMediaType(
177+
List<String> acceptableMediaTypes, String actualMimeType) {
178+
if (!checkMimeTypeMatch(acceptableMediaTypes, actualMimeType)) {
179+
throw new ServiceException(
180+
ErrorStatuses.UNSUPPORTED_MEDIA_TYPE,
181+
"The attachment file type '{}' is not allowed. Allowed types are: {}",
182+
actualMimeType,
183+
String.join(", ", acceptableMediaTypes));
184+
}
185+
}
186+
187+
private static String resolveMimeType(String fileName) {
188+
189+
String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
190+
String actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension);
191+
if (actualMimeType == null) {
192+
logger.warn(
193+
"Could not determine mime type for file: {}. Setting mime type to default: {}",
194+
fileName,
195+
DEFAULT_MEDIA_TYPE);
196+
actualMimeType = DEFAULT_MEDIA_TYPE;
197+
}
198+
return actualMimeType;
199+
}
200+
201+
protected static boolean checkMimeTypeMatch(
202+
Collection<String> acceptableMediaTypes, String mimeType) {
203+
if (mimeType == null) {
204+
return false; // forces UNSUPPORTED_MEDIA_TYPE
205+
}
206+
if (acceptableMediaTypes == null
207+
|| acceptableMediaTypes.isEmpty()
208+
|| acceptableMediaTypes.contains("*/*")) return true;
209+
210+
String baseMimeType = mimeType.trim().toLowerCase();
211+
212+
return acceptableMediaTypes.stream()
213+
.anyMatch(
214+
type -> {
215+
String normalizedType = type.trim().toLowerCase();
216+
return normalizedType.endsWith("/*")
217+
? baseMimeType.startsWith(
218+
normalizedType.substring(0, normalizedType.length() - 2) + "/")
219+
: baseMimeType.equals(normalizedType);
220+
});
221+
}
222+
223+
private AttachmentValidationHelper() {
224+
// prevent instantiation
225+
}
226+
}

0 commit comments

Comments
 (0)