|
| 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