001/*
002 * (C) Copyright 2018-2020 Nuxeo (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 *     Funsho David
018 */
019
020package org.nuxeo.ecm.platform.comment.impl;
021
022import static java.lang.Boolean.TRUE;
023import static org.nuxeo.ecm.core.api.security.ACL.LOCAL_ACL;
024import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_NAME;
025import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_PARENTID;
026import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_PRIMARYTYPE;
027import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_UUID;
028import static org.nuxeo.ecm.platform.comment.api.CommentConstants.COMMENT_PARENT_ID_PROPERTY;
029import static org.nuxeo.ecm.platform.comment.api.CommentConstants.MIGRATION_STATE_PROPERTY;
030import static org.nuxeo.ecm.platform.comment.api.CommentConstants.MIGRATION_STATE_RELATION;
031import static org.nuxeo.ecm.platform.comment.api.CommentConstants.MIGRATION_STATE_SECURED;
032import static org.nuxeo.ecm.platform.comment.api.CommentConstants.MIGRATION_STEP_PROPERTY_TO_SECURED;
033import static org.nuxeo.ecm.platform.comment.api.CommentConstants.MIGRATION_STEP_RELATION_TO_PROPERTY;
034import static org.nuxeo.ecm.platform.comment.impl.AbstractCommentManager.COMMENTS_DIRECTORY;
035import static org.nuxeo.ecm.platform.comment.impl.PropertyCommentManager.HIDDEN_FOLDER_TYPE;
036import static org.nuxeo.ecm.platform.ec.notification.NotificationConstants.DISABLE_NOTIFICATION_SERVICE;
037
038import java.util.ArrayList;
039import java.util.Collections;
040import java.util.List;
041import java.util.Map;
042import java.util.Set;
043import java.util.stream.Collectors;
044
045import org.apache.commons.lang3.StringUtils;
046import org.apache.logging.log4j.LogManager;
047import org.apache.logging.log4j.Logger;
048import org.nuxeo.ecm.core.api.CoreSession;
049import org.nuxeo.ecm.core.api.DocumentModel;
050import org.nuxeo.ecm.core.api.DocumentRef;
051import org.nuxeo.ecm.core.api.IdRef;
052import org.nuxeo.ecm.core.api.NuxeoException;
053import org.nuxeo.ecm.core.api.security.ACP;
054import org.nuxeo.ecm.core.migrator.AbstractRepositoryMigrator;
055import org.nuxeo.ecm.core.repository.RepositoryService;
056import org.nuxeo.ecm.platform.comment.service.CommentService;
057import org.nuxeo.ecm.platform.comment.service.CommentServiceConfig;
058import org.nuxeo.ecm.platform.relations.api.Graph;
059import org.nuxeo.ecm.platform.relations.api.RelationManager;
060import org.nuxeo.ecm.platform.relations.api.ResourceAdapter;
061import org.nuxeo.ecm.platform.relations.api.Statement;
062import org.nuxeo.ecm.platform.relations.api.impl.QNameResourceImpl;
063import org.nuxeo.runtime.api.Framework;
064import org.nuxeo.runtime.migration.MigrationService.MigrationContext;
065
066/**
067 * Migrator of comments.
068 *
069 * @since 10.3
070 */
071public class CommentsMigrator extends AbstractRepositoryMigrator {
072
073    private static final Logger log = LogManager.getLogger(CommentsMigrator.class);
074
075    protected static final int BATCH_SIZE = 50;
076
077    public static final String UNMIGRATED_COMMENTS_FOLDER_NAME = "UnMigratedComments";
078
079    /**
080     * @since 11.1.
081     */
082    public static final String GET_COMMENTS_FOLDERS_QUERY = String.format(
083            "SELECT %s FROM Document WHERE %s = '%s' AND %s ='%s'", ECM_UUID, ECM_NAME, COMMENTS_DIRECTORY,
084            ECM_PRIMARYTYPE, HIDDEN_FOLDER_TYPE);
085
086    @Override
087    protected String probeSession(CoreSession session) {
088        CommentService commentComponent = (CommentService) Framework.getRuntime().getComponent(CommentService.NAME);
089        CommentServiceConfig commentServiceConfig = commentComponent.getConfig();
090        if (commentServiceConfig != null) {
091            Graph graph = Framework.getService(RelationManager.class).getGraph(commentServiceConfig.graphName, session);
092            if (!graph.getStatements().isEmpty()) {
093                return MIGRATION_STATE_RELATION;
094            }
095        }
096        // If not in relation, check if there are still comments under hidden Comments folder(s)
097        // Do a new query one to check the step and the second the make the migration (do all comments)
098        if (hasUnsecuredComments(session)) {
099            return MIGRATION_STATE_PROPERTY;
100        }
101        // Else, comments are already secured
102        return MIGRATION_STATE_SECURED;
103    }
104
105    @Override
106    public void run(String step, MigrationContext migrationContext) {
107        if (!Set.of(MIGRATION_STEP_RELATION_TO_PROPERTY, MIGRATION_STEP_PROPERTY_TO_SECURED).contains(step)) {
108            throw new NuxeoException("Unknown migration step: " + step);
109        }
110        // needed for reportProgress
111        this.migrationContext = migrationContext;
112        reportProgress("Initializing", 0, -1); // unknown
113        List<String> repositoryNames = Framework.getService(RepositoryService.class).getRepositoryNames();
114        try {
115            repositoryNames.forEach(repoName -> migrateRepository(step, migrationContext, repoName));
116        } catch (MigrationShutdownException e) {
117            log.trace("Migration is shutdown");
118        }
119    }
120
121    @Override
122    protected void migrateSession(String step, MigrationContext migrationContext, CoreSession session) {
123        if (MIGRATION_STEP_RELATION_TO_PROPERTY.equals(step)) {
124            migrateSessionRelationToProperty(session, migrationContext);
125        } else if (MIGRATION_STEP_PROPERTY_TO_SECURED.equals(step)) {
126            migrateSessionPropertyToSecured(session, migrationContext);
127        }
128    }
129
130    /**
131     * @since 11.1
132     */
133    protected void migrateSessionRelationToProperty(CoreSession session, MigrationContext migrationContext) {
134        CommentService commentComponent = Framework.getService(CommentService.class);
135        CommentServiceConfig commentServiceConfig = commentComponent.getConfig();
136        if (commentServiceConfig != null) {
137            RelationManager relationManager = Framework.getService(RelationManager.class);
138            Graph graph = relationManager.getGraph(commentServiceConfig.graphName, session);
139            List<Statement> statements = graph.getStatements();
140            checkShutdownRequested(migrationContext);
141
142            processBatched(migrationContext, BATCH_SIZE, statements,
143                    statement -> migrateCommentsFromRelationToProperty(session, relationManager, commentServiceConfig,
144                            statement),
145                    "Migrating comments from Relation to Property");
146            reportProgress("Done Migrating from Relation to Property", statements.size(), statements.size());
147        }
148    }
149
150    /**
151     * @since 11.1
152     */
153    protected void migrateCommentsFromRelationToProperty(CoreSession session, RelationManager relationManager,
154            CommentServiceConfig config, Statement statement) {
155        Map<String, Object> ctxMap = Collections.singletonMap(ResourceAdapter.CORE_SESSION_CONTEXT_KEY, session);
156        QNameResourceImpl object = (QNameResourceImpl) statement.getObject();
157        DocumentModel parent = (DocumentModel) relationManager.getResourceRepresentation(config.documentNamespace,
158                object, ctxMap);
159
160        QNameResourceImpl subject = (QNameResourceImpl) statement.getSubject();
161        DocumentModel comment = (DocumentModel) relationManager.getResourceRepresentation(config.commentNamespace,
162                subject, ctxMap);
163
164        if (parent != null && comment != null) {
165            comment.putContextData(DISABLE_NOTIFICATION_SERVICE, TRUE); // Remove notifications
166            comment.setPropertyValue(COMMENT_PARENT_ID_PROPERTY, parent.getId());
167            session.saveDocument(comment);
168        } else if (parent == null && comment == null) {
169            log.warn("Documents {} and {} do not exist, they cannot be migrated", object.getLocalName(),
170                    subject.getLocalName());
171        } else {
172            log.warn("Document {} does not exist, it cannot be migrated",
173                    () -> parent == null ? object.getLocalName() : subject.getLocalName());
174        }
175
176        Graph graph = relationManager.getGraph(config.graphName, session);
177        graph.remove(statement);
178
179    }
180
181    /**
182     * @since 11.1
183     */
184    protected void migrateSessionPropertyToSecured(CoreSession session, MigrationContext migrationContext) {
185        List<String> comments = getUnsecuredCommentIds(session);
186        // For migration purpose and to avoid any duplication, we should rely mainly on `TreeCommentManager`
187        // For 10.10 (backward compatibility) Framework.getService(CommentManager.class) will return
188        // `PropertyCommentManager` the new location should be computed by the `TreeCommentManager`
189        TreeCommentManager treeCommentManager = new TreeCommentManager();
190        processBatched(migrationContext, BATCH_SIZE, comments,
191                comment -> migrateCommentsFromPropertyToSecured(session, treeCommentManager, new IdRef(comment)),
192                "Migrating comments from Property to Secured");
193
194        // All comments were migrated, now we can delete the empty folders
195        int totalOfComments = comments.size();
196        int nbeOfUnMigratedComments = getUnsecuredCommentIds(session).size();
197        if (nbeOfUnMigratedComments == 0) {
198            session.query(GET_COMMENTS_FOLDERS_QUERY).forEach((folder -> session.removeDocument(folder.getRef())));
199
200            IdRef[] documentsToRemove = session.queryProjection(GET_COMMENTS_FOLDERS_QUERY, 0, 0)
201                                               .stream()
202                                               .map(m -> new IdRef((String) m.get(ECM_UUID)))
203                                               .toArray(IdRef[]::new);
204
205            session.removeDocuments(documentsToRemove);
206
207            reportProgress("Done Migrating from Property to Secured", totalOfComments, totalOfComments);
208        } else {
209            // For some reason some comments still not migrated (for example a comment without a comment:parentId)
210            // in this case we just rename the Comments folder (see getCommentFolders)
211            log.warn(
212                    "Some comments have not been migrated, see logs for more information. The folder containing these comments will be renamed to {}",
213                    UNMIGRATED_COMMENTS_FOLDER_NAME);
214
215            session.query(GET_COMMENTS_FOLDERS_QUERY).forEach(docModel -> {
216                session.move(docModel.getRef(), null, UNMIGRATED_COMMENTS_FOLDER_NAME);
217                docModel.putContextData(DISABLE_NOTIFICATION_SERVICE, TRUE);
218                session.saveDocument(docModel);
219            });
220            session.save();
221
222            reportProgress("Done Migrating from Property to Secured", totalOfComments - nbeOfUnMigratedComments,
223                    totalOfComments);
224        }
225    }
226
227    /**
228     * @since 11.1
229     */
230    protected void migrateCommentsFromPropertyToSecured(CoreSession session, TreeCommentManager treeCommentManager,
231            IdRef commentIdRef) {
232        DocumentModel commentDoc = session.getDocument(commentIdRef);
233        String parentId = (String) commentDoc.getPropertyValue(COMMENT_PARENT_ID_PROPERTY);
234        if (StringUtils.isEmpty(parentId)) {
235            log.warn(
236                    "The comment document model with IdRef: {} cannot be migrated, because its 'comment:parentId' is not defined",
237                    commentIdRef);
238            return;
239        }
240
241        DocumentRef parentDocRef = new IdRef(parentId);
242        if (!session.exists(parentDocRef)) {
243            log.warn(
244                    "The comment document model with IdRef: {} cannot be migrated, because its parent: {} cannot be found",
245                    commentIdRef, parentId);
246            return;
247        }
248
249        DocumentModel parentDoc = session.getDocument(parentDocRef);
250
251        DocumentRef destination = treeCommentManager.getLocationRefOfCommentCreation(session, parentDoc);
252
253        // Move the commentIdRef under the new destination (under the `Comments` folder in the case of the first comment
254        // or under the comment itself in the case of reply)
255        session.move(commentIdRef, destination, null);
256
257        // Remove notifications
258        commentDoc.putContextData(DISABLE_NOTIFICATION_SERVICE, TRUE);
259
260        // Strip ACLs
261        ACP acp = session.getACP(commentIdRef);
262        // Case where a comment is on a placeless document which has no acp
263        if (acp != null) {
264            acp.removeACL(LOCAL_ACL);
265            session.setACP(commentIdRef, acp, true);
266        }
267
268        session.saveDocument(commentDoc);
269        session.save();
270    }
271
272    /**
273     * @since 11.1
274     */
275    protected boolean hasUnsecuredComments(CoreSession session) {
276        List<String> folderIds = getCommentFolders(session);
277        return folderIds.stream().anyMatch(folderId -> session.hasChildren(new IdRef(folderId)));
278    }
279
280    /**
281     * @since 11.1
282     */
283    protected List<String> getUnsecuredCommentIds(CoreSession session) {
284        List<String> parentIds = getCommentFolders(session);
285        if (parentIds.isEmpty()) {
286            return Collections.emptyList();
287        }
288
289        String query = String.format("SELECT %s FROM Comment WHERE %s IN (%s)", ECM_UUID, ECM_PARENTID,
290                buildInClause(parentIds));
291
292        return session.queryProjection(query, 0, 0)
293                      .stream()
294                      .map(m -> (String) m.get(ECM_UUID))
295                      .collect(Collectors.toList());
296    }
297
298    /**
299     * @since 11.1
300     */
301    protected List<String> getCommentFolders(CoreSession session) {
302        List<String> parentIds = new ArrayList<>();
303
304        List<String> rootCommentsFolderIds = session.queryProjection(GET_COMMENTS_FOLDERS_QUERY, 0, 0)
305                                                    .stream()
306                                                    .map(entry -> (String) entry.get(ECM_UUID))
307                                                    .collect(Collectors.toList());
308
309        // According to the case:
310        // Comments created using PropertyCommentManager are stored directly under `Comments` hidden folder (one per
311        // domain)
312        // Comments created using CommentManagerImpl are stored under subfolder (named with a timestamp) of the
313        // `Comments` folder
314        if (!rootCommentsFolderIds.isEmpty()) {
315            // Get all `Comments` hidden folders
316            String query = String.format("SELECT %s FROM Document WHERE %s IN (%s) AND %s = '%s'", ECM_UUID,
317                    ECM_PARENTID, buildInClause(rootCommentsFolderIds), ECM_PRIMARYTYPE, HIDDEN_FOLDER_TYPE);
318
319            // Get timestamp subfolders (see CommentManagerImpl#getCommentPathList)
320            List<String> timestampCommentFoldersIds = session.queryProjection(query, 0, 0)
321                                                             .stream()
322                                                             .map(entry -> (String) entry.get(ECM_UUID))
323                                                             .collect(Collectors.toList());
324            parentIds.addAll(rootCommentsFolderIds);
325            parentIds.addAll(timestampCommentFoldersIds);
326        }
327        return parentIds;
328    }
329
330    @Override
331    public String probeState() {
332        List<String> repositoryNames = Framework.getService(RepositoryService.class).getRepositoryNames();
333        Set<String> probes = repositoryNames.stream().map(this::probeRepository).collect(Collectors.toSet());
334        if (probes.contains(MIGRATION_STATE_RELATION)) {
335            return MIGRATION_STATE_RELATION;
336        } else if (probes.contains(MIGRATION_STATE_PROPERTY)) {
337            return MIGRATION_STATE_PROPERTY;
338        }
339        return MIGRATION_STATE_SECURED;
340    }
341
342    @Override
343    public void notifyStatusChange() {
344        CommentService commentComponent = (CommentService) Framework.getRuntime().getComponent(CommentService.NAME);
345        commentComponent.invalidateCommentManagerImplementation();
346    }
347
348    /**
349     * @since 11.1
350     */
351    protected String buildInClause(List<String> parentIds) {
352        return parentIds.stream().collect(Collectors.joining("', '", "'", "'"));
353    }
354}