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