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