001/*
002 * (C) Copyright 2012 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Antoine Taillefer <ataillefer@nuxeo.com>
018 */
019package org.nuxeo.drive.adapter.impl;
020
021import static org.nuxeo.ecm.platform.query.nxql.CoreQueryDocumentPageProvider.CORE_SESSION_PROPERTY;
022
023import java.io.IOException;
024import java.io.Serializable;
025import java.util.ArrayList;
026import java.util.HashMap;
027import java.util.Iterator;
028import java.util.List;
029import java.util.Map;
030import java.util.UUID;
031import java.util.concurrent.Semaphore;
032
033import org.apache.commons.lang3.StringUtils;
034import org.apache.logging.log4j.LogManager;
035import org.apache.logging.log4j.Logger;
036import org.nuxeo.drive.adapter.FileItem;
037import org.nuxeo.drive.adapter.FileSystemItem;
038import org.nuxeo.drive.adapter.FolderItem;
039import org.nuxeo.drive.adapter.RootlessItemException;
040import org.nuxeo.drive.adapter.ScrollFileSystemItemList;
041import org.nuxeo.drive.service.FileSystemItemAdapterService;
042import org.nuxeo.ecm.core.api.Blob;
043import org.nuxeo.ecm.core.api.CoreInstance;
044import org.nuxeo.ecm.core.api.CoreSession;
045import org.nuxeo.ecm.core.api.DocumentModel;
046import org.nuxeo.ecm.core.api.DocumentModelList;
047import org.nuxeo.ecm.core.api.DocumentRef;
048import org.nuxeo.ecm.core.api.DocumentSecurityException;
049import org.nuxeo.ecm.core.api.IdRef;
050import org.nuxeo.ecm.core.api.IterableQueryResult;
051import org.nuxeo.ecm.core.api.NuxeoException;
052import org.nuxeo.ecm.core.api.security.SecurityConstants;
053import org.nuxeo.ecm.core.cache.Cache;
054import org.nuxeo.ecm.core.cache.CacheService;
055import org.nuxeo.ecm.core.query.sql.NXQL;
056import org.nuxeo.ecm.core.schema.FacetNames;
057import org.nuxeo.ecm.platform.filemanager.api.FileImporterContext;
058import org.nuxeo.ecm.platform.filemanager.api.FileManager;
059import org.nuxeo.ecm.platform.query.api.PageProvider;
060import org.nuxeo.ecm.platform.query.api.PageProviderService;
061import org.nuxeo.runtime.api.Framework;
062import org.nuxeo.runtime.services.config.ConfigurationService;
063
064/**
065 * {@link DocumentModel} backed implementation of a {@link FolderItem}.
066 *
067 * @author Antoine Taillefer
068 */
069public class DocumentBackedFolderItem extends AbstractDocumentBackedFileSystemItem implements FolderItem {
070
071    private static final Logger log = LogManager.getLogger(DocumentBackedFolderItem.class);
072
073    private static final String FOLDER_ITEM_CHILDREN_PAGE_PROVIDER = "FOLDER_ITEM_CHILDREN";
074
075    protected static final String DESCENDANTS_SCROLL_CACHE = "driveDescendantsScroll";
076
077    protected static final String MAX_DESCENDANTS_BATCH_SIZE_PROPERTY = "org.nuxeo.drive.maxDescendantsBatchSize";
078
079    protected static final int MAX_DESCENDANTS_BATCH_SIZE_DEFAULT = 1000;
080
081    protected static final int VCS_CHUNK_SIZE = 100;
082
083    protected boolean canCreateChild;
084
085    protected boolean canScrollDescendants;
086
087    public DocumentBackedFolderItem(String factoryName, DocumentModel doc) {
088        this(factoryName, doc, false);
089    }
090
091    public DocumentBackedFolderItem(String factoryName, DocumentModel doc, boolean relaxSyncRootConstraint) {
092        this(factoryName, doc, relaxSyncRootConstraint, true);
093    }
094
095    public DocumentBackedFolderItem(String factoryName, DocumentModel doc, boolean relaxSyncRootConstraint,
096            boolean getLockInfo) {
097        super(factoryName, doc, relaxSyncRootConstraint, getLockInfo);
098        initialize(doc);
099    }
100
101    public DocumentBackedFolderItem(String factoryName, FolderItem parentItem, DocumentModel doc) {
102        this(factoryName, parentItem, doc, false);
103    }
104
105    public DocumentBackedFolderItem(String factoryName, FolderItem parentItem, DocumentModel doc,
106            boolean relaxSyncRootConstraint) {
107        this(factoryName, parentItem, doc, relaxSyncRootConstraint, true);
108    }
109
110    public DocumentBackedFolderItem(String factoryName, FolderItem parentItem, DocumentModel doc,
111            boolean relaxSyncRootConstraint, boolean getLockInfo) {
112        super(factoryName, parentItem, doc, relaxSyncRootConstraint, getLockInfo);
113        initialize(doc);
114    }
115
116    protected DocumentBackedFolderItem() {
117        // Needed for JSON deserialization
118    }
119
120    /*--------------------- FileSystemItem ---------------------*/
121    @Override
122    public void rename(String name) {
123        CoreSession session = CoreInstance.getCoreSession(repositoryName, principal);
124        // Update doc properties
125        DocumentModel doc = getDocument(session);
126        doc.setPropertyValue("dc:title", name);
127        doc.putContextData(CoreSession.SOURCE, "drive");
128        doc = session.saveDocument(doc);
129        session.save();
130        // Update FileSystemItem attributes
131        this.docTitle = name;
132        this.name = name;
133        updateLastModificationDate(doc);
134    }
135
136    /*--------------------- FolderItem -----------------*/
137    @Override
138    @SuppressWarnings("unchecked")
139    public List<FileSystemItem> getChildren() {
140        CoreSession session = CoreInstance.getCoreSession(repositoryName, principal);
141        PageProviderService pageProviderService = Framework.getService(PageProviderService.class);
142        Map<String, Serializable> props = new HashMap<>();
143        props.put(CORE_SESSION_PROPERTY, (Serializable) session);
144        PageProvider<DocumentModel> childrenPageProvider = (PageProvider<DocumentModel>) pageProviderService.getPageProvider(
145                FOLDER_ITEM_CHILDREN_PAGE_PROVIDER, null, null, 0L, props, docId);
146        long pageSize = childrenPageProvider.getPageSize();
147
148        List<FileSystemItem> children = new ArrayList<>();
149        int nbChildren = 0;
150        boolean reachedPageSize = false;
151        boolean hasNextPage = true;
152        // Since query results are filtered, make sure we iterate on PageProvider to get at most its page size
153        // number of
154        // FileSystemItems
155        while (nbChildren < pageSize && hasNextPage) {
156            List<DocumentModel> dmChildren = childrenPageProvider.getCurrentPage();
157            for (DocumentModel dmChild : dmChildren) {
158                // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo
159                FileSystemItem child = getFileSystemItemAdapterService().getFileSystemItem(dmChild, this, false, false,
160                        false);
161                if (child != null) {
162                    children.add(child);
163                    nbChildren++;
164                    if (nbChildren == pageSize) {
165                        reachedPageSize = true;
166                        break;
167                    }
168                }
169            }
170            if (!reachedPageSize) {
171                hasNextPage = childrenPageProvider.isNextPageAvailable();
172                if (hasNextPage) {
173                    childrenPageProvider.nextPage();
174                }
175            }
176        }
177
178        return children;
179    }
180
181    @Override
182    public boolean getCanScrollDescendants() {
183        return canScrollDescendants;
184    }
185
186    @Override
187    public ScrollFileSystemItemList scrollDescendants(String scrollId, int batchSize, long keepAlive) {
188        Semaphore semaphore = Framework.getService(FileSystemItemAdapterService.class).getScrollBatchSemaphore();
189        try {
190            log.trace("Thread [{}] acquiring scroll batch semaphore", Thread::currentThread);
191            semaphore.acquire();
192            try {
193                log.trace("Thread [{}] acquired scroll batch semaphore, available permits reduced to {}",
194                        Thread::currentThread, semaphore::availablePermits);
195                return doScrollDescendants(scrollId, batchSize, keepAlive);
196            } finally {
197                semaphore.release();
198                log.trace("Thread [{}] released scroll batch semaphore, available permits increased to {}",
199                        Thread::currentThread, semaphore::availablePermits);
200            }
201        } catch (InterruptedException cause) {
202            Thread.currentThread().interrupt();
203            throw new NuxeoException("Scroll batch interrupted", cause);
204        }
205    }
206
207    protected ScrollFileSystemItemList doScrollDescendants(String scrollId, int batchSize, long keepAlive) {
208        CoreSession session = CoreInstance.getCoreSession(repositoryName, principal);
209
210        // Limit batch size sent by the client
211        checkBatchSize(batchSize);
212
213        // Scroll through a batch of documents
214        ScrollDocumentModelList descendantDocsBatch = getScrollBatch(scrollId, batchSize, session, keepAlive);
215        String newScrollId = descendantDocsBatch.getScrollId();
216        if (descendantDocsBatch.isEmpty()) {
217            // No more descendants left to return
218            return new ScrollFileSystemItemListImpl(newScrollId, 0);
219        }
220
221        // Adapt documents as FileSystemItems
222        List<FileSystemItem> descendants = adaptDocuments(descendantDocsBatch, session);
223        log.debug("Retrieved {} descendants of FolderItem {} (batchSize = {})", descendants::size, () -> docPath,
224                () -> batchSize);
225        return new ScrollFileSystemItemListImpl(newScrollId, descendants);
226    }
227
228    protected void checkBatchSize(int batchSize) {
229        int maxDescendantsBatchSize = Framework.getService(ConfigurationService.class).getInteger(
230                MAX_DESCENDANTS_BATCH_SIZE_PROPERTY, MAX_DESCENDANTS_BATCH_SIZE_DEFAULT);
231        if (batchSize > maxDescendantsBatchSize) {
232            throw new NuxeoException(String.format(
233                    "Batch size %d is greater than the maximum batch size allowed %d. If you need to increase this limit you can set the %s configuration property but this is not recommended for performance reasons.",
234                    batchSize, maxDescendantsBatchSize, MAX_DESCENDANTS_BATCH_SIZE_PROPERTY));
235        }
236    }
237
238    @SuppressWarnings("unchecked")
239    protected ScrollDocumentModelList getScrollBatch(String scrollId, int batchSize, CoreSession session,
240            long keepAlive) { // NOSONAR
241        Cache scrollingCache = Framework.getService(CacheService.class).getCache(DESCENDANTS_SCROLL_CACHE);
242        if (scrollingCache == null) {
243            throw new NuxeoException("Cache not found: " + DESCENDANTS_SCROLL_CACHE);
244        }
245        String newScrollId;
246        List<String> descendantIds;
247        if (StringUtils.isEmpty(scrollId)) {
248            // Perform initial query to fetch ids of all the descendant documents and put the result list in a
249            // cache, aka "search context"
250            descendantIds = new ArrayList<>();
251            StringBuilder sb = new StringBuilder(
252                    String.format("SELECT ecm:uuid FROM Document WHERE ecm:ancestorId = '%s'", docId));
253            sb.append(" AND ecm:isTrashed = 0");
254            sb.append(" AND ecm:mixinType != 'HiddenInNavigation'");
255            // Don't need to add ecm:isVersion = 0 because versions are already excluded by the
256            // ecm:ancestorId clause since they have no path
257            String query = sb.toString();
258            log.debug("Executing initial query to scroll through the descendants of {}: {}", docPath, query);
259            try (IterableQueryResult res = session.queryAndFetch(sb.toString(), NXQL.NXQL)) {
260                Iterator<Map<String, Serializable>> it = res.iterator();
261                while (it.hasNext()) {
262                    descendantIds.add((String) it.next().get(NXQL.ECM_UUID));
263                }
264            }
265            // Generate a scroll id
266            newScrollId = UUID.randomUUID().toString();
267            log.debug("Put initial query result list (search context) in the {} cache at key (scrollId) {}",
268                    DESCENDANTS_SCROLL_CACHE, newScrollId);
269            scrollingCache.put(newScrollId, (Serializable) descendantIds);
270        } else {
271            // Get the descendant ids from the cache
272            descendantIds = (List<String>) scrollingCache.get(scrollId);
273            if (descendantIds == null) {
274                throw new NuxeoException(String.format("No search context found in the %s cache for scrollId [%s]",
275                        DESCENDANTS_SCROLL_CACHE, scrollId));
276            }
277            newScrollId = scrollId;
278        }
279
280        if (descendantIds.isEmpty()) {
281            return new ScrollDocumentModelList(newScrollId, 0);
282        }
283
284        // Extract a batch of descendant ids
285        List<String> descendantIdsBatch = getBatch(descendantIds, batchSize);
286        // Update descendant ids in the cache
287        scrollingCache.put(newScrollId, (Serializable) descendantIds);
288        // Fetch documents from VCS
289        DocumentModelList descendantDocsBatch = fetchFromVCS(descendantIdsBatch, session);
290        return new ScrollDocumentModelList(newScrollId, descendantDocsBatch);
291    }
292
293    /**
294     * Extracts batchSize elements from the input list.
295     */
296    protected List<String> getBatch(List<String> ids, int batchSize) {
297        List<String> batch = new ArrayList<>(batchSize);
298        int idCount = 0;
299        Iterator<String> it = ids.iterator();
300        while (it.hasNext() && idCount < batchSize) {
301            batch.add(it.next());
302            it.remove();
303            idCount++;
304        }
305        return batch;
306    }
307
308    protected DocumentModelList fetchFromVCS(List<String> ids, CoreSession session) {
309        DocumentModelList res = null;
310        int size = ids.size();
311        int start = 0;
312        int end = Math.min(VCS_CHUNK_SIZE, size);
313        boolean done = false;
314        while (!done) {
315            DocumentModelList docs = fetchFromVcsChunk(ids.subList(start, end), session);
316            if (res == null) {
317                res = docs;
318            } else {
319                res.addAll(docs);
320            }
321            if (end >= ids.size()) {
322                done = true;
323            } else {
324                start = end;
325                end = Math.min(start + VCS_CHUNK_SIZE, size);
326            }
327        }
328        return res;
329    }
330
331    protected DocumentModelList fetchFromVcsChunk(final List<String> ids, CoreSession session) {
332        int docCount = ids.size();
333        StringBuilder sb = new StringBuilder();
334        sb.append("SELECT * FROM Document WHERE ecm:uuid IN (");
335        for (int i = 0; i < docCount; i++) {
336            sb.append(NXQL.escapeString(ids.get(i)));
337            if (i < docCount - 1) {
338                sb.append(", ");
339            }
340        }
341        sb.append(")");
342        String query = sb.toString();
343        log.debug("Fetching {} documents from VCS: {}", docCount, query);
344        return session.query(query);
345    }
346
347    /**
348     * Adapts the given {@link DocumentModelList} as {@link FileSystemItem}s using a cache for the {@link FolderItem}
349     * ancestors.
350     */
351    protected List<FileSystemItem> adaptDocuments(DocumentModelList docs, CoreSession session) {
352        Map<DocumentRef, FolderItem> ancestorCache = new HashMap<>();
353        log.trace("Caching current FolderItem for doc {}: {}", () -> docPath, this::getPath);
354        ancestorCache.put(new IdRef(docId), this);
355        List<FileSystemItem> descendants = new ArrayList<>(docs.size());
356        for (DocumentModel doc : docs) {
357            FolderItem parent = populateAncestorCache(ancestorCache, doc, session, false);
358            if (parent == null) {
359                log.debug("Cannot adapt parent document of {} as a FileSystemItem, skipping descendant document",
360                        doc::getPathAsString);
361                continue;
362            }
363            // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo
364            FileSystemItem descendant = getFileSystemItemAdapterService().getFileSystemItem(doc, parent, false, false,
365                    false);
366            if (descendant != null) {
367                if (descendant.isFolder()) {
368                    log.trace("Caching descendant FolderItem for doc {}: {}", doc::getPathAsString,
369                            descendant::getPath);
370                    ancestorCache.put(doc.getRef(), (FolderItem) descendant);
371                }
372                descendants.add(descendant);
373            }
374        }
375        return descendants;
376    }
377
378    protected FolderItem populateAncestorCache(Map<DocumentRef, FolderItem> cache, DocumentModel doc,
379            CoreSession session, boolean cacheItem) {
380        DocumentRef parentDocRef = session.getParentDocumentRef(doc.getRef());
381        if (parentDocRef == null) {
382            throw new RootlessItemException("Reached repository root");
383        }
384
385        FolderItem parentItem = cache.get(parentDocRef);
386        if (parentItem != null) {
387            log.trace("Found parent FolderItem in cache for doc {}: {}", doc::getPathAsString, parentItem::getPath);
388            return getFolderItem(cache, doc, parentItem, cacheItem);
389        }
390
391        log.trace("No parent FolderItem found in cache for doc {}, computing ancestor cache", doc::getPathAsString);
392        DocumentModel parentDoc = null;
393        try {
394            parentDoc = session.getDocument(parentDocRef);
395        } catch (DocumentSecurityException e) {
396            log.debug("User {} has no READ access on parent of document {} ({}).", principal::getName,
397                    doc::getPathAsString, doc::getId, () -> e);
398            return null;
399        }
400        parentItem = populateAncestorCache(cache, parentDoc, session, true);
401        if (parentItem == null) {
402            return null;
403        }
404        return getFolderItem(cache, doc, parentItem, cacheItem);
405    }
406
407    protected FolderItem getFolderItem(Map<DocumentRef, FolderItem> cache, DocumentModel doc, FolderItem parentItem,
408            boolean cacheItem) {
409        if (cacheItem) {
410            // NXP-19442: Avoid useless and costly call to DocumentModel#getLockInfo
411            FileSystemItem fsItem = getFileSystemItemAdapterService().getFileSystemItem(doc, parentItem, true, false,
412                    false);
413            if (fsItem == null) {
414                log.debug(
415                        "Reached document {} that cannot be  adapted as a (possibly virtual) descendant of the top level folder item.",
416                        doc::getPathAsString);
417                return null;
418            }
419            FolderItem folderItem = (FolderItem) fsItem;
420            log.trace("Caching FolderItem for doc {}: {}", doc::getPathAsString, folderItem::getPath);
421            cache.put(doc.getRef(), folderItem);
422            return folderItem;
423        } else {
424            return parentItem;
425        }
426    }
427
428    @Override
429    public boolean getCanCreateChild() {
430        return canCreateChild;
431    }
432
433    @Override
434    public FolderItem createFolder(String name, boolean overwrite) {
435        try {
436            CoreSession session = CoreInstance.getCoreSession(repositoryName, principal);
437            DocumentModel folder = getFileManager().createFolder(session, name, docPath, overwrite);
438            if (folder == null) {
439                throw new NuxeoException(String.format(
440                        "Cannot create folder named '%s' as a child of doc %s. Probably because of the allowed sub-types for this doc type, please check them.",
441                        name, docPath));
442            }
443            return (FolderItem) getFileSystemItemAdapterService().getFileSystemItem(folder, this);
444        } catch (NuxeoException e) {
445            e.addInfo(String.format("Error while trying to create folder %s as a child of doc %s", name, docPath));
446            throw e;
447        } catch (IOException e) {
448            throw new NuxeoException(
449                    String.format("Error while trying to create folder %s as a child of doc %s", name, docPath), e);
450        }
451    }
452
453    @Override
454    public FileItem createFile(Blob blob, boolean overwrite) {
455        String fileName = blob.getFilename();
456        try {
457            CoreSession session = CoreInstance.getCoreSession(repositoryName, principal);
458            FileImporterContext context = FileImporterContext.builder(session, blob, docPath)
459                                                             .overwrite(overwrite)
460                                                             .excludeOneToMany(true)
461                                                             .build();
462            DocumentModel file = getFileManager().createOrUpdateDocument(context);
463            if (file == null) {
464                throw new NuxeoException(String.format(
465                        "Cannot create file '%s' as a child of doc %s. Probably because there are no file importers registered, please check the contributions to the <extension target=\"org.nuxeo.ecm.platform.filemanager.service.FileManagerService\" point=\"plugins\"> extension point.",
466                        fileName, docPath));
467            }
468            return (FileItem) getFileSystemItemAdapterService().getFileSystemItem(file, this);
469        } catch (NuxeoException e) {
470            e.addInfo(String.format("Error while trying to create file %s as a child of doc %s", fileName, docPath));
471            throw e;
472        } catch (IOException e) {
473            throw new NuxeoException(
474                    String.format("Error while trying to create file %s as a child of doc %s", fileName, docPath), e);
475        }
476    }
477
478    /*--------------------- Object -----------------*/
479    // Override equals and hashCode to explicitly show that their implementation rely on the parent class and doesn't
480    // depend on the fields added to this class.
481    @Override
482    public boolean equals(Object obj) {
483        return super.equals(obj);
484    }
485
486    @Override
487    public int hashCode() {
488        return super.hashCode();
489    }
490
491    /*--------------------- Protected -----------------*/
492    protected void initialize(DocumentModel doc) {
493        this.name = docTitle;
494        this.folder = true;
495        this.canCreateChild = !doc.hasFacet(FacetNames.PUBLISH_SPACE);
496        if (canCreateChild) {
497            if (Framework.getService(ConfigurationService.class).isBooleanTrue(PERMISSION_CHECK_OPTIMIZED_PROPERTY)) {
498                // In optimized mode consider that canCreateChild <=> canRename because canRename <=> WriteProperties
499                // and by default WriteProperties <=> Write <=> AddChildren
500                this.canCreateChild = canRename;
501            } else {
502                // In non optimized mode check AddChildren
503                this.canCreateChild = doc.getCoreSession().hasPermission(doc.getRef(), SecurityConstants.ADD_CHILDREN);
504            }
505        }
506        this.canScrollDescendants = true;
507    }
508
509    protected FileManager getFileManager() {
510        return Framework.getService(FileManager.class);
511    }
512
513    /*---------- Needed for JSON deserialization ----------*/
514    protected void setCanCreateChild(boolean canCreateChild) {
515        this.canCreateChild = canCreateChild;
516    }
517
518    protected void setCanScrollDescendants(boolean canScrollDescendants) {
519        this.canScrollDescendants = canScrollDescendants;
520    }
521
522}