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}