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