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}