001/*
002 *  Copyright 2015 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.runtime.plugins.admin.system;
017
018import java.io.File;
019import java.io.FileInputStream;
020import java.io.FileOutputStream;
021import java.io.InputStream;
022import java.io.OutputStream;
023import java.time.ZonedDateTime;
024import java.util.HashMap;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Properties;
028
029import javax.xml.transform.OutputKeys;
030import javax.xml.transform.TransformerFactory;
031import javax.xml.transform.sax.SAXTransformerFactory;
032import javax.xml.transform.sax.TransformerHandler;
033import javax.xml.transform.stream.StreamResult;
034
035import org.apache.avalon.framework.activity.Initializable;
036import org.apache.avalon.framework.component.Component;
037import org.apache.avalon.framework.configuration.Configuration;
038import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder;
039import org.apache.avalon.framework.context.Context;
040import org.apache.avalon.framework.context.ContextException;
041import org.apache.avalon.framework.context.Contextualizable;
042import org.apache.avalon.framework.logger.AbstractLogEnabled;
043import org.apache.avalon.framework.service.ServiceException;
044import org.apache.avalon.framework.service.ServiceManager;
045import org.apache.avalon.framework.service.Serviceable;
046import org.apache.cocoon.ProcessingException;
047import org.apache.cocoon.components.ContextHelper;
048import org.apache.cocoon.xml.XMLUtils;
049import org.apache.commons.io.FileUtils;
050import org.apache.commons.lang3.StringUtils;
051import org.xml.sax.helpers.AttributesImpl;
052
053import org.ametys.core.cache.AbstractCacheManager;
054import org.ametys.core.cache.Cache;
055import org.ametys.core.ui.Callable;
056import org.ametys.core.util.DateUtils;
057import org.ametys.core.util.I18nUtils;
058import org.ametys.runtime.i18n.I18nizableText;
059import org.ametys.runtime.servlet.RuntimeConfig;
060import org.ametys.runtime.util.AmetysHomeHelper;
061
062/**
063 * Helper for manipulating system announcement
064 */
065public class SystemHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable
066{
067    /** The relative path to the file where system information are saved (announcement, maintenance...) */
068    public static final String ADMINISTRATOR_SYSTEM_FILENAME = "system.xml";
069    /** Avalon role */
070    public static final String ROLE = SystemHelper.class.getName();
071    
072    private static final String SYSTEM_ANNOUNCEMENT_CACHE = SystemHelper.class.getName() + "$SystemAnnouncement";
073    private static final String SYSTEM_ANNOUNCEMENT_CACHE_KEY = SystemHelper.class.getName() + "$SystemAnnouncement";
074    
075    
076    private I18nUtils _i18nUtils;
077    private Context _context;
078    private AbstractCacheManager _cacheManager;
079    
080    @Override
081    public void service(ServiceManager serviceManager) throws ServiceException
082    {
083        _i18nUtils = (I18nUtils) serviceManager.lookup(I18nUtils.ROLE);
084        _cacheManager = (AbstractCacheManager) serviceManager.lookup(AbstractCacheManager.ROLE);
085    }
086    
087    public void initialize() throws Exception
088    {
089        _cacheManager.createMemoryCache(SYSTEM_ANNOUNCEMENT_CACHE,
090                new I18nizableText("plugin.admin", "PLUGINS_ADMIN_CACHE_SYSTEM_ANNOUNCEMENT_LABEL"),
091                new I18nizableText("plugin.admin", "PLUGINS_ADMIN_CACHE_SYSTEM_ANNOUNCEMENT_DESCRIPTION"),
092                true,
093                null);
094    }
095    
096    @Override
097    public void contextualize(Context context) throws ContextException
098    {
099        _context = context;
100    }
101    
102    /**
103     * Enables or disable system announcement
104     * @param available true to enable system announcement
105     * @throws ProcessingException if an error occurred
106     */
107    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
108    public void setAnnouncementAvailable (boolean available) throws ProcessingException
109    {
110        SystemAnnouncement systemAnnouncement = readValues();
111        
112        _save(available ? "on" : "off", null, null, systemAnnouncement.getMessages());
113    }
114    
115    /**
116     * Schedule a system announcement
117     * @param startDateStr the planned start date. Can be null for no start date
118     * @param endDateStr the planned end date. Can be null for no end date
119     * @throws ProcessingException if an error occurred
120     */
121    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
122    public void scheduleAnnouncement(String startDateStr, String endDateStr) throws ProcessingException
123    {
124        // Parsing will throw an exception if string is not valid
125        // preventing the saving of bogus values
126        ZonedDateTime startDate = DateUtils.parseZonedDateTime(startDateStr);
127        ZonedDateTime endDate = DateUtils.parseZonedDateTime(endDateStr);
128        
129        SystemAnnouncement systemAnnouncement = readValues();
130        
131        _save("scheduled", startDate, endDate, systemAnnouncement.getMessages());
132    }
133    
134    /**
135     * Add or edit a system announcement
136     * @param language the language typed in by the user or "*" if modifying the default message
137     * @param message the message to add nor edit
138     * @param override true to override the existing value if exists
139     * @return the result map
140     * @throws Exception if an exception occurs
141     */
142    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
143    public Map<String, Object> editAnnouncement(String language, String message, boolean override) throws Exception
144    {
145        Map<String, Object> result = new HashMap<> ();
146        
147        SystemAnnouncement systemAnnouncement = readValues();
148        
149        Map<String, String> messages = systemAnnouncement.getMessages();
150        if (messages.containsKey(language) && !override)
151        {
152            result.put("already-exists", true);
153            return result;
154        }
155        
156        // Add or edit message
157        messages.put(language, message);
158        
159        _save(systemAnnouncement.getState(), systemAnnouncement.getStartDate(), systemAnnouncement.getEndDate(), messages);
160        
161        return result;
162    }
163    
164    /**
165     * Delete a announcement
166     * @param language the language of the announcement to delete
167     * @throws ProcessingException if an exception occurs
168     * @return an empty map
169     */
170    @Callable(rights = "Runtime_Rights_Admin_Access", context = "/admin")
171    public Map deleteAnnouncement(String language) throws ProcessingException
172    {
173        Map<String, Object> result = new HashMap<> ();
174        
175        SystemAnnouncement systemAnnouncement = readValues();
176        
177        Map<String, String> messages = systemAnnouncement.getMessages();
178        if (messages.containsKey(language))
179        {
180            messages.remove(language);
181            
182            _save(systemAnnouncement.getState(), systemAnnouncement.getStartDate(), systemAnnouncement.getEndDate(), messages);
183        }
184        
185        return result;
186    }
187    
188    private File _getSystemFile()
189    {
190        return FileUtils.getFile(RuntimeConfig.getInstance().getAmetysHome(), AmetysHomeHelper.AMETYS_HOME_ADMINISTRATOR_DIR, ADMINISTRATOR_SYSTEM_FILENAME);
191    }
192    
193    /**
194     * Saves the system announcement's values
195     * @param state true to enable system announcement
196     * @param messages the messages
197     * @throws ProcessingException if an error ocurred
198     */
199    private void _save (String state, ZonedDateTime startDate, ZonedDateTime endDate, Map<String, String> messages) throws ProcessingException
200    {
201        File systemFile = _getSystemFile();
202        
203        try
204        {
205            // Create file if not exists
206            if (!systemFile.exists())
207            {
208                systemFile.getParentFile().mkdirs();
209                systemFile.createNewFile();
210            }
211            
212            // create a transformer for saving sax into a file
213            TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler();
214
215            // create the result where to write
216            try (OutputStream os = new FileOutputStream(systemFile))
217            {
218                StreamResult sResult = new StreamResult(os);
219                th.setResult(sResult);
220    
221                // create the format of result
222                Properties format = new Properties();
223                format.put(OutputKeys.METHOD, "xml");
224                format.put(OutputKeys.INDENT, "yes");
225                format.put(OutputKeys.ENCODING, "UTF-8");
226                th.getTransformer().setOutputProperties(format);
227    
228                // Send SAX events
229                th.startDocument();
230    
231                AttributesImpl announcementsAttrs = new AttributesImpl();
232                announcementsAttrs.addAttribute("", "state", "state", "CDATA", state);
233                if (startDate != null)
234                {
235                    announcementsAttrs.addAttribute("", "start-date", "start-date", "CDATA", DateUtils.zonedDateTimeToString(startDate));
236                }
237                if (endDate != null)
238                {
239                    announcementsAttrs.addAttribute("", "end-date", "end-date", "CDATA", DateUtils.zonedDateTimeToString(endDate));
240                }
241                
242                XMLUtils.startElement(th, "announcements", announcementsAttrs);
243                
244                for (String id : messages.keySet())
245                {
246                    AttributesImpl announcementAttrs = new AttributesImpl();
247                    if (!"*".equals(id))
248                    {
249                        announcementAttrs.addAttribute("", "lang", "lang", "CDATA", id);
250                    }
251                    
252                    XMLUtils.createElement(th, "announcement", announcementAttrs, messages.get(id));
253                }
254                
255                XMLUtils.endElement(th, "announcements");
256                
257                th.endDocument();
258            }
259        }
260        catch (Exception e)
261        {
262            throw new ProcessingException("Unable to save system announcement values", e);
263        }
264        finally
265        {
266            // clear the cache
267            _cacheManager.get(SYSTEM_ANNOUNCEMENT_CACHE).invalidateAll();
268        }
269    }
270    
271    /**
272     * Tests if system announcements are active.
273     * @return true if system announcements are active.
274     */
275    @Callable (rights = Callable.SKIP_BUILTIN_CHECK)
276    public boolean isSystemAnnouncementAvailable()
277    {
278        SystemAnnouncement systemAnnouncement = readValues();
279        return systemAnnouncement.isAvailable();
280    }
281    
282    /**
283     * Get the system announcement availability
284     * @return a map with the state, start date and end date
285     */
286    public Map<String, Object> getSystemAnnouncementAvailability()
287    {
288        SystemAnnouncement announcement = readValues();
289        // Use HashMap to be able to store null value
290        Map<String, Object> result = new HashMap<>(4);
291        result.put("state", announcement.getState());
292        result.put("start-date", announcement.getStartDate());
293        result.put("end-date", announcement.getEndDate());
294        
295        return result;
296    }
297    
298    /**
299     * Return the date of the last modification of the annonce
300     * @return The date of the last modification or 0 if there is no announce file
301     */
302    public long getSystemAnnoucementLastModificationDate()
303    {
304        try
305        {
306            File systemFile = _getSystemFile();
307            if (!systemFile.exists() || !systemFile.isFile())
308            {
309                return 0;
310            }
311            
312            return systemFile.lastModified();
313        }
314        catch (Exception e)
315        {
316            throw new RuntimeException("Unable to get system announcements", e);
317        }
318    }
319
320    /**
321     * Returns the system announcement for the given language code, or for the default language code if there is no specified announcement for the given language code.<br>
322     * Returns null if the system announcements are not activated.
323     * @param languageCode the desired language code of the system announcement
324     * @return the system announcement in the specified language code, or in the default language code, or null if announcements are not active.
325     */
326    public String getSystemAnnouncement(String languageCode)
327    {
328        SystemAnnouncement systemAnnouncement = readValues();
329        
330        if (!systemAnnouncement.isAvailable())
331        {
332            return null;
333        }
334        
335        Map<String, String> messages = systemAnnouncement.getMessages();
336        
337        String announcement = null;
338        if (messages.containsKey(languageCode))
339        {
340            announcement = messages.get(languageCode);
341        }
342        
343        if (StringUtils.isEmpty(announcement))
344        {
345            String defaultAnnouncement = messages.containsKey("*") ? messages.get("*") : null;
346            if (StringUtils.isEmpty(defaultAnnouncement))
347            {
348                throw new IllegalStateException("There must be a default announcement.");
349            }
350            
351            return defaultAnnouncement;
352        }
353        
354        return announcement;
355    }
356    
357    /**
358     * Read the system announcement's values
359     * @return The system announcement values;
360     */
361    public SystemAnnouncement readValues ()
362    {
363        Cache<String, SystemAnnouncement> cache = _cacheManager.get(SYSTEM_ANNOUNCEMENT_CACHE);
364        return cache.get(SYSTEM_ANNOUNCEMENT_CACHE_KEY, str -> _readValues());
365    }
366    
367    private SystemAnnouncement _readValues()
368    {
369        SystemAnnouncement announcement = new SystemAnnouncement();
370        
371        try
372        {
373            File systemFile = _getSystemFile();
374            if (!systemFile.exists() || !systemFile.isFile())
375            {
376                _setDefaultValues();
377            }
378            
379            Configuration configuration;
380            try (InputStream is = new FileInputStream(systemFile))
381            {
382                configuration = new DefaultConfigurationBuilder().build(is);
383            }
384            
385            // State
386            String state = configuration.getAttribute("state", "off");
387            announcement.setState(state);
388            
389            String startDate = configuration.getAttribute("start-date", null);
390            if (startDate != null)
391            {
392                announcement.setStartDate(DateUtils.parseZonedDateTime(startDate));
393            }
394            
395            String endDate = configuration.getAttribute("end-date", null);
396            if (endDate != null)
397            {
398                announcement.setEndDate(DateUtils.parseZonedDateTime(endDate));
399            }
400            
401            // Announcements
402            for (Configuration announcementConfiguration : configuration.getChildren("announcement"))
403            {
404                String lang = announcementConfiguration.getAttribute("lang", "*");
405                String message = announcementConfiguration.getValue();
406                
407                announcement.addMessage(lang, message);
408            }
409            
410            return announcement;
411        }
412        catch (Exception e)
413        {
414            throw new RuntimeException("Unable to get system announcements", e);
415        }
416    }
417    
418    private void _setDefaultValues () throws ProcessingException
419    {
420        Map objectModel = ContextHelper.getObjectModel(_context);
421        Locale locale = org.apache.cocoon.i18n.I18nUtils.findLocale(objectModel, "locale", null, Locale.getDefault(), true);
422        String defaultMessage = _i18nUtils.translate(new I18nizableText("plugin.admin", "PLUGINS_ADMIN_SYSTEM_DEFAULTMESSAGE"), locale.getLanguage());
423
424        Map<String, String> messages = new HashMap<> ();
425        messages.put("*", defaultMessage);
426        
427        _save("off", null, null, messages);
428    }
429    
430    /**
431     * Class representing the system announcement file
432     */
433    public static class SystemAnnouncement
434    {
435        private String _state;
436        private Map<String, String> _messages;
437        private ZonedDateTime _startDate;
438        private ZonedDateTime _endDate;
439        
440        /**
441         * Constructor
442         */
443        public SystemAnnouncement()
444        {
445            _state = "off";
446            _startDate = null;
447            _endDate = null;
448            _messages = new HashMap<>();
449        }
450        
451        /**
452         * Is the system announcement available ?
453         * @return true if the system announcement is available, false otherwise
454         */
455        public boolean isAvailable()
456        {
457            return StringUtils.equals(_state, "on")
458                || StringUtils.equals(_state, "scheduled")
459                    && (_startDate == null || ZonedDateTime.now().isAfter(_startDate))
460                    && (_endDate == null || ZonedDateTime.now().isBefore(_endDate));
461        }
462        
463        /**
464         * Get the messages by language
465         * @return the messages by languaga
466         */
467        public Map<String, String> getMessages ()
468        {
469            return _messages;
470        }
471        
472        /**
473         * Set the state of the system announcement
474         * @param state 'on' to set the enable system announcement,
475         * 'scheduled' to enable it base on the start and end date
476         * disabled otherwise
477         */
478        public void setState (String state)
479        {
480            _state = state;
481        }
482        
483        /**
484         * Get the state
485         * @return "on", "off", "scheduled"
486         */
487        public String getState()
488        {
489            return _state;
490        }
491        
492        /**
493         * Set the start date for scheduled announcement
494         * @param startDate the start date or null
495         */
496        public void setStartDate(ZonedDateTime startDate)
497        {
498            _startDate = startDate;
499        }
500        
501        /**
502         * Get the start date for scheduled announcement
503         * @return the start date or null
504         */
505        public ZonedDateTime getStartDate()
506        {
507            return _startDate;
508        }
509        
510        /**
511         * Set the end date for scheduled announcement
512         * @param endDate the end date or null
513         */
514        public void setEndDate(ZonedDateTime endDate)
515        {
516            _endDate = endDate;
517        }
518        
519        /**
520         * Get the end date for scheduled announcement
521         * @return the end date or null
522         */
523        public ZonedDateTime getEndDate()
524        {
525            return _endDate;
526        }
527        
528        /**
529         * Add a message to the list of announcements
530         * @param lang the language of the message
531         * @param message the message itself
532         */
533        public void addMessage (String lang, String message)
534        {
535            _messages.put(lang, message);
536        }
537        
538    }
539}