001/* 002 * Copyright 2023 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; 017 018import java.io.IOException; 019import java.io.InputStream; 020import java.util.Arrays; 021import java.util.HashMap; 022import java.util.List; 023import java.util.Map; 024import java.util.Optional; 025import java.util.Set; 026 027import javax.xml.transform.TransformerConfigurationException; 028import javax.xml.transform.TransformerFactory; 029import javax.xml.transform.TransformerFactoryConfigurationError; 030import javax.xml.transform.dom.DOMResult; 031import javax.xml.transform.sax.SAXTransformerFactory; 032import javax.xml.transform.sax.TransformerHandler; 033 034import org.apache.avalon.framework.component.Component; 035import org.apache.avalon.framework.configuration.Configuration; 036import org.apache.avalon.framework.configuration.ConfigurationException; 037import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 038import org.apache.avalon.framework.configuration.DefaultConfigurationSerializer; 039import org.apache.avalon.framework.service.ServiceException; 040import org.apache.avalon.framework.service.ServiceManager; 041import org.apache.avalon.framework.service.Serviceable; 042import org.apache.commons.lang3.LocaleUtils; 043import org.apache.commons.lang3.StringUtils; 044import org.apache.commons.lang3.Strings; 045import org.apache.excalibur.source.Source; 046import org.apache.excalibur.source.SourceNotFoundException; 047import org.apache.excalibur.source.SourceResolver; 048import org.apache.excalibur.xml.sax.SAXParser; 049import org.w3c.dom.Document; 050import org.w3c.dom.Element; 051import org.xml.sax.SAXException; 052 053import org.ametys.cms.contenttype.ContentType; 054import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 055import org.ametys.cms.repository.ContentDAO.TagMode; 056import org.ametys.cms.repository.ModifiableWorkflowAwareContent; 057import org.ametys.cms.transformation.Configuration2XMLValuesTransformer; 058import org.ametys.cms.workflow.AbstractContentWorkflowComponent; 059import org.ametys.cms.workflow.ContentWorkflowHelper; 060import org.ametys.core.observation.Event; 061import org.ametys.core.observation.ObservationManager; 062import org.ametys.core.right.ProfileAssignmentStorageExtensionPoint; 063import org.ametys.core.right.RightManager; 064import org.ametys.core.user.CurrentUserProvider; 065import org.ametys.core.util.I18nUtils; 066import org.ametys.plugins.repository.AmetysRepositoryException; 067import org.ametys.plugins.repository.data.extractor.ModelAwareValuesExtractor; 068import org.ametys.plugins.repository.data.extractor.xml.ModelAwareXMLValuesExtractor; 069import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder; 070import org.ametys.plugins.repository.jcr.NameHelper; 071import org.ametys.plugins.workflow.component.CheckRightsCondition; 072import org.ametys.runtime.i18n.I18nizableText; 073import org.ametys.runtime.model.Model; 074import org.ametys.runtime.model.type.DataContext; 075import org.ametys.runtime.plugin.component.AbstractLogEnabled; 076import org.ametys.web.ObservationConstants; 077import org.ametys.web.repository.page.ModifiablePage; 078import org.ametys.web.repository.page.ModifiableSitemapElement; 079import org.ametys.web.repository.page.ModifiableZone; 080import org.ametys.web.repository.page.ModifiableZoneItem; 081import org.ametys.web.repository.page.Page; 082import org.ametys.web.repository.page.Page.PageType; 083import org.ametys.web.repository.page.PageDAO; 084import org.ametys.web.repository.page.ZoneItem.ZoneType; 085import org.ametys.web.service.Service; 086import org.ametys.web.service.ServiceExtensionPoint; 087import org.ametys.web.skin.Skin; 088import org.ametys.web.skin.SkinTemplate; 089import org.ametys.web.skin.SkinTemplateZone; 090import org.ametys.web.skin.SkinsManager; 091 092import com.opensymphony.workflow.InvalidActionException; 093import com.opensymphony.workflow.WorkflowException; 094 095/** 096 * Component allowing to create and fill a page from a configuration file 097 */ 098public class PagePopulator extends AbstractLogEnabled implements Serviceable, Component 099{ 100 /** The avalon role */ 101 public static final String ROLE = PagePopulator.class.getName(); 102 103 /** the source resolver */ 104 protected SourceResolver _sourceResolver; 105 /** the i18n utils component */ 106 protected I18nUtils _i18nUtils; 107 /** the service extension point */ 108 protected ServiceExtensionPoint _serviceEP; 109 /** the observation manager */ 110 protected ObservationManager _observationManager; 111 /** the workflow helper */ 112 protected ContentWorkflowHelper _workflowHelper; 113 /** the current user provider */ 114 protected CurrentUserProvider _currentUserProvider; 115 /** the page dao */ 116 protected PageDAO _pageDAO; 117 /** the profile assignment storage extension point */ 118 protected ProfileAssignmentStorageExtensionPoint _profileAssignementStorageEP; 119 /** the skins manager */ 120 protected SkinsManager _skinsManager; 121 /** the content type extension point */ 122 protected ContentTypeExtensionPoint _contentTypeEP; 123 /** Excalibur SaxParser */ 124 protected SAXParser _saxParser; 125 126 public void service(ServiceManager manager) throws ServiceException 127 { 128 _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 129 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 130 _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE); 131 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 132 _pageDAO = (PageDAO) manager.lookup(PageDAO.ROLE); 133 _profileAssignementStorageEP = (ProfileAssignmentStorageExtensionPoint) manager.lookup(ProfileAssignmentStorageExtensionPoint.ROLE); 134 _serviceEP = (ServiceExtensionPoint) manager.lookup(ServiceExtensionPoint.ROLE); 135 _skinsManager = (SkinsManager) manager.lookup(SkinsManager.ROLE); 136 _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE); 137 _workflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 138 _saxParser = (SAXParser) manager.lookup(SAXParser.ROLE); 139 } 140 141 /** 142 * Create a new page based on a configuration file. 143 * 144 * @param parent the parent where the page should be inserted 145 * @param path the path where the configuration can be found 146 * @return the newly created page or {@code Optional#empty()} if no page was created. 147 * @throws IOException if an error occurred while reading the file 148 * @throws SAXException if an error occurred while parsing the configuration file 149 * @throws ConfigurationException if the configuration is not valid; A required info is missing. 150 */ 151 public Optional<ModifiablePage> initPage(ModifiableSitemapElement parent, String path) throws IOException, SAXException, ConfigurationException 152 { 153 Source cfgFile = null; 154 try 155 { 156 cfgFile = _sourceResolver.resolveURI(path); 157 if (!cfgFile.exists()) 158 { 159 throw new SourceNotFoundException(cfgFile.getURI() + " does not exist"); 160 } 161 162 try (InputStream is = cfgFile.getInputStream()) 163 { 164 Configuration configuration = new DefaultConfigurationBuilder().build(is); 165 166 return initPage(parent, configuration); 167 } 168 } 169 catch (ConfigurationException e) 170 { 171 throw new ConfigurationException("There is an issue with configuration file '" + cfgFile.getURI() + "'. This prevented the initialization of the page.", e); 172 } 173 finally 174 { 175 _sourceResolver.release(cfgFile); 176 } 177 } 178 179 /** 180 * Create and configure a new page based on a configuration. 181 * 182 * @param parent the parent where the page should be added 183 * @param configuration the page configuration 184 * @return the newly created page or {@code Optional#empty()} if no page was created. 185 * @throws ConfigurationException if the configuration is not valid; A required info is missing. 186 */ 187 public Optional<ModifiablePage> initPage(ModifiableSitemapElement parent, Configuration configuration) throws ConfigurationException 188 { 189 Optional<ModifiablePage> page = createPage(parent, configuration); 190 if (page.isPresent()) 191 { 192 ModifiablePage newPage = page.get(); 193 configurePage(newPage, configuration); 194 195 setReaderAccess(newPage, configuration); 196 197 newPage.saveChanges(); 198 } 199 return page; 200 } 201 202 /** 203 * Create a new page based on a configuration. 204 * 205 * @param parent the sitemap element where the page should be added 206 * @param configuration the page configuration 207 * @return the newly created page or {@code Optional#empty()} if the page already exist. 208 * @throws ConfigurationException if the configuration is not valid; A required info is missing. 209 */ 210 protected Optional<ModifiablePage> createPage(ModifiableSitemapElement parent, Configuration configuration) throws ConfigurationException 211 { 212 String lang = parent.getSitemapName(); 213 I18nizableText i18nTitle = I18nizableText.parseI18nizableText(configuration.getChild("title"), "application"); 214 String title = _i18nUtils.translate(i18nTitle, lang); 215 // Title should not be missing, but just in case if the i18n message or the whole catalog does not exists in the requested language 216 // to prevent a non-user-friendly error and still generate the project workspace. 217 title = StringUtils.defaultIfBlank(title, "Missing title"); 218 219 String name = configuration.getAttribute("name", NameHelper.filterName(title)); 220 221 if (!parent.hasChild(name)) 222 { 223 return Optional.of(_pageDAO.createPage(parent, name, title, null)); 224 } 225 return Optional.empty(); 226 } 227 228 /** 229 * Use a configuration to edit a page. 230 * 231 * Via configuration, it's possible to define the page tags, template and zone items (service or content) 232 * @param newPage the page that needs configuration 233 * @param configuration the configuration describing the expected page 234 * @throws ConfigurationException if the configuration is not valid 235 */ 236 protected void configurePage(ModifiablePage newPage, Configuration configuration) throws ConfigurationException 237 { 238 Configuration tagsCfg = configuration.getChild("tags", true); 239 List<String> tags = Arrays.stream(tagsCfg.getChildren("tag")) 240 .map(cfg -> cfg.getValue(StringUtils.EMPTY)) 241 .filter(StringUtils::isNotEmpty) 242 .toList(); 243 if (!tags.isEmpty()) 244 { 245 _pageDAO.tag(newPage, tags, TagMode.INSERT); 246 } 247 248 String templateName = configuration.getAttribute("template", null); 249 if (templateName != null) 250 { 251 Skin skin = _skinsManager.getSkin(newPage.getSite().getSkinId()); 252 if (skin == null) 253 { 254 // This should never be the case but just to be sure, terminate the creation. 255 getLogger().warn("The site is configured with an unexisting skin. Impossible to configure the page."); 256 return; 257 } 258 SkinTemplate template = skin.getTemplate(templateName); 259 if (template == null) 260 { 261 throw new ConfigurationException("Trying to configure page with an unexisting template named '" + templateName + "' for skin '" + skin.getId() + "'."); 262 } 263 newPage.setType(PageType.CONTAINER); 264 newPage.setTemplate(templateName); 265 266 Configuration templateParams = configuration.getChild("parameters", false); 267 if (templateParams != null) 268 { 269 DataContext context = DataContext.newInstance(); 270 context.withLocale(LocaleUtils.toLocale(newPage.getSitemapName())); 271 272 try 273 { 274 Element paramsElement = _interpretConfiguration(templateParams, context); 275 ModifiableModelAwareDataHolder templateParametersHolder = newPage.getTemplateParametersHolder(); 276 ModelAwareValuesExtractor extractor = new ModelAwareXMLValuesExtractor(paramsElement, templateParametersHolder.getModel()); 277 templateParametersHolder.synchronizeValues(extractor.extractValues()); 278 279 Map<String, Object> eventParams = new HashMap<>(); 280 eventParams.put(ObservationConstants.ARGS_SITEMAP_ELEMENT, newPage); 281 _observationManager.notify(new Event(ObservationConstants.EVENT_VIEW_PARAMETERS_MODIFIED, _currentUserProvider.getUser(), eventParams)); 282 } 283 catch (Exception e) 284 { 285 getLogger().warn("Failed to set template parameters for page '" + newPage.getName() + "'.", e); 286 } 287 } 288 289 Map<String, SkinTemplateZone> templateZones = template.getZones(); 290 for (Configuration zoneCfg : configuration.getChildren("zone")) 291 { 292 SkinTemplateZone templateZone = templateZones.get(zoneCfg.getAttribute("id")); 293 if (templateZone == null) 294 { 295 throw new ConfigurationException("Trying to configure unexisting page zone '" + zoneCfg.getAttribute("id") + "' for template '" + templateName + "' in skin '" + skin.getId() + "'."); 296 } 297 createAndConfigureZone(newPage, zoneCfg); 298 } 299 300 // Notify change after the set template (this also seems to covers all the indexation and live sync from new zone item…) 301 Map<String, Object> eventParams = new HashMap<>(); 302 eventParams.put(ObservationConstants.ARGS_PAGE, newPage); 303 eventParams.put(ObservationConstants.ARGS_PAGE_ID, newPage.getId()); 304 _observationManager.notify(new Event(ObservationConstants.EVENT_PAGE_CHANGED, _currentUserProvider.getUser(), eventParams)); 305 } 306 } 307 308 /** 309 * Create and configure a zone based on configuration 310 * @param newPage the new page where the zone should be added 311 * @param zoneCfg the configuration to use 312 * @throws ConfigurationException if the configuration is invalid 313 */ 314 protected void createAndConfigureZone(ModifiablePage newPage, Configuration zoneCfg) throws ConfigurationException 315 { 316 String zoneId = zoneCfg.getAttribute("id"); 317 ModifiableZone zone = newPage.createZone(zoneId); 318 for (Configuration itemCfg : zoneCfg.getChildren()) 319 { 320 if (Strings.CS.equals(itemCfg.getName(), "service")) 321 { 322 createAndConfigureServiceItem(zone, itemCfg); 323 } 324 else if (Strings.CS.equals(itemCfg.getName(), "content")) 325 { 326 createAndConfigureContentItem(zone, itemCfg); 327 } 328 } 329 } 330 331 /** 332 * Create and configure a zone item based on a service configuration 333 * @param zone the zone where the zone item should be added 334 * @param serviceCfg the configuration to use 335 * @throws ConfigurationException if the configuration is invalid 336 */ 337 protected void createAndConfigureServiceItem(ModifiableZone zone, Configuration serviceCfg) throws ConfigurationException 338 { 339 String serviceId = serviceCfg.getAttribute("id"); 340 Service service = _serviceEP.getExtension(serviceId); 341 if (service != null) 342 { 343 ModifiableZoneItem item = zone.addZoneItem(); 344 item.setType(ZoneType.SERVICE); 345 item.setServiceId(serviceId); 346 347 DataContext dataContext = DataContext.newInstance(); 348 dataContext.withLocale(LocaleUtils.toLocale(zone.getSitemapElement().getSitemapName())); 349 350 try 351 { 352 // Configuration may requires interpretation before extraction of the value 353 // for exemple to translate i18n keys 354 Element xmlValues = _interpretConfiguration(serviceCfg, dataContext); 355 356 // provide the result to XML values extractor 357 ModelAwareValuesExtractor extractor = new ModelAwareXMLValuesExtractor(xmlValues, service); 358 item.getServiceParameters().synchronizeValues(extractor.extractValues()); 359 } 360 catch (Exception e) 361 { 362 throw new ConfigurationException("Failed to extract the value from configuration for item with service id '" + serviceId + "'.", e); 363 } 364 } 365 else 366 { 367 throw new ConfigurationException("Trying to create unexisting service '" + serviceId + "' for page '" + zone.getSitemapElement().getName() + "'."); 368 } 369 } 370 371 /** 372 * Create a new content based on configuration and add it to a new zone item 373 * @param zone the zone where the zone item should be added 374 * @param contentCfg the configuration to use 375 * @throws ConfigurationException if the configuration is invalid 376 */ 377 protected void createAndConfigureContentItem(ModifiableZone zone, Configuration contentCfg) throws ConfigurationException 378 { 379 Map<String, Object> params = new HashMap<>(); 380 params.put(org.ametys.web.workflow.CreateContentFunction.SITE_KEY, zone.getSitemapElement().getSiteName()); 381 ModifiableWorkflowAwareContent content; 382 383 try 384 { 385 Configuration cTypesCfg = contentCfg.getChild("contentTypes"); 386 Configuration[] cTypeCfgs = cTypesCfg.getChildren("contentType"); 387 388 String[] cTypeIds = new String[cTypeCfgs.length]; 389 Model[] cTypes = new Model[cTypeCfgs.length]; 390 391 // initialize default workflow name 392 String workflowName = "content"; 393 394 int i = 0; 395 for (Configuration cfg : cTypeCfgs) 396 { 397 String cTypeId = cfg.getAttribute("id"); 398 ContentType contentType = _contentTypeEP.getExtension(cTypeId); 399 if (contentType == null) 400 { 401 throw new ConfigurationException("Could not create new content for page '" + zone.getSitemapElement().getName() + "'. The configuration file references an unexisting content type '" + cTypeId + "'."); 402 } 403 cTypes[i] = contentType; 404 cTypeIds[i++] = cTypeId; 405 406 // try to find a default workflow name based on content type 407 Optional<String> defaultWorkflow = contentType.getDefaultWorkflowName(); 408 if (defaultWorkflow.isPresent()) 409 { 410 workflowName = defaultWorkflow.get(); 411 } 412 } 413 414 // use required workflow name or computed workflow name based on content type 415 Configuration workflow = contentCfg.getChild("workflow"); 416 workflowName = workflow.getAttribute("name", "content"); 417 418 DataContext dataContext = DataContext.newInstance(); 419 dataContext.withLocale(LocaleUtils.toLocale(zone.getSitemapElement().getSitemapName())); 420 421 // Configuration may requires interpretation before extraction of the value 422 // for example to translate i18n keys 423 Element xmlValues = _interpretConfiguration(contentCfg, dataContext); 424 425 // provide the result to XML values extractor 426 ModelAwareValuesExtractor extractor = new ModelAwareXMLValuesExtractor(xmlValues, Arrays.asList(cTypes)); 427 Map<String, Object> contentValues = extractor.extractValues(); 428 429 String title = (String) contentValues.get("title"); 430 if (title == null) 431 { 432 throw new ConfigurationException("Failed to retrieve a translation for the provided configuration.", contentCfg.getChild("title")); 433 } 434 String name = NameHelper.filterName(contentCfg.getAttribute("name", title)); 435 int createAction = workflow.getAttributeAsInteger("init-action-id", 1); 436 content = (ModifiableWorkflowAwareContent) _workflowHelper.createContent(workflowName, createAction, name, title, cTypeIds, null, zone.getSitemapElement().getSitemapName(), params).get(AbstractContentWorkflowComponent.CONTENT_KEY); 437 438 content.synchronizeValues(contentValues); 439 440 Configuration tagsCfg = contentCfg.getChild("tags"); 441 for (Configuration tag : tagsCfg.getChildren("tag")) 442 { 443 content.tag(tag.getValue()); 444 } 445 446 content.saveChanges(); 447 448 int validateAction = workflow.getAttributeAsInteger("validate-action-id", -1); 449 if (validateAction > 0) 450 { 451 try 452 { 453 // Current user most probably don't have any right on the context so we bypass 454 // the check right 455 Map<String, Object> inputs = new HashMap<>(); 456 inputs.put(CheckRightsCondition.FORCE, true); 457 _workflowHelper.doAction(content, validateAction, inputs); 458 } 459 catch (WorkflowException | InvalidActionException e) 460 { 461 getLogger().warn("Failed to validate new content '" + content.getId() + "'."); 462 } 463 } 464 ModifiableZoneItem item = zone.addZoneItem(); 465 item.setType(ZoneType.CONTENT); 466 item.setContent(content); 467 } 468 catch (AmetysRepositoryException | WorkflowException e) 469 { 470 getLogger().warn("Could not create new content for page '" + zone.getSitemapElement().getName() + "'."); 471 } 472 catch (Exception e) 473 { 474 getLogger().warn("Failed to extract content value for page '" + zone.getSitemapElement().getName() + "'.", e); 475 } 476 } 477 478 private Element _interpretConfiguration(Configuration contentCfg, DataContext dataContext) 479 throws SAXException, ConfigurationException 480 { 481 DOMResult domResult = new DOMResult(); 482 483 try 484 { 485 TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler(); 486 th.setResult(domResult); 487 488 Configuration2XMLValuesTransformer handler = new Configuration2XMLValuesTransformer(th, dataContext, _i18nUtils); 489 new DefaultConfigurationSerializer().serialize(handler, contentCfg); 490 Element values = ((Document) domResult.getNode()).getDocumentElement(); 491 return values; 492 } 493 catch (TransformerConfigurationException | TransformerFactoryConfigurationError e) 494 { 495 throw new IllegalStateException("Failed to retrive transformer handler. Impossible to interpret the configuration", e); 496 } 497 } 498 499 /** 500 * Set page reader access based on configuration 501 * @param newPage the newly created page 502 * @param configuration the page configuration 503 * @throws ConfigurationException if an unrecognized group is present in configuration 504 */ 505 protected void setReaderAccess(ModifiablePage newPage, Configuration configuration) throws ConfigurationException 506 { 507 Configuration accessCfg = configuration.getChild("reader-access", true); 508 for (Configuration cfg : accessCfg.getChildren()) 509 { 510 String name = cfg.getName(); 511 switch (name) 512 { 513 case "anonymous": 514 _setAnonymousPermission(newPage, cfg); 515 break; 516 case "any-connected": 517 _setAnyConnectedPermission(newPage, cfg); 518 break; 519 default : 520 throw new ConfigurationException("Unknown identity found in configuration. Could not define reader permission", cfg); 521 } 522 } 523 } 524 525 private void _setAnyConnectedPermission(Page page, Configuration cfg) 526 { 527 boolean deny = cfg.getAttributeAsBoolean("deny", false); 528 if (deny) 529 { 530 _profileAssignementStorageEP.denyProfileToAnyConnectedUser(RightManager.READER_PROFILE_ID, page); 531 _notifyACLChange(page, Set.of(RightManager.READER_PROFILE_ID)); 532 } 533 else 534 { 535 _profileAssignementStorageEP.allowProfileToAnyConnectedUser(RightManager.READER_PROFILE_ID, page); 536 _notifyACLChange(page, Set.of(RightManager.READER_PROFILE_ID)); 537 } 538 } 539 540 private void _setAnonymousPermission(Page page, Configuration cfg) 541 { 542 boolean deny = cfg.getAttributeAsBoolean("deny", false); 543 if (deny) 544 { 545 _profileAssignementStorageEP.denyProfileToAnonymous(RightManager.READER_PROFILE_ID, page); 546 _notifyACLChange(page, Set.of(RightManager.READER_PROFILE_ID)); 547 } 548 else 549 { 550 _profileAssignementStorageEP.allowProfileToAnonymous(RightManager.READER_PROFILE_ID, page); 551 _notifyACLChange(page, Set.of(RightManager.READER_PROFILE_ID)); 552 } 553 } 554 555 /** 556 * Utility method to notify a change of ACL on a context 557 * @param context the impacted context 558 * @param profilesId the assigned or removed profiles 559 */ 560 protected void _notifyACLChange(Object context, Set<String> profilesId) 561 { 562 Map<String, Object> eventParams = new HashMap<>(); 563 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_CONTEXT, context); 564 eventParams.put(org.ametys.core.ObservationConstants.ARGS_ACL_PROFILES, profilesId); 565 566 _observationManager.notify(new Event(org.ametys.core.ObservationConstants.EVENT_ACL_UPDATED, _currentUserProvider.getUser(), eventParams)); 567 } 568}