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