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