001/*
002 *  Copyright 2020 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.calendar.icsreader;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.net.HttpURLConnection;
021import java.net.URL;
022import java.nio.charset.StandardCharsets;
023import java.time.Duration;
024import java.time.Instant;
025import java.time.LocalDate;
026import java.time.temporal.ChronoUnit;
027import java.util.ArrayList;
028import java.util.Base64;
029import java.util.Collection;
030import java.util.Date;
031import java.util.List;
032
033import org.apache.avalon.framework.activity.Initializable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.slf4j.Logger;
038
039import org.ametys.cms.tag.Tag;
040import org.ametys.core.cache.AbstractCacheManager;
041import org.ametys.core.cache.Cache;
042import org.ametys.plugins.calendar.events.EventsFilterHelper;
043import org.ametys.plugins.calendar.icsreader.ical4j.AmetysParameterFactorySupplier;
044import org.ametys.plugins.calendar.icsreader.ical4j.DefaultComponentFactorySupplier;
045import org.ametys.plugins.calendar.icsreader.ical4j.DefaultPropertyFactorySupplier;
046import org.ametys.runtime.config.Config;
047import org.ametys.runtime.i18n.I18nizableText;
048import org.ametys.runtime.plugin.component.LogEnabled;
049
050import net.fortuna.ical4j.data.CalendarBuilder;
051import net.fortuna.ical4j.data.CalendarParserFactory;
052import net.fortuna.ical4j.filter.Filter;
053import net.fortuna.ical4j.filter.PeriodRule;
054import net.fortuna.ical4j.model.Calendar;
055import net.fortuna.ical4j.model.Component;
056import net.fortuna.ical4j.model.ComponentList;
057import net.fortuna.ical4j.model.DateList;
058import net.fortuna.ical4j.model.DateTime;
059import net.fortuna.ical4j.model.Period;
060import net.fortuna.ical4j.model.Property;
061import net.fortuna.ical4j.model.TimeZoneRegistryFactory;
062import net.fortuna.ical4j.model.component.CalendarComponent;
063import net.fortuna.ical4j.model.component.VEvent;
064import net.fortuna.ical4j.model.parameter.Value;
065import net.fortuna.ical4j.model.property.DateProperty;
066import net.fortuna.ical4j.model.property.DtEnd;
067import net.fortuna.ical4j.model.property.DtStart;
068import net.fortuna.ical4j.model.property.RRule;
069
070/**
071 * Read a distant ICS file for a certain number of events in the future
072 */
073public class IcsReader implements Serviceable, org.apache.avalon.framework.component.Component, Initializable, LogEnabled
074{
075    /** The Avalon role. */
076    public static final String ROLE = IcsReader.class.getName();
077    
078    private static final String __ICS_CACHE_ID = IcsReader.class.getName() + "$icsCache";
079
080    /** logger */
081    protected Logger _logger;
082
083    private AbstractCacheManager _abstractCacheManager;
084
085    @Override
086    public void service(ServiceManager smanager) throws ServiceException
087    {
088        _abstractCacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
089    }
090    
091    /**
092     * Get a list of events from an ics file
093     * @param url url of the ics file
094     * @param dateRange range of dates to fetch
095     * @param nbEvents number of events to read
096     * @param maxFileSize max ics file size (in bytes)
097     * @return a List of {@link VEvent}
098     */
099    public IcsEvents getEventList(String url, EventsFilterHelper.DateRange dateRange, Long nbEvents, Long maxFileSize)
100    {
101        getLogger().debug("Fetch ics url : {}", url);
102        CacheKey cacheKey = new CacheKey(url, dateRange, nbEvents, maxFileSize);
103        
104        Cache<CacheKey, IcsEvents> cache = getIcsCache();
105        return cache.get(cacheKey, key -> _getEventList(url, dateRange, nbEvents, maxFileSize));
106    }
107    
108    /**
109     * Get a list of events from an ics file, without trying the cache
110     * @param url url of the ics file
111     * @param dateRange range of dates to fetch
112     * @param nbEvents number of events to read
113     * @param maxFileSize max ics file size (in bytes)
114     * @return a List of {@link VEvent}
115     */
116    protected IcsEvents _getEventList(String url, EventsFilterHelper.DateRange dateRange, Long nbEvents, Long maxFileSize)
117    {
118        try
119        {
120            long fileSize = getFileSize(url);
121            if (fileSize > maxFileSize)
122            {
123                getLogger().debug("ICS File is too big : {}", url);
124                return new IcsEvents(url, null, IcsEvents.Status.OVERSIZED);
125            }
126            
127            URL icsUrl = new URL(url);
128            
129            HttpURLConnection connection = (HttpURLConnection) icsUrl.openConnection();
130            
131            String userInfo = icsUrl.getUserInfo();
132            if (userInfo != null)
133            {
134                String basicAuth = "Basic " + Base64.getEncoder().encodeToString(userInfo.getBytes(StandardCharsets.UTF_8));
135                connection.setRequestProperty("Authorization", basicAuth);
136            }
137    
138            try (InputStream body = connection.getInputStream())
139            {
140                CalendarBuilder builder = new CalendarBuilder(CalendarParserFactory.getInstance().get(),
141                                                              new AmetysParameterFactorySupplier(), 
142                                                              new DefaultPropertyFactorySupplier(),
143                                                              new DefaultComponentFactorySupplier(),
144                                                              TimeZoneRegistryFactory.getInstance().createRegistry());
145                Calendar calendar = builder.build(body);
146        
147                getLogger().debug("Calendar is built for url : {}", url);
148                
149                ComponentList<CalendarComponent> components = calendar.getComponents(Component.VEVENT);
150                
151                Collection<CalendarComponent> componentList;
152                
153                if (dateRange != null)
154                {
155                    Period period = new Period(new DateTime(dateRange.getStartDate()), new DateTime(dateRange.getEndDate()));
156                    Filter<CalendarComponent> filter = new Filter<>(new PeriodRule<>(period));
157                    componentList = filter.filter(components);
158                }
159                else
160                {
161                    componentList = components;
162                }
163                
164                getLogger().debug("Calendar is filtered for url : {}", url);
165                
166                List<VEvent> eventList = new ArrayList<>();
167                Long nbEventsRemaining = nbEvents;
168                for (CalendarComponent calendarComponent : componentList)
169                {
170                    if (nbEventsRemaining > 0)
171                    {
172                        if (calendarComponent instanceof VEvent)
173                        {
174                            eventList.add((VEvent) calendarComponent);
175                            nbEventsRemaining--;
176                        }
177                    }
178                    else
179                    {
180                        break;
181                    }
182                }
183                getLogger().debug("List is generated for url : {}", url);
184                
185                return new IcsEvents(url, eventList);
186            }
187        }
188        catch (Exception e)
189        {
190            getLogger().error("Error while reading ics with url = '" + url + "'", e);
191            return new IcsEvents(url, null, IcsEvents.Status.ERROR);
192        }
193    }
194
195    public void initialize() throws Exception
196    {
197        System.setProperty("ical4j.unfolding.relaxed", "true");
198        System.setProperty("net.fortuna.ical4j.timezone.cache.impl", "net.fortuna.ical4j.util.MapTimeZoneCache");
199        
200        Long cacheTtlConf = Config.getInstance().getValue("org.ametys.plugins.calendar.ics.reader.cache.ttl");
201        Long cacheTtl = (long) (cacheTtlConf != null && cacheTtlConf.intValue() > 0 ? cacheTtlConf.intValue() : 60);
202        
203        Duration duration = Duration.ofMinutes(cacheTtl);
204        
205        _abstractCacheManager.createMemoryCache(__ICS_CACHE_ID,
206                new I18nizableText("plugin.calendar", "CALENDAR_SERVICE_AGENDA_ICS_CACHE_LABEL"),
207                new I18nizableText("plugin.calendar", "CALENDAR_SERVICE_AGENDA_ICS_CACHE_DESC"),
208                false, // ical4j events crash the api that calculates the size, sorry
209                duration);
210    }
211    
212    private Cache<CacheKey, IcsEvents> getIcsCache()
213    {
214        return _abstractCacheManager.get(__ICS_CACHE_ID);
215    }
216    
217    /**
218     * Try to get the size of the file, and download it if needed
219     * @param url the url to get
220     * @return size in octet, or -1 if error
221     * @throws IOException Something went wrong
222     */
223    private long getFileSize(String url) throws IOException
224    {
225        getLogger().debug("Start to try to determine size of the file : {}", url);
226        
227        URL icsUrl = new URL(url);
228        
229        HttpURLConnection connexion = (HttpURLConnection) icsUrl.openConnection();
230        
231        long nbByte = connexion.getContentLengthLong();
232        
233        if (nbByte < 0)
234        {
235            try (InputStream flux = connexion.getInputStream())
236            {
237                getLogger().debug("Unable to get size from header, we download the file : {}", url);
238                nbByte = 0;
239                while (flux.read() != -1)
240                {
241                    nbByte++;
242                }
243            }
244        }
245        getLogger().debug("End of estimation of the size of the file, {} bytes : {}", nbByte, url);
246        return nbByte;
247    }
248    
249    /**
250     * Get a list of {@link LocalDate} covered by this event
251     * @param event the event to test
252     * @param dateRange the dates to check, can be null but this will return null
253     * @param tag the tag used for this ICS
254     * @return a list of {@link LocalDate} for the days in which this event appears, or null if nothing matches
255     */
256    public List<LocalVEvent> getEventDates(VEvent event, EventsFilterHelper.DateRange dateRange, Tag tag)
257    {
258        List<LocalVEvent> result = new ArrayList<>();
259
260        Property rRuleProperty = event.getProperty(Property.RRULE);
261        if (rRuleProperty instanceof RRule)
262        {
263            Property startProperty = event.getProperty(Property.DTSTART);
264            Property endProperty = event.getProperty(Property.DTEND);
265            if (startProperty instanceof DtStart && endProperty instanceof DtEnd)
266            {
267                if (dateRange != null)
268                {
269                    DtStart dtStart = (DtStart) startProperty;
270                    DtEnd dtEnd = (DtEnd) endProperty;
271                    long eventDurationInMs = dtEnd.getDate().getTime() - dtStart.getDate().getTime();
272    
273                    RRule rRule = (RRule) rRuleProperty;
274                    net.fortuna.ical4j.model.Date periodeStart = new net.fortuna.ical4j.model.Date(dateRange.getStartDate());
275                    net.fortuna.ical4j.model.Date periodeEnd = new net.fortuna.ical4j.model.Date(dateRange.getEndDate());
276                    
277                    DateList dates = rRule.getRecur().getDates(dtStart.getDate(), periodeStart, periodeEnd, Value.DATE_TIME);
278                
279                    for (net.fortuna.ical4j.model.Date startDate : dates)
280                    {
281                        long eventEnd = startDate.getTime() + eventDurationInMs;
282                        Date endDate;
283                        if (dtEnd.getDate() instanceof DateTime)
284                        {
285                            endDate = new DateTime(eventEnd);
286                        }
287                        else
288                        {
289                            endDate = new Date(Instant.ofEpochMilli(eventEnd).minus(1, ChronoUnit.DAYS).toEpochMilli());
290                        }
291                        result.add(new LocalVEvent(event, startDate, endDate, tag));
292                    }
293                }
294                else
295                {
296                    getLogger().debug("Impossible to get the lest of events without a date range, it can be too much");
297                }
298            }
299        }
300        else
301        {
302            Date startDate = _getEventDateTime(event, Property.DTSTART);
303            Date endDate = _getEventDateTime(event, Property.DTEND);
304            
305            // If no dates, it can not be displayed.
306            // If one date is missing, consider the other equals
307            if (startDate == null && endDate == null)
308            {
309                return result;
310            }
311            else if (startDate == null)
312            {
313                startDate = endDate;
314            }
315            else if (endDate == null)
316            {
317                endDate = startDate;
318            }
319            
320            result.add(new LocalVEvent(event, startDate, endDate, tag));
321        }
322        return result;
323    }
324    
325    /**
326     * Return a string representing the start/end date of an event, or null if no start/end date was found
327     * @param event the event to read
328     * @param property a {@link Property} to check ( {@link Property#DTSTART} or  {@link Property#DTEND} )
329     * @return a string representing the date
330     */
331    private Date _getEventDateTime(CalendarComponent event, String property)
332    {
333        Date result = null;
334        Property checkedProperty = event.getProperty(property);
335        if (checkedProperty instanceof DateProperty)
336        {
337            DateProperty checked = (DateProperty) checkedProperty;
338            result = checked.getDate();
339            
340            // if we check the DTEND and it is just a DATE (not a DATETIME), we remove one day to it (because it is exclusive)
341            if (result != null && Property.DTEND.equals(property) && !(result instanceof DateTime))
342            {
343                result = new Date(result.toInstant().minus(1, ChronoUnit.DAYS).toEpochMilli());
344            }
345        }
346        return result;
347    }
348
349    public void setLogger(Logger logger)
350    {
351        _logger = logger;
352    }
353    
354    private Logger getLogger()
355    {
356        return _logger;
357    }
358    
359    /**
360     * Object wrapper for ics events
361     */
362    public static class IcsEvents
363    {
364        /**
365         * The status of the ics
366         */
367        public static enum Status 
368        {
369            /** If the ics exceeds the authorized max size */
370            OVERSIZED,
371            /** If there is some erros reading ics url */
372            ERROR,
373            /** If the ics is OK */
374            OK
375        }
376        
377        private String _url;
378        private List<VEvent> _events;
379        private Status _status;
380        private Tag _tag;
381        
382        /**
383         * The constructor
384         * @param url the url
385         * @param events the list of event of the ics
386         */
387        public IcsEvents(String url, List<VEvent> events)
388        {
389            this(url, events, Status.OK);
390        }
391        
392        /**
393         * The constructor
394         * @param url the url
395         * @param events the list of event of the ics
396         * @param status the status of the ics
397         */
398        public IcsEvents(String url, List<VEvent> events, Status status)
399        {
400            _url = url;
401            _events = events;
402            _tag = null;
403            _status = status;
404        }
405        
406        /**
407         * Get the url of the ics
408         * @return the url
409         */
410        public String getUrl()
411        {
412            return _url;
413        }
414        
415        /**
416         * <code>true</code> if the ics has events
417         * @return <code>true</code> if the ics has events
418         */
419        public boolean hasEvents()
420        {
421            return _events != null && _events.size() > 0;
422        }
423        
424        /**
425         * Get the list of events of the ics
426         * @return the list of events
427         */
428        public List<VEvent> getEvents()
429        {
430            return _events;
431        }
432        
433        /**
434         * Get the tag of the ics
435         * @return the tag
436         */
437        public Tag getTag()
438        {
439            return _tag;
440        }
441        
442        /**
443         * Set the tag of the ics
444         * @param tag the tag
445         */
446        public void setTag(Tag tag)
447        {
448            _tag = tag;
449        }
450        
451        /**
452         * Get the status of the ics
453         * @return the status
454         */
455        public Status getStatus()
456        {
457            return _status;
458        }
459    }
460}