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}