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