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}