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