Skip to content
Snippets Groups Projects
ICalServlet.java 19.7 KiB
Newer Older
/**
* OLAT - Online Learning and Training<br>
* http://www.olat.org
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); <br>
* you may not use this file except in compliance with the License.<br>
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing,<br>
* software distributed under the License is distributed on an "AS IS" BASIS, <br>
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
* See the License for the specific language governing permissions and <br>
* limitations under the License.
* <p>
* Copyright (c) since 2004 at Multimedia- & E-Learning Services (MELS),<br>
* University of Zurich, Switzerland.
* <hr>
* <a href="http://www.openolat.org">
* OpenOLAT - Online Learning and Training</a><br>
* This file has been modified by the OpenOLAT community. Changes are licensed
* under the Apache 2.0 license as the original file.  
* <p>
*/ 

package org.olat.commons.calendar;

import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.StringTokenizer;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.logging.log4j.Logger;
import org.olat.commons.calendar.model.CalendarFileInfos;
import org.olat.commons.calendar.model.CalendarUserConfiguration;
import org.olat.core.CoreSpringFactory;
import org.olat.core.commons.persistence.DBFactory;
import org.olat.core.id.Identity;
import org.olat.core.logging.OLATRuntimeException;
import org.olat.core.logging.Tracing;
import org.olat.core.util.StringHelper;
import org.olat.core.util.i18n.I18nManager;

import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.ComponentList;
import net.fortuna.ical4j.model.DateList;
import net.fortuna.ical4j.model.Parameter;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.PropertyList;
import net.fortuna.ical4j.model.TimeZone;
import net.fortuna.ical4j.model.ValidationException;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.component.VTimeZone;
import net.fortuna.ical4j.model.parameter.TzId;
import net.fortuna.ical4j.model.parameter.Value;
import net.fortuna.ical4j.model.property.CalScale;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.ExDate;
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.Url;
import net.fortuna.ical4j.model.property.Version;
import net.fortuna.ical4j.model.property.XProperty;
import net.fortuna.ical4j.util.ResourceLoader;
import net.fortuna.ical4j.util.Strings;


/**
 * Description:<BR>
 * Servlet that serves the ical document.
 * <P>
 * Initial Date:  June 1, 2008
 *
 * @author Udit Sajjanhar
 * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com
 */
public class ICalServlet extends HttpServlet {

	private static final long serialVersionUID = -155266285395912535L;
	private static final Logger log = Tracing.createLoggerFor(ICalServlet.class);
	private static final int TTL_MINUTES = 15;
	private static final ConcurrentMap<String,VTimeZone> outlookVTimeZones = new ConcurrentHashMap<>();

	/**
	 * Default constructor.
	 */
	public ICalServlet() {
		//
	}

	@Override
	protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		Tracing.setHttpRequest(req);
		try {
			super.service(req, resp);
		} finally {
			//consume the userrequest.
			Tracing.clearHttpRequest();
			I18nManager.remove18nInfoFromThread();
			DBFactory.getInstance().commitAndCloseSession();
		}
	}

	@Override
	protected void doGet(HttpServletRequest request, HttpServletResponse response) {
		String requestUrl = request.getPathInfo();
			//log need a session before the response is committed
			request.getSession();
			log.debug("doGet pathInfo={}", requestUrl);
			if ((requestUrl == null) || (requestUrl.equals(""))) {
			getIcalDocument(requestUrl, request, response);
		} catch (ValidationException e) {
			log.warn("Validation Error when generate iCal stream for path::{}", request.getPathInfo(), e);
			sendError(response, HttpServletResponse.SC_CONFLICT);
		} catch (IOException e) {
			log.warn("IOException Error when generate iCal stream for path::{}", request.getPathInfo(), e);
			sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
		} catch (Exception e) {
			log.warn("Unknown Error in icalservlet", e);
			sendError(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
		}
	}
	
	private void sendError(HttpServletResponse response, int status) {
		try {
			response.sendError(status);
		} catch (IOException e) {
			log.error("", e);
	 * Reads in the appropriate ics file, depending upon the pathInfo:<br>
	 * <ul>
	 * 	<li>/aggregated/<config key>/AUTH_TOKEN.ics</li>
	 *  <li>/user/<user_name>/AUTH_TOKEN.ics</li>
	 *  <li>/group/<user_name>/AUTH_TOKEN/<group_id>.ics</li>
	 *  <li>/course/<user_name>/AUTH_TOKEN/<course_unique_id>.ics</li>
	 * </ul>
	 * @param pathInfo
	 * @return Calendar
	private void getIcalDocument(String requestUrl, HttpServletRequest request, HttpServletResponse response)
	throws ValidationException, IOException {
		// get the individual path tokens
		String pathInfo;
		int icsIndex = requestUrl.indexOf(".ics");
		if(icsIndex > 0) {
			pathInfo = requestUrl.substring(0, icsIndex);
		} else {
			pathInfo = requestUrl;
		}
		
		String[] pathInfoTokens = pathInfo.split("/");
		if(pathInfoTokens.length < 4) {
			response.sendError(HttpServletResponse.SC_BAD_REQUEST, requestUrl);
			return;
		}
		
		String calendarType = pathInfoTokens[1];
		String userName = pathInfoTokens[2];
		String authToken = pathInfoTokens[3];
		String calendarID;
		if(CalendarManager.TYPE_COURSE.equals(calendarType) || CalendarManager.TYPE_GROUP.equals(calendarType)) {
			if(pathInfoTokens.length < 5) {
				response.sendError(HttpServletResponse.SC_BAD_REQUEST, requestUrl);
				return;
			calendarID = pathInfoTokens[4];
		} else if(CalendarManager.TYPE_USER.equals(calendarType)) {
			if(pathInfoTokens.length < 5) {
				calendarID = userName;
			} else {
				calendarID = pathInfoTokens[4];
			}
		} else if(CalendarManager.TYPE_USER_AGGREGATED.equals(calendarType)) {
			calendarID = userName;
		} else {
			response.sendError(HttpServletResponse.SC_BAD_REQUEST, requestUrl);
			log.warn("Type not supported: {}", pathInfo);
		
		try {
			response.setCharacterEncoding("UTF-8");
			setCacheControl(response);

		CalendarManager calendarManager = CoreSpringFactory.getImpl(CalendarManager.class);
		if(CalendarManager.TYPE_USER_AGGREGATED.equals(calendarType)) {
			// check the authentication token
			CalendarUserConfiguration config = calendarManager.getCalendarUserConfiguration(Long.parseLong(userName));
			String savedToken = config == null ? null : config.getToken();
			if (authToken == null || savedToken == null || !savedToken.equals(authToken)) {
				log.warn("Authenticity Check failed for the ical feed path: {}", pathInfo);
				response.sendError(HttpServletResponse.SC_UNAUTHORIZED, requestUrl);
				generateAggregatedCalendar(config.getIdentity(), request, response);
			}
		} else if (calendarManager.calendarExists(calendarType, calendarID)) {
			// check the authentication token
			String savedToken = null;
			if(StringHelper.isLong(userName)) {
				CalendarUserConfiguration config = calendarManager.getCalendarUserConfiguration(Long.parseLong(userName));
				savedToken = config == null ? null : config.getToken();
				savedToken = calendarManager.getCalendarToken(calendarType, calendarID, userName);
			}
			if (authToken == null || savedToken == null || !savedToken.equals(authToken)) {
				log.warn("Authenticity Check failed for the ical feed path: {}", pathInfo);
				response.sendError(HttpServletResponse.SC_UNAUTHORIZED, requestUrl);
			} else {
				// read and return the calendar file
				Calendar calendar = calendarManager.readCalendar(calendarType, calendarID);
				DBFactory.getInstance().commitAndCloseSession();
				outputCalendar(calendar, request, response);
			response.sendError(HttpServletResponse.SC_NOT_FOUND, requestUrl);
	private void setCacheControl(HttpServletResponse httpResponse) {
	    httpResponse.setDateHeader("Expires", 0l);
	    httpResponse.setHeader("Cache-Control", "max-age=0");
	private void outputCalendar(Calendar calendar, HttpServletRequest request, HttpServletResponse response)
	throws ValidationException, IOException {
		updateUrlProperties(calendar);
		
		Writer out = response.getWriter();
		out.write(Calendar.BEGIN);
		out.write(':');
		out.write(Calendar.VCALENDAR);
		out.write(Strings.LINE_SEPARATOR);
		out.write(Version.VERSION_2_0.toString());
		
		boolean calScale = false;
		for (Iterator<?> propIter = calendar.getProperties().iterator(); propIter.hasNext();) {
			Object pobject = propIter.next();
			if(pobject instanceof Property) {
				Property property = (Property)pobject;
				if(Property.VERSION.equals(property.getName())) {
				} else if(Property.CALSCALE.equals(property.getName())) {
					out.write(property.toString());
					calScale = true;
				} else {
					out.write(property.toString());
				}
			}
		}
		
		if(!calScale) {
			out.write(CalScale.GREGORIAN.toString());
		}


		Set<String> timezoneIds = new HashSet<>();
		outputCalendarComponents(calendar, out, agent, timezoneIds);
		if(agent == Agent.outlook) {
			outputTimeZoneForOutlook(timezoneIds, out);
		}
		
		out.write(Calendar.END);
		out.write(':');
		out.write(Calendar.VCALENDAR);
	}
	
	/**
	 * Collect all the calendars, update the URL properties and the UUID.
	 * 
	 * @param identity
	 * @param request
	 * @param response
	 * @throws IOException
	 */
	private void generateAggregatedCalendar(Identity identity, HttpServletRequest request, HttpServletResponse response) throws IOException {
		PersonalCalendarManager homeCalendarManager = CoreSpringFactory.getImpl(PersonalCalendarManager.class);
		if(identity == null) {
			response.sendError(HttpServletResponse.SC_NOT_FOUND);
		} else {
			List<CalendarFileInfos> iCalFiles = homeCalendarManager.getListOfCalendarsFiles(identity);
			DBFactory.getInstance().commitAndCloseSession();
			Writer out = response.getWriter();
			out.write(Calendar.BEGIN);
			out.write(':');
			out.write(Calendar.VCALENDAR);
			out.write(Strings.LINE_SEPARATOR);
			out.write(Version.VERSION_2_0.toString());
			out.write(CalScale.GREGORIAN.toString());
			Set<String> timezoneIds = new HashSet<>();
			int numOfFiles = iCalFiles.size();
			for(int i=0; i<numOfFiles; i++) {
				outputCalendar(iCalFiles.get(i), out, agent, timezoneIds);
				outputTimeZoneForOutlook(timezoneIds, out);
			out.write(Calendar.END);
			out.write(':');
			out.write(Calendar.VCALENDAR);
	private Agent getAgent(HttpServletRequest request) {
		String userAgent = request.getHeader("User-Agent");
		if(userAgent == null) {
			return Agent.unkown;
		} else if(userAgent.indexOf("Microsoft Outlook") >= 0 || userAgent.indexOf("Microsoft Office") >= 0
				|| userAgent.equals("Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)")) {// <- this is the user agent of Outlook live
		} else if(userAgent.indexOf("Google") >= 0 && userAgent.indexOf("Calendar") >= 0) {
		} else if(userAgent.indexOf("CalendarAgent/") >= 0) {
			return Agent.calendar;
	/**
	 * Append TTL:<br>
	 * @see http://stackoverflow.com/questions/17152251/specifying-name-description-and-refresh-interval-in-ical-ics-format
	 * @see http://tools.ietf.org/html/draft-daboo-icalendar-extensions-06
	 * 
	 * @param out
	 * @throws IOException
	 */
	private void outputTTL(Agent agent, Writer out)
		out.write("X-PUBLISHED-TTL:PT" + TTL_MINUTES + "M");
		out.write(Strings.LINE_SEPARATOR);
			out.write("REFRESH-INTERVAL;VALUE=DURATION:PT" + TTL_MINUTES + "M");
	private void outputTimeZoneForOutlook(Set<String> timezoneIds,  Writer out) {
		for(String timezoneId:timezoneIds) {
			if(StringHelper.containsNonWhitespace(timezoneId)) {
				try {
					VTimeZone vTimeZone = getOutlookVTimeZone(timezoneId);
					if(vTimeZone != null) {
						out.write(vTimeZone.toString());
					}
				} catch (Exception e) {
	private void outputCalendar(CalendarFileInfos fileInfos, Writer out, Agent agent, Set<String> timezoneIds)
		try {
			CalendarManager calendarManager = CoreSpringFactory.getImpl(CalendarManager.class);
			Calendar calendar = calendarManager.readCalendar(fileInfos.getCalendarFile());
			updateUrlProperties(calendar);
			
			String prefix = fileInfos.getType() + "-" + fileInfos.getCalendarId() + "-";
			updateUUID(calendar, prefix);
			
			outputCalendarComponents(calendar, out, agent, timezoneIds);
		} catch (IOException | OLATRuntimeException e) {
			log.error("", e);
		}
	}
	
	private void outputCalendarComponents(Calendar calendar, Writer out, Agent agent, Set<String> timezoneIds)
			ComponentList events = calendar.getComponents();
			for (final Iterator<?> i = events.iterator(); i.hasNext();) {
				outputCalendarComponent(i.next(), out, agent, timezoneIds);
			}
		} catch (IOException | OLATRuntimeException e) {
			log.error("", e);
	private void outputCalendarComponent(Object component, Writer out, Agent agent, Set<String> timezoneIds) throws IOException {
		if (component instanceof VEvent) {
			rewriteExDate((VEvent)component);
		}

		String event = component.toString();
		if (agent == Agent.outlook && component instanceof VEvent) {
			event = quoteTimeZone(event, (VEvent)component, timezoneIds);
		}
		if(agent == Agent.googleCalendar) {
			event = event.replace("CLASS:PRIVATE" + Strings.LINE_SEPARATOR, "");
			event = event.replace("X-OLAT-MANAGED:all" + Strings.LINE_SEPARATOR, "");
			event = event.replace("DESCRIPTION:" + Strings.LINE_SEPARATOR, "");
			event = event.replace("LOCATION:" + Strings.LINE_SEPARATOR, "");
		}
		
		out.write(event);
	}
	
	/**
	 * 
	 * @param event The event to rewrite
	 */
	private void rewriteExDate(VEvent event) {
		DtStart start = event.getStartDate();
		ExDate exDate = (ExDate)event.getProperties().getProperty(Property.EXDATE);

		if(exDate != null && start != null) {
			Date startDate = start.getDate();
			java.util.Calendar startCal = java.util.Calendar.getInstance();
			startCal.setTime(startDate);
				
			TimeZone startZone = start.getTimeZone();
			TimeZone exZone = exDate.getTimeZone();
			DateList dateList = exDate.getDates();
			
			java.util.Calendar excCal = java.util.Calendar.getInstance();

			Parameter dateParameter = event.getProperties().getProperty(Property.DTSTART)
					.getParameters().getParameter(Value.DATE.getName());
			boolean dateOnly = dateParameter != null;

			DateList newDateList = dateOnly ? new DateList(Value.DATE) : new DateList();
			for(Object obj:dateList) {
				Date d = (Date)obj;
				if(dateOnly) {
					newDateList.add(CalendarUtils.createDate(d));
				} else {
					excCal.setTime(d);
					if(excCal.get(java.util.Calendar.HOUR_OF_DAY) != startCal.get(java.util.Calendar.HOUR_OF_DAY)) {
						excCal.set(java.util.Calendar.HOUR_OF_DAY, startCal.get(java.util.Calendar.HOUR_OF_DAY));
						excCal.set(java.util.Calendar.MINUTE, startCal.get(java.util.Calendar.MINUTE));
						excCal.set(java.util.Calendar.SECOND, startCal.get(java.util.Calendar.SECOND));
						d = excCal.getTime();
					}
					newDateList.add(CalendarUtils.createDateTime(d));
				}
			}
			
			ExDate newExDate = new ExDate(newDateList);
			if(exZone == null && startZone != null) {
				newExDate.setTimeZone(startZone);
			}
			
			event.getProperties().remove(exDate);
			event.getProperties().add(newExDate);
		}
	}
	
	private String quoteTimeZone(String event, VEvent vEvent, Set<String> timezoneIds) {
		if(vEvent == null || vEvent.getStartDate().getTimeZone() == null
				|| vEvent.getStartDate().getTimeZone().getVTimeZone() == null) {
			return event;
		}

		String timezoneId = vEvent.getStartDate().getTimeZone().getID();
		timezoneIds.add(timezoneId);
		TzId tzId = (TzId)vEvent.getStartDate().getParameter(Parameter.TZID);
		String tzidReplacement = "TZID=\"" + timezoneId + "\"";	
		return event.replace(tzId.toString(), tzidReplacement);
	}
	
	private void updateUUID(Calendar calendar, String prefix) {
		for (Iterator<?> eventIter = calendar.getComponents().iterator(); eventIter.hasNext();) {
			Object comp = eventIter.next();
			if (comp instanceof VEvent) {
				VEvent event = (VEvent)comp;
				Uid uid = event.getUid();
				if(uid != null) {
					String newUid = prefix.concat(uid.getValue());
					uid.setValue(newUid);
				}
		}
	}
	
	private void updateUrlProperties(Calendar calendar) {
		for (Iterator<?> eventIter = calendar.getComponents().iterator(); eventIter.hasNext();) {
			Object comp = eventIter.next();
			if (comp instanceof VEvent) {
				VEvent event = (VEvent)comp;
				
				PropertyList ooLinkProperties = event.getProperties(CalendarManager.ICAL_X_OLAT_LINK);
				if(ooLinkProperties.isEmpty()) {
					continue;
				}
				
				Url currentUrl = event.getUrl();
				if(currentUrl != null) {
					continue;
				}
				
				for (Iterator<?> iter = ooLinkProperties.iterator(); iter.hasNext();) {
					XProperty linkProperty = (XProperty) iter.next();
					if (linkProperty != null) {
						String encodedLink = linkProperty.getValue();
						StringTokenizer st = new StringTokenizer(encodedLink, "§", false);
						if (st.countTokens() >= 4) {
							st.nextToken();//provider
							st.nextToken();//id
							st.nextToken();//displayname
							
							String uri = st.nextToken();
							try {
								Url urlProperty = new Url();
								urlProperty.setValue(uri);
								event.getProperties().add(urlProperty);
								break;
							} catch (URISyntaxException e) {
								log.error("Invalid URL:{}", uri);
	
	/**
     * Load the VTimeZone for Outlook. ical4j use a static map to reuse the TimeZone objects, we need to load
     * and save our specialized TimeZone in a separate map.
     */
    private VTimeZone getOutlookVTimeZone(final String id) {
    	return outlookVTimeZones.computeIfAbsent(id, timeZoneId -> {
        	try {
				URL resource = ResourceLoader.getResource("zoneinfo-outlook/" + id + ".ics");
				Calendar calendar = buildCalendar(resource);
				return calendar == null ? null : (VTimeZone)calendar.getComponent(Component.VTIMEZONE);
			} catch (Exception e) {
				log.error("", e);
				return null;
			}
    	});
    }
    private Calendar buildCalendar(URL resource) {
    	CalendarBuilder builder = new CalendarBuilder();
    	try(InputStream in = resource.openStream()) {
    		return builder.build(in);
    	} catch(IOException | ParserException e) {
    		log.error("", e);
    		return null;
    	}
    }
    
    		calendar,// macos, iOS