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