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}