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}