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