001/* 002 * Copyright 2019 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.odfweb.cart; 017 018import java.io.ByteArrayOutputStream; 019import java.io.IOException; 020import java.io.InputStream; 021import java.nio.charset.StandardCharsets; 022import java.util.ArrayList; 023import java.util.Arrays; 024import java.util.Collections; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.Objects; 029import java.util.stream.Collectors; 030 031import javax.mail.MessagingException; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.context.Context; 035import org.apache.avalon.framework.context.ContextException; 036import org.apache.avalon.framework.context.Contextualizable; 037import org.apache.avalon.framework.service.ServiceException; 038import org.apache.avalon.framework.service.ServiceManager; 039import org.apache.avalon.framework.service.Serviceable; 040import org.apache.cocoon.components.ContextHelper; 041import org.apache.cocoon.components.source.impl.SitemapSource; 042import org.apache.cocoon.environment.Request; 043import org.apache.cocoon.xml.AttributesImpl; 044import org.apache.cocoon.xml.XMLUtils; 045import org.apache.commons.io.IOUtils; 046import org.apache.commons.lang.StringUtils; 047import org.apache.excalibur.source.Source; 048import org.apache.excalibur.source.SourceResolver; 049import org.apache.excalibur.source.SourceUtil; 050import org.xml.sax.ContentHandler; 051import org.xml.sax.SAXException; 052 053import org.ametys.cms.contenttype.ContentType; 054import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 055import org.ametys.cms.contenttype.ContentTypesHelper; 056import org.ametys.cms.repository.Content; 057import org.ametys.cms.transformation.xslt.ResolveURIComponent; 058import org.ametys.core.user.User; 059import org.ametys.core.user.UserIdentity; 060import org.ametys.core.userpref.UserPreferencesException; 061import org.ametys.core.userpref.UserPreferencesManager; 062import org.ametys.core.util.DateUtils; 063import org.ametys.core.util.I18nUtils; 064import org.ametys.core.util.IgnoreRootHandler; 065import org.ametys.core.util.mail.SendMailHelper; 066import org.ametys.odf.course.Course; 067import org.ametys.odf.program.AbstractProgram; 068import org.ametys.odf.program.Program; 069import org.ametys.odf.program.SubProgram; 070import org.ametys.plugins.core.user.UserHelper; 071import org.ametys.plugins.odfweb.repository.OdfPageResolver; 072import org.ametys.plugins.repository.AmetysObjectResolver; 073import org.ametys.plugins.repository.UnknownAmetysObjectException; 074import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector; 075import org.ametys.runtime.i18n.I18nizableText; 076import org.ametys.runtime.plugin.component.AbstractLogEnabled; 077import org.ametys.web.WebConstants; 078import org.ametys.web.renderingcontext.RenderingContext; 079import org.ametys.web.renderingcontext.RenderingContextHandler; 080import org.ametys.web.repository.page.Page; 081import org.ametys.web.repository.site.Site; 082import org.ametys.web.repository.site.SiteManager; 083import org.ametys.web.userpref.FOUserPreferencesConstants; 084 085/** 086 * Component to handle ODF cart items 087 * 088 */ 089public class ODFCartManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable 090{ 091 /** The avalon role */ 092 public static final String ROLE = ODFCartManager.class.getName(); 093 094 /** The id of user preference for cart's elements */ 095 public static final String CART_USER_PREF_CONTENT_IDS = "cartOdfContentIds"; 096 097 private UserPreferencesManager _userPrefManager; 098 private AmetysObjectResolver _resolver; 099 private SourceResolver _srcResolver; 100 private OdfPageResolver _odfPageResolver; 101 private ContentTypeExtensionPoint _cTypeEP; 102 private ContentTypesHelper _cTypesHelper; 103 private ODFCartUserPreferencesStorage _odfUserPrefStorage; 104 private I18nUtils _i18nUtils; 105 private UserHelper _userHelper; 106 private SiteManager _siteManager; 107 private RenderingContextHandler _renderingContextHandler; 108 109 private Context _context; 110 111 112 113 @Override 114 public void service(ServiceManager serviceManager) throws ServiceException 115 { 116 _userPrefManager = (UserPreferencesManager) serviceManager.lookup(UserPreferencesManager.ROLE + ".FO"); 117 _resolver = (AmetysObjectResolver) serviceManager.lookup(AmetysObjectResolver.ROLE); 118 _srcResolver = (SourceResolver) serviceManager.lookup(SourceResolver.ROLE); 119 _odfPageResolver = (OdfPageResolver) serviceManager.lookup(OdfPageResolver.ROLE); 120 _cTypeEP = (ContentTypeExtensionPoint) serviceManager.lookup(ContentTypeExtensionPoint.ROLE); 121 _cTypesHelper = (ContentTypesHelper) serviceManager.lookup(ContentTypesHelper.ROLE); 122 _odfUserPrefStorage = (ODFCartUserPreferencesStorage) serviceManager.lookup(ODFCartUserPreferencesStorage.ROLE); 123 _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE); 124 _userHelper = (UserHelper) serviceManager.lookup(UserHelper.ROLE); 125 _siteManager = (SiteManager) serviceManager.lookup(SiteManager.ROLE); 126 _renderingContextHandler = (RenderingContextHandler) serviceManager.lookup(RenderingContextHandler.ROLE); 127 } 128 129 public void contextualize(Context context) throws ContextException 130 { 131 _context = context; 132 } 133 134 /** 135 * Get the id of ODF's cart items for a given user 136 * @param user the user 137 * @param siteName the current site name 138 * @return the list of contents' id 139 * @throws UserPreferencesException if failed to get cart items 140 */ 141 public List<String> getCartItemIds(UserIdentity user, String siteName) throws UserPreferencesException 142 { 143 Map<String, String> contextVars = new HashMap<>(); 144 contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_SITENAME, siteName); 145 146 String contentIdsAsStr = _userPrefManager.getUserPreferenceAsString(user, "/sites/" + siteName, contextVars, CART_USER_PREF_CONTENT_IDS); 147 if (StringUtils.isNotBlank(contentIdsAsStr)) 148 { 149 return Arrays.asList(StringUtils.split(contentIdsAsStr, ",")); 150 } 151 152 return Collections.emptyList(); 153 } 154 155 /** 156 * Get the ODF's cart items for a given user 157 * @param owner the user 158 * @param siteName the current site name 159 * @return the list of contents 160 * @throws UserPreferencesException if failed to get cart items 161 */ 162 public List<ODFCartItem> getCartItems(UserIdentity owner, String siteName) throws UserPreferencesException 163 { 164 List<ODFCartItem> items = new ArrayList<>(); 165 166 List<String> itemIds = getCartItemIds(owner, siteName); 167 for (String itemId : itemIds) 168 { 169 ODFCartItem item = getCartItem(itemId); 170 if (item != null) 171 { 172 items.add(item); 173 } 174 else 175 { 176 getLogger().warn("The item with id '{}' stored in cart of user {} does not match an existing content anymore. It will be ignored", itemId, owner); 177 } 178 } 179 180 return items; 181 } 182 183 /** 184 * Get a cart item from its id 185 * @param itemId the item's id 186 * @return the cart item or null if no content was found 187 */ 188 public ODFCartItem getCartItem(String itemId) 189 { 190 int i = itemId.indexOf(';'); 191 192 String contentId = itemId; 193 String parentId = null; 194 195 if (i != -1) 196 { 197 contentId = itemId.substring(0, i); 198 parentId = itemId.substring(i + 1); 199 } 200 201 try 202 { 203 return new ODFCartItem(_resolver.resolveById(contentId), parentId != null ? _resolver.resolveById(parentId) : null); 204 } 205 catch (UnknownAmetysObjectException e) 206 { 207 return null; 208 } 209 } 210 211 /** 212 * Set the cart's items 213 * @param owner The cart owner 214 * @param itemIds the id of items to set in the cart 215 * @param siteName the site name 216 * @throws UserPreferencesException if failed to save cart 217 */ 218 public void setCartItems(UserIdentity owner, List<String> itemIds, String siteName) throws UserPreferencesException 219 { 220 Map<String, String> contextVars = new HashMap<>(); 221 contextVars.put(FOUserPreferencesConstants.CONTEXT_VAR_SITENAME, siteName); 222 223 Map<String, String> preferences = new HashMap<>(); 224 preferences.put(CART_USER_PREF_CONTENT_IDS, StringUtils.join(itemIds, ",")); 225 226 _odfUserPrefStorage.setUserPreferences(owner, "/sites/" + siteName, contextVars, preferences); 227 } 228 229 /** 230 * Add a content to the cart 231 * @param owner the cart owner 232 * @param itemId the id of content to add into the cart 233 * @param siteName the site name 234 * @return true if the content was successfuly added 235 * @throws UserPreferencesException if failed to save cart 236 */ 237 public boolean addCartItem(UserIdentity owner, String itemId, String siteName) throws UserPreferencesException 238 { 239 List<ODFCartItem> items = getCartItems(owner, siteName); 240 241 ODFCartItem item = getCartItem(itemId); 242 if (item != null) 243 { 244 items.add(item); 245 } 246 else 247 { 248 getLogger().warn("Unknown item with id {}. It cannot be added to user cart", itemId); 249 return false; 250 } 251 252 List<String> itemIds = items.stream() 253 .map(c -> c.getId()) 254 .collect(Collectors.toList()); 255 256 setCartItems(owner, itemIds, siteName); 257 258 return true; 259 } 260 261 /** 262 * Share the cart's items by mail 263 * @param owner The cart owner 264 * @param itemIds the id of contents to set in the cart 265 * @param recipients the mails to share with 266 * @param siteName the site name 267 * @param language the language 268 * @param message the message to add to selection 269 * @return the results 270 */ 271 public Map<String, Object> shareCartItems(UserIdentity owner, List<String> itemIds, List<String> recipients, String siteName, String language, String message) 272 { 273 Map<String, Object> result = new HashMap<>(); 274 275 User user = _userHelper.getUser(owner); 276 String sender = user.getEmail(); 277 278 if (StringUtils.isEmpty(sender)) 279 { 280 getLogger().error("Cart's owner has no email, his ODF cart selection can not be shared"); 281 result.put("success", false); 282 result.put("error", "no-owner-mail"); 283 return result; 284 } 285 286 List<ODFCartItem> items = itemIds.stream() 287 .map(i -> getCartItem(i)) 288 .filter(Objects::nonNull) 289 .collect(Collectors.toList()); 290 291 Site site = _siteManager.getSite(siteName); 292 Map<String, I18nizableText> i18nparam = new HashMap<>(); 293 i18nparam.put("siteTitle", new I18nizableText(site.getTitle())); // {siteTitle} 294 295 I18nizableText i18nTextSubject = new I18nizableText("plugin.odf-web", "PLUGINS_ODFWEB_CART_SHARE_MAIL_SUBJECT", i18nparam); 296 String subject = _i18nUtils.translate(i18nTextSubject); 297 298 String htmlBody = null; 299 String textBody = null; 300 try 301 { 302 303 htmlBody = getMailBody(items, message, owner, siteName, language, false); 304 textBody = getMailBody(items, message, owner, siteName, language, true); 305 } 306 catch (IOException e) 307 { 308 getLogger().error("Fail to get mail body to share ODF cart selection", e); 309 result.put("success", false); 310 return result; 311 } 312 313 List<String> mailsInError = new ArrayList<>(); 314 315 for (String recipient : recipients) 316 { 317 try 318 { 319 SendMailHelper.sendMail(subject, htmlBody, textBody, Collections.singletonList(recipient), sender); 320 } 321 catch (MessagingException e) 322 { 323 getLogger().error("Failed to send ODF cart selection to '" + recipient + "'", e); 324 mailsInError.add(recipient); 325 } 326 } 327 328 if (mailsInError.size() > 0) 329 { 330 result.put("success", false); 331 result.put("mailsInError", mailsInError); 332 } 333 else 334 { 335 result.put("success", true); 336 } 337 338 return result; 339 } 340 341 /** 342 * Get the mail subject for sharing cart 343 * @param siteName The site name 344 * @param language the language 345 * @return the mail subject 346 */ 347 protected String getMailSubject(String siteName, String language) 348 { 349 Site site = _siteManager.getSite(siteName); 350 Map<String, I18nizableText> i18nparam = new HashMap<>(); 351 i18nparam.put("siteTitle", new I18nizableText(site.getTitle())); // {siteTitle} 352 353 I18nizableText i18nTextSubject = new I18nizableText("plugin.odf-web", "PLUGINS_ODFWEB_CART_SHARE_MAIL_SUBJECT", i18nparam); 354 return _i18nUtils.translate(i18nTextSubject, language); 355 } 356 357 /** 358 * Get the mail body to sharing cart 359 * @param items The cart's items 360 * @param message The message 361 * @param owner The cart's owner 362 * @param siteName The site name 363 * @param language the language 364 * @param text true to get the body to text body (html otherwise) 365 * @return the cart items to HTML format 366 * @throws IOException if failed to mail body 367 */ 368 protected String getMailBody(List<ODFCartItem> items, String message, UserIdentity owner, String siteName, String language, boolean text) throws IOException 369 { 370 Request request = ContextHelper.getRequest(_context); 371 372 String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request); 373 RenderingContext currentContext = _renderingContextHandler.getRenderingContext(); 374 375 Source source = null; 376 try 377 { 378 // Force live workspace and FRONT context to resolve page 379 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, WebConstants.LIVE_WORKSPACE); 380 _renderingContextHandler.setRenderingContext(RenderingContext.FRONT); 381 382 Map<String, Object> parameters = new HashMap<>(); 383 384 parameters.put("items", items); 385 parameters.put("message", message); 386 parameters.put("owner", owner); 387 parameters.put("siteName", siteName); 388 parameters.put("lang", language); 389 parameters.put("format", text ? "text" : "html"); 390 391 source = _srcResolver.resolveURI("cocoon://_plugins/odf-web/cart/mail/body", null, parameters); 392 393 try (InputStream is = source.getInputStream()) 394 { 395 ByteArrayOutputStream bos = new ByteArrayOutputStream(); 396 SourceUtil.copy(is, bos); 397 398 return bos.toString("UTF-8"); 399 } 400 } 401 finally 402 { 403 RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace); 404 _renderingContextHandler.setRenderingContext(currentContext); 405 406 if (source != null) 407 { 408 _srcResolver.release(source); 409 } 410 } 411 } 412 413 /** 414 * SAX the cart's items 415 * @param contentHandler The content handler to sax into 416 * @param owner the cart owner 417 * @param siteName the site name 418 * @throws SAXException if an error occurred while saxing 419 * @throws IOException if an I/O exception occurred 420 * @throws UserPreferencesException if failed to get cart items 421 */ 422 public void saxCartItems(ContentHandler contentHandler, UserIdentity owner, String siteName) throws SAXException, IOException, UserPreferencesException 423 { 424 List<ODFCartItem> items = getCartItems(owner, siteName); 425 426 XMLUtils.startElement(contentHandler, "items"); 427 for (ODFCartItem item : items) 428 { 429 saxCartItem(contentHandler, item, siteName); 430 431 } 432 XMLUtils.endElement(contentHandler, "items"); 433 434 } 435 436 /** 437 * SAX a cart's item 438 * @param contentHandler The content handler to sax into 439 * @param item the cart's item 440 * @param siteName the site name 441 * @throws SAXException if an error occurred while saxing 442 * @throws IOException if an I/O exception occurred 443 */ 444 public void saxCartItem(ContentHandler contentHandler, ODFCartItem item, String siteName) throws SAXException, IOException 445 { 446 AttributesImpl attrs = new AttributesImpl(); 447 attrs.addCDATAAttribute("id", item.getId()); 448 XMLUtils.startElement(contentHandler, "item", attrs); 449 450 Content content = item.getContent(); 451 saxTypes(contentHandler, content.getTypes()); 452 saxContent(contentHandler, content, "cart"); 453 saxPage(contentHandler, item, siteName); 454 455 Program parentProgram = item.getParentProgram(); 456 if (parentProgram != null) 457 { 458 attrs = new AttributesImpl(); 459 attrs.addCDATAAttribute("id", parentProgram.getId()); 460 attrs.addCDATAAttribute("title", parentProgram.getTitle()); 461 Page parentPage = _odfPageResolver.getProgramPage(parentProgram, siteName); 462 if (parentPage != null) 463 { 464 attrs.addCDATAAttribute("pageId", parentPage.getId()); 465 } 466 XMLUtils.createElement(contentHandler, "parent", attrs); 467 468 } 469 XMLUtils.endElement(contentHandler, "item"); 470 } 471 472 /** 473 * Get the JSON representation of a cart item 474 * @param item The cart's item 475 * @param siteName The site name 476 * @param viewName The name of content view to use 477 * @return The cart items properties 478 * @throws IOException if failed to read content view 479 */ 480 public Map<String, Object> cartItem2Json(ODFCartItem item, String siteName, String viewName) throws IOException 481 { 482 Map<String, Object> result = new HashMap<>(); 483 484 Content content = item.getContent(); 485 486 result.put("id", item.getId()); 487 result.put("contentId", content.getId()); 488 result.put("title", content.getTitle()); 489 result.put("name", content.getName()); 490 491 Program parentProgram = item.getParentProgram(); 492 if (parentProgram != null) 493 { 494 result.put("parentProgramId", parentProgram.getId()); 495 result.put("parentProgramTitle", parentProgram.getTitle()); 496 } 497 498 Page page = getPage(item, siteName); 499 if (page != null) 500 { 501 result.put("pageId", page.getId()); 502 result.put("pageTitle", page.getTitle()); 503 result.put("pagePath", page.getPathInSitemap()); 504 } 505 506 String cTypeId = content.getTypes()[0]; 507 ContentType cType = _cTypeEP.getExtension(cTypeId); 508 509 result.put("contentTypeId", cTypeId); 510 result.put("contentTypeLabel", cType.getLabel()); 511 512 if (viewName != null && _cTypesHelper.getMetadataSetForView(viewName, content.getTypes(), content.getMixinTypes()) != null) 513 { 514 String uri = "cocoon://_content.html?contentId=" + content.getId() + "&metadataSetName=" + viewName + (parentProgram != null ? "&parentProgramId=" + parentProgram.getId() : ""); 515 SitemapSource src = null; 516 517 try 518 { 519 src = (SitemapSource) _srcResolver.resolveURI(uri); 520 try (InputStream is = src.getInputStream()) 521 { 522 String view = IOUtils.toString(is, StandardCharsets.UTF_8); 523 result.put("view", view); 524 } 525 } 526 finally 527 { 528 _srcResolver.release(src); 529 } 530 } 531 532 return result; 533 } 534 535 /** 536 * Sax the content types 537 * @param handler The content handler to sax into 538 * @param types The content types 539 * @throws SAXException if an error occurred while saxing 540 */ 541 protected void saxTypes(ContentHandler handler, String[] types) throws SAXException 542 { 543 XMLUtils.startElement(handler, "types"); 544 545 for (String id : types) 546 { 547 ContentType cType = _cTypeEP.getExtension(id); 548 if (cType != null) 549 { 550 AttributesImpl attrs = new AttributesImpl(); 551 attrs.addCDATAAttribute("id", cType.getId()); 552 553 XMLUtils.startElement(handler, "type", attrs); 554 cType.getLabel().toSAX(handler); 555 XMLUtils.endElement(handler, "type"); 556 } 557 } 558 XMLUtils.endElement(handler, "types"); 559 } 560 561 /** 562 * SAX the content view 563 * @param handler The content handler to sax into 564 * @param content The content 565 * @param metadataSetName The view 566 * @throws SAXException if an error occurred while saxing 567 * @throws IOException if an I/O exception occurred 568 */ 569 protected void saxContent (ContentHandler handler, Content content, String metadataSetName) throws SAXException, IOException 570 { 571 AttributesImpl attrs = new AttributesImpl(); 572 attrs.addCDATAAttribute("id", content.getId()); 573 attrs.addCDATAAttribute("name", content.getName()); 574 attrs.addCDATAAttribute("title", content.getTitle(null)); 575 attrs.addCDATAAttribute("lastModifiedAt", DateUtils.dateToString(content.getLastModified())); 576 577 XMLUtils.startElement(handler, "content", attrs); 578 579 if (_cTypesHelper.getMetadataSetForView("cart", content.getTypes(), content.getMixinTypes()) != null) 580 { 581 String uri = "cocoon://_content.html?contentId=" + content.getId() + "&metadataSetName=" + metadataSetName; 582 SitemapSource src = null; 583 584 try 585 { 586 src = (SitemapSource) _srcResolver.resolveURI(uri); 587 src.toSAX(new IgnoreRootHandler(handler)); 588 } 589 finally 590 { 591 _srcResolver.release(src); 592 } 593 } 594 595 XMLUtils.endElement(handler, "content"); 596 } 597 598 /** 599 * Sax the content's page 600 * @param handler The content handler to sax into 601 * @param item The cart's item 602 * @param siteName The current site name 603 * @throws SAXException if an error occurred while saxing 604 */ 605 protected void saxPage(ContentHandler handler, ODFCartItem item, String siteName) throws SAXException 606 { 607 Page page = getPage(item, siteName); 608 if (page != null) 609 { 610 String pageId = page.getId(); 611 612 AttributesImpl attrs = new AttributesImpl(); 613 attrs.addCDATAAttribute("id", pageId); 614 attrs.addCDATAAttribute("path", ResolveURIComponent.resolve("page", pageId)); 615 XMLUtils.createElement(handler, "page", attrs, page.getTitle()); 616 } 617 } 618 619 /** 620 * Get the page associated to this cart's item 621 * @param item The item 622 * @param siteName The site name 623 * @return the page or <code>null</code> if not found 624 */ 625 protected Page getPage(ODFCartItem item, String siteName) 626 { 627 Content content = item.getContent(); 628 if (content instanceof Course) 629 { 630 return _odfPageResolver.getCoursePage((Course) content, (AbstractProgram) item.getParentProgram(), siteName); 631 } 632 else if (content instanceof Program) 633 { 634 return _odfPageResolver.getProgramPage((Program) content, siteName); 635 } 636 else if (content instanceof SubProgram) 637 { 638 return _odfPageResolver.getSubProgramPage((SubProgram) content, item.getParentProgram(), siteName); 639 } 640 641 getLogger().info("No page found of content {} in ODF cart", content.getId()); 642 return null; 643 } 644 645 class ODFCartItem 646 { 647 private Content _content; 648 private Program _parentProgram; 649 650 public ODFCartItem(Content content) 651 { 652 this(content, null); 653 } 654 655 public ODFCartItem(Content content, Program parentProgram) 656 { 657 _content = content; 658 _parentProgram = parentProgram; 659 } 660 661 String getId() 662 { 663 return _content.getId() + (_parentProgram != null ? ";" + _parentProgram.getId() : ""); 664 } 665 666 Content getContent() 667 { 668 return _content; 669 } 670 671 Program getParentProgram() 672 { 673 return _parentProgram; 674 } 675 } 676}