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