Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
import static java.util.Objects.requireNonNull;

import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Validator;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
Expand Down Expand Up @@ -44,27 +42,28 @@ public class UpdateAttachmentsHandler implements EventHandler {
private static final Logger logger = LoggerFactory.getLogger(UpdateAttachmentsHandler.class);

private final ModifyAttachmentEventFactory eventFactory;
private final AttachmentsReader attachmentsReader;
private final AttachmentService attachmentService;
private final ThreadDataStorageReader storageReader;
private final AttachmentsReader attachmentsReader;

public UpdateAttachmentsHandler(
ModifyAttachmentEventFactory eventFactory,
AttachmentsReader attachmentsReader,
AttachmentService attachmentService,
ThreadDataStorageReader storageReader) {
this.eventFactory = requireNonNull(eventFactory, "eventFactory must not be null");
this.attachmentsReader =
requireNonNull(attachmentsReader, "attachmentsReader must not be null");
this.attachmentService =
requireNonNull(attachmentService, "attachmentService must not be null");
this.storageReader = requireNonNull(storageReader, "storageReader must not be null");
this.attachmentsReader =
requireNonNull(attachmentsReader, "attachmentsReader must not be null");
}

@Before
@HandlerOrder(OrderConstants.Before.CHECK_CAPABILITIES)
void processBeforeForDraft(CdsUpdateEventContext context, List<CdsData> data) {
// before the attachment's readonly fields are removed by the runtime, preserve them in a custom
// before the attachment's readonly fields are removed by the runtime, preserve
// them in a custom
// field in data
ReadonlyDataContextEnhancer.preserveReadonlyFields(
context.getTarget(), data, storageReader.get());
Expand All @@ -73,20 +72,20 @@ void processBeforeForDraft(CdsUpdateEventContext context, List<CdsData> data) {
@Before
@HandlerOrder(HandlerOrder.LATE)
void processBefore(CdsUpdateEventContext context, List<CdsData> data) {

CdsEntity target = context.getTarget();
boolean associationsAreUnchanged = associationsAreUnchanged(target, data);

if (ApplicationHandlerHelper.containsContentField(target, data) || !associationsAreUnchanged) {
logger.debug("Processing before {} event for entity {}", context.getEvent(), target);

// Query database only for validation (single query for all attachments)
CqnSelect select = CqnUtils.toSelect(context.getCqn(), context.getTarget());
List<Attachments> attachments =
attachmentsReader.readAttachments(context.getModel(), target, select);

List<Attachments> condensedAttachments =
ApplicationHandlerHelper.condenseAttachments(attachments, target);
ModifyApplicationHandlerHelper.handleAttachmentForEntities(
target, data, condensedAttachments, eventFactory, context);
target, data, attachments, eventFactory, context);

if (!associationsAreUnchanged) {
deleteRemovedAttachments(attachments, data, target, context.getUserInfo());
Expand All @@ -95,35 +94,36 @@ void processBefore(CdsUpdateEventContext context, List<CdsData> data) {
}

private boolean associationsAreUnchanged(CdsEntity entity, List<CdsData> data) {
// TODO: check if this should be replaced with entity.assocations().noneMatch(...)
// TODO: check if this should be replaced with
// entity.assocations().noneMatch(...)
return entity
.compositions()
.noneMatch(
association -> data.stream().anyMatch(d -> d.containsKey(association.getName())));
}

private void deleteRemovedAttachments(
List<Attachments> existingAttachments,
List<CdsData> data,
List<Attachments> dbAttachments,
List<CdsData> requestData,
CdsEntity entity,
UserInfo userInfo) {
List<Attachments> condensedAttachments =
ApplicationHandlerHelper.condenseAttachments(data, entity);

Validator validator =
(path, element, value) -> {
Map<String, Object> keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys());
boolean entryExists =
condensedAttachments.stream()
.anyMatch(
updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData));
if (!entryExists) {
String contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
attachmentService.markAttachmentAsDeleted(new MarkAsDeletedInput(contentId, userInfo));
}
};
CdsDataProcessor.create()
.addValidator(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, validator)
.process(existingAttachments, entity);
List<Attachments> requestAttachments =
ApplicationHandlerHelper.condenseAttachments(requestData, entity);

for (Attachments dbAttachment : dbAttachments) {
Map<String, Object> dbKeys = ApplicationHandlerHelper.extractKeys(dbAttachment, entity);
Map<String, Object> keys = ApplicationHandlerHelper.removeDraftKey(dbKeys);

boolean existsInRequest =
requestAttachments.stream()
.anyMatch(
requestAttachment ->
ApplicationHandlerHelper.areKeysInData(keys, requestAttachment));

if (!existsInRequest && dbAttachment.getContentId() != null) {
attachmentService.markAttachmentAsDeleted(
new MarkAsDeletedInput(dbAttachment.getContentId(), userInfo));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* © 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.ErrorStatus;

public enum ExtendedErrorStatuses implements ErrorStatus {
CONTENT_TOO_LARGE(413, "The content size exceeds the maximum allowed limit.", 413);

private final int code;
private final String description;
private final int httpStatus;

ExtendedErrorStatuses(int code, String description, int httpStatus) {
this.code = code;
this.description = description;
this.httpStatus = httpStatus;
}

@Override
public String getDescription() {
return description;
}

@Override
public int getHttpStatus() {
return httpStatus;
}

/**
* @param code the code
* @return the ErrorStatus from this enum, associated with the given code or {@code null}
*/
public static ErrorStatus getByCode(int code) {
for (ExtendedErrorStatuses errorStatus : values()) {
if (Integer.parseInt(errorStatus.getCodeString()) == code) {
return errorStatus;
}
}
return null;
}

@Override
public String getCodeString() {
return Integer.toString(code);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import java.math.BigDecimal;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class FileSizeUtils {
private static final Pattern SIZE =
Pattern.compile("^\\s*([0-9]+(?:\\.[0-9]+)?)\\s*([a-zA-Z]*)\\s*$");
private static final Map<String, Long> MULTIPLIER =
Map.ofEntries(
Map.entry("", 1L),
Map.entry("B", 1L),

// Decimal
Map.entry("KB", 1000L),
Map.entry("MB", 1000L * 1000),
Map.entry("GB", 1000L * 1000 * 1000),
Map.entry("TB", 1000L * 1000 * 1000 * 1000),

// Binary
Map.entry("KIB", 1024L),
Map.entry("MIB", 1024L * 1024),
Map.entry("GIB", 1024L * 1024 * 1024),
Map.entry("TIB", 1024L * 1024 * 1024 * 1024));

private FileSizeUtils() {}

public static long parseFileSizeToBytes(String input) {
// First validate string
if (input == null) throw new IllegalArgumentException("Value for Max File Size is null");

Matcher m = SIZE.matcher(input);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid size: " + input);
}
BigDecimal value = new BigDecimal(m.group(1));
String unitRaw = m.group(2); // Cannot be null due to * quantifier in regex
String unit = unitRaw.toUpperCase();

// if (unit.length() == 1) unit = unit + "B"; // for people using K instead of KB
Long mul = MULTIPLIER.get(unit);
if (mul == null) {
throw new IllegalArgumentException("Unknown Unit: " + unitRaw);
}
BigDecimal bytes = value.multiply(BigDecimal.valueOf(mul));
return bytes.longValueExact();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,46 @@
import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Converter;
import com.sap.cds.CdsDataProcessor.Filter;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEvent;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.ql.cqn.Path;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.ServiceException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

public final class ModifyApplicationHandlerHelper {

private static final Filter VALMAX_FILTER = (path, element, type) -> element.getName().contentEquals("content")
&& element.findAnnotation("Validation.Maximum").isPresent();

/**
* Handles attachments for entities.
*
* @param entity the {@link CdsEntity entity} to handle attachments for
* @param data the given list of {@link CdsData data}
* @param entity the {@link CdsEntity entity} to handle attachments
* for
* @param data the given list of {@link CdsData data}
* @param existingAttachments the given list of existing {@link CdsData data}
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event
* @param eventContext the current {@link EventContext}
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create
* the corresponding event
* @param eventContext the current {@link EventContext}
*/
public static void handleAttachmentForEntities(
CdsEntity entity,
List<CdsData> data,
List<Attachments> existingAttachments,
ModifyAttachmentEventFactory eventFactory,
EventContext eventContext) {
Converter converter =
(path, element, value) ->
handleAttachmentForEntity(
existingAttachments, eventFactory, eventContext, path, (InputStream) value);
Converter converter = (path, element, value) -> handleAttachmentForEntity(
existingAttachments, eventFactory, eventContext, path, (InputStream) value);

CdsDataProcessor.create()
.addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter)
Expand All @@ -47,11 +55,13 @@ public static void handleAttachmentForEntities(
/**
* Handles attachments for a single entity.
*
* @param existingAttachments the list of existing {@link Attachments} to check against
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event
* @param eventContext the current {@link EventContext}
* @param path the {@link Path} of the attachment
* @param content the content of the attachment
* @param existingAttachments the list of existing {@link Attachments} to check
* against
* @param eventFactory the {@link ModifyAttachmentEventFactory} to create
* the corresponding event
* @param eventContext the current {@link EventContext}
* @param path the {@link Path} of the attachment
* @param content the content of the attachment
* @return the processed content as an {@link InputStream}
*/
public static InputStream handleAttachmentForEntity(
Expand All @@ -64,12 +74,30 @@ public static InputStream handleAttachmentForEntity(
ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values());
Attachments attachment = getExistingAttachment(keys, existingAttachments);
String contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
String contentLength = eventContext.getParameterInfo().getHeader("Content-Length");

InputStream wrappedContent = wrapWithCountingStream(content, path.target().entity(), existingAttachments,
contentLength);

// for the current request find the event to process
ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(content, contentId, attachment);
ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment);

// process the event
return eventToProcess.processEvent(path, content, attachment, eventContext);
return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext);
}

private static String getValMaxValue(CdsEntity entity, List<? extends CdsData> data) {
AtomicReference<String> annotationValue = new AtomicReference<>();
CdsDataProcessor.create()
.addValidator(
VALMAX_FILTER,
(path, element, value) -> {
element
.findAnnotation("Validation.Maximum")
.ifPresent(annotation -> annotationValue.set(annotation.getValue().toString()));
})
.process(data, entity);
return annotationValue.get();
}

private static Attachments getExistingAttachment(
Expand All @@ -80,6 +108,27 @@ private static Attachments getExistingAttachment(
.orElse(Attachments.create());
}

private static InputStream wrapWithCountingStream(
InputStream content, CdsEntity entity, List<? extends CdsData> data, String contentLength) {
String maxSizeStr = getValMaxValue(entity, data);

if (maxSizeStr != null && content != null) {
try {
long maxSize = FileSizeUtils.parseFileSizeToBytes(maxSizeStr);
// if (contentLength != null && Long.parseLong(contentLength) > maxSize) {
// throw new RuntimeException();
// }
return new CountingInputStream(content, maxSize);
} catch (ArithmeticException e) {
throw new ServiceException("Maximum file size value is too large", e);
} catch (RuntimeException e) {
throw new ServiceException(
ExtendedErrorStatuses.CONTENT_TOO_LARGE, "AttachmentSizeExceeded", maxSizeStr);
}
}
return content;
}

private ModifyApplicationHandlerHelper() {
// avoid instantiation
}
Expand Down
Loading
Loading