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