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}