diff --git a/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java b/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java index 16ae54715..c98155f11 100644 --- a/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java +++ b/basyx.aasenvironment/basyx.aasenvironment-core/src/main/java/org/eclipse/digitaltwin/basyx/aasenvironment/base/DefaultAASEnvironment.java @@ -371,7 +371,9 @@ private void handleFileError(File file) { private byte[] getFileContent(String submodelId, File file) throws IOException { - return submodelRepository.getFileByFilePath(submodelId, file.getValue()).readAllBytes(); + try (InputStream fileContent = submodelRepository.getFileByFilePath(submodelId, file.getValue())) { + return fileContent.readAllBytes(); + } } private boolean isFileAlreadyAdded(String filePath){ @@ -386,7 +388,7 @@ private void addThumbnailToRelatedFiles(String aasId, Resource thumbnail, List> getAllAas(List assetIds, String idShort, PaginationInfo pInfo); /** * Retrieves a specific AAS - * + * * @param aasId * the id of the AAS * @return the requested AAS @@ -64,7 +67,7 @@ public interface AasRepository { /** * Creates a new AAS at the endpoint - * + * * @param aas * the AAS to be created * @throws MissingIdentifierException @@ -74,7 +77,7 @@ public interface AasRepository { /** * Deletes a specific AAS - * + * * @param aasId * the id of the AAS to be deleted */ @@ -82,14 +85,14 @@ public interface AasRepository { /** * Overwrites an existing AAS - * + * * @param aas */ public void updateAas(String aasId, AssetAdministrationShell aas); /** * Returns a List of References to Submodels - * + * * @param aasId * @param pInfo * @return @@ -98,21 +101,21 @@ public interface AasRepository { /** * Adds a Submodel Reference - * + * * @param submodelReference */ public void addSubmodelReference(String aasId, Reference submodelReference); /** * Removes a Submodel Reference - * + * * @param submodelId */ public void removeSubmodelReference(String aasId, String submodelId); /** * Sets the asset-information of a specific AAS - * + * * @param aasId * the id of the AAS */ @@ -120,26 +123,45 @@ public interface AasRepository { /** * Retrieves the asset-information of a specific AAS - * + * * @param aasId * the id of the AAS - * + * * @return the requested AAS */ public AssetInformation getAssetInformation(String aasId) throws ElementDoesNotExistException; /** * Get Thumbnail of the specific aas - * + * * @param aasId * the id of the AAS * @return the file of the thumbnail */ public File getThumbnail(String aasId); + /** + * Get Thumbnail content of the specific aas as a stream + *

+ * The caller is responsible for closing the returned stream. + * + * @param aasId + * the id of the AAS + * @return the stream of the thumbnail + * @throws org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException + * if no thumbnail is stored + */ + public default InputStream getThumbnailInputStream(String aasId) { + try { + return new FileInputStream(getThumbnail(aasId)); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open thumbnail stream.", e); + } + } + /** * Set Thumbnail of the AAS - * + * * @param aasId * the id of the AAS * @param fileName @@ -153,19 +175,19 @@ public interface AasRepository { /** * Delete the thumbnail file of the AAS - * + * * @param aasId * the id of the AAS */ public void deleteThumbnail(String aasId); - + /** * Returns the name of the repository - * + * * @return repoName */ public default String getName() { return "aas-repo"; } - + } diff --git a/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java b/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java index f8d0c21f5..8ffbf7ac2 100644 --- a/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java +++ b/basyx.aasrepository/basyx.aasrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/aasrepository/AasRepositoryAasServiceWrapper.java @@ -88,6 +88,11 @@ public File getThumbnail() { return repoApi.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream() { + return repoApi.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { repoApi.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java index 5530b173a..d535d2861 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/authorization/AuthorizedAasRepository.java @@ -1,6 +1,6 @@ /******************************************************************************* * Copyright (C) 2023 the Eclipse BaSyx Authors - * + * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including @@ -8,10 +8,10 @@ * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: - * + * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -19,7 +19,7 @@ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - * + * * SPDX-License-Identifier: MIT ******************************************************************************/ @@ -62,7 +62,7 @@ public class AuthorizedAasRepository implements AasRepository { private static final Logger LOGGER = LoggerFactory.getLogger(AuthorizedAasRepository.class); private AasRepository decorated; private RbacPermissionResolver permissionResolver; - + public AuthorizedAasRepository(AasRepository decorated, RbacPermissionResolver permissionResolver) { this.decorated = decorated; @@ -72,15 +72,15 @@ public AuthorizedAasRepository(AasRepository decorated, RbacPermissionResolver> getAllAas(List assetIds, String idShort, PaginationInfo pInfo) { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList("*"))); - + if (isAuthorized) return decorated.getAllAas(assetIds, idShort, pInfo); - + List targetInformations = permissionResolver.getMatchingTargetInformationInRules(Action.READ, new AasTargetInformation(getIdAsList("*"))); - + List allIds = targetInformations.stream().map(AasTargetInformation.class::cast) .map(AasTargetInformation::getAasIds).flatMap(List::stream).collect(Collectors.toList()); - + List shells = allIds.stream().map(id -> { try { return getAas(id); @@ -92,7 +92,7 @@ public CursorResult> getAllAas(List aasMap = shells.stream().collect(Collectors.toMap(AssetAdministrationShell::getId, aas -> aas, (a, b) -> a, TreeMap::new)); PaginationSupport paginationSupport = new PaginationSupport<>(aasMap, AssetAdministrationShell::getId); @@ -103,120 +103,129 @@ public CursorResult> getAllAas(List> getSubmodelReferences(String shellId, PaginationInfo paginationInfo) { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + return decorated.getSubmodelReferences(shellId, paginationInfo); } @Override public void addSubmodelReference(String shellId, Reference submodelReference) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.addSubmodelReference(shellId, submodelReference); } @Override public void removeSubmodelReference(String shellId, String submodelId) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.removeSubmodelReference(shellId, submodelId); } @Override public void setAssetInformation(String shellId, AssetInformation shellInfo) throws ElementDoesNotExistException { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.setAssetInformation(shellId, shellInfo); } @Override public AssetInformation getAssetInformation(String shellId) throws ElementDoesNotExistException { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + return decorated.getAssetInformation(shellId); } @Override public File getThumbnail(String shellId) { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + return decorated.getThumbnail(shellId); } + @Override + public InputStream getThumbnailInputStream(String shellId) { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new AasTargetInformation(getIdAsList(shellId))); + + throwExceptionIfInsufficientPermission(isAuthorized); + + return decorated.getThumbnailInputStream(shellId); + } + @Override public void setThumbnail(String shellId, String fileName, String contentType, InputStream inputStream) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.setThumbnail(shellId, fileName, contentType, inputStream); } @Override public void deleteThumbnail(String shellId) { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new AasTargetInformation(getIdAsList(shellId))); - + throwExceptionIfInsufficientPermission(isAuthorized); - + decorated.deleteThumbnail(shellId); } - + @Override public String getName() { return decorated.getName(); } - + private ArrayList getIdAsList(String id) { return new ArrayList<>(Arrays.asList(id)); } - + private void throwExceptionIfInsufficientPermission(boolean isAuthorized) { if (!isAuthorized) throw new InsufficientPermissionException("Insufficient Permission: The current subject does not have the required permissions for this operation."); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java index b711a4746..5412d75a4 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-discovery-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/discoveryintegration/DiscoveryIntegrationAasRepository.java @@ -135,6 +135,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java index e95af50c7..7f274fc9a 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/kafka/KafkaAasRepository.java @@ -119,6 +119,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java index 95a0cf662..f42d9e96f 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/mqtt/MqttAasRepository.java @@ -195,6 +195,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java index 544600f07..229a2b605 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/registry/integration/RegistryIntegrationAasRepository.java @@ -181,6 +181,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java b/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java index 8cac5deea..ef9ebbd5b 100644 --- a/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java +++ b/basyx.aasrepository/basyx.aasrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/feature/search/SearchAasRepository.java @@ -121,6 +121,11 @@ public File getThumbnail(String aasId) { return decorated.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream(String aasId) { + return decorated.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java b/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java index 85c6c28b1..0d872f5c2 100644 --- a/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java +++ b/basyx.aasrepository/basyx.aasrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/aasrepository/http/AasRepositoryApiHTTPController.java @@ -41,6 +41,7 @@ import org.eclipse.digitaltwin.basyx.aasrepository.http.pagination.GetAssetAdministrationShellsResult; import org.eclipse.digitaltwin.basyx.aasrepository.http.pagination.GetReferencesResult; import org.eclipse.digitaltwin.basyx.core.exceptions.CollidingSubmodelReferenceException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.http.BaSyxMediaType; @@ -49,7 +50,7 @@ import org.eclipse.digitaltwin.basyx.http.pagination.PagedResult; import org.eclipse.digitaltwin.basyx.http.pagination.PagedResultPagingMetadata; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -201,14 +202,22 @@ public ResponseEntity deleteThumbnailAasRepository(Base64UrlEncodedIdentif @Override public ResponseEntity getThumbnailAasRepository(Base64UrlEncodedIdentifier aasIdentifier) { String aasId = aasIdentifier.getIdentifier(); - Resource resource = new FileSystemResource(aasRepository.getThumbnail(aasId)); - String contentType = aasRepository.getAssetInformation(aasId).getDefaultThumbnail().getContentType(); + org.eclipse.digitaltwin.aas4j.v3.model.Resource thumbnail = aasRepository.getAssetInformation(aasId).getDefaultThumbnail(); + + if (!isThumbnailSet(thumbnail)) + throw new FileDoesNotExistException(); + + Resource resource = new InputStreamResource(aasRepository.getThumbnailInputStream(aasId)); return ResponseEntity.ok() - .contentType(BaSyxMediaType.parseOrOctetStream(contentType)) + .contentType(BaSyxMediaType.parseOrOctetStream(thumbnail.getContentType())) .body(resource); } + private static boolean isThumbnailSet(org.eclipse.digitaltwin.aas4j.v3.model.Resource thumbnail) { + return thumbnail != null && thumbnail.getPath() != null && !thumbnail.getPath().isBlank(); + } + @Override public ResponseEntity putThumbnailAasRepository(Base64UrlEncodedIdentifier aasIdentifier, String fileName, @Valid MultipartFile file) { InputStream fileInputstream = null; diff --git a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java index b737beda1..ecb67ce40 100644 --- a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java +++ b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperations.java @@ -51,23 +51,97 @@ public AasThumbnailOperations(AasOperations aasOperations, FileRepository fileRe this.fileRepository = fileRepository; } + /** + * Retrieves the AAS thumbnail as a temporary local file. + *

+ * The caller owns the returned temporary file and is responsible for deleting + * it when it is no longer needed. + * + * @param aasId + * the id of the AAS + * @return the thumbnail file + * @throws FileDoesNotExistException + * if no thumbnail is stored + */ public File getThumbnail(String aasId) { return FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, getThumbnailResourcePathOrThrow(aasOperations.getAssetInformation(aasId))); } + /** + * Retrieves the AAS thumbnail content as a stream. + *

+ * The caller is responsible for closing the returned stream. + * + * @param aasId + * the id of the AAS + * @return the thumbnail content stream + * @throws FileDoesNotExistException + * if no thumbnail is stored + */ + public InputStream getThumbnailInputStream(String aasId) { + return FileRepositoryHelper.getFileInputStream(fileRepository, getThumbnailResourcePathOrThrow(aasOperations.getAssetInformation(aasId))); + } + + /** + * Stores a new AAS thumbnail and updates the thumbnail resource path. + * + * @param aasId + * the id of the AAS + * @param fileName + * the logical display name of the uploaded thumbnail + * @param contentType + * the thumbnail MIME type + * @param inputStream + * the thumbnail content stream + */ public void setThumbnail(String aasId, String fileName, String contentType, InputStream inputStream) { - String filePath = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, fileName, contentType, inputStream); - aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(aasOperations.getAssetInformation(aasId), contentType, filePath)); + AssetInformation assetInformation = aasOperations.getAssetInformation(aasId); + Resource oldThumbnail = assetInformation.getDefaultThumbnail(); + String oldThumbnailPath = getThumbnailResourcePath(assetInformation).orElse(null); + + FileRepositoryHelper.saveAndUpdateReference(fileRepository, oldThumbnailPath, fileName, contentType, inputStream, filePath -> setThumbnailReference(aasId, assetInformation, oldThumbnail, contentType, filePath)); } + /** + * Removes the thumbnail reference and deletes the stored thumbnail content. + * + * @param aasId + * the id of the AAS + * @throws FileDoesNotExistException + * if no thumbnail is stored + */ public void deleteThumbnail(String aasId) { AssetInformation assetInformation = aasOperations.getAssetInformation(aasId); - FileRepositoryHelper.removeFileIfExists(fileRepository, getThumbnailResourcePathOrThrow(assetInformation)); - aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(assetInformation, "", "")); + Resource oldThumbnail = assetInformation.getDefaultThumbnail(); + String thumbnailPath = getThumbnailResourcePathOrThrow(assetInformation); + + FileRepositoryHelper.updateReferenceAndDeleteFile(fileRepository, thumbnailPath, () -> clearThumbnailReference(aasId, assetInformation, oldThumbnail)); + } + + private void setThumbnailReference(String aasId, AssetInformation assetInformation, Resource oldThumbnail, String contentType, String filePath) { + try { + aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(assetInformation, contentType, filePath)); + } catch (RuntimeException e) { + assetInformation.setDefaultThumbnail(oldThumbnail); + throw e; + } + } + + private void clearThumbnailReference(String aasId, AssetInformation assetInformation, Resource oldThumbnail) { + try { + aasOperations.setAssetInformation(aasId, configureAssetInformationThumbnail(assetInformation, "", "")); + } catch (RuntimeException e) { + assetInformation.setDefaultThumbnail(oldThumbnail); + throw e; + } } private static String getThumbnailResourcePathOrThrow(AssetInformation assetInformation) { - return Optional.ofNullable(assetInformation).map(AssetInformation::getDefaultThumbnail).map(Resource::getPath).orElseThrow(FileDoesNotExistException::new); + return getThumbnailResourcePath(assetInformation).orElseThrow(FileDoesNotExistException::new); + } + + private static Optional getThumbnailResourcePath(AssetInformation assetInformation) { + return Optional.ofNullable(assetInformation).map(AssetInformation::getDefaultThumbnail).map(Resource::getPath); } private static AssetInformation configureAssetInformationThumbnail(AssetInformation assetInformation, String contentType, String filePath) { diff --git a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java index 156f4b351..385e680cf 100644 --- a/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java +++ b/basyx.aasservice/basyx.aasservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/backend/CrudAasService.java @@ -95,6 +95,11 @@ public File getThumbnail() { return thumbnailOperations.getThumbnail(aasId); } + @Override + public InputStream getThumbnailInputStream() { + return thumbnailOperations.getThumbnailInputStream(aasId); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { thumbnailOperations.setThumbnail(aasId, fileName, contentType, inputStream); diff --git a/basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java b/basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java new file mode 100644 index 000000000..c8f3ce792 --- /dev/null +++ b/basyx.aasservice/basyx.aasservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/backend/AasThumbnailOperationsTest.java @@ -0,0 +1,257 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.aasservice.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; +import org.eclipse.digitaltwin.aas4j.v3.model.AssetInformation; +import org.eclipse.digitaltwin.aas4j.v3.model.Reference; +import org.eclipse.digitaltwin.aas4j.v3.model.Resource; +import org.eclipse.digitaltwin.aas4j.v3.model.SpecificAssetId; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultAssetInformation; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultResource; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; +import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; +import org.junit.Test; + +public class AasThumbnailOperationsTest { + + private static final String AAS_ID = "aas-1"; + + @Test + public void setThumbnailRejectsPathTraversalFileName() throws IOException { + Path victimFile = Files.createTempDirectory("basyx-victim").resolve("pwned.txt"); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(new InMemoryAasOperations(), new GridFsLikeFileRepository()); + + try { + assertThrows(SecurityException.class, () -> thumbnailOperations.setThumbnail(AAS_ID, victimFile.toAbsolutePath().toString(), "image/png", new ByteArrayInputStream("payload".getBytes(UTF_8)))); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile.getParent()); + } + } + + @Test + public void getThumbnailWithAbsoluteResourcePathWritesOnlyTempFile() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + + InMemoryAasOperations aasOperations = new InMemoryAasOperations(); + aasOperations.setAssetInformation(AAS_ID, createAssetInformationWithThumbnail(maliciousRepositoryKey)); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + try { + java.io.File localFile = thumbnailOperations.getThumbnail(AAS_ID); + + assertTrue(localFile.exists()); + assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); + assertFalse(Files.exists(victimFile)); + assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath())); + Files.deleteIfExists(localFile.toPath()); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void getThumbnailInputStreamWithAbsoluteResourcePathDoesNotWriteToDisk() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + + InMemoryAasOperations aasOperations = new InMemoryAasOperations(); + aasOperations.setAssetInformation(AAS_ID, createAssetInformationWithThumbnail(maliciousRepositoryKey)); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + try (InputStream inputStream = thumbnailOperations.getThumbnailInputStream(AAS_ID)) { + assertArrayEquals(attackerPayload, inputStream.readAllBytes()); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void setThumbnailCleansNewFileAndKeepsOldFileWhenAssetInformationUpdateFails() { + String oldThumbnailPath = "old-thumbnail"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldThumbnailPath, "old".getBytes(UTF_8)); + FailingAasOperations aasOperations = new FailingAasOperations(); + aasOperations.assetInformation = createAssetInformationWithThumbnail(oldThumbnailPath); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + assertThrows(RuntimeException.class, () -> thumbnailOperations.setThumbnail(AAS_ID, "new.png", "image/png", new ByteArrayInputStream("new".getBytes(UTF_8)))); + + assertTrue(fileRepository.exists(oldThumbnailPath)); + assertEquals(1, fileRepository.size()); + assertEquals(oldThumbnailPath, aasOperations.getAssetInformation(AAS_ID).getDefaultThumbnail().getPath()); + } + + @Test + public void deleteThumbnailKeepsFileWhenAssetInformationUpdateFails() { + String oldThumbnailPath = "old-thumbnail"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldThumbnailPath, "old".getBytes(UTF_8)); + FailingAasOperations aasOperations = new FailingAasOperations(); + aasOperations.assetInformation = createAssetInformationWithThumbnail(oldThumbnailPath); + AasThumbnailOperations thumbnailOperations = new AasThumbnailOperations(aasOperations, fileRepository); + + assertThrows(RuntimeException.class, () -> thumbnailOperations.deleteThumbnail(AAS_ID)); + + assertTrue(fileRepository.exists(oldThumbnailPath)); + assertEquals(1, fileRepository.size()); + assertEquals(oldThumbnailPath, aasOperations.getAssetInformation(AAS_ID).getDefaultThumbnail().getPath()); + } + + private static AssetInformation createAssetInformationWithThumbnail(String path) { + Resource thumbnail = new DefaultResource.Builder().path(path).contentType("image/png").build(); + AssetInformation assetInformation = new DefaultAssetInformation.Builder().build(); + assetInformation.setDefaultThumbnail(thumbnail); + return assetInformation; + } + + private static class InMemoryAasOperations implements AasOperations { + protected AssetInformation assetInformation = new DefaultAssetInformation.Builder().build(); + + @Override + public void setAssetInformation(String aasId, AssetInformation assetInformation) { + this.assetInformation = assetInformation; + } + + @Override + public AssetInformation getAssetInformation(String aasId) { + return assetInformation; + } + + @Override + public CursorResult> getShells(List assetIds, String idShort, PaginationInfo pInfo) { + throw new UnsupportedOperationException(); + } + + @Override + public CursorResult> getSubmodelReferences(String aasId, PaginationInfo pInfo) { + throw new UnsupportedOperationException(); + } + + @Override + public void addSubmodelReference(String aasId, Reference submodelReference) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeSubmodelReference(String aasId, String submodelId) { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable getAllAas(List assetIds, String idShort) { + throw new UnsupportedOperationException(); + } + } + + private static class FailingAasOperations extends InMemoryAasOperations { + @Override + public void setAssetInformation(String aasId, AssetInformation assetInformation) { + throw new RuntimeException("Could not update asset information."); + } + } + + private static class GridFsLikeFileRepository implements FileRepository { + private final Map store = new HashMap<>(); + + @Override + public String save(FileMetadata metadata) throws FileHandlingException { + if (exists(metadata.getFileName())) + throw new FileHandlingException("File '%s' already exists.".formatted(metadata.getFileName())); + + try { + store.put(metadata.getFileName(), metadata.getFileContent().readAllBytes()); + } catch (IOException e) { + throw new FileHandlingException(metadata.getFileName(), e); + } + + return metadata.getFileName(); + } + + @Override + public InputStream find(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + return new ByteArrayInputStream(store.get(fileId)); + } + + @Override + public void delete(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + store.remove(fileId); + } + + @Override + public boolean exists(String fileId) { + return fileId != null && store.containsKey(fileId); + } + + void put(String fileId, byte[] content) { + store.put(fileId, content); + } + + int size() { + return store.size(); + } + } +} diff --git a/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java b/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java index 299d54acc..b8c494fbd 100644 --- a/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java +++ b/basyx.aasservice/basyx.aasservice-client/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/client/ConnectedAasService.java @@ -27,7 +27,10 @@ package org.eclipse.digitaltwin.basyx.aasservice.client; import java.io.File; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.util.List; import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; @@ -41,6 +44,7 @@ import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.http.Base64UrlEncoder; @@ -128,6 +132,11 @@ public File getThumbnail() { } } + @Override + public InputStream getThumbnailInputStream() { + return openFileAndDeleteOnClose(getThumbnail()); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { try { @@ -177,4 +186,21 @@ private RuntimeException mapAasAccess(ApiException e) { return e; } + private static InputStream openFileAndDeleteOnClose(File file) { + try { + return new FilterInputStream(Files.newInputStream(file.toPath())) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + Files.deleteIfExists(file.toPath()); + } + } + }; + } catch (IOException e) { + throw new FileHandlingException("Could not open thumbnail stream.", e); + } + } + } diff --git a/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java b/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java index c0b6783cd..b67301479 100644 --- a/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java +++ b/basyx.aasservice/basyx.aasservice-core/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/AasService.java @@ -1,6 +1,6 @@ /******************************************************************************* * Copyright (C) 2023 the Eclipse BaSyx Authors - * + * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including @@ -8,10 +8,10 @@ * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: - * + * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. - * + * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND @@ -19,24 +19,27 @@ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - * + * * SPDX-License-Identifier: MIT ******************************************************************************/ package org.eclipse.digitaltwin.basyx.aasservice; import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.InputStream; import java.util.List; import org.eclipse.digitaltwin.aas4j.v3.model.AssetAdministrationShell; import org.eclipse.digitaltwin.aas4j.v3.model.AssetInformation; import org.eclipse.digitaltwin.aas4j.v3.model.Reference; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; /** * Specifies the overall AasService API - * + * * @author schnicke, mateusmolina * */ @@ -44,14 +47,14 @@ public interface AasService { /** * Retrieves the AAS contained in the server - * + * * @return */ public AssetAdministrationShell getAAS(); /** * Retrieves all Submodel References - * + * * @return A List containing all Submodel References */ public CursorResult> getSubmodelReferences(PaginationInfo pInfo); @@ -65,29 +68,46 @@ public interface AasService { * Removes a Submodel Reference */ public void removeSubmodelReference(String submodelId); - + /** * Sets the asset-information of the AAS contained in the server */ public void setAssetInformation(AssetInformation aasInfo); - + /** * Retrieves the asset-information of the AAS contained in the server - * + * * @return the Asset-Information of the AAS */ - public AssetInformation getAssetInformation(); + public AssetInformation getAssetInformation(); /** * Get Thumbnail of the specific aas - * + * * @return the file of the thumbnail */ public File getThumbnail(); + /** + * Get Thumbnail content as a stream. + *

+ * The caller is responsible for closing the returned stream. + * + * @return the stream of the thumbnail + * @throws org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException + * if no thumbnail is stored + */ + public default InputStream getThumbnailInputStream() { + try { + return new FileInputStream(getThumbnail()); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open thumbnail stream.", e); + } + } + /** * Set Thumbnail of the AAS - * + * * @param fileName * name of the thumbnail file with extension * @param contentType @@ -99,7 +119,7 @@ public interface AasService { /** * Delete the thumbnail file of the AAS - * + * */ public void deleteThumbnail(); } diff --git a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java index cc8909ff3..96e53e264 100644 --- a/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java +++ b/basyx.aasservice/basyx.aasservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/MqttAasService.java @@ -190,6 +190,11 @@ public File getThumbnail() { return decorated.getThumbnail(); } + @Override + public InputStream getThumbnailInputStream() { + return decorated.getThumbnailInputStream(); + } + @Override public void setThumbnail(String fileName, String contentType, InputStream inputStream) { decorated.setThumbnail(fileName, contentType, inputStream); diff --git a/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java b/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java index 8646db52f..f1b0c9cab 100644 --- a/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java +++ b/basyx.aasxfileserver/basyx.aasxfileserver-backend/src/main/java/org/eclipse/digitaltwin/basyx/aasxfileserver/backend/CrudAASXFileServer.java @@ -40,8 +40,8 @@ import org.eclipse.digitaltwin.basyx.aasxfileserver.model.Package; import org.eclipse.digitaltwin.basyx.aasxfileserver.model.PackagesBody; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; -import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationSupport; @@ -96,17 +96,12 @@ public InputStream getAASXByPackageId(String packageId) throws ElementDoesNotExi @Override public void updateAASXByPackageId(String packageId, List shellIds, InputStream file, String filename) throws ElementDoesNotExistException { - deleteAASXByPackageId(packageId); + Package existingPackage = packageBackend.findById(packageId).orElseThrow(ElementDoesNotExistException::new); PackageDescription packageDescription = createPackageDescription(shellIds, packageId); + String oldFilePath = existingPackage.getPackagesBody().getFilePath(); - String filepath = fileRepository.save(new FileMetadata(filename, AASX_CONTENT_TYPE, file)); - - PackagesBody packagesBody = createPackagesBody(shellIds, filename, filepath); - - Package pkg = createPackage(packageDescription, packagesBody); - - packageBackend.save(pkg); + saveAASXFileAndUpdatePackageReference(shellIds, file, filename, packageDescription, oldFilePath); } @Override @@ -116,13 +111,7 @@ public PackageDescription createAASXPackage(List shellIds, InputStream f PackageDescription packageDescription = createPackageDescription(shellIds, newpackageId); - String filepath = fileRepository.save(new FileMetadata(fileName, AASX_CONTENT_TYPE, file)); - - PackagesBody packagesBody = createPackagesBody(shellIds, fileName, filepath); - - Package pkg = createPackage(packageDescription, packagesBody); - - packageBackend.save(pkg); + saveAASXFileAndUpdatePackageReference(shellIds, file, fileName, packageDescription, null); return packageDescription; } @@ -141,6 +130,13 @@ private InputStream getISFromPackage(Package pkg) { return fileRepository.find(pkg.getPackagesBody().getFilePath()); } + private void saveAASXFileAndUpdatePackageReference(List shellIds, InputStream file, String fileName, PackageDescription packageDescription, String oldFilePath) { + FileRepositoryHelper.saveAndUpdateReference(fileRepository, oldFilePath, fileName, AASX_CONTENT_TYPE, file, filePath -> { + PackagesBody packagesBody = createPackagesBody(shellIds, fileName, filePath); + packageBackend.save(createPackage(packageDescription, packagesBody)); + }); + } + /** * * @param pkg diff --git a/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java b/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java index cd854af75..c18ac29a3 100644 --- a/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java +++ b/basyx.aasxfileserver/basyx.aasxfileserver-core/src/test/java/org/eclipse/digitaltwin/basyx/aasxfileserver/core/AASXFileServerSuite.java @@ -27,6 +27,7 @@ import static org.junit.Assert.*; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Arrays; @@ -95,6 +96,21 @@ public void createAASXPackage() { assertEquals(expectedPackageDescription, actualPackageDescription); } + @Test(expected = SecurityException.class) + public void createAASXPackageRejectsPathTraversalFileName() { + AASXFileServer server = getAASXFileServer(); + + server.createAASXPackage(DummyAASXFileServerFactory.FIRST_SHELL_IDS, createAASXFileContent(), "../test.aasx"); + } + + @Test(expected = SecurityException.class) + public void updateAASXPackageRejectsPathTraversalFileName() { + AASXFileServer server = getAASXFileServer(); + PackageDescription packageDescription = DummyAASXFileServerFactory.createFirstDummyAASXPackageOnServer(server); + + server.updateAASXByPackageId(packageDescription.getPackageId(), DummyAASXFileServerFactory.FIRST_SHELL_IDS, createAASXFileContent(), "../test.aasx"); + } + @Test public void getAllAASXPackageIdsEmpty() { String shellId = "testShellId"; @@ -181,6 +197,10 @@ private void updateAASXPackage(AASXFileServer server, String packageId, List expectedPackageDescriptions, List actualPackageDescriptions) { assertTrue(expectedPackageDescriptions.containsAll(actualPackageDescriptions)); assertTrue(actualPackageDescriptions.containsAll(expectedPackageDescriptions)); diff --git a/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java b/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java index 6ba52154d..897966859 100644 --- a/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java +++ b/basyx.common/basyx.filerepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelper.java @@ -26,10 +26,13 @@ package org.eclipse.digitaltwin.basyx.core.filerepository; import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; +import java.util.function.Consumer; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; @@ -40,69 +43,231 @@ * @author mateusmolina */ public class FileRepositoryHelper { + private static final int MAX_EXTENSION_LENGTH = 16; + private FileRepositoryHelper() { } /** - * Retrieves the content of a file from the repository and creates a local file. + * Retrieves repository content and materializes it as a temporary local file. + *

+ * The repository id is never used as a filesystem path. A conservative file + * extension is preserved for compatibility with callers that inspect the + * returned file name. The caller owns the returned temporary file and is + * responsible for deleting it when it is no longer needed. * * @param fileRepository * the file repository instance * @param filePath + * the repository file id * @return the retrieved file * @throws FileHandlingException * if an IO error occurs */ public static File fetchAndStoreFileLocally(FileRepository fileRepository, String filePath) { + Path temporaryFile = null; + try (InputStream fileIs = fileRepository.find(filePath)) { - byte[] content = fileIs.readAllBytes(); - writeToFile(filePath, content); - return new File(filePath); + temporaryFile = Files.createTempFile("basyx-file-", getSafeTempFileSuffix(filePath)); + Files.copy(fileIs, temporaryFile, StandardCopyOption.REPLACE_EXISTING); + return temporaryFile.toFile(); } catch (IOException e) { + deleteIfExists(temporaryFile); throw new FileHandlingException("Error while retrieving file content:" + filePath, e); } } /** - * Writes byte content to a file on disk. + * Saves a file to the repository under a server-generated id. + *

+ * The supplied {@code fileName} is treated as a logical display name only. It + * must not be blank, contain control characters, contain path separators, + * contain {@code ..}, or start with a Windows drive prefix. The stored file id + * is a UUID plus a conservative ASCII alphanumeric extension, if one is + * present. * - * @param filePath - * the path where the file should be created - * @param content - * the byte content to be written to the file - * @throws FileHandlingException - * if an IO error occurs + * @param fileRepository + * the file repository instance + * @param fileName + * the logical name of the file + * @param contentType + * the MIME type of the file + * @param inputStream + * the input stream containing file data + * @return the generated repository file id + * @throws IllegalArgumentException + * if the logical filename is null, blank, or contains control + * characters + * @throws SecurityException + * if the logical filename looks like a path traversal attempt */ - public static void writeToFile(String filePath, byte[] content) { - try (OutputStream outputStream = new FileOutputStream(filePath)) { - outputStream.write(content); - } catch (IOException e) { - throw new FileHandlingException("Error while writing to file: " + filePath, e); + public static String saveOrOverwriteFile(FileRepository fileRepository, String fileName, String contentType, InputStream inputStream) { + String logicalFileName = validateLogicalFileName(fileName); + String storedFileName = createUniqueFileName(logicalFileName); + FileMetadata fileMetadata = new FileMetadata(storedFileName, logicalFileName, contentType, inputStream); + + if (fileRepository.exists(storedFileName)) { + fileRepository.delete(storedFileName); } + return fileRepository.save(fileMetadata); } /** - * Saves a file to the repository, deleting any existing file with the same - * name. + * Saves a new file, updates its owning domain reference, and then removes the + * old repository file on success. + *

+ * If {@code updateReference} fails, the newly saved repository file is deleted + * before the exception is rethrown. * * @param fileRepository * the file repository instance + * @param oldFilePath + * the previous repository file id, or null if none exists * @param fileName - * the name of the file + * the logical display name of the uploaded file * @param contentType * the MIME type of the file * @param inputStream * the input stream containing file data - * @return the path where the file is saved + * @param updateReference + * callback that stores the new repository file id in the owning + * domain object + * @return the generated repository file id */ - public static String saveOrOverwriteFile(FileRepository fileRepository, String fileName, String contentType, InputStream inputStream) { - FileMetadata fileMetadata = new FileMetadata(fileName, contentType, inputStream); + public static String saveAndUpdateReference(FileRepository fileRepository, String oldFilePath, String fileName, String contentType, InputStream inputStream, Consumer updateReference) { + String filePath = saveOrOverwriteFile(fileRepository, fileName, contentType, inputStream); - if (fileRepository.exists(fileName)) { - fileRepository.delete(fileName); + try { + updateReference.accept(filePath); + } catch (RuntimeException e) { + deleteFileIfExists(fileRepository, filePath); + throw e; + } + + deleteFileIfExists(fileRepository, oldFilePath); + + return filePath; + } + + /** + * Updates the owning domain reference to no longer point at a repository file + * and then removes that repository file on success. + * + * @param fileRepository + * the file repository instance + * @param filePath + * the repository file id to delete + * @param updateReference + * callback that clears the owning domain reference + * @throws FileDoesNotExistException + * if {@code filePath} does not exist in the repository + */ + public static void updateReferenceAndDeleteFile(FileRepository fileRepository, String filePath, Runnable updateReference) { + if (!fileRepository.exists(filePath)) + throw new FileDoesNotExistException(); + + updateReference.run(); + + deleteFileIfExists(fileRepository, filePath); + } + + /** + * Opens repository content as a stream. The caller must close the returned + * stream. + * + * @param fileRepository + * the file repository instance + * @param filePath + * the repository file id + * @return the repository content stream + * @throws FileDoesNotExistException + * if {@code filePath} does not exist in the repository + */ + public static InputStream getFileInputStream(FileRepository fileRepository, String filePath) { + return fileRepository.find(filePath); + } + + /** + * Best-effort deletion of a repository file. + * + * @param fileRepository + * the file repository instance + * @param filePath + * the repository file id + */ + public static void deleteFileIfExists(FileRepository fileRepository, String filePath) { + if (filePath == null) + return; + + try { + if (fileRepository.exists(filePath)) + fileRepository.delete(filePath); + } catch (FileDoesNotExistException e) { + } + } + + private static String createUniqueFileName(String logicalFileName) { + return UUID.randomUUID() + getFileExtension(logicalFileName); + } + + private static String getFileExtension(String fileName) { + if (fileName == null) + return ""; + + int extensionStart = fileName.lastIndexOf('.'); + + if (extensionStart <= 0 || extensionStart >= fileName.length() - 1) + return ""; + + String extension = fileName.substring(extensionStart + 1); + + if (extension.length() > MAX_EXTENSION_LENGTH || !extension.chars().allMatch(FileRepositoryHelper::isAsciiLetterOrDigit)) + return ""; + + return "." + extension; + } + + private static String getSafeTempFileSuffix(String filePath) { + String extension = getFileExtension(filePath); + + if (extension.isEmpty()) + return ".tmp"; + + return extension; + } + + private static boolean isAsciiLetterOrDigit(int character) { + return character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9'; + } + + private static String validateLogicalFileName(String fileName) { + if (fileName == null || fileName.isBlank()) + throw new IllegalArgumentException("File name must not be null or blank."); + + String logicalFileName = fileName.replace("\r", "_").replace("\n", "_").trim(); + + if (logicalFileName.chars().anyMatch(Character::isISOControl)) + throw new IllegalArgumentException("File name must not contain control characters."); + + if (logicalFileName.contains("..") || logicalFileName.contains("/") || logicalFileName.contains("\\") || startsWithWindowsDrivePrefix(logicalFileName)) + throw new SecurityException("Path traversal attempt detected."); + + return logicalFileName; + } + + private static boolean startsWithWindowsDrivePrefix(String fileName) { + return fileName.length() >= 2 && Character.isLetter(fileName.charAt(0)) && fileName.charAt(1) == ':'; + } + + private static void deleteIfExists(Path path) { + if (path == null) + return; + + try { + Files.deleteIfExists(path); + } catch (IOException e) { } - return fileRepository.save(fileMetadata); } /** @@ -111,7 +276,7 @@ public static String saveOrOverwriteFile(FileRepository fileRepository, String f * @param fileRepository * the file repository instance * @param filePath - * the path of the file to be deleted + * the repository file id to delete * @throws FileDoesNotExistException * if the file does not exist */ diff --git a/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java b/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java new file mode 100644 index 000000000..a89e832e5 --- /dev/null +++ b/basyx.common/basyx.filerepository-backend/src/test/java/org/eclipse/digitaltwin/basyx/core/filerepository/FileRepositoryHelperTest.java @@ -0,0 +1,198 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.core.filerepository; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; +import org.junit.Test; + +public class FileRepositoryHelperTest { + + @Test + public void saveOrOverwriteFileRejectsPathTraversalFileName() throws IOException { + Path victimFile = Files.createTempDirectory("basyx-victim").resolve("pwned.txt"); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + try { + assertThrows(SecurityException.class, () -> FileRepositoryHelper.saveOrOverwriteFile(fileRepository, victimFile.toAbsolutePath().toString(), "text/plain", content("payload"))); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile.getParent()); + } + } + + @Test + public void saveOrOverwriteFileUsesGeneratedStorageIdAndPreservesOriginalName() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + String storedId = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.png", "image/png", content("payload")); + + assertNotEquals("thumbnail.png", storedId); + assertTrue(storedId.matches("[0-9a-fA-F\\-]{36}\\.png")); + assertTrue(fileRepository.exists(storedId)); + assertEquals("thumbnail.png", fileRepository.getOriginalFileName(storedId)); + assertArrayEquals("payload".getBytes(UTF_8), fileRepository.content(storedId)); + } + + @Test + public void saveOrOverwriteFileDropsUnsafeExtensionFromStorageId() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + String storedId = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.pn:g", "image/png", content("payload")); + + assertTrue(storedId.matches("[0-9a-fA-F\\-]{36}")); + assertTrue(fileRepository.exists(storedId)); + assertEquals("thumbnail.pn:g", fileRepository.getOriginalFileName(storedId)); + } + + @Test + public void saveOrOverwriteFileDropsLongExtensionFromStorageId() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + String storedId = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.abcdefghijklmnopq", "image/png", content("payload")); + + assertTrue(storedId.matches("[0-9a-fA-F\\-]{36}")); + assertTrue(fileRepository.exists(storedId)); + assertEquals("thumbnail.abcdefghijklmnopq", fileRepository.getOriginalFileName(storedId)); + } + + @Test + public void saveOrOverwriteFileRejectsControlCharacterFileName() { + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + + assertThrows(IllegalArgumentException.class, () -> FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "thumbnail.pn\0g", "image/png", content("payload"))); + } + + @Test + public void fetchAndStoreFileLocallyDoesNotUseRepositoryKeyAsFilesystemPath() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + + try { + java.io.File localFile = FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, maliciousRepositoryKey); + + assertTrue(localFile.exists()); + assertTrue(localFile.getName().endsWith(".txt")); + assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); + assertFalse(Files.exists(victimFile)); + assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath())); + Files.deleteIfExists(localFile.toPath()); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + private static ByteArrayInputStream content(String content) { + return new ByteArrayInputStream(content.getBytes(UTF_8)); + } + + private static class GridFsLikeFileRepository implements FileRepository { + private final Map store = new HashMap<>(); + + @Override + public String save(FileMetadata metadata) throws FileHandlingException { + if (exists(metadata.getFileName())) + throw new FileHandlingException("File '%s' already exists.".formatted(metadata.getFileName())); + + try { + put(metadata.getFileName(), metadata.getOriginalFileName(), metadata.getFileContent().readAllBytes()); + } catch (IOException e) { + throw new FileHandlingException(metadata.getFileName(), e); + } + + return metadata.getFileName(); + } + + @Override + public InputStream find(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + return new ByteArrayInputStream(content(fileId)); + } + + @Override + public void delete(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + store.remove(fileId); + } + + @Override + public boolean exists(String fileId) { + return fileId != null && store.containsKey(fileId); + } + + @Override + public String getOriginalFileName(String fileId) { + return store.get(fileId).originalFileName; + } + + void put(String fileId, byte[] content) { + put(fileId, fileId, content); + } + + void put(String fileId, String originalFileName, byte[] content) { + store.put(fileId, new StoredFile(originalFileName, content)); + } + + byte[] content(String fileId) { + return store.get(fileId).content; + } + } + + private static class StoredFile { + private final String originalFileName; + private final byte[] content; + + StoredFile(String originalFileName, byte[] content) { + this.originalFileName = originalFileName; + this.content = content; + } + } +} diff --git a/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java index 5f3d51df9..38e12bcd1 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/backend/CrudSubmodelRepository.java @@ -187,6 +187,11 @@ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) return getService(submodelId).getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return getService(submodelId).getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { getService(submodelId).setFileValue(idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java index 814c52e25..2f1f527ce 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/client/ConnectedSubmodelRepository.java @@ -235,6 +235,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return getConnectedSubmodelService(submodelId).getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return getConnectedSubmodelService(submodelId).getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { getConnectedSubmodelService(submodelId).setFileValue(idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java index f238dd7b3..53694c74f 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/SubmodelRepository.java @@ -25,6 +25,8 @@ package org.eclipse.digitaltwin.basyx.submodelrepository; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.InputStream; import java.util.List; @@ -35,6 +37,7 @@ import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.exceptions.MissingIdentifierException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; @@ -231,7 +234,10 @@ public default String getName() { public Submodel getSubmodelByIdMetadata(String submodelId) throws ElementDoesNotExistException; /** - * Retrieves the file of a file submodelelement + * Retrieves the file of a file submodelelement. + *

+ * Implementations that materialize repository content as a temporary file must + * document whether the caller is responsible for deleting the returned file. * * @param submodelId * the Submodel id @@ -245,6 +251,33 @@ public default String getName() { */ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException; + /** + * Retrieves the content of a file submodel element as a stream. + *

+ * The file is resolved by submodel id and submodel element idShort path, not by + * the repository storage id. The caller is responsible for closing the returned + * stream. + * + * @param submodelId + * the Submodel id + * @param idShortPath + * the IdShort path of the file element + * @return File InputStream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ + public default InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + try { + return new FileInputStream(getFileByPathSubmodel(submodelId, idShortPath)); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open file stream.", e); + } + } + /** * Uploads a file to a file submodelelement * @@ -288,13 +321,15 @@ public default String getName() { public void patchSubmodelElements(String submodelId, List submodelElementList); /** - * Retrieves the file of a file submodelelement via its absolute path + * Retrieves file content by repository storage id. * * @param submodelId * the Submodel id * @param filePath - * the path of the file + * the repository file id * @return File InputStream + * @throws FileDoesNotExistException + * if the repository file id does not exist */ public InputStream getFileByFilePath(String submodelId, String filePath); diff --git a/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java b/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java index ef667a6f2..bcb4acfe8 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java +++ b/basyx.submodelrepository/basyx.submodelrepository-core/src/test/java/org/eclipse/digitaltwin/basyx/submodelrepository/core/SubmodelRepositorySubmodelServiceWrapper.java @@ -124,6 +124,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return repoApi.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return repoApi.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { repoApi.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java index 74497804d..9ae372996 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java @@ -277,6 +277,15 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(getIdAsList(submodelId), getIdAsList(idShortPath))); + + throwExceptionIfInsufficientPermission(isAuthorized); + + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { boolean isAuthorized = permissionResolver.hasPermission(Action.UPDATE, new SubmodelTargetInformation(getIdAsList(submodelId), getIdAsList(idShortPath))); @@ -306,6 +315,10 @@ public void patchSubmodelElements(String submodelId, List submo @Override public InputStream getFileByFilePath(String submodelId, String filePath) { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(getIdAsList(submodelId), getIdAsList(filePath))); + + throwExceptionIfInsufficientPermission(isAuthorized); + return decorated.getFileByFilePath(submodelId, filePath); } diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java index 8aa5f7800..94606d152 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java @@ -174,6 +174,11 @@ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void deleteFileValue(String identifier, String idShortPath) { decorated.deleteFileValue(identifier, idShortPath); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java index 5ca4f42ea..63d5020dc 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java @@ -183,6 +183,11 @@ public java.io.File getFileByPathSubmodel(String submodelId, String idShortPath) return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void deleteFileValue(String identifier, String idShortPath) { SubmodelElement submodelElement = decorated.getSubmodelElement(identifier, idShortPath); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java index fd7e09601..d91b11ca8 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java @@ -158,6 +158,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java index c1cf57654..db6ea53a6 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-registry-integration/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/registry/integration/RegistryIntegrationSubmodelRepository.java @@ -173,6 +173,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java b/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java index 4524d8fee..a5afb007a 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java +++ b/basyx.submodelrepository/basyx.submodelrepository-feature-search/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/search/SearchSubmodelRepository.java @@ -156,6 +156,11 @@ public File getFileByPathSubmodel(String submodelId, String idShortPath) throws return decorated.getFileByPathSubmodel(submodelId, idShortPath); } + @Override + public InputStream getFileByPathSubmodelAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathSubmodelAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java b/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java index 7e422586d..410dbe254 100644 --- a/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java +++ b/basyx.submodelrepository/basyx.submodelrepository-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/http/SubmodelRepositoryApiHTTPController.java @@ -60,8 +60,9 @@ import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue; import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelValueOnly; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -232,15 +233,17 @@ public ResponseEntity getSubmodelByIdMetadata(Base64UrlEncodedIdentifi @Override public ResponseEntity getFileByPath(Base64UrlEncodedIdentifier submodelIdentifier, String idShortPath) { String submodelId = submodelIdentifier.getIdentifier(); - java.io.File file = repository.getFileByPathSubmodel(submodelId, idShortPath); - Resource resource = new FileSystemResource(file); - org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement = (org.eclipse.digitaltwin.aas4j.v3.model.File) repository.getSubmodelElement(submodelId, idShortPath); + SubmodelElement submodelElement = repository.getSubmodelElement(submodelId, idShortPath); + + if (!(submodelElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement)) + throw new ElementNotAFileException(submodelElement.getIdShort()); String fileName = repository.getOriginalFileNameByPath(submodelId, idShortPath); + Resource resource = new InputStreamResource(repository.getFileByPathSubmodelAsStream(submodelId, idShortPath)); return ResponseEntity.ok() .contentType(BaSyxMediaType.parseOrOctetStream(fileSubmodelElement.getContentType())) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(fileName).build().toString()) .body(resource); } diff --git a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java index 36ea383bd..850002cfc 100644 --- a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelService.java @@ -44,9 +44,8 @@ import java.io.File; import java.io.InputStream; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; +import java.util.Objects; /** * Default {@link SubmodelService} based on {@link SubmodelBackend} @@ -113,7 +112,7 @@ public void updateSubmodelElement(String idShortPath, SubmodelElement newElement boolean newSubmodelElementIsFile = newElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File; if (existingSubmodelElementIsFile && newSubmodelElementIsFile) { - handleFileAttachmentUpdate(submodelId, idShortPath, newElement); + handleFileAttachmentUpdate(idShortPath, existingSubmodelElement, newElement); } else if (existingSubmodelElementIsFile) { deleteAssociatedFileIfAny(idShortPath); } @@ -121,9 +120,9 @@ public void updateSubmodelElement(String idShortPath, SubmodelElement newElement backend.updateSubmodelElement(submodelId, idShortPath, newElement); } - private void handleFileAttachmentUpdate(String submodelId, String idShortPath, SubmodelElement updatedElement) { + private void handleFileAttachmentUpdate(String idShortPath, SubmodelElement existingElement, SubmodelElement updatedElement) { try { - if (hasFilePathChanged(submodelId, idShortPath, updatedElement)) { + if (hasFilePathChanged(existingElement, updatedElement)) { deleteAssociatedFileIfAny(idShortPath); } } catch (Exception e) { @@ -132,27 +131,18 @@ private void handleFileAttachmentUpdate(String submodelId, String idShortPath, S } } - private boolean hasFilePathChanged(String submodelId, String idShortPath, SubmodelElement updatedElement) { - if (!(updatedElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File file)) { - logger.warn("Expected File element but got: {}", updatedElement.getClass().getSimpleName()); - return true; - } - - String newPathString = extractFilePath(file); - if (newPathString == null) { + private boolean hasFilePathChanged(SubmodelElement existingElement, SubmodelElement updatedElement) { + if (!(existingElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File existingFile)) { + logger.warn("Expected File element but got: {}", existingElement.getClass().getSimpleName()); return true; } - String oldPathString = getExistingFilePath(submodelId, idShortPath); - if (oldPathString == null) { + if (!(updatedElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File file)) { + logger.warn("Expected File element but got: {}", updatedElement.getClass().getSimpleName()); return true; } - return pathsDiffer(oldPathString, newPathString); - } - - private boolean isFileElement(SubmodelElement element) { - return element instanceof org.eclipse.digitaltwin.aas4j.v3.model.File; + return !Objects.equals(extractFilePath(existingFile), extractFilePath(file)); } // Extracts the new file path (value) from the updated File element @@ -160,26 +150,6 @@ private String extractFilePath(org.eclipse.digitaltwin.aas4j.v3.model.File fileE return fileElement.getValue(); } - private String getExistingFilePath(String submodelId, String idShortPath) { - try { - return submodelFileOperations.getFile(submodelId, idShortPath).getPath(); - } catch (Exception e) { - logger.warn("Failed to retrieve existing file path for '{}': {}", idShortPath, e.getMessage(), e); - return null; - } - } - - private boolean pathsDiffer(String path1, String path2) { - try { - Path normalized1 = Paths.get(path1).normalize().toAbsolutePath(); - Path normalized2 = Paths.get(path2).normalize().toAbsolutePath(); - return !normalized1.equals(normalized2); - } catch (Exception e) { - logger.error("Error comparing file paths: {}", e.getMessage(), e); - return true; // Consider paths different in case of error - } - } - @Override public void deleteSubmodelElement(String idShortPath) throws ElementDoesNotExistException { deleteAssociatedFileIfAny(idShortPath); @@ -207,6 +177,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return submodelFileOperations.getFile(submodelId, idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return submodelFileOperations.getFileAsStream(submodelId, idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { submodelFileOperations.setFileValue(submodelId, idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java index 99c4f4e26..ebd5ff5cf 100644 --- a/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java +++ b/basyx.submodelservice/basyx.submodelservice-backend/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/SubmodelFileOperations.java @@ -25,20 +25,15 @@ package org.eclipse.digitaltwin.basyx.submodelservice.backend; -import java.io.FileOutputStream; -import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; -import java.util.UUID; import org.eclipse.digitaltwin.aas4j.v3.model.File; import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; -import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; -import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper; import org.eclipse.digitaltwin.basyx.submodelservice.value.FileBlobValue; /** @@ -55,6 +50,24 @@ public SubmodelFileOperations(FileRepository fileRepository, SubmodelOperations this.submodelOperations = operations; } + /** + * Retrieves a file submodel element's content as a temporary local file. + *

+ * The caller owns the returned temporary file and is responsible for deleting + * it when it is no longer needed. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @return the file content materialized as a temporary file + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ public java.io.File getFile(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); @@ -63,36 +76,84 @@ public java.io.File getFile(String submodelId, String idShortPath) throws Elemen File fileSmElement = (File) submodelElement; String filePath = getFilePath(fileSmElement); - InputStream fileContent = getFileInputStream(filePath); + return FileRepositoryHelper.fetchAndStoreFileLocally(fileRepository, filePath); + } + + /** + * Retrieves a file submodel element's content as a stream. + *

+ * The file is resolved by submodel id and element idShort path, not by the + * repository storage id. The caller is responsible for closing the returned + * stream. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @return the file content stream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ + public InputStream getFileAsStream(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); + + throwIfSmElementIsNotAFile(submodelElement); - return createFile(filePath, fileContent); + File fileSmElement = (File) submodelElement; + String filePath = getFilePath(fileSmElement); + + return FileRepositoryHelper.getFileInputStream(fileRepository, filePath); } + /** + * Stores new content for a file submodel element and updates the element value + * with the generated repository id. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @param fileName + * the logical display name of the uploaded file + * @param contentType + * the file MIME type + * @param inputStream + * the file content stream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + */ public void setFileValue(String submodelId, String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); throwIfSmElementIsNotAFile(submodelElement); File fileSmElement = (File) submodelElement; + String oldFilePath = fileSmElement.getValue(); - if (fileRepository.exists(fileSmElement.getValue())) - fileRepository.delete(fileSmElement.getValue()); - - String logicalFileName = validateLogicalFileName(fileName); - String uniqueFileName = createUniqueFileName(logicalFileName); - - FileMetadata fileMetadata = new FileMetadata(uniqueFileName, logicalFileName, contentType, inputStream); - - if (fileRepository.exists(fileMetadata.getFileName())) - fileRepository.delete(fileMetadata.getFileName()); - - String filePath = fileRepository.save(fileMetadata); - - FileBlobValue fileValue = new FileBlobValue(contentType, filePath); - - submodelOperations.setSubmodelElementValue(submodelId, idShortPath, fileValue); + FileRepositoryHelper.saveAndUpdateReference(fileRepository, oldFilePath, fileName, contentType, inputStream, filePath -> submodelOperations.setSubmodelElementValue(submodelId, idShortPath, new FileBlobValue(contentType, filePath))); } + /** + * Clears a file submodel element value and deletes the referenced repository + * content. + * + * @param submodelId + * the id of the Submodel + * @param idShortPath + * the idShort path of the file element + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ public void deleteFileValue(String submodelId, String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { SubmodelElement submodelElement = submodelOperations.getSubmodelElement(submodelId, idShortPath); @@ -101,17 +162,32 @@ public void deleteFileValue(String submodelId, String idShortPath) throws Elemen File fileSubmodelElement = (File) submodelElement; String filePath = fileSubmodelElement.getValue(); - fileRepository.delete(filePath); - - FileBlobValue fileValue = new FileBlobValue(" ", " "); - - submodelOperations.setSubmodelElementValue(submodelId, idShortPath, fileValue); + FileRepositoryHelper.updateReferenceAndDeleteFile(fileRepository, filePath, () -> submodelOperations.setSubmodelElementValue(submodelId, idShortPath, new FileBlobValue(" ", " "))); } - public InputStream getInputStream(String filePath) throws FileDoesNotExistException{ - return fileRepository.find(filePath); + /** + * Retrieves repository content by repository storage id. + *

+ * Prefer {@link #getFileAsStream(String, String)} for domain-level file + * downloads. The caller is responsible for closing the returned stream. + * + * @param filePath + * the repository file id + * @return the repository content stream + * @throws FileDoesNotExistException + * if the repository file id does not exist + */ + public InputStream getInputStream(String filePath) throws FileDoesNotExistException { + return FileRepositoryHelper.getFileInputStream(fileRepository, filePath); } + /** + * Resolves the original logical filename for a repository file id. + * + * @param filePath + * the repository file id + * @return the original logical filename + */ public String getOriginalFileName(String filePath) { return fileRepository.getOriginalFileName(filePath); } @@ -120,74 +196,14 @@ private static boolean isFileSubmodelElement(SubmodelElement submodelElement) { return submodelElement instanceof File; } - private InputStream getFileInputStream(String filePath) { - InputStream fileContent; - - try { - fileContent = fileRepository.find(filePath); - } catch (FileDoesNotExistException e) { - throw new FileDoesNotExistException(String.format("File at path '%s' could not be found.", filePath)); - } - - return fileContent; - } - - private static java.io.File createFile(String filePath, InputStream fileIs) { - - try { - byte[] content = fileIs.readAllBytes(); - fileIs.close(); - - createOutputStream(filePath, content); - - return new java.io.File(filePath); - } catch (IOException e) { - throw new FileHandlingException("Exception occurred while creating file from the InputStream." + e.getMessage()); - } - - } - private static String getFilePath(File fileSubmodelElement) { return fileSubmodelElement.getValue(); } - private static String createUniqueFileName(String logicalFileName) { - return UUID.randomUUID() + getFileExtension(logicalFileName); - } - - private static String getFileExtension(String fileName) { - int extensionStart = fileName.lastIndexOf('.'); - - if (extensionStart <= 0 || extensionStart >= fileName.length() - 1) - return ""; - - return fileName.substring(extensionStart); - } - - private static String validateLogicalFileName(String fileName) { - if (fileName == null || fileName.isBlank()) - throw new IllegalArgumentException("File name must not be null or blank."); - - if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) - throw new SecurityException("Path traversal attempt detected."); - - return fileName.replace("\r", "_").replace("\n", "_").trim(); - } - private static void throwIfSmElementIsNotAFile(SubmodelElement submodelElement) { if (!isFileSubmodelElement(submodelElement)) throw new ElementNotAFileException(submodelElement.getIdShort()); } - private static void createOutputStream(String filePath, byte[] content) throws IOException { - - try (OutputStream outputStream = new FileOutputStream(filePath)) { - outputStream.write(content); - } catch (IOException e) { - throw new FileHandlingException("Exception occurred while creating OutputStream from byte[]." + e.getMessage()); - } - - } - } diff --git a/basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java b/basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java new file mode 100644 index 000000000..97edc5e9b --- /dev/null +++ b/basyx.submodelservice/basyx.submodelservice-backend/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/backend/CrudSubmodelServiceFileHandlingTest.java @@ -0,0 +1,265 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.submodelservice.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.digitaltwin.aas4j.v3.model.SubmodelElement; +import org.eclipse.digitaltwin.aas4j.v3.model.impl.DefaultFile; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata; +import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository; +import org.junit.Test; + +public class CrudSubmodelServiceFileHandlingTest { + + private static final String SUBMODEL_ID = "submodel-1"; + private static final String FILE_ID_SHORT = "document"; + + @Test + public void getFileByPathWithAbsoluteStoredPathWritesOnlyTempFile() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(maliciousRepositoryKey)); + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try { + java.io.File localFile = service.getFileByPath(FILE_ID_SHORT); + + assertTrue(localFile.exists()); + assertNotEquals(victimFile.toAbsolutePath().toString(), localFile.toPath().toAbsolutePath().toString()); + assertFalse(Files.exists(victimFile)); + assertArrayEquals(attackerPayload, Files.readAllBytes(localFile.toPath())); + Files.deleteIfExists(localFile.toPath()); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void getFileByPathAsStreamWithAbsoluteStoredPathDoesNotWriteToDisk() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + byte[] attackerPayload = "attacker bytes".getBytes(UTF_8); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, attackerPayload); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(maliciousRepositoryKey)); + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try (InputStream inputStream = service.getFileByPathAsStream(FILE_ID_SHORT)) { + assertArrayEquals(attackerPayload, inputStream.readAllBytes()); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void updateFileElementWithUnchangedPathDoesNotMaterializeExistingFile() throws IOException { + Path victimDirectory = Files.createTempDirectory("basyx-victim"); + Path victimFile = victimDirectory.resolve("pwned.txt"); + String maliciousRepositoryKey = victimFile.toAbsolutePath().toString(); + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(maliciousRepositoryKey, "attacker bytes".getBytes(UTF_8)); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(maliciousRepositoryKey)); + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try { + service.updateSubmodelElement(FILE_ID_SHORT, fileElement(maliciousRepositoryKey)); + + assertEquals(0, fileRepository.findCalls); + assertEquals(1, backend.updateCalls); + assertFalse(Files.exists(victimFile)); + } finally { + Files.deleteIfExists(victimFile); + Files.deleteIfExists(victimDirectory); + } + } + + @Test + public void setFileValueCleansNewFileAndKeepsOldFileWhenValueUpdateFails() { + String oldFilePath = "old-file"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldFilePath, "old".getBytes(UTF_8)); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(oldFilePath)); + backend.failSetSubmodelElementValue = true; + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + try { + service.setFileValue(FILE_ID_SHORT, "new.txt", "text/plain", new ByteArrayInputStream("new".getBytes(UTF_8))); + } catch (RuntimeException e) { + assertTrue(fileRepository.exists(oldFilePath)); + assertEquals(1, fileRepository.size()); + return; + } + + throw new AssertionError("Expected setFileValue to fail."); + } + + @Test + public void deleteFileValueKeepsFileWhenValueUpdateFails() { + String oldFilePath = "old-file"; + GridFsLikeFileRepository fileRepository = new GridFsLikeFileRepository(); + fileRepository.put(oldFilePath, "old".getBytes(UTF_8)); + TestSubmodelBackend backend = new TestSubmodelBackend(fileElement(oldFilePath)); + backend.failSetSubmodelElementValue = true; + CrudSubmodelService service = new CrudSubmodelService(backend.proxy(), fileRepository, SUBMODEL_ID); + + assertThrows(RuntimeException.class, () -> service.deleteFileValue(FILE_ID_SHORT)); + + assertTrue(fileRepository.exists(oldFilePath)); + assertEquals(1, fileRepository.size()); + } + + private static org.eclipse.digitaltwin.aas4j.v3.model.File fileElement(String value) { + return new DefaultFile.Builder().idShort(FILE_ID_SHORT).contentType("text/plain").value(value).build(); + } + + private static class TestSubmodelBackend implements InvocationHandler { + private final AtomicReference submodelElement; + private int updateCalls; + private boolean failSetSubmodelElementValue; + + TestSubmodelBackend(SubmodelElement submodelElement) { + this.submodelElement = new AtomicReference<>(submodelElement); + } + + SubmodelBackend proxy() { + return (SubmodelBackend) Proxy.newProxyInstance(SubmodelBackend.class.getClassLoader(), new Class[] { SubmodelBackend.class }, this); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + if (method.getDeclaringClass() == Object.class) + return invokeObjectMethod(proxy, method, args); + + switch (method.getName()) { + case "getSubmodelElement": + return submodelElement.get(); + case "updateSubmodelElement": + submodelElement.set((SubmodelElement) args[2]); + updateCalls++; + return null; + case "setSubmodelElementValue": + if (failSetSubmodelElementValue) + throw new RuntimeException("Could not update submodel element value."); + + return null; + default: + throw new UnsupportedOperationException(method.getName()); + } + } + + private Object invokeObjectMethod(Object proxy, Method method, Object[] args) { + switch (method.getName()) { + case "equals": + return proxy == args[0]; + case "hashCode": + return System.identityHashCode(proxy); + case "toString": + return "TestSubmodelBackend"; + default: + throw new UnsupportedOperationException(method.getName()); + } + } + } + + private static class GridFsLikeFileRepository implements FileRepository { + private final Map store = new HashMap<>(); + private int findCalls; + + @Override + public String save(FileMetadata metadata) throws FileHandlingException { + if (exists(metadata.getFileName())) + throw new FileHandlingException("File '%s' already exists.".formatted(metadata.getFileName())); + + try { + store.put(metadata.getFileName(), metadata.getFileContent().readAllBytes()); + } catch (IOException e) { + throw new FileHandlingException(metadata.getFileName(), e); + } + + return metadata.getFileName(); + } + + @Override + public InputStream find(String fileId) throws FileDoesNotExistException { + findCalls++; + + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + return new ByteArrayInputStream(store.get(fileId)); + } + + @Override + public void delete(String fileId) throws FileDoesNotExistException { + if (!exists(fileId)) + throw new FileDoesNotExistException(); + + store.remove(fileId); + } + + @Override + public boolean exists(String fileId) { + return fileId != null && store.containsKey(fileId); + } + + void put(String fileId, byte[] content) { + store.put(fileId, content); + } + + int size() { + return store.size(); + } + } +} diff --git a/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java index a31ad8c0c..48da15edf 100644 --- a/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-client/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/client/ConnectedSubmodelService.java @@ -27,7 +27,10 @@ package org.eclipse.digitaltwin.basyx.submodelservice.client; import java.io.File; +import java.io.FilterInputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.util.List; import org.apache.commons.lang3.NotImplementedException; @@ -176,6 +179,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio } } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return openFileAndDeleteOnClose(getFileByPath(idShortPath)); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { try { @@ -208,6 +216,23 @@ public InputStream getFileByFilePath(String filePath) { throw new NotImplementedException("This Method is not implemented in the Client"); } + private static InputStream openFileAndDeleteOnClose(File file) { + try { + return new FilterInputStream(Files.newInputStream(file.toPath())) { + @Override + public void close() throws IOException { + try { + super.close(); + } finally { + Files.deleteIfExists(file.toPath()); + } + } + }; + } catch (IOException e) { + throw new FileHandlingException("Could not open file stream.", e); + } + } + private RuntimeException mapExceptionFileAccess(String idShortPath, ApiException e) { if (e.getCode() == HttpStatus.NOT_FOUND.value()) return new FileDoesNotExistException(idShortPath); diff --git a/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java b/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java index 50eb3c08c..76f175bd3 100644 --- a/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-core/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/SubmodelService.java @@ -25,6 +25,8 @@ package org.eclipse.digitaltwin.basyx.submodelservice; +import java.io.FileInputStream; +import java.io.FileNotFoundException; import java.io.InputStream; import java.util.List; @@ -34,6 +36,7 @@ import org.eclipse.digitaltwin.basyx.core.exceptions.ElementDoesNotExistException; import org.eclipse.digitaltwin.basyx.core.exceptions.ElementNotAFileException; import org.eclipse.digitaltwin.basyx.core.exceptions.FileDoesNotExistException; +import org.eclipse.digitaltwin.basyx.core.exceptions.FileHandlingException; import org.eclipse.digitaltwin.basyx.core.pagination.CursorResult; import org.eclipse.digitaltwin.basyx.core.pagination.PaginationInfo; import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue; @@ -147,7 +150,10 @@ public interface SubmodelService { public OperationVariable[] invokeOperation(String idShortPath, OperationVariable[] input) throws ElementDoesNotExistException; /** - * Retrieves the file of a file submodelelement + * Retrieves the file of a file submodelelement. + *

+ * Implementations that materialize repository content as a temporary file must + * document whether the caller is responsible for deleting the returned file. * * @param idShortPath * the IdShort path of the file element @@ -158,6 +164,31 @@ public interface SubmodelService { */ public java.io.File getFileByPath(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException; + /** + * Retrieves the content of a file submodel element as a stream. + *

+ * The file is resolved by the submodel element idShort path, not by the + * repository storage id. The caller is responsible for closing the returned + * stream. + * + * @param idShortPath + * the IdShort path of the file element + * @return File InputStream + * @throws ElementDoesNotExistException + * if the SubmodelElement does not exist + * @throws ElementNotAFileException + * if the SubmodelElement is not a File + * @throws FileDoesNotExistException + * if the referenced file content does not exist + */ + public default InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + try { + return new FileInputStream(getFileByPath(idShortPath)); + } catch (FileNotFoundException e) { + throw new FileHandlingException("Could not open file stream.", e); + } + } + /** * Uploads a file to a file submodelelement * @@ -189,11 +220,13 @@ public interface SubmodelService { /** - * Retrieves the file of a file submodelelement via its absolute path + * Retrieves file content by repository storage id. * * @param filePath - * the path of the file + * the repository file id * @return File InputStream + * @throws FileDoesNotExistException + * if the repository file id does not exist */ public InputStream getFileByFilePath(String filePath); diff --git a/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java index 4b2e3a58a..812f458a5 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelService.java @@ -86,6 +86,13 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(List.of(smId), List.of(idShortPath))); + throwExceptionIfInsufficientPermission(isAuthorized); + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public SubmodelElement getSubmodelElement(String idShortPath) throws ElementDoesNotExistException { boolean isAuthorized = permissionResolver.hasPermission(Action.READ, new SubmodelTargetInformation(List.of(smId), List.of(idShortPath))); diff --git a/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java new file mode 100644 index 000000000..07faa2a1a --- /dev/null +++ b/basyx.submodelservice/basyx.submodelservice-feature-authorization/src/test/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/authorization/AuthorizedSubmodelServiceFileStreamTest.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (C) 2026 the Eclipse BaSyx Authors + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * SPDX-License-Identifier: MIT + ******************************************************************************/ + +package org.eclipse.digitaltwin.basyx.submodelservice.feature.authorization; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.List; + +import org.eclipse.digitaltwin.basyx.authorization.rbac.Action; +import org.eclipse.digitaltwin.basyx.authorization.rbac.RbacPermissionResolver; +import org.eclipse.digitaltwin.basyx.core.exceptions.InsufficientPermissionException; +import org.eclipse.digitaltwin.basyx.submodelservice.SubmodelService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class AuthorizedSubmodelServiceFileStreamTest { + + private static final String SUBMODEL_ID = "smId"; + private static final String FILE_ID_SHORT_PATH = "document"; + private static final String REPOSITORY_FILE_ID = "file-repository-id"; + private static final byte[] FILE_CONTENT = "content".getBytes(); + + @Test + void getFileByPathAsStreamAuthorizesByFileIdShortPath() throws Exception { + SubmodelService decorated = mock(SubmodelService.class); + when(decorated.getFileByPathAsStream(FILE_ID_SHORT_PATH)).thenReturn(new ByteArrayInputStream(FILE_CONTENT)); + RbacPermissionResolver permissionResolver = permissionResolverAllowing(FILE_ID_SHORT_PATH); + + AuthorizedSubmodelService service = new AuthorizedSubmodelService(decorated, permissionResolver, SUBMODEL_ID); + + try (InputStream result = service.getFileByPathAsStream(FILE_ID_SHORT_PATH)) { + assertArrayEquals(FILE_CONTENT, result.readAllBytes()); + } + + ArgumentCaptor targetInformation = ArgumentCaptor.forClass(SubmodelTargetInformation.class); + verify(permissionResolver).hasPermission(eq(Action.READ), targetInformation.capture()); + assertEquals(List.of(SUBMODEL_ID), targetInformation.getValue().getSubmodelIds()); + assertEquals(List.of(FILE_ID_SHORT_PATH), targetInformation.getValue().getSubmodelElementIdShortPaths()); + verify(decorated).getFileByPathAsStream(FILE_ID_SHORT_PATH); + verify(decorated, never()).getFileByFilePath(anyString()); + } + + @Test + void getFileByPathAsStreamDoesNotAuthorizeByRepositoryFileId() throws Exception { + SubmodelService decorated = mock(SubmodelService.class); + RbacPermissionResolver permissionResolver = permissionResolverAllowing(REPOSITORY_FILE_ID); + + AuthorizedSubmodelService service = new AuthorizedSubmodelService(decorated, permissionResolver, SUBMODEL_ID); + + assertThrows(InsufficientPermissionException.class, () -> service.getFileByPathAsStream(FILE_ID_SHORT_PATH)); + verify(decorated, never()).getFileByPathAsStream(anyString()); + verify(decorated, never()).getFileByFilePath(anyString()); + } + + private static RbacPermissionResolver permissionResolverAllowing(String allowedIdShortPath) { + @SuppressWarnings("unchecked") + RbacPermissionResolver permissionResolver = mock(RbacPermissionResolver.class); + when(permissionResolver.hasPermission(eq(Action.READ), any(SubmodelTargetInformation.class))).thenAnswer(invocation -> { + SubmodelTargetInformation targetInformation = invocation.getArgument(1); + return List.of(SUBMODEL_ID).equals(targetInformation.getSubmodelIds()) && List.of(allowedIdShortPath).equals(targetInformation.getSubmodelElementIdShortPaths()); + }); + return permissionResolver; + } + +} diff --git a/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java index 7686b9eb4..0190f0421 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/kafka/KafkaSubmodelService.java @@ -129,6 +129,12 @@ public File getFileByPath(String idShortPath) return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) + throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { diff --git a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java index 190a43f2f..2a8773c68 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/mqtt/MqttSubmodelService.java @@ -139,6 +139,11 @@ public File getFileByPath(String idShortPath) throws ElementDoesNotExistExceptio return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { decorated.setFileValue(idShortPath, fileName, contentType, inputStream); diff --git a/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java b/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java index bf50af5de..8d69a3264 100644 --- a/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java +++ b/basyx.submodelservice/basyx.submodelservice-feature-operation-dispatching/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/feature/operationdispatching/AbstractSubmodelServiceDecorator.java @@ -115,6 +115,12 @@ public File getFileByPath(String idShortPath) return decorated.getFileByPath(idShortPath); } + @Override + public InputStream getFileByPathAsStream(String idShortPath) + throws ElementDoesNotExistException, ElementNotAFileException, FileDoesNotExistException { + return decorated.getFileByPathAsStream(idShortPath); + } + @Override public void setFileValue(String idShortPath, String fileName, String contentType, InputStream inputStream) throws ElementDoesNotExistException, ElementNotAFileException { @@ -131,4 +137,4 @@ public void deleteFileValue(String idShortPath) public InputStream getFileByFilePath(String filePath) { return decorated.getFileByFilePath(filePath); } -} \ No newline at end of file +} diff --git a/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java b/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java index 6b39f31a1..b59bdd332 100644 --- a/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java +++ b/basyx.submodelservice/basyx.submodelservice-http/src/main/java/org/eclipse/digitaltwin/basyx/submodelservice/http/SubmodelServiceHTTPApiController.java @@ -52,7 +52,7 @@ import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelElementValue; import org.eclipse.digitaltwin.basyx.submodelservice.value.SubmodelValueOnly; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -259,8 +259,12 @@ private String getEncodedCursorFromCursorResult(CursorResult cursorResult) { @Override public ResponseEntity getFileByPath(String idShortPath) { - Resource resource = new FileSystemResource(service.getFileByPath(idShortPath)); - org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement = (org.eclipse.digitaltwin.aas4j.v3.model.File) service.getSubmodelElement(idShortPath); + SubmodelElement submodelElement = service.getSubmodelElement(idShortPath); + + if (!(submodelElement instanceof org.eclipse.digitaltwin.aas4j.v3.model.File fileSubmodelElement)) + throw new ElementNotAFileException(submodelElement.getIdShort()); + + Resource resource = new InputStreamResource(service.getFileByPathAsStream(idShortPath)); return ResponseEntity.ok() .contentType(BaSyxMediaType.parseOrOctetStream(fileSubmodelElement.getContentType()))