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