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