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}