001/*
002 *  Copyright 2018 Anyware Services
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 */
016package org.ametys.plugins.workspaces.wall;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.Collections;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025import java.util.concurrent.ExecutionException;
026import java.util.stream.Collectors;
027
028import javax.jcr.RepositoryException;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.cocoon.servlet.multipart.Part;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.commons.lang3.tuple.Pair;
037import org.apache.excalibur.xml.sax.SAXParser;
038import org.apache.solr.client.solrj.SolrServerException;
039import org.xml.sax.InputSource;
040
041import org.ametys.cms.ObservationConstants;
042import org.ametys.cms.content.RichTextHandler;
043import org.ametys.cms.content.indexing.solr.SolrIndexer;
044import org.ametys.cms.contenttype.ContentType;
045import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
046import org.ametys.cms.data.Binary;
047import org.ametys.cms.data.RichText;
048import org.ametys.cms.data.type.ResourceElementTypeHelper;
049import org.ametys.cms.fo.ForceDefaultRepositoryWorkspaceCallableDecorator;
050import org.ametys.cms.indexing.IndexingObserver;
051import org.ametys.cms.repository.Content;
052import org.ametys.cms.repository.ContentDAO;
053import org.ametys.cms.repository.ContentDAO.TagMode;
054import org.ametys.cms.repository.comment.ui.CommentsAndReportsTreeComponent;
055import org.ametys.cms.rights.ContentRightAssignmentContext;
056import org.ametys.core.observation.Event;
057import org.ametys.core.observation.ObservationManager;
058import org.ametys.core.observation.ObservationManager.ObserverFuture;
059import org.ametys.core.right.RightManager;
060import org.ametys.core.ui.Callable;
061import org.ametys.core.ui.mail.StandardMailBodyHelper;
062import org.ametys.core.ui.mail.StandardMailBodyHelper.MailBodyBuilder;
063import org.ametys.core.upload.Upload;
064import org.ametys.core.upload.UploadManager;
065import org.ametys.core.user.CurrentUserProvider;
066import org.ametys.core.user.User;
067import org.ametys.core.user.UserIdentity;
068import org.ametys.core.user.UserManager;
069import org.ametys.core.util.I18nUtils;
070import org.ametys.core.util.language.UserLanguagesManager;
071import org.ametys.core.util.mail.SendMailHelper;
072import org.ametys.plugins.explorer.resources.ResourceCollection;
073import org.ametys.plugins.repository.AmetysObjectResolver;
074import org.ametys.plugins.repository.AmetysRepositoryException;
075import org.ametys.plugins.workspaces.WorkspacesConstants;
076import org.ametys.plugins.workspaces.WorkspacesHelper;
077import org.ametys.plugins.workspaces.project.ProjectManager;
078import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
079import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
080import org.ametys.plugins.workspaces.project.objects.Project;
081import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
082import org.ametys.runtime.authentication.AccessDeniedException;
083import org.ametys.runtime.config.Config;
084import org.ametys.runtime.i18n.I18nizableText;
085import org.ametys.runtime.i18n.I18nizableTextParameter;
086import org.ametys.runtime.plugin.component.AbstractLogEnabled;
087import org.ametys.web.content.FOContentCreationHelper;
088import org.ametys.web.repository.site.Site;
089import org.ametys.web.repository.site.SiteManager;
090
091import com.opensymphony.workflow.WorkflowException;
092
093import jakarta.mail.MessagingException;
094
095/**
096 * Helper for wall contents
097 *
098 */
099public class WallContentManager extends AbstractLogEnabled implements Component, Serviceable
100{
101    /** The Avalon role */
102    public static final String ROLE = WallContentManager.class.getName();
103    /** The tag for pin */
104    public static final String WALL_CONTENT_PIN_TAG = "WORKSPACES_CONTENT_PINNED";
105    /** Wall content creation right id */
106    public static final String PUBLISH_WALL_CONTENT_RIGHT_ID = "Plugins_Workspaces_Right_WallContent_Create";
107    /** Wall content pin right id */
108    public static final String PIN_WALL_CONTENT_RIGHT_ID = "Plugins_Workspaces_Right_Pin_WallContent";
109
110    private static final int __INITIAL_WORKFLOW_ACTION_ID = 1111;
111    private static final String __WORKFLOW_NAME = "wall-content";
112    
113    private FOContentCreationHelper _foContentHelper;
114    private ContentTypeExtensionPoint _cTypeEP;
115    private I18nUtils _i18nUtils;
116    private ContentDAO _contentDAO;
117    private ObservationManager _observationManager;
118    private RightManager _rightManager;
119    private AmetysObjectResolver _resolver;
120    private UserManager _userManager;
121    private CurrentUserProvider _currentUserProvider;
122    private ProjectManager _projectManager;
123    private SiteManager _siteManager;
124    private ServiceManager _smanager;
125    private UploadManager _uploadManager;
126    private SolrIndexer _solrIndexer;
127    private WorkspaceModuleExtensionPoint _moduleEP;
128    private CommentsAndReportsTreeComponent _commentAndReportCmp;
129    private ProjectRightHelper _projectRightHelper;
130    private UserLanguagesManager _userLanguagesManager;
131    private WorkspacesHelper _workspaceHelper;
132    
133    public void service(ServiceManager manager) throws ServiceException
134    {
135        _smanager = manager;
136        _foContentHelper = (FOContentCreationHelper) manager.lookup(FOContentCreationHelper.ROLE);
137        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
138        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
139        _contentDAO = (ContentDAO) manager.lookup(ContentDAO.ROLE);
140        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
141        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
142        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
143        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
144        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
145        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
146        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
147        _uploadManager = (UploadManager) manager.lookup(UploadManager.ROLE);
148        _solrIndexer = (SolrIndexer) manager.lookup(SolrIndexer.ROLE);
149        _moduleEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
150        _commentAndReportCmp = (CommentsAndReportsTreeComponent) manager.lookup(CommentsAndReportsTreeComponent.ROLE);
151        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
152        _userLanguagesManager = (UserLanguagesManager) manager.lookup(UserLanguagesManager.ROLE);
153        _workspaceHelper = (WorkspacesHelper) manager.lookup(WorkspacesHelper.ROLE);
154    }
155    
156    /**
157     * Create and publish a new wall content
158     * @param text the text
159     * @param part the file part for illustration. Can be null.
160     * @param siteName the site name
161     * @param lang the language
162     * @return the results
163     */
164    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
165    public Map<String, Object> publishContent(String text, Part part, String siteName, String lang)
166    {
167        Map<String, Object> results = new HashMap<>();
168
169        try
170        {
171            if (!_projectRightHelper.hasRightOnModule(PUBLISH_WALL_CONTENT_RIGHT_ID, WallContentModule.WALLCONTENT_MODULE_ID))
172            {
173                throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to publish wall content on non authorized project");
174            }
175            
176            ContentType cType = _cTypeEP.getExtension(WorkspacesConstants.WALL_CONTENT_CONTENT_TYPE_ID);
177            
178            String contentTitle = _i18nUtils.translate(cType.getDefaultTitle(), lang);
179            
180            Map<String, Object> userValues = new HashMap<>();
181            userValues.put(Content.ATTRIBUTE_TITLE, contentTitle);
182            userValues.put("content", text);
183            userValues.put("comment", true); // active comments
184            
185            if (part != null)
186            {
187                try (InputStream is = part.getInputStream())
188                {
189                    Upload upload = _uploadManager.storeUpload(_currentUserProvider.getUser(), part.getFileName(), is);
190                    Binary fileValue = ResourceElementTypeHelper.binaryFromUpload(upload);
191                    
192                    userValues.put("illustration", Map.of("image", fileValue));
193                }
194                catch (IOException e)
195                {
196                    getLogger().error("Failed to store uploaded wall content illustration", e);
197                }
198            }
199            
200            try
201            {
202                results = _foContentHelper.createAndEditContent(__INITIAL_WORKFLOW_ACTION_ID, WorkspacesConstants.WALL_CONTENT_CONTENT_TYPE_ID, siteName, contentTitle, contentTitle, lang, userValues, __WORKFLOW_NAME, null);
203                
204                Content content = (Content) results.get(Content.class.getName());
205                _notifyContentCreation(content);
206                
207                // remove Content from result
208                results.remove(Content.class.getName());
209                
210                results.put("success", true);
211            }
212            finally
213            {
214                _commitAllChanges();
215            }
216        }
217        catch (AmetysRepositoryException | WorkflowException e)
218        {
219            results.put("success", false);
220            getLogger().error("Failed to create wall content for site {} and language {}", siteName, lang, e);
221        }
222        return results;
223    }
224    
225    /**
226     * Commit all changes in solr
227     */
228    protected void _commitAllChanges()
229    {
230        // Before trying to commit, be sure all the async observers of the current request are finished
231        List<ObserverFuture> futuresForRequest = _observationManager.getFuturesForRequest();
232        for (ObserverFuture observerFuture : futuresForRequest)
233        {
234            if (observerFuture.traits().contains(IndexingObserver.INDEXING_OBSERVER))
235            {
236                try
237                {
238                    observerFuture.future().get();
239                }
240                catch (ExecutionException | InterruptedException e)
241                {
242                    getLogger().info("An exception occured when calling #get() on Future result of an observer." , e);
243                }
244            }
245        }
246        
247        // Force commit all uncommited changes
248        try
249        {
250            _solrIndexer.commit();
251        }
252        catch (IOException | SolrServerException e)
253        {
254            getLogger().error("Impossible to commit changes", e);
255        }
256    }
257    
258    private void _notifyContentCreation(Content content)
259    {
260        Map<String, Object> eventParams = new HashMap<>();
261        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
262        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
263        
264        _observationManager.notify(new Event(org.ametys.plugins.workspaces.ObservationConstants.EVENT_WALLCONTENT_ADDED, content.getCreator(), eventParams));
265    }
266    
267    /**
268     * Pin a wall content
269     * @param contentId the content id
270     * @param contextualParameters the contextual parameters
271     * @return the result
272     */
273    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
274    public Map<String, Object> pinContent(String contentId, Map<String, Object> contextualParameters)
275    {
276        if (!_projectRightHelper.hasRightOnModule(PIN_WALL_CONTENT_RIGHT_ID, WallContentModule.WALLCONTENT_MODULE_ID))
277        {
278            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to pin content '" + contentId + "' without convenient right.");
279        }
280        
281        return _pinOrUnpinContent(contentId, contextualParameters, TagMode.INSERT);
282    }
283    
284    /**
285     * Unpin a wall content
286     * @param contentId the content id
287     * @param contextualParameters the contextual parameters
288     * @return the result
289     */
290    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
291    public Map<String, Object> unpinContent(String contentId, Map<String, Object> contextualParameters)
292    {
293        if (!_projectRightHelper.hasRightOnModule(PIN_WALL_CONTENT_RIGHT_ID, WallContentModule.WALLCONTENT_MODULE_ID))
294        {
295            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to unpin content '" + contentId + "' without convenient right.");
296        }
297        
298        return _pinOrUnpinContent(contentId, contextualParameters, TagMode.REMOVE);
299    }
300    
301    private Map<String, Object> _pinOrUnpinContent(String contentId,  Map<String, Object> contextualParameters, TagMode mode)
302    {
303        try
304        {
305            Map<String, Object> result = _contentDAO.tag(Collections.singletonList(contentId), Collections.singletonList(WALL_CONTENT_PIN_TAG), mode, contextualParameters, true);
306            return result;
307        }
308        finally
309        {
310            _commitAllChanges();
311        }
312    }
313    
314    /**
315     * Report content to webmasters (user with report notification right on wall contents)
316     * @param siteName the current site name
317     * @param contentId the id of content to report
318     * @return true if the content was successfully reported
319     */
320    @Callable (rights = ContentDAO.CONTENT_REPORT_RIGHT, paramIndex = 1, rightContext = ContentRightAssignmentContext.ID, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
321    public boolean reportContent(String siteName, String contentId)
322    {
323        Content content = _resolver.resolveById(contentId);
324        User reporter = _userManager.getUser(_currentUserProvider.getUser());
325        Site site = _siteManager.getSite(siteName);
326        
327        // Add the report to the content
328        _contentDAO.report(content);
329        
330        // Send a mail to the allowed users
331        List<Project> projects = _projectManager.getProjectsForSite(site);
332        if (!projects.isEmpty())
333        {
334            Project project = projects.get(0);
335            
336            Map<String, List<String>> recipientsByLanguage = _getReportsRecipientsByLanguage(project);
337            if (!recipientsByLanguage.isEmpty())
338            {
339                Map<String, I18nizableTextParameter> i18nParams = new HashMap<>();
340                i18nParams.put("projectTitle", new I18nizableText(project.getTitle()));
341                i18nParams.put("siteTitle", new I18nizableText(site.getTitle()));
342                
343                I18nizableText i18nSubject = new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_WALL_CONTENT_REPORTED_SUBJECT", i18nParams);
344                
345                i18nParams.put("projectUrl", new I18nizableText(site.getUrl()));
346                i18nParams.put("reporter", new I18nizableText(reporter.getFullName()));
347                i18nParams.put("content", new I18nizableText(getExcerpt(content, 200)));
348                
349                String from = site.getValue("site-mail-from");
350                
351                MailBodyBuilder bodyBuilder = StandardMailBodyHelper.newHTMLBody()
352                        .withTitle(i18nSubject)
353                        .withMessage(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_WALL_CONTENT_REPORTED_BODY", i18nParams))
354                        .withDetails(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_WALL_CONTENT_REPORTED_BODY_EXTRACT"), getExcerpt(content, 200), false)
355                        .withLink(site.getUrl(), new I18nizableText("plugin.workspaces", "PROJECT_MAIL_NOTIFICATION_BODY_DEFAULT_BUTTON_TEXT"));
356                
357                for (String userLanguage : recipientsByLanguage.keySet())
358                {
359                    try
360                    {
361                        List<String> emails = recipientsByLanguage.get(userLanguage);
362                        String subject = _i18nUtils.translate(i18nSubject, userLanguage);
363                        
364                        String htmlBody = bodyBuilder.withLanguage(userLanguage).build();
365                        
366                        SendMailHelper.newMail()
367                            .withSubject(subject)
368                            .withHTMLBody(htmlBody)
369                            .withSender(from)
370                            .withRecipients(emails)
371                            .sendMail();
372                    }
373                    catch (MessagingException | IOException e)
374                    {
375                        getLogger().warn("Could not send a notification mail to {}", recipientsByLanguage.get(userLanguage), e);
376                    }
377                }
378
379                return true;
380            }
381        }
382        
383        return false;
384    }
385    
386    /**
387     * Get the excerpt of content
388     * @param content the content
389     * @param maxLength the max length for content excerpt
390     * @return the excerpt
391     */
392    public String getExcerpt(Content content, int maxLength)
393    {
394        if (content.hasValue("content"))
395        {
396            RichText richText = content.getValue("content");
397            SAXParser saxParser = null;
398            try (InputStream is = richText.getInputStream())
399            {
400                RichTextHandler txtHandler = new RichTextHandler(maxLength);
401                saxParser = (SAXParser) _smanager.lookup(SAXParser.ROLE);
402                saxParser.parse(new InputSource(is), txtHandler);
403                
404                return txtHandler.getValue();
405            }
406            catch (Exception e)
407            {
408                getLogger().error("Cannot extract excerpt from content {}", content.getId(), e);
409            }
410            finally
411            {
412                _smanager.release(saxParser);
413            }
414        }
415     
416        return "";
417    }
418    
419    /**
420     * Retrieves the list of recipients for reports notification sending
421     * @param project the current project
422     * @return the list of recipients for reports notification sending
423     */
424    protected Map<String, List<String>> _getReportsRecipientsByLanguage(Project project)
425    {
426        WorkspaceModule module = _moduleEP.getModule(WallContentModule.WALLCONTENT_MODULE_ID);
427        ResourceCollection moduleRoot = module.getModuleRoot(project, false);
428        
429        Set<UserIdentity> users = _rightManager.getAllowedUsers("Plugins_Workspaces_Right_ReportNotification_WallContent", moduleRoot).resolveAllowedUsers(Config.getInstance().getValue("runtime.mail.massive.sending"));
430        
431        String defaultLanguage = _workspaceHelper.getLang(project, _userLanguagesManager.getDefaultLanguage());
432        
433        return users.stream()
434                .map(_userManager::getUser)
435                .filter(user -> user != null)
436                .map(user -> Pair.of(user, user.getEmail()))
437                .filter(p -> StringUtils.isNotBlank(p.getRight()))
438                .collect(Collectors.groupingBy(
439                        p -> {
440                            return StringUtils.defaultIfBlank(p.getLeft().getLanguage(), defaultLanguage);
441                        },
442                        Collectors.mapping(
443                                Pair::getRight,
444                                Collectors.toList()
445                        )
446                    )
447                );
448    }
449    
450    /**
451     * Add or remove a reaction on a content
452     * @param contentId The content id
453     * @param reactionName the reaction name (ex: LIKE)
454     * @param remove true to remove the reaction, false to add reaction
455     * @return the result with the current actors of this reaction
456     */
457    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
458    public Map<String, Object> react(String contentId, String reactionName, boolean remove)
459    {
460        return _contentDAO.react(contentId, reactionName, remove);
461    }
462    
463    /**
464     * Retrieves the comments and reports of the contents of the given type
465     * Manages only contents that have at least one report (on itself or on a comment)
466     * @param contentTypeId the content type identifier
467     * @return the comments and reports of the contents
468     * @throws RepositoryException if an error occurs while retrieving contents from the repository
469     */
470    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
471    public Map getCommentsAndReportsFromContentTypeId(String contentTypeId) throws RepositoryException
472    {
473        return _commentAndReportCmp.getCommentsAndReportsFromContentTypeId(contentTypeId, "Plugins_Workspaces_Right_See_Reports_WallContent");
474    }
475    
476}