001/*
002 *  Copyright 2016 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.core.group;
017
018import java.io.File;
019import java.io.FileOutputStream;
020import java.io.IOException;
021import java.io.OutputStream;
022import java.nio.file.Files;
023import java.nio.file.StandardCopyOption;
024import java.time.Instant;
025import java.time.temporal.ChronoUnit;
026import java.util.ArrayList;
027import java.util.Collections;
028import java.util.HashMap;
029import java.util.LinkedHashMap;
030import java.util.List;
031import java.util.Map;
032import java.util.Properties;
033import java.util.Set;
034import java.util.regex.Pattern;
035
036import javax.xml.transform.OutputKeys;
037import javax.xml.transform.TransformerConfigurationException;
038import javax.xml.transform.TransformerFactory;
039import javax.xml.transform.TransformerFactoryConfigurationError;
040import javax.xml.transform.sax.SAXTransformerFactory;
041import javax.xml.transform.sax.TransformerHandler;
042import javax.xml.transform.stream.StreamResult;
043
044import org.apache.avalon.framework.activity.Disposable;
045import org.apache.avalon.framework.activity.Initializable;
046import org.apache.avalon.framework.component.Component;
047import org.apache.avalon.framework.configuration.Configuration;
048import org.apache.avalon.framework.configuration.ConfigurationException;
049import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
050import org.apache.avalon.framework.service.ServiceException;
051import org.apache.avalon.framework.service.ServiceManager;
052import org.apache.avalon.framework.service.Serviceable;
053import org.apache.cocoon.components.LifecycleHelper;
054import org.apache.cocoon.xml.AttributesImpl;
055import org.apache.cocoon.xml.XMLUtils;
056import org.apache.xml.serializer.OutputPropertiesFactory;
057import org.xml.sax.SAXException;
058
059import org.ametys.core.group.directory.GroupDirectory;
060import org.ametys.core.group.directory.GroupDirectoryFactory;
061import org.ametys.core.group.directory.GroupDirectoryModel;
062import org.ametys.core.ui.Callable;
063import org.ametys.runtime.i18n.I18nizableText;
064import org.ametys.runtime.model.DefinitionContext;
065import org.ametys.runtime.model.ElementDefinition;
066import org.ametys.runtime.model.type.DataContext;
067import org.ametys.runtime.model.type.ElementType;
068import org.ametys.runtime.plugin.PluginsManager;
069import org.ametys.runtime.plugin.PluginsManager.Status;
070import org.ametys.runtime.plugin.component.AbstractLogEnabled;
071import org.ametys.runtime.util.AmetysHomeHelper;
072
073/**
074 * DAO for accessing {@link GroupDirectory}
075 */
076public class GroupDirectoryDAO extends AbstractLogEnabled implements Component, Initializable, Serviceable, Disposable
077{
078    /** Avalon Role */
079    public static final String ROLE = GroupDirectoryDAO.class.getName();
080    
081    /** The path of the XML file containing the group directories */
082    private static File __GROUP_DIRECTORIES_FILE;
083    
084    /** The regular expression for an id of a group directory */
085    private static final String __ID_REGEX = "^[a-z][a-z0-9_-]*";
086    
087    /** The date (as a long) of the last time the {@link #__GROUP_DIRECTORIES_FILE GroupDirectories file} was read (last update) */
088    private long _lastFileReading;
089    
090    /** The whole group directories of the application */
091    private Map<String, GroupDirectory> _groupDirectories;
092    
093    /** The factory for group directories */
094    private GroupDirectoryFactory _groupDirectoryFactory;
095    
096    @Override
097    public void initialize()
098    {
099        __GROUP_DIRECTORIES_FILE = new File(AmetysHomeHelper.getAmetysHome(), "config" + File.separator + "group-directories.xml");
100        _groupDirectories = new LinkedHashMap<>();
101        _lastFileReading = 0;
102    }
103    
104    @Override
105    public void service(ServiceManager manager) throws ServiceException
106    {
107        _groupDirectoryFactory = (GroupDirectoryFactory) manager.lookup(GroupDirectoryFactory.ROLE);
108    }
109    
110    /**
111     * Gets all the group directories to JSON format
112     * @return A list of object representing the {@link GroupDirectory GroupDirectories}
113     */
114    public List<Object> getGroupDirectories2Json()
115    {
116        List<Object> result = new ArrayList<>();
117        for (GroupDirectory groupDirectory : getGroupDirectories())
118        {
119            result.add(getGroupDirectory2Json(groupDirectory));
120        }
121        return result;
122    }
123    
124    /**
125     * gets a group directory to JSON format
126     * @param groupDirectory The group directory to get
127     * @return An object representing a {@link GroupDirectory}
128     */
129    public Map<String, Object> getGroupDirectory2Json(GroupDirectory groupDirectory)
130    {
131        Map<String, Object> result = new LinkedHashMap<>();
132        result.put("id", groupDirectory.getId());
133        result.put("label", groupDirectory.getLabel());
134        String modelId = groupDirectory.getGroupDirectoryModelId();
135        GroupDirectoryModel model = _groupDirectoryFactory.getExtension(modelId);
136        result.put("modelLabel", model.getLabel());
137        return result;
138    }
139    
140    /**
141     * Gets all the group directories of this application
142     * @return A list of {@link GroupDirectory GroupDirectories}
143     */
144    public List<GroupDirectory> getGroupDirectories()
145    {
146        // Don't read in safe mode, we know that only the admin population is needed in this case and we want to prevent some warnings in the logs for non-safe features not found
147        if (Status.OK.equals(PluginsManager.getInstance().getStatus()))
148        {
149            _read(false);
150            return new ArrayList<>(_groupDirectories.values());
151        }
152        else
153        {
154            return Collections.EMPTY_LIST;
155        }
156    }
157    
158    /**
159     * Gets a group directory by its id.
160     * @param id The id of the group directory
161     * @return The {@link GroupDirectory}, or null if not found
162     */
163    public GroupDirectory getGroupDirectory(String id)
164    {
165        _read(false);
166        return _groupDirectories.get(id);
167    }
168    
169    /**
170     * Gets the list of the ids of all the group directories of the application
171     * @return The list of the ids of all the group directories
172     */
173    @Callable
174    public Set<String> getGroupDirectoriesIds()
175    {
176        _read(false);
177        return _groupDirectories.keySet();
178    }
179    
180    /**
181     * Gets the configuration for creating/editing a group directory.
182     * @return A map containing information about what is needed to create/edit a group directory
183     * @throws Exception If an error occurs.
184     */
185    @Callable
186    public Map<String, Object> getEditionConfiguration() throws Exception
187    {
188        Map<String, Object> result = new LinkedHashMap<>();
189        
190        List<Object> groupDirectoryModels = new ArrayList<>();
191        for (String extensionId : _groupDirectoryFactory.getExtensionsIds())
192        {
193            GroupDirectoryModel model = _groupDirectoryFactory.getExtension(extensionId);
194            Map<String, Object> gdMap = new LinkedHashMap<>();
195            gdMap.put("id", extensionId);
196            gdMap.put("label", model.getLabel());
197            gdMap.put("description", model.getDescription());
198            
199            Map<String, Object> params = new LinkedHashMap<>();
200            for (String paramId : model.getParameters().keySet())
201            {
202                // prefix in case of two parameters from two different models have the same id which can lead to some errorsin client-side
203                params.put(extensionId + "$" + paramId, model.getParameters().get(paramId).toJSON(DefinitionContext.newInstance().withEdition(true)));
204            }
205            gdMap.put("parameters", params);
206            
207            groupDirectoryModels.add(gdMap);
208        }
209        result.put("groupDirectoryModels", groupDirectoryModels);
210        
211        return result;
212    }
213    
214    /**
215     * Gets the values of the parameters of the given group directory
216     * @param id The id of the group directory
217     * @return The values of the parameters
218     */
219    @Callable
220    public Map<String, Object> getGroupDirectoryParameterValues(String id)
221    {
222        Map<String, Object> result = new LinkedHashMap<>();
223        
224        _read(false);
225        GroupDirectory gd = _groupDirectories.get(id);
226        
227        if (gd == null)
228        {
229            getLogger().error("The GroupDirectory of id '{}' does not exist.", id);
230            result.put("error", "unknown");
231            return result;
232        }
233        
234        result.put("label", gd.getLabel());
235        result.put("id", gd.getId());
236        String modelId = gd.getGroupDirectoryModelId();
237        result.put("modelId", modelId);
238        Map<String, Object> params = new HashMap<>();
239        for (String key : gd.getParameterValues().keySet())
240        {
241            params.put(modelId + "$" + key, gd.getParameterValues().get(key));
242        }
243        result.put("params", params);
244        
245        return result;
246    }
247    
248    /**
249     * Adds a new group directory
250     * @param id The unique id of the group directory
251     * @param label The label of the group directory
252     * @param modelId The id of the group directory model
253     * @param params The parameters of the group directory
254     * @return A map containing the id of the created group directory, or the kind of error that occured
255     */
256    @Callable
257    public Map<String, Object> add(String id, String label, String modelId, Map<String, String> params)
258    {
259        _read(false);
260        
261        Map<String, Object> result = new LinkedHashMap<>();
262        
263        if (!_isCorrectId(id))
264        {
265            return null;
266        }
267        
268        GroupDirectory gd = _createGroupDirectory(id, label, modelId, params);
269        if (gd == null)
270        {
271            getLogger().error("An error occured when creating the GroupDirectory with id '{}'. See previous logs for more information.", id);
272            result.put("error", "server");
273            return result;
274        }
275        
276        _groupDirectories.put(id, gd);
277        if (_write())
278        {
279            getLogger().error("An error occured when writing the configuration file which contains the group directories.", id);
280            result.put("error", "server");
281            return result;
282        }
283        
284        result.put("id", id);
285        return result;
286    }
287    
288    private boolean _isCorrectId(String id)
289    {
290        if (_groupDirectories.get(id) != null)
291        {
292            getLogger().error("The id '{}' is already used for a group directory.", id);
293            return false;
294        }
295        
296        if (!Pattern.matches(__ID_REGEX, id))
297        {
298            getLogger().error("The id '{}' is not a correct id for a group directory.", id);
299            return false;
300        }
301        
302        return true;
303    }
304    
305    /**
306     * Edits the given group directory
307     * @param id The id of the group directory to edit
308     * @param label The label of the group directory
309     * @param modelId The id of the group directory model
310     * @param params The parameters of the group directory
311     * @return A map containing the id of the edited group directory, or the kind of error that occured
312     */
313    @Callable
314    public Map<String, Object> edit(String id, String label, String modelId, Map<String, String> params)
315    {
316        _read(false);
317        
318        Map<String, Object> result = new LinkedHashMap<>();
319        
320        GroupDirectory gd = _groupDirectories.get(id);
321        if (gd == null)
322        {
323            getLogger().error("The GroupDirectory with id '{}' does not exist, it cannot be edited.", id);
324            result.put("error", "unknown");
325            return result;
326        }
327        else
328        {
329            GroupDirectory removedGroupDirectory = _groupDirectories.remove(id);
330            LifecycleHelper.dispose(removedGroupDirectory);
331        }
332        
333        GroupDirectory newGd = _createGroupDirectory(id, label, modelId, params);
334        if (newGd == null)
335        {
336            getLogger().error("An error occured when editing the GroupDirectory with id '{}'. See previous logs for more information.", id);
337            result.put("error", "server");
338            return result;
339        }
340        
341        _groupDirectories.put(id, newGd);
342        if (_write())
343        {
344            getLogger().error("An error occured when writing the configuration file which contains the group directories.", id);
345            result.put("error", "server");
346            return result;
347        }
348        
349        result.put("id", id);
350        return result;
351    }
352    
353    private GroupDirectory _createGroupDirectory(String id, String label, String modelId, Map<String, String> params)
354    {
355        Map<String, Object> typedParams = _getTypedParams(params, modelId);
356        return _groupDirectoryFactory.createGroupDirectory(id, new I18nizableText(label), modelId, typedParams);
357    }
358    
359    private Map<String, Object> _getTypedParams(Map<String, String> params, String modelId)
360    {
361        Map<String, Object> resultParameters = new LinkedHashMap<>();
362        
363        Map<String, ? extends ElementDefinition> declaredParameters = _groupDirectoryFactory.getExtension(modelId).getParameters();
364        for (String paramNameWithPrefix : params.keySet())
365        {
366            String[] splitStr = paramNameWithPrefix.split("\\$", 2);
367            String prefix = splitStr[0];
368            String paramName = splitStr[1];
369            if (prefix.equals(modelId) && declaredParameters.containsKey(paramName))
370            {
371                String originalValue = params.get(paramNameWithPrefix);
372                
373                ElementDefinition parameter = declaredParameters.get(paramName);
374                ElementType type = parameter.getType();
375                
376                Object typedValue = type.castValue(originalValue);
377                resultParameters.put(paramName, typedValue);
378            }
379            else if (prefix.equals(modelId))
380            {
381                getLogger().warn("The parameter {} is not declared in extension {}. It will be ignored", paramName, modelId);
382            }
383        }
384        
385        return resultParameters;
386    }
387    
388    /**
389     * Removes the given group directory
390     * @param id The id of the group directory to remove
391     * @return A map containing the id of the removed group directory
392     */
393    @Callable
394    public Map<String, Object> remove(String id)
395    {
396        Map<String, Object> result = new LinkedHashMap<>();
397        
398        _read(false);
399        GroupDirectory removedGroupDirectory = _groupDirectories.remove(id);
400        if (removedGroupDirectory == null)
401        {
402            getLogger().error("The GroupDirectory with id '{}' does not exist, it cannot be removed.", id);
403            result.put("error", "unknown");
404            return result;
405        }
406        
407        LifecycleHelper.dispose(removedGroupDirectory);
408        
409        if (_write())
410        {
411            return null;
412        }
413        
414        result.put("id", id);
415        return result;
416    }
417    
418    /**
419     * If needed, reads the config file representing the group directories and then
420     * reinitializes and updates the internal representation of the group directories.
421     * @param forceRead True to avoid the use of the cache and force the reading of the file
422     */
423    private synchronized void _read(boolean forceRead)
424    {
425        try
426        {
427            if (!__GROUP_DIRECTORIES_FILE.exists())
428            {
429                _createDirectoriesFile(__GROUP_DIRECTORIES_FILE);
430            }
431            
432            // In Linux file systems, the precision of java.io.File.lastModified() is the second, so we need here to always have
433            // this (bad!) precision by doing the truncation to second precision (/1000 * 1000) on the millis time value.
434            // Therefore, the boolean outdated is computed with '>=' operator, and not '>', which will lead to sometimes (but rarely) unnecessarily re-read the file.
435            long fileLastModified = (__GROUP_DIRECTORIES_FILE.lastModified() / 1000) * 1000;
436            if (forceRead || fileLastModified >= _lastFileReading)
437            {
438                long lastFileReading = Instant.now().truncatedTo(ChronoUnit.SECONDS).toEpochMilli();
439                Map<String, GroupDirectory> groupDirectories = new LinkedHashMap<>();
440                
441                Configuration cfg = new DefaultConfigurationBuilder().buildFromFile(__GROUP_DIRECTORIES_FILE);
442                for (Configuration childCfg : cfg.getChildren("groupDirectory"))
443                {
444                    try
445                    {
446                        GroupDirectory groupDirectory = _configureGroupDirectory(childCfg);
447                        if (groupDirectory != null)
448                        {
449                            groupDirectories.put(groupDirectory.getId(), groupDirectory);
450                        }
451                    }
452                    catch (ConfigurationException e)
453                    {
454                        getLogger().error("Error configuring the group directory '" + childCfg.getAttribute("id", "") + "'. The group directory will be ignored.", e);
455                    }
456                }
457                
458                // Release previous components
459                this.dispose();
460                
461                _lastFileReading = lastFileReading;
462                _groupDirectories = groupDirectories;
463            }
464        }
465        catch (IOException | TransformerConfigurationException | ConfigurationException | SAXException e)
466        {
467            if (getLogger().isErrorEnabled())
468            {
469                getLogger().error("Error retrieving group directories with the configuration file " + __GROUP_DIRECTORIES_FILE, e);
470            }
471        }
472    }
473    
474    private void _createDirectoriesFile(File file) throws IOException, TransformerConfigurationException, SAXException
475    {
476        file.createNewFile();
477        try (OutputStream os = new FileOutputStream(file))
478        {
479            // create a transformer for saving sax into a file
480            TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
481            
482            StreamResult result = new StreamResult(os);
483            th.setResult(result);
484
485            // create the format of result
486            Properties format = new Properties();
487            format.put(OutputKeys.METHOD, "xml");
488            format.put(OutputKeys.INDENT, "yes");
489            format.put(OutputKeys.ENCODING, "UTF-8");
490            format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4");
491            th.getTransformer().setOutputProperties(format);
492            th.startDocument();
493            XMLUtils.createElement(th, "groupDirectories");
494            th.endDocument();
495        }
496    }
497    
498    private GroupDirectory _configureGroupDirectory(Configuration configuration) throws ConfigurationException
499    {
500        String id = configuration.getAttribute("id");
501        String modelId = configuration.getAttribute("modelId");
502        I18nizableText label = new I18nizableText(configuration.getChild("label").getValue());
503        Map<String, Object> paramValues = _getParametersFromConfiguration(configuration.getChild("params"), modelId);
504        if (paramValues != null)
505        {
506            GroupDirectory gd = _groupDirectoryFactory.createGroupDirectory(id, label, modelId, paramValues);
507            if (gd != null)
508            {
509                return gd;
510            }
511        }
512        
513        return null;
514    }
515    
516    private Map<String, Object> _getParametersFromConfiguration(Configuration conf, String modelId)
517    {
518        Map<String, Object> parameters = new LinkedHashMap<>();
519        
520        if (!_groupDirectoryFactory.hasExtension(modelId))
521        {
522            getLogger().warn("The model id '{}' is referenced in the file containing the group directories but seems to not exist.", modelId);
523            return null;
524        }
525        
526        Map<String, ? extends ElementDefinition> declaredParameters = _groupDirectoryFactory.getExtension(modelId).getParameters();
527        for (Configuration paramConf : conf.getChildren())
528        {
529            String paramName = paramConf.getName();
530            if (declaredParameters.containsKey(paramName))
531            {
532                String valueAsString = paramConf.getValue("");
533                
534                ElementDefinition parameter = declaredParameters.get(paramName);
535                ElementType type = parameter.getType();
536                
537                Object typedValue = type.castValue(valueAsString);
538                parameters.put(paramName, typedValue);
539            }
540            else
541            {
542                getLogger().warn("The parameter '{}' is not declared in extension '{}'. It will be ignored", paramName, modelId);
543            }
544        }
545        
546        return parameters;
547    }
548    
549    /**
550     * Erases the config file representing the group directories and rebuild it 
551     * from the internal representation of the group directories.
552     * @return True if an error occured
553     */
554    private boolean _write()
555    {
556        File backup = new File(__GROUP_DIRECTORIES_FILE.getPath() + ".tmp");
557        boolean errorOccured = false;
558        
559        // Create a backup file
560        try
561        {
562            Files.copy(__GROUP_DIRECTORIES_FILE.toPath(), backup.toPath());
563        }
564        catch (IOException e)
565        {
566            if (getLogger().isErrorEnabled())
567            {
568                getLogger().error("Error when creating backup '" + __GROUP_DIRECTORIES_FILE + "' file", e);
569            }
570        }
571        
572        // Do writing
573        try (OutputStream os = new FileOutputStream(__GROUP_DIRECTORIES_FILE))
574        {
575            // create a transformer for saving sax into a file
576            TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
577            
578            StreamResult result = new StreamResult(os);
579            th.setResult(result);
580
581            // create the format of result
582            Properties format = new Properties();
583            format.put(OutputKeys.METHOD, "xml");
584            format.put(OutputKeys.INDENT, "yes");
585            format.put(OutputKeys.ENCODING, "UTF-8");
586            format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4");
587            th.getTransformer().setOutputProperties(format);
588
589            // sax the config
590            try
591            {
592                _toSAX(th);
593            }
594            catch (Exception e)
595            {
596                if (getLogger().isErrorEnabled())
597                {
598                    getLogger().error("Error when saxing the groupDirectories", e);
599                }
600                errorOccured = true;
601            }
602        }
603        catch (IOException | TransformerConfigurationException | TransformerFactoryConfigurationError e)
604        {
605            if (getLogger().isErrorEnabled())
606            {
607                getLogger().error("Error when trying to modify the group directories with the configuration file " + __GROUP_DIRECTORIES_FILE, e);
608            }
609        }
610        
611        // Restore the file if an error previously occured
612        try
613        {
614            if (errorOccured)
615            {
616                // An error occured, restore the original
617                Files.copy(backup.toPath(), __GROUP_DIRECTORIES_FILE.toPath(), StandardCopyOption.REPLACE_EXISTING);
618                // Force to reread the file
619                _read(true);
620            }
621            Files.deleteIfExists(backup.toPath());
622        }
623        catch (IOException e)
624        {
625            if (getLogger().isErrorEnabled())
626            {
627                getLogger().error("Error when restoring backup '" + __GROUP_DIRECTORIES_FILE + "' file", e);
628            }
629        }
630        
631        return errorOccured;
632    }
633    
634    private void _toSAX(TransformerHandler handler) throws SAXException, IOException
635    {
636        handler.startDocument();
637        XMLUtils.startElement(handler, "groupDirectories");
638        for (GroupDirectory gd : _groupDirectories.values())
639        {
640            _saxGroupDirectory(gd, handler);
641        }
642        
643        XMLUtils.endElement(handler, "groupDirectories");
644        handler.endDocument();
645    }
646    
647    private void _saxGroupDirectory(GroupDirectory groupDirectory, TransformerHandler handler) throws SAXException, IOException
648    {
649        AttributesImpl atts = new AttributesImpl();
650        atts.addCDATAAttribute("id", groupDirectory.getId());
651        atts.addCDATAAttribute("modelId", groupDirectory.getGroupDirectoryModelId());
652        XMLUtils.startElement(handler, "groupDirectory", atts);
653        
654        groupDirectory.getLabel().toSAX(handler, "label");
655        
656        XMLUtils.startElement(handler, "params");
657        Map<String, Object> paramValues = groupDirectory.getParameterValues();
658        GroupDirectoryModel groupDirectoryModel = _groupDirectoryFactory.getExtension(groupDirectory.getGroupDirectoryModelId());
659        Map<String, ? extends ElementDefinition> definitions = groupDirectoryModel.getParameters();
660        for (String paramName : paramValues.keySet())
661        {
662            Object value = paramValues.get(paramName);
663            ElementDefinition definition = definitions.get(paramName);
664            if (definition != null)
665            {
666                definition.getType().valueToSAX(handler, paramName, value, DataContext.newInstance());
667            }
668        }
669        XMLUtils.endElement(handler, "params");
670        
671        XMLUtils.endElement(handler, "groupDirectory");
672    }
673    
674    @Override
675    public void dispose()
676    {
677        for (GroupDirectory gd : _groupDirectories.values())
678        {
679            LifecycleHelper.dispose(gd);
680        }
681        _lastFileReading = 0;
682    }
683}