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.ugc.clientsideelement;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.cocoon.ProcessingException;
029import org.apache.commons.lang.StringUtils;
030
031import org.ametys.cms.ObservationConstants;
032import org.ametys.cms.repository.Content;
033import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
034import org.ametys.cms.workflow.ContentWorkflowHelper;
035import org.ametys.core.observation.Event;
036import org.ametys.core.observation.ObservationManager;
037import org.ametys.core.ui.Callable;
038import org.ametys.core.ui.StaticClientSideElement;
039import org.ametys.plugins.repository.AmetysObjectResolver;
040import org.ametys.plugins.repository.AmetysRepositoryException;
041import org.ametys.plugins.repository.ModifiableAmetysObject;
042import org.ametys.plugins.repository.RemovableAmetysObject;
043import org.ametys.plugins.repository.data.holder.ModifiableDataHolder;
044import org.ametys.plugins.repository.version.VersionableAmetysObject;
045import org.ametys.plugins.ugc.UGCConstants;
046import org.ametys.runtime.authentication.AccessDeniedException;
047import org.ametys.runtime.model.exception.UndefinedItemPathException;
048import org.ametys.web.repository.content.WebContent;
049import org.ametys.web.repository.content.jcr.DefaultWebContent;
050import org.ametys.web.repository.page.ModifiablePage;
051import org.ametys.web.repository.page.ModifiableZone;
052import org.ametys.web.repository.page.ModifiableZoneItem;
053import org.ametys.web.repository.page.Page;
054import org.ametys.web.repository.page.Page.PageType;
055import org.ametys.web.repository.page.PageDAO;
056import org.ametys.web.repository.page.ZoneItem.ZoneType;
057import org.ametys.web.repository.site.Site;
058import org.ametys.web.repository.site.SiteManager;
059import org.ametys.web.skin.Skin;
060import org.ametys.web.skin.SkinTemplate;
061import org.ametys.web.skin.SkinsManager;
062
063import com.opensymphony.workflow.WorkflowException;
064
065/**
066 * Client side element for UGC content moderation
067 *
068 */
069public class UGCContentModerationClientSideElement extends StaticClientSideElement
070{
071    /** The Ametys resolver */
072    protected AmetysObjectResolver _resolver;
073    /** The page DAO */
074    protected PageDAO _pageDAO;
075    /** The site manager */
076    protected SiteManager _siteManager;
077    /** The skins manager */
078    protected SkinsManager _skinsManager;
079    /** The observation manager */
080    protected ObservationManager _observationManager;
081    /** The content workflow helper */
082    protected ContentWorkflowHelper _contentWorkflowHelper;
083
084    @Override   
085    public void service(ServiceManager smanager) throws ServiceException
086    {
087        super.service(smanager);
088        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
089        _pageDAO = (PageDAO) smanager.lookup(PageDAO.ROLE);
090        _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE);
091        _skinsManager = (SkinsManager) smanager.lookup(SkinsManager.ROLE);
092        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
093        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
094    }
095    
096    /**
097     * Accept a UGC content
098     * @param contentIds The id of UGC contents
099     * @param targetContentType The id of target content type. Can be null or empty to not change content type
100     * @param targetWorkflowName The workflow name for contents to create
101     * @param initActionId The id of workflow init action
102     * @param mode The insertion mode ('new' to insert contents in a new page, 'affect' to insert contents on a existing page or 'none' to keep content as orphan).
103     * @param pageId The page id. Can be null for mode 'none'
104     * @return the result
105     * @throws ProcessingException if failed to transform UGC contents
106     */
107    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
108    public Map<String, Object> acceptUGCContent(List<String> contentIds, String targetContentType, String targetWorkflowName, int initActionId, String mode, String pageId) throws ProcessingException
109    {
110        Map<String, String> rights = getRights(Map.of());
111        if (rights.isEmpty())
112        {
113            throw new IllegalStateException("UGC moderation action cannot be called from a non-protected controller '" + getId() + "'. Right configuration is required.");
114        }
115        
116        if (!hasRight(rights))
117        {
118            throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " try to access privileges feature without sufficient rights");
119        }
120        
121        Map<String, Object> result = new HashMap<>();
122        
123        result.put("createdContents", new ArrayList<>());
124        result.put("deletedContents", new HashSet<>());
125        
126        for (String contentId : contentIds)
127        {
128            try
129            {
130                DefaultWebContent ugcContent = _resolver.resolveById(contentId);
131                
132                // Create the content
133                Content newContent = createContent(ugcContent, targetWorkflowName, initActionId, targetContentType);
134                
135                try
136                {
137                    // Copy data
138                    ugcContent.copyTo((ModifiableDataHolder) newContent);
139                }
140                catch (UndefinedItemPathException e)
141                {
142                    getLogger().warn("The target content type '{}' is not compatible with the source UGC content type '{}'", targetContentType, ugcContent.getTypes(), e);
143                    
144                    result.put("success", false);
145                    result.put("error", "invalid-content-type");
146                    return result;
147                }
148                
149                // Save changes
150                ((ModifiableAmetysObject) newContent).saveChanges();
151                
152                // Notify observers
153                Map<String, Object> contentEventParams = new HashMap<>();
154                contentEventParams.put(ObservationConstants.ARGS_CONTENT, newContent);
155                contentEventParams.put(ObservationConstants.ARGS_CONTENT_ID, newContent.getId());
156                contentEventParams.put(ObservationConstants.ARGS_CONTENT_NAME, newContent.getName());
157                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), contentEventParams));
158                
159                // Create a new version
160                ((VersionableAmetysObject) newContent).checkpoint();
161                
162                ModifiablePage page = null;
163                if ("new".equals(mode))
164                {
165                    // Create new page to insert content
166                    page = createPage (pageId, ugcContent);
167                }
168                else if ("affect".equals(mode))
169                {
170                    // Insert content on given page
171                    page = _resolver.resolveById(pageId);
172                }
173                
174                String zoneName = null;
175                if (page != null)
176                {
177                    zoneName = getZoneName (page);
178                    if (zoneName == null)
179                    {
180                        getLogger().warn("Selected page '{}' is not a container page: can not affect a content", pageId);
181                        
182                        result.put("success", false);
183                        result.put("error", "invalid-page");
184                        return result;
185                    }
186                    
187                    ModifiableZone zone = null;
188                    if (page.hasZone(zoneName))
189                    {
190                        zone = page.getZone(zoneName);
191                    }
192                    else
193                    {
194                        zone = page.createZone(zoneName);
195                    }
196                    
197                    ModifiableZoneItem zoneItem = zone.addZoneItem();
198                    zoneItem.setType(ZoneType.CONTENT);
199                    zoneItem.setContent(newContent);
200                    zoneItem.setViewName("main");
201                    
202                    page.saveChanges();
203                    
204                    Map<String, Object> pageEventParams = new HashMap<>();
205                    pageEventParams.put(org.ametys.web.ObservationConstants.ARGS_SITEMAP_ELEMENT, page);
206                    pageEventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_ITEM_ID, zoneItem.getId());
207                    pageEventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_TYPE, ZoneType.CONTENT);
208                    pageEventParams.put(org.ametys.web.ObservationConstants.ARGS_ZONE_ITEM_CONTENT, newContent);
209                    _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_ZONEITEM_ADDED, _currentUserProvider.getUser(), pageEventParams));
210                }
211                
212                Map<String, Object> contentInfo = new HashMap<>();
213                contentInfo.put("title", newContent.getTitle());
214                contentInfo.put("id", newContent.getId());
215                if (page != null)
216                {
217                    contentInfo.put("pageId", page.getId());
218                }
219                
220                @SuppressWarnings("unchecked")
221                List<Map<String, Object>> acceptedContents = (List<Map<String, Object>>) result.get("createdContents");
222                acceptedContents.add(contentInfo);
223                
224                // Notify observers
225                _observationManager.notify(new Event(org.ametys.plugins.ugc.observation.ObservationConstants.EVENT_UGC_CONTENT_ACCEPTED, _currentUserProvider.getUser(), contentEventParams));
226                
227                // Delete initial content
228                String ugcContentId = ugcContent.getId();
229                deleteContent(ugcContent);
230                @SuppressWarnings("unchecked")
231                Set<String> deletedContents = (Set<String>) result.get("deletedContents");
232                deletedContents.add(ugcContentId);
233            }
234            catch (WorkflowException | AmetysRepositoryException e)
235            {
236                getLogger().error("Unable to transform UGC content '" + contentId + "'", e);
237                throw new ProcessingException("Unable to transform UGC content '" + contentId + "'", e);
238            }
239        }
240        
241        result.put("success", true);
242        return result;
243    }
244    
245    /**
246     * Refuse UGC content
247     * @param contentIds The id of UGC contents to refuse
248     * @param comment The reject's comment
249     * @param withNotification True to notify UGC author of the refuse
250     * @return the result
251     * @throws ProcessingException if failed to transform UGC contents
252     */
253    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION)
254    public Map<String, Object> refuseUGCContent(List<String> contentIds, String comment, boolean withNotification) throws ProcessingException
255    {
256        Map<String, String> rights = getRights(Map.of());
257        if (rights.isEmpty())
258        {
259            throw new IllegalStateException("UGC moderation action cannot be called from a non-protected controller '" + getId() + "'. Right configuration is required.");
260        }
261        
262        if (!hasRight(rights))
263        {
264            throw new AccessDeniedException("User " + _currentUserProvider.getUser() + " try to access privileges feature without sufficient rights");
265        }
266        
267        Map<String, Object> result = new HashMap<>();
268        result.put("deletedContents", new HashSet<>());
269        
270        for (String contentId : contentIds)
271        {
272            try
273            {
274                DefaultWebContent ugcContent = _resolver.resolveById(contentId);
275                
276                // Notify observers
277                Map<String, Object> eventParams = new HashMap<>();
278                eventParams.put(ObservationConstants.ARGS_CONTENT, ugcContent);
279                eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, ugcContent.getName());
280                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, ugcContent.getId());
281                eventParams.put(org.ametys.plugins.ugc.observation.ObservationConstants.ARGS_UGC_REFUSE_NOTIFY, withNotification);
282                eventParams.put(org.ametys.plugins.ugc.observation.ObservationConstants.ARGS_UGC_REFUSE_COMMENT, comment);
283                
284                _observationManager.notify(new Event(org.ametys.plugins.ugc.observation.ObservationConstants.EVENT_UGC_CONTENT_REFUSED, _currentUserProvider.getUser(), eventParams));
285                
286                // Delete initial content
287                String ugcContentId = ugcContent.getId();
288                deleteContent(ugcContent);
289                @SuppressWarnings("unchecked")
290                Set<String> deletedContents = (Set<String>) result.get("deletedContents");
291                deletedContents.add(ugcContentId);
292            }
293            catch (AmetysRepositoryException e)
294            {
295                getLogger().error("Unable to refuse UGC content '" + contentId + "'", e);
296                throw new ProcessingException("Unable to refuse UGC content '" + contentId + "'", e);
297            }
298        }
299        
300        result.put("success", true);
301        return result;
302    }
303    
304    /**
305     * Create page under a parent page
306     * @param parentId the parent page id or empty for the sitemap root
307     * @param content the UGC content
308     * @return the new created page
309     */
310    protected ModifiablePage createPage (String parentId, DefaultWebContent content)
311    {
312        String realParentId = parentId;
313        if (StringUtils.isEmpty(parentId))
314        {
315            Site site = _siteManager.getSite(content.getSiteName());
316            realParentId = site.getSitemap(content.getLanguage()).getId();
317        }
318
319        Map<String, Object> result = _pageDAO.createPage(realParentId, content.getTitle(), "");
320        ModifiablePage page = _resolver.resolveById((String) result.get("id"));
321        
322        _pageDAO.setTemplate(Collections.singletonList(page.getId()), "page");
323        
324        return page;
325    }
326    
327    /**
328     * Get the name of zone where to insert content
329     * @param page The page
330     * @return the zone's name
331     */
332    protected String getZoneName (Page page)
333    {
334        if (page.getType() == PageType.CONTAINER)
335        {
336            String skinId = page.getSite().getSkinId();
337            Skin skin = _skinsManager.getSkin(skinId);
338            SkinTemplate template = skin.getTemplate(page.getTemplate());
339            
340            // Has a default zone ?
341            if (template.getZone("default") != null)
342            {
343                return "default";
344            }
345            else
346            {
347                return template.getZones().keySet().iterator().next();
348            }
349        }
350        return null;
351    }
352    
353    /**
354     * Create the content from the proposed content
355     * @param initialContent The initial content
356     * @param workflowName the workflow name
357     * @param actionId The init action id
358     * @param cTypeId the content type
359     * @return the created content
360     * @throws WorkflowException if failed to create content
361     */
362    protected Content createContent (Content initialContent, String workflowName, int actionId, String cTypeId) throws WorkflowException
363    {
364        Map<String, Object> inputs = new HashMap<>();
365        inputs.put("prevent-version-creation", "true");
366        
367        if (initialContent instanceof WebContent)
368        {
369            inputs.put(org.ametys.web.workflow.CreateContentFunction.SITE_KEY, ((WebContent) initialContent).getSiteName());
370        }
371        
372        Map<String, Object> result =  _contentWorkflowHelper.createContent(workflowName,
373                actionId, 
374                initialContent.getName(), 
375                initialContent.getTitle(), 
376                new String[] {cTypeId}, 
377                new String[] {UGCConstants.UGC_MIXIN_TYPE}, 
378                initialContent.getLanguage(), 
379                inputs);
380        
381        return (Content) result.get(AbstractContentWorkflowComponent.CONTENT_KEY);
382        
383    }
384    
385    /**
386     * Delete the content
387     * @param content the content to delete
388     */
389    protected void deleteContent(Content content)
390    {
391        Map<String, Object> eventParams = new HashMap<>();
392        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
393        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
394        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
395        
396        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
397        
398        RemovableAmetysObject removableContent = (RemovableAmetysObject) content;
399        ModifiableAmetysObject parent = removableContent.getParent();
400        
401        // Remove the content.
402        removableContent.remove();
403        
404        parent.saveChanges();
405        
406        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
407    }
408}