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}