diff --git a/remote-api/src/main/java/org/alfresco/rest/api/impl/DownloadsImpl.java b/remote-api/src/main/java/org/alfresco/rest/api/impl/DownloadsImpl.java index 87b428927b..5e3d47bacd 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/impl/DownloadsImpl.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/impl/DownloadsImpl.java @@ -25,22 +25,32 @@ */ package org.alfresco.rest.api.impl; +import java.util.Arrays; import java.util.HashSet; +import java.util.Map; +import java.util.Set; import org.alfresco.model.ContentModel; +import org.alfresco.repo.content.ObjectStorageProps; import org.alfresco.repo.download.DownloadModel; import org.alfresco.rest.api.Downloads; import org.alfresco.rest.api.Nodes; import org.alfresco.rest.api.model.Download; import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException; import org.alfresco.rest.framework.core.exceptions.PermissionDeniedException; +import org.alfresco.service.Experimental; import org.alfresco.service.cmr.download.DownloadService; import org.alfresco.service.cmr.download.DownloadStatus; +import org.alfresco.service.cmr.module.ModuleService; +import org.alfresco.service.cmr.repository.ArchivedIOException; +import org.alfresco.service.cmr.repository.ContentService; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.security.AccessStatus; import org.alfresco.service.cmr.security.PermissionService; import org.alfresco.service.namespace.QName; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * @@ -49,23 +59,39 @@ import org.alfresco.service.namespace.QName; */ public class DownloadsImpl implements Downloads { + private static Logger logger = LoggerFactory.getLogger(Downloads.class); + private DownloadService downloadService; + private ModuleService moduleService; private NodeService nodeService; + private ContentService contentService; private Nodes nodes; private PermissionService permissionService; + private int archiveCheckLimit; public static final String DEFAULT_ARCHIVE_NAME = "archive.zip"; public static final String DEFAULT_ARCHIVE_EXTENSION = ".zip"; + public static final String [] CLOUD_CONNECTOR_MODULES = {"org_alfresco_integrations_AzureConnector", "org_alfresco_integrations_S3Connector"}; public void setDownloadService(DownloadService downloadService) { this.downloadService = downloadService; } + public void setModuleService(ModuleService moduleService) + { + this.moduleService = moduleService; + } + public void setNodeService(NodeService nodeService) { this.nodeService = nodeService; } + public void setContentService(ContentService contentService) + { + this.contentService = contentService; + } + public void setNodes(Nodes nodes) { this.nodes = nodes; @@ -76,6 +102,11 @@ public class DownloadsImpl implements Downloads this.permissionService = permissionService; } + public void setArchiveCheckLimit(int checkLimit) + { + this.archiveCheckLimit = checkLimit; + } + @Override public Download createDownloadNode(Download download) { @@ -87,6 +118,8 @@ public class DownloadsImpl implements Downloads checkNodeIdsReadPermission(zipContentNodeRefs); + checkArchiveStatus(zipContentNodeRefs, archiveCheckLimit); + NodeRef zipNodeRef = downloadService.createDownload(zipContentNodeRefs, true); String archiveName = zipContentNodeRefs.length > 1 ? @@ -172,4 +205,95 @@ public class DownloadsImpl implements Downloads return downloadInfo; } + /** + * Checks the supplied nodes for any content that is archived. + * Any folders will be expanded and their children checked. + * A limit can be applied to prevent large sized requests preventing the asynchronous call to start. + * + * @param nodeRefs + * @param checkLimit The maximum number of nodes to check, set to -1 for no limit + * @see #checkArchiveStatus(NodeRef[], int, Set) + */ + @Experimental + protected void checkArchiveStatus(NodeRef[] nodeRefs, int checkLimit) + { + if (canCheckArchived()) + { + checkArchiveStatus(nodeRefs, checkLimit, null); + } + } + + /** + * Checks the supplied nodes for any content that is archived. + * Any folders will be expanded and their children checked. + * A limit can be applied to prevent large sized requests preventing the asynchronous call to start. + * The cache is used to prevent duplication of checks, as it is possible to provide a folder and its contents as + * separate nodes in the download request. + * + * @param nodeRefs + * @param checkLimit The maximum number of nodes to check, set to -1 for no limit + * @param cache Tracks nodes that we have already checked, if null an empty cache will be created + */ + @Experimental + private void checkArchiveStatus(NodeRef[] nodeRefs, int checkLimit, Set cache) + { + // Create the cache for recursive calls. + if (cache == null) + { + cache = new HashSet(); + } + + Set folders = new HashSet(); + for (NodeRef nodeRef : nodeRefs) + { + // We hit the number of nodes we want to check. + if (cache.size() == checkLimit) + { + if (logger.isInfoEnabled()) + { + logger.info( + String.format( + "Maximum check of %d reached for archived content. No more checks will be performed and download will still be created.", + checkLimit)); + } + return; + } + // Already checked this node, we can skip. + if (cache.contains(nodeRef)) + { + continue; + } + + QName qName = nodeService.getType(nodeRef); + if (qName.equals(ContentModel.TYPE_FOLDER)) + { + // We'll check the child nodes at the end in case there are other nodes in this loop that is archived. + folders.add(nodeRef); + } + else if (qName.equals(ContentModel.TYPE_CONTENT)) + { + Map props = contentService.getStorageProperties(nodeRef, qName); + if (!props.isEmpty() && Boolean.valueOf(props.get(ObjectStorageProps.X_ALF_ARCHIVED.getValue()))) + { + throw new ArchivedIOException("One or more nodes' content is archived and not accessible."); + } + } + cache.add(nodeRef); // No need to check this node again. + } + + // We re-run the folder contents at the end in case we hit content that is archived in the first loop and can stop early. + for (NodeRef nodeRef : folders) + { + NodeRef[] childRefs = nodeService.getChildAssocs(nodeRef).stream() + .map(childAssoc -> childAssoc.getChildRef()) + .toArray(NodeRef[]::new); + checkArchiveStatus(childRefs, checkLimit, cache); // We'll keep going until we have no more folders in children. + } + } + + @Experimental + protected boolean canCheckArchived() + { + return Arrays.stream(CLOUD_CONNECTOR_MODULES).anyMatch(m-> moduleService.getModule(m) != null); + } } diff --git a/remote-api/src/main/resources/alfresco/public-rest-context.xml b/remote-api/src/main/resources/alfresco/public-rest-context.xml index 1c642a8323..0da8a5f0f2 100644 --- a/remote-api/src/main/resources/alfresco/public-rest-context.xml +++ b/remote-api/src/main/resources/alfresco/public-rest-context.xml @@ -565,8 +565,11 @@ + + + diff --git a/remote-api/src/test/java/org/alfresco/AppContext04TestSuite.java b/remote-api/src/test/java/org/alfresco/AppContext04TestSuite.java index 4548e38d4c..8f41edd452 100644 --- a/remote-api/src/test/java/org/alfresco/AppContext04TestSuite.java +++ b/remote-api/src/test/java/org/alfresco/AppContext04TestSuite.java @@ -76,6 +76,7 @@ import org.junit.runners.Suite; org.alfresco.repo.web.scripts.site.SurfConfigTest.class, org.alfresco.repo.web.scripts.node.NodeWebScripTest.class, org.alfresco.rest.api.impl.CommentsImplUnitTest.class, + org.alfresco.rest.api.impl.DownloadsImplCheckArchiveStatusUnitTest.class, org.alfresco.rest.api.impl.RestApiDirectUrlConfigUnitTest.class }) public class AppContext04TestSuite diff --git a/remote-api/src/test/java/org/alfresco/rest/api/impl/DownloadsImplCheckArchiveStatusUnitTest.java b/remote-api/src/test/java/org/alfresco/rest/api/impl/DownloadsImplCheckArchiveStatusUnitTest.java new file mode 100644 index 0000000000..273c4754a2 --- /dev/null +++ b/remote-api/src/test/java/org/alfresco/rest/api/impl/DownloadsImplCheckArchiveStatusUnitTest.java @@ -0,0 +1,203 @@ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2021 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ +package org.alfresco.rest.api.impl; + +import static org.alfresco.model.ContentModel.TYPE_CONTENT; +import static org.alfresco.model.ContentModel.TYPE_FOLDER; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; + +import org.alfresco.repo.content.ObjectStorageProps; +import org.alfresco.repo.module.ModuleDetailsImpl; +import org.alfresco.rest.api.Nodes; +import org.alfresco.service.cmr.download.DownloadService; +import org.alfresco.service.cmr.module.ModuleDetails; +import org.alfresco.service.cmr.module.ModuleService; +import org.alfresco.service.cmr.repository.ArchivedIOException; +import org.alfresco.service.cmr.repository.ChildAssociationRef; +import org.alfresco.service.cmr.repository.ContentService; +import org.alfresco.service.cmr.repository.NodeRef; +import org.alfresco.service.cmr.repository.NodeService; +import org.alfresco.service.cmr.security.PermissionService; +import org.alfresco.service.namespace.QName; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class DownloadsImplCheckArchiveStatusUnitTest +{ + @InjectMocks + private final DownloadsImpl downloads = new DownloadsImpl(); + + @Mock + private DownloadService downloadService; + @Mock + private ModuleService moduleService; + @Mock + private NodeService nodeService; + @Mock + private ContentService contentService; + @Mock + private Nodes nodes; + @Mock + private PermissionService permissionService; + + private final NodeRef contentNode1 = new NodeRef("://Content:/Node1"); + private final NodeRef contentNode2 = new NodeRef("://Content:/Node2"); + private final NodeRef contentNode3 = new NodeRef("://Content:/Node3"); + private final NodeRef contentNode4 = new NodeRef("://Content:/Node4"); + private final NodeRef contentNode5 = new NodeRef("://Content:/Node5"); + private final NodeRef contentNode6 = new NodeRef("://Content:/Node6"); + private final NodeRef folderParent1 = new NodeRef("://Folder:/Parent1"); + private final NodeRef folderParent2 = new NodeRef("://Folder:/Parent2"); + private final NodeRef folder1 = new NodeRef("://Folder:/1"); + private final NodeRef folder2 = new NodeRef("://Folder:/2"); + + // folderParent1 + private final List folderParent1ChildAssocs = List.of( + new ChildAssociationRef(TYPE_FOLDER, folderParent1, TYPE_CONTENT, contentNode3), + new ChildAssociationRef(TYPE_FOLDER, folderParent1, TYPE_CONTENT, contentNode4), + new ChildAssociationRef(TYPE_FOLDER, folderParent1, TYPE_CONTENT, contentNode5), + new ChildAssociationRef(TYPE_FOLDER, folderParent1, TYPE_FOLDER, folder2), + new ChildAssociationRef(TYPE_FOLDER, folderParent1, TYPE_FOLDER, folderParent2) + ); + + // folderParent2 + private final List folderParent2ChildAssocs = List.of( + new ChildAssociationRef(TYPE_FOLDER, folderParent2, TYPE_FOLDER, folder1) + ); + + // folder1 + private final List folder1ChildAssocs = List.of( + new ChildAssociationRef(TYPE_FOLDER, folder1, TYPE_CONTENT, contentNode1), + new ChildAssociationRef(TYPE_FOLDER, folder1, TYPE_CONTENT, contentNode5), + new ChildAssociationRef(TYPE_FOLDER, folder1, TYPE_CONTENT, contentNode6) + ); + + // folder2 empty + private final List folder2ChildAssocs = List.of(); + + + private final Map archivedProps = Map.of(ObjectStorageProps.X_ALF_ARCHIVED.getValue(), "true"); + private final Map nonArchivedProps = Map.of(ObjectStorageProps.X_ALF_ARCHIVED.getValue(), "false"); + + private final ModuleDetails mockDetails = new ModuleDetailsImpl("id", null, "title", "description"); // doesn't need to do anything, just exist + + private final NodeRef[] nodeRefsToTest = { contentNode1, contentNode2, folderParent1, folder1 }; + + @Before + public void setup() + { + when(nodeService.getType(any())).thenReturn(TYPE_CONTENT); + when(nodeService.getType(folderParent1)).thenReturn(TYPE_FOLDER); + when(nodeService.getType(folderParent2)).thenReturn(TYPE_FOLDER); + when(nodeService.getType(folder1)).thenReturn(TYPE_FOLDER); + when(nodeService.getType(folder2)).thenReturn(TYPE_FOLDER); + + when(nodeService.getChildAssocs(folderParent1)).thenReturn(folderParent1ChildAssocs); + when(nodeService.getChildAssocs(folderParent2)).thenReturn(folderParent2ChildAssocs); + when(nodeService.getChildAssocs(folder1)).thenReturn(folder1ChildAssocs); + when(nodeService.getChildAssocs(folder2)).thenReturn(folder2ChildAssocs); + + when(moduleService.getModule("org_alfresco_integrations_S3Connector")).thenReturn(mockDetails); + + final NodeRef[] childNodeRefsExpectedFolderParent1 = { contentNode3, contentNode4, contentNode5, folder2, folderParent2 }; + final NodeRef[] childNodeRefsExpectedFolderParent2 = { folder1 }; + final NodeRef[] childNodeRefsExpectedFolder1 = { contentNode1, contentNode5, contentNode6 }; + final NodeRef[] childNodeRefsExpectedFolder2 = {}; + + assertChildNodeRefMocks(folderParent1, childNodeRefsExpectedFolderParent1); + assertChildNodeRefMocks(folderParent2, childNodeRefsExpectedFolderParent2); + assertChildNodeRefMocks(folder1, childNodeRefsExpectedFolder1); + assertChildNodeRefMocks(folder2, childNodeRefsExpectedFolder2); + } + + + @Test + public void testAllPass() + { + when(contentService.getStorageProperties(any(NodeRef.class), any(QName.class))).thenReturn(nonArchivedProps); + + // No archived nodes, each content node should only be checked once + downloads.checkArchiveStatus(nodeRefsToTest, -1); + + verify(contentService, times(1)).getStorageProperties(contentNode1, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode2, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode3, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode4, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode5, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode6, TYPE_CONTENT); + verifyNoMoreInteractions(contentService); + } + + @Test + public void testFirstItemArchived() + { + when(contentService.getStorageProperties(contentNode1, TYPE_CONTENT)).thenReturn(archivedProps); + + assertThrows(ArchivedIOException.class, () -> downloads.checkArchiveStatus(nodeRefsToTest, -1)); + + verify(contentService, times(1)).getStorageProperties(contentNode1, TYPE_CONTENT); + verifyNoMoreInteractions(contentService); + } + + @Test + public void testContentNode3Archived() + { + // fail node3 (within another folder) + when(contentService.getStorageProperties(any(NodeRef.class), any(QName.class))).thenReturn(nonArchivedProps); + when(contentService.getStorageProperties(contentNode3, TYPE_CONTENT)).thenReturn(archivedProps); + + assertThrows(ArchivedIOException.class, () -> downloads.checkArchiveStatus(nodeRefsToTest, -1)); + + verify(contentService, times(1)).getStorageProperties(contentNode1, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode2, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode3, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode5, TYPE_CONTENT); + verify(contentService, times(1)).getStorageProperties(contentNode6, TYPE_CONTENT); + verifyNoMoreInteractions(contentService); + } + + private void assertChildNodeRefMocks(final NodeRef nodeRef, final NodeRef[] expecteds) + { + final NodeRef[] actuals = nodeService.getChildAssocs(nodeRef).stream() + .map(childAssoc -> childAssoc.getChildRef()) + .toArray(NodeRef[]::new); + assertArrayEquals(expecteds, actuals); + } +} diff --git a/repository/src/main/resources/alfresco/repository.properties b/repository/src/main/resources/alfresco/repository.properties index 44e2cf2d90..236dea047a 100644 --- a/repository/src/main/resources/alfresco/repository.properties +++ b/repository/src/main/resources/alfresco/repository.properties @@ -852,6 +852,12 @@ fileFolderService.checkHidden.enabled=true ticket.cleanup.cronExpression=0 0 * * * ? +# +# Maximum number of items to check for archive status when creating downloads node set to -1 for unlimited checks +# only used if azure-connector or s3-connector is installed +# +download.archiveCheckLimit=500 + # # Download Service Cleanup #