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