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}