View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2009  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  package davmail.caldav;
20  
21  import davmail.AbstractConnection;
22  import davmail.BundleMessage;
23  import davmail.DavGateway;
24  import davmail.Settings;
25  import davmail.exception.*;
26  import davmail.exchange.ExchangeSession;
27  import davmail.exchange.ExchangeSessionFactory;
28  import davmail.exchange.ICSBufferedReader;
29  import davmail.exchange.XMLStreamUtil;
30  import davmail.ui.tray.DavGatewayTray;
31  import davmail.util.IOUtil;
32  import davmail.util.StringUtil;
33  import org.apache.commons.httpclient.HttpException;
34  import org.apache.commons.httpclient.HttpStatus;
35  import org.apache.commons.httpclient.URI;
36  import org.apache.commons.httpclient.URIException;
37  import org.apache.commons.httpclient.util.URIUtil;
38  import org.apache.log4j.Logger;
39  
40  import javax.xml.stream.XMLStreamConstants;
41  import javax.xml.stream.XMLStreamException;
42  import javax.xml.stream.XMLStreamReader;
43  import java.io.*;
44  import java.net.Socket;
45  import java.net.SocketException;
46  import java.net.SocketTimeoutException;
47  import java.net.URL;
48  import java.text.SimpleDateFormat;
49  import java.util.*;
50  
51  /**
52   * Handle a caldav connection.
53   */
54  public class CaldavConnection extends AbstractConnection {
55      /**
56       * Maximum keep alive time in seconds
57       */
58      protected static final int MAX_KEEP_ALIVE_TIME = 300;
59      protected final Logger wireLogger = Logger.getLogger(this.getClass());
60  
61      protected boolean closed;
62  
63      /**
64       * custom url encode path set for iCal 5
65       */
66      public static final BitSet ical_allowed_abs_path = new BitSet(256);
67      static {
68          ical_allowed_abs_path.or(URI.allowed_abs_path);
69          ical_allowed_abs_path.clear('@');
70      }
71  
72      static String encodePath(CaldavRequest request, String path) throws URIException {
73          if (request.isIcal5()) {
74              return URIUtil.encode(path, ical_allowed_abs_path, "UTF-8");
75          } else {
76              return URIUtil.encodePath(path, "UTF-8");
77          }
78      }
79  
80      /**
81       * Initialize the streams and start the thread.
82       *
83       * @param clientSocket Caldav client socket
84       */
85      public CaldavConnection(Socket clientSocket) {
86          super(CaldavConnection.class.getSimpleName(), clientSocket, "UTF-8");
87          // set caldav logging to davmail logging level
88          wireLogger.setLevel(Settings.getLoggingLevel("davmail"));
89      }
90  
91      protected Map<String, String> parseHeaders() throws IOException {
92          HashMap<String, String> headers = new HashMap<String, String>();
93          String line;
94          while ((line = readClient()) != null && line.length() > 0) {
95              int index = line.indexOf(':');
96              if (index <= 0) {
97                  wireLogger.warn("Invalid header: " + line);
98                  throw new DavMailException("EXCEPTION_INVALID_HEADER");
99              }
100             headers.put(line.substring(0, index).toLowerCase(), line.substring(index + 1).trim());
101         }
102         return headers;
103     }
104 
105     protected String getContent(String contentLength) throws IOException {
106         if (contentLength == null || contentLength.length() == 0) {
107             return null;
108         } else {
109             int size;
110             try {
111                 size = Integer.parseInt(contentLength);
112             } catch (NumberFormatException e) {
113                 throw new DavMailException("EXCEPTION_INVALID_CONTENT_LENGTH", contentLength);
114             }
115             String content = in.readContentAsString(size);
116             if (wireLogger.isDebugEnabled()) {
117                 wireLogger.debug("< " + content);
118             }
119             return content;
120         }
121     }
122 
123     protected void setSocketTimeout(String keepAliveValue) throws IOException {
124         if (keepAliveValue != null && keepAliveValue.length() > 0) {
125             int keepAlive;
126             try {
127                 keepAlive = Integer.parseInt(keepAliveValue);
128             } catch (NumberFormatException e) {
129                 throw new DavMailException("EXCEPTION_INVALID_KEEPALIVE", keepAliveValue);
130             }
131             if (keepAlive > MAX_KEEP_ALIVE_TIME) {
132                 keepAlive = MAX_KEEP_ALIVE_TIME;
133             }
134             client.setSoTimeout(keepAlive * 1000);
135             DavGatewayTray.debug(new BundleMessage("LOG_SET_SOCKET_TIMEOUT", keepAlive));
136         }
137     }
138 
139     @Override
140     public void run() {
141         String line;
142         StringTokenizer tokens;
143 
144         try {
145             while (!closed) {
146                 line = readClient();
147                 // unable to read line, connection closed ?
148                 if (line == null) {
149                     break;
150                 }
151                 tokens = new StringTokenizer(line);
152                 String command = tokens.nextToken();
153                 Map<String, String> headers = parseHeaders();
154                 String encodedPath = StringUtil.encodePlusSign(tokens.nextToken());
155                 String path = URIUtil.decode(encodedPath);
156                 String content = getContent(headers.get("content-length"));
157                 setSocketTimeout(headers.get("keep-alive"));
158                 // client requested connection close
159                 closed = "close".equals(headers.get("connection"));
160                 if ("OPTIONS".equals(command)) {
161                     sendOptions();
162                 } else if (!headers.containsKey("authorization")) {
163                     sendUnauthorized();
164                 } else {
165                     decodeCredentials(headers.get("authorization"));
166                     // need to check session on each request, credentials may have changed or session expired
167                     try {
168                         session = ExchangeSessionFactory.getInstance(userName, password);
169                         logConnection("LOGON", userName);
170                         handleRequest(command, path, headers, content);
171                     } catch (DavMailAuthenticationException e) {
172                         logConnection("FAILED", userName);
173                         if (Settings.getBooleanProperty("davmail.enableKerberos")) {
174                             // authentication failed in Kerberos mode => not available
175                             throw new HttpServerErrorException("Kerberos authentication failed");
176                         } else {
177                             sendUnauthorized();
178                         }
179                     }
180                 }
181 
182                 os.flush();
183                 DavGatewayTray.resetIcon();
184             }
185         } catch (SocketTimeoutException e) {
186             DavGatewayTray.debug(new BundleMessage("LOG_CLOSE_CONNECTION_ON_TIMEOUT"));
187         } catch (SocketException e) {
188             DavGatewayTray.debug(new BundleMessage("LOG_CONNECTION_CLOSED"));
189         } catch (Exception e) {
190             if (!(e instanceof HttpNotFoundException)) {
191                 DavGatewayTray.log(e);
192             }
193             try {
194                 sendErr(e);
195             } catch (IOException e2) {
196                 DavGatewayTray.debug(new BundleMessage("LOG_EXCEPTION_SENDING_ERROR_TO_CLIENT"), e2);
197             }
198         } finally {
199             close();
200         }
201         DavGatewayTray.resetIcon();
202     }
203 
204     /**
205      * Handle caldav request.
206      *
207      * @param command Http command
208      * @param path    request path
209      * @param headers Http headers map
210      * @param body    request body
211      * @throws IOException on error
212      */
213     public void handleRequest(String command, String path, Map<String, String> headers, String body) throws IOException {
214         CaldavRequest request = new CaldavRequest(command, path, headers, body);
215         if (request.isOptions()) {
216             sendOptions();
217         } else if (request.isPropFind() && request.isRoot()) {
218             sendRoot(request);
219         } else if (request.isGet() && request.isRoot()) {
220             sendGetRoot();
221         } else if (request.isPath(1, "principals")) {
222             handlePrincipals(request);
223         } else if (request.isPath(1, "users")) {
224             if (request.isPropFind() && request.isPathLength(3)) {
225                 sendUserRoot(request);
226             } else {
227                 handleFolderOrItem(request);
228             }
229         } else if (request.isPath(1, "public")) {
230             handleFolderOrItem(request);
231         } else if (request.isPath(1, "directory")) {
232             sendDirectory(request);
233         } else if (request.isPath(1, ".well-known")) {
234             sendWellKnown();
235         } else {
236             sendUnsupported(request);
237         }
238     }
239 
240     protected void handlePrincipals(CaldavRequest request) throws IOException {
241         if (request.isPath(2, "users")) {
242             if (request.isPropFind() && request.isPathLength(4)) {
243                 sendPrincipal(request, "users", URIUtil.decode(request.getPathElement(3)));
244                 // send back principal on search
245             } else if (request.isReport() && request.isPathLength(3)) {
246                 sendPrincipal(request, "users", session.getEmail());
247                 // iCal current-user-principal request
248             } else if (request.isPropFind() && request.isPathLength(3)) {
249                 sendPrincipalsFolder(request);
250             } else {
251                 sendUnsupported(request);
252             }
253         } else if (request.isPath(2, "public")) {
254             StringBuilder prefixBuffer = new StringBuilder("public");
255             for (int i = 3; i < request.getPathLength() - 1; i++) {
256                 prefixBuffer.append('/').append(request.getPathElement(i));
257             }
258             sendPrincipal(request, URIUtil.decode(prefixBuffer.toString()), URIUtil.decode(request.getLastPath()));
259         } else {
260             sendUnsupported(request);
261         }
262     }
263 
264     protected void handleFolderOrItem(CaldavRequest request) throws IOException {
265         String lastPath = StringUtil.xmlDecode(request.getLastPath());
266         // folder requests
267         if (request.isPropFind() && "inbox".equals(lastPath)) {
268             sendInbox(request);
269         } else if (request.isPropFind() && "outbox".equals(lastPath)) {
270             sendOutbox(request);
271         } else if (request.isPost() && "outbox".equals(lastPath)) {
272             if (request.isFreeBusy()) {
273                 sendFreeBusy(request.getBody());
274             } else {
275                 int status = session.sendEvent(request.getBody());
276                 // TODO: implement Itip response body
277                 sendHttpResponse(status);
278             }
279         } else if (request.isPropFind()) {
280             sendFolderOrItem(request);
281         } else if (request.isPropPatch()) {
282             patchCalendar(request);
283         } else if (request.isReport()) {
284             reportItems(request);
285             // event requests
286         } else if (request.isPut()) {
287             String etag = request.getHeader("if-match");
288             String noneMatch = request.getHeader("if-none-match");
289             ExchangeSession.ItemResult itemResult = session.createOrUpdateItem(request.getFolderPath(), lastPath, request.getBody(), etag, noneMatch);
290             sendHttpResponse(itemResult.status, buildEtagHeader(itemResult.etag), null, "", true);
291 
292         } else if (request.isDelete()) {
293             if (request.getFolderPath().endsWith("inbox")) {
294                 session.processItem(request.getFolderPath(), lastPath);
295             } else {
296                 session.deleteItem(request.getFolderPath(), lastPath);
297             }
298             sendHttpResponse(HttpStatus.SC_OK);
299         } else if (request.isGet()) {
300             if (request.path.endsWith("/")) {
301                 // GET request on a folder => build ics content of all folder events
302                 String folderPath = request.getFolderPath();
303                 ExchangeSession.Folder folder = session.getFolder(folderPath);
304                 if (folder.isContact()) {
305                     List<ExchangeSession.Contact> contacts = session.getAllContacts(folderPath);
306                     ChunkedResponse response = new ChunkedResponse(HttpStatus.SC_OK, "text/vcard;charset=UTF-8");
307 
308                     for (ExchangeSession.Contact contact : contacts) {
309                         String contactBody = contact.getBody();
310                         if (contactBody != null) {
311                             response.append(contactBody);
312                             response.append("\n");
313                         }
314                     }
315                     response.close();
316 
317                 } else if (folder.isCalendar() || folder.isTask()) {
318                     List<ExchangeSession.Event> events = session.getAllEvents(folderPath);
319                     ChunkedResponse response = new ChunkedResponse(HttpStatus.SC_OK, "text/calendar;charset=UTF-8");
320                     response.append("BEGIN:VCALENDAR\r\n");
321                     response.append("VERSION:2.0\r\n");
322                     response.append("PRODID:-//davmail.sf.net/NONSGML DavMail Calendar V1.1//EN\r\n");
323                     response.append("METHOD:PUBLISH\r\n");
324 
325                     for (ExchangeSession.Event event : events) {
326                         String icsContent = StringUtil.getToken(event.getBody(), "BEGIN:VTIMEZONE", "END:VCALENDAR");
327                         if (icsContent != null) {
328                             response.append("BEGIN:VTIMEZONE");
329                             response.append(icsContent);
330                         } else {
331                             icsContent = StringUtil.getToken(event.getBody(), "BEGIN:VEVENT", "END:VCALENDAR");
332                             if (icsContent != null) {
333                                 response.append("BEGIN:VEVENT");
334                                 response.append(icsContent);
335                             }
336                         }
337                     }
338                     response.append("END:VCALENDAR");
339                     response.close();
340                 } else {
341                     sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(folder.etag), "text/html", (byte[]) null, true);
342                 }
343             } else {
344                 ExchangeSession.Item item = session.getItem(request.getFolderPath(), lastPath);
345                 sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(item.getEtag()), item.getContentType(), item.getBody(), true);
346             }
347         } else if (request.isHead()) {
348             // test event
349             ExchangeSession.Item item = session.getItem(request.getFolderPath(), lastPath);
350             sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(item.getEtag()), item.getContentType(), (byte[]) null, true);
351         } else if (request.isMkCalendar()) {
352             HashMap<String, String> properties = new HashMap<String, String>();
353             //properties.put("displayname", request.getProperty("displayname"));
354             int status = session.createCalendarFolder(request.getFolderPath(), properties);
355             sendHttpResponse(status, null);
356         } else if (request.isMove()) {
357             String destinationUrl = request.getHeader("destination");
358             session.moveItem(request.path, URIUtil.decode(new URL(destinationUrl).getPath()));
359             sendHttpResponse(HttpStatus.SC_CREATED, null);
360         } else {
361             sendUnsupported(request);
362         }
363 
364     }
365 
366     protected HashMap<String, String> buildEtagHeader(String etag) {
367         if (etag != null) {
368             HashMap<String, String> etagHeader = new HashMap<String, String>();
369             etagHeader.put("ETag", etag);
370             return etagHeader;
371         } else {
372             return null;
373         }
374     }
375 
376     private void appendContactsResponses(CaldavResponse response, CaldavRequest request, List<ExchangeSession.Contact> contacts) throws IOException {
377         int size = contacts.size();
378         int count = 0;
379         for (ExchangeSession.Contact contact : contacts) {
380             DavGatewayTray.debug(new BundleMessage("LOG_LISTING_ITEM", ++count, size));
381             DavGatewayTray.switchIcon();
382             appendItemResponse(response, request, contact);
383         }
384     }
385 
386     protected void appendEventsResponses(CaldavResponse response, CaldavRequest request, List<ExchangeSession.Event> events) throws IOException {
387         int size = events.size();
388         int count = 0;
389         for (ExchangeSession.Event event : events) {
390             DavGatewayTray.debug(new BundleMessage("LOG_LISTING_ITEM", ++count, size));
391             DavGatewayTray.switchIcon();
392             appendItemResponse(response, request, event);
393         }
394     }
395 
396     protected void appendItemResponse(CaldavResponse response, CaldavRequest request, ExchangeSession.Item item) throws IOException {
397         StringBuilder eventPath = new StringBuilder();
398         eventPath.append(encodePath(request, request.getPath()));
399         if (!(eventPath.charAt(eventPath.length() - 1) == '/')) {
400             eventPath.append('/');
401         }
402         String itemName = StringUtil.xmlEncode(item.getName());
403         eventPath.append(URIUtil.encodeWithinQuery(itemName));
404         response.startResponse(eventPath.toString());
405         response.startPropstat();
406         if (request.hasProperty("calendar-data") && item instanceof ExchangeSession.Event) {
407             response.appendCalendarData(item.getBody());
408         }
409         if (request.hasProperty("address-data") && item instanceof ExchangeSession.Contact) {
410             response.appendContactData(item.getBody());
411         }
412         if (request.hasProperty("getcontenttype")) {
413             if (item instanceof ExchangeSession.Event) {
414                 response.appendProperty("D:getcontenttype", "text/calendar; component=vevent");
415             } else if (item instanceof ExchangeSession.Contact) {
416                 response.appendProperty("D:getcontenttype", "text/vcard");
417             }
418         }
419         if (request.hasProperty("getetag")) {
420             response.appendProperty("D:getetag", item.getEtag());
421         }
422         if (request.hasProperty("resourcetype")) {
423             response.appendProperty("D:resourcetype");
424         }
425         if (request.hasProperty("displayname")) {
426             response.appendProperty("D:displayname", itemName);
427         }
428         response.endPropStatOK();
429         response.endResponse();
430     }
431 
432     /**
433      * Append folder object to Caldav response.
434      *
435      * @param response  Caldav response
436      * @param request   Caldav request
437      * @param folder    folder object
438      * @param subFolder calendar folder path relative to request path
439      * @throws IOException on error
440      */
441     public void appendFolderOrItem(CaldavResponse response, CaldavRequest request, ExchangeSession.Folder folder, String subFolder) throws IOException {
442         response.startResponse(encodePath(request, request.getPath(subFolder)));
443         response.startPropstat();
444 
445         if (request.hasProperty("resourcetype")) {
446             if (folder.isContact()) {
447                 response.appendProperty("D:resourcetype", "<D:collection/>" +
448                         "<E:addressbook/>");
449             } else if (folder.isCalendar() || folder.isTask()) {
450                 response.appendProperty("D:resourcetype", "<D:collection/>" + "<C:calendar/>");
451             } else {
452                 response.appendProperty("D:resourcetype", "<D:collection/>");
453             }
454 
455         }
456         if (request.hasProperty("owner")) {
457             if ("users".equals(request.getPathElement(1))) {
458                 response.appendHrefProperty("D:owner", "/principals/users/" + request.getPathElement(2));
459             } else {
460                 response.appendHrefProperty("D:owner", "/principals" + request.getPath());
461             }
462         }
463         if (request.hasProperty("getcontenttype")) {
464             if (folder.isContact()) {
465                 response.appendProperty("D:getcontenttype", "text/x-vcard");
466             } else if (folder.isCalendar()) {
467                 response.appendProperty("D:getcontenttype", "text/calendar; component=vevent");
468             } else if (folder.isTask()) {
469                 response.appendProperty("D:getcontenttype", "text/calendar; component=vtodo");
470             }
471         }
472         if (request.hasProperty("getetag")) {
473             response.appendProperty("D:getetag", folder.etag);
474         }
475         if (request.hasProperty("getctag")) {
476             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"",
477                     IOUtil.encodeBase64AsString(folder.ctag));
478         }
479         if (request.hasProperty("displayname")) {
480             if (subFolder == null || subFolder.length() == 0) {
481                 // use i18n calendar name as display name
482                 String displayname = request.getLastPath();
483                 if ("calendar".equals(displayname)) {
484                     displayname = folder.displayName;
485                 }
486                 response.appendProperty("D:displayname", displayname);
487             } else {
488                 response.appendProperty("D:displayname", subFolder);
489             }
490         }
491         if (request.hasProperty("calendar-description")) {
492             response.appendProperty("C:calendar-description", "");
493         }
494         if (request.hasProperty("supported-calendar-component-set")) {
495             if (folder.isCalendar()) {
496                 response.appendProperty("C:supported-calendar-component-set", "<C:comp name=\"VEVENT\"/><C:comp name=\"VTODO\"/>");
497             } else if (folder.isTask()) {
498                 response.appendProperty("C:supported-calendar-component-set", "<C:comp name=\"VTODO\"/>");
499             }
500         }
501 
502         if (request.hasProperty("current-user-privilege-set")) {
503             response.appendProperty("D:current-user-privilege-set", "<D:privilege><D:read/><D:write/></D:privilege>");
504         }
505 
506         response.endPropStatOK();
507         response.endResponse();
508     }
509 
510     /**
511      * Append calendar inbox object to Caldav response.
512      *
513      * @param response  Caldav response
514      * @param request   Caldav request
515      * @param subFolder inbox folder path relative to request path
516      * @throws IOException on error
517      */
518     public void appendInbox(CaldavResponse response, CaldavRequest request, String subFolder) throws IOException {
519         String ctag = "0";
520         String etag = "0";
521         String folderPath = request.getFolderPath(subFolder);
522         // do not try to access inbox on shared calendar
523         if (!session.isSharedFolder(folderPath)) {
524             try {
525                 ExchangeSession.Folder folder = session.getFolder(folderPath);
526                 ctag = IOUtil.encodeBase64AsString(folder.ctag);
527                 etag = IOUtil.encodeBase64AsString(folder.etag);
528             } catch (HttpException e) {
529                 // unauthorized access, probably an inbox on shared calendar
530                 DavGatewayTray.debug(new BundleMessage("LOG_ACCESS_FORBIDDEN", folderPath, e.getMessage()));
531             }
532         }
533         response.startResponse(encodePath(request, request.getPath(subFolder)));
534         response.startPropstat();
535 
536         if (request.hasProperty("resourcetype")) {
537             response.appendProperty("D:resourcetype", "<D:collection/>" +
538                     "<C:schedule-inbox xmlns:C=\"urn:ietf:params:xml:ns:caldav\"/>");
539         }
540         if (request.hasProperty("getcontenttype")) {
541             response.appendProperty("D:getcontenttype", "text/calendar; component=vevent");
542         }
543         if (request.hasProperty("getctag")) {
544             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"", ctag);
545         }
546         if (request.hasProperty("getetag")) {
547             response.appendProperty("D:getetag", etag);
548         }
549         if (request.hasProperty("displayname")) {
550             response.appendProperty("D:displayname", "inbox");
551         }
552         response.endPropStatOK();
553         response.endResponse();
554     }
555 
556     /**
557      * Append calendar outbox object to Caldav response.
558      *
559      * @param response  Caldav response
560      * @param request   Caldav request
561      * @param subFolder outbox folder path relative to request path
562      * @throws IOException on error
563      */
564     public void appendOutbox(CaldavResponse response, CaldavRequest request, String subFolder) throws IOException {
565         response.startResponse(encodePath(request, request.getPath(subFolder)));
566         response.startPropstat();
567 
568         if (request.hasProperty("resourcetype")) {
569             response.appendProperty("D:resourcetype", "<D:collection/>" +
570                     "<C:schedule-outbox xmlns:C=\"urn:ietf:params:xml:ns:caldav\"/>");
571         }
572         if (request.hasProperty("getctag")) {
573             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"",
574                     "0");
575         }
576         if (request.hasProperty("getetag")) {
577             response.appendProperty("D:getetag", "0");
578         }
579         if (request.hasProperty("displayname")) {
580             response.appendProperty("D:displayname", "outbox");
581         }
582         response.endPropStatOK();
583         response.endResponse();
584     }
585 
586     /**
587      * Send simple html response to GET /.
588      *
589      * @throws IOException on error
590      */
591     public void sendGetRoot() throws IOException {
592         StringBuilder buffer = new StringBuilder();
593         buffer.append("Connected to DavMail<br/>");
594         buffer.append("UserName :").append(userName).append("<br/>");
595         buffer.append("Email :").append(session.getEmail()).append("<br/>");
596         sendHttpResponse(HttpStatus.SC_OK, null, "text/html;charset=UTF-8", buffer.toString(), true);
597     }
598 
599     /**
600      * Send inbox response for request.
601      *
602      * @param request Caldav request
603      * @throws IOException on error
604      */
605     public void sendInbox(CaldavRequest request) throws IOException {
606         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
607         response.startMultistatus();
608         appendInbox(response, request, null);
609         // do not try to access inbox on shared calendar
610         if (!session.isSharedFolder(request.getFolderPath(null)) && request.getDepth() == 1
611                 && !request.isLightning()) {
612             try {
613                 DavGatewayTray.debug(new BundleMessage("LOG_SEARCHING_CALENDAR_MESSAGES"));
614                 List<ExchangeSession.Event> events = session.getEventMessages(request.getFolderPath());
615                 DavGatewayTray.debug(new BundleMessage("LOG_FOUND_CALENDAR_MESSAGES", events.size()));
616                 appendEventsResponses(response, request, events);
617             } catch (HttpException e) {
618                 // unauthorized access, probably an inbox on shared calendar
619                 DavGatewayTray.debug(new BundleMessage("LOG_ACCESS_FORBIDDEN", request.getFolderPath(), e.getMessage()));
620             }
621         }
622         response.endMultistatus();
623         response.close();
624     }
625 
626     /**
627      * Send outbox response for request.
628      *
629      * @param request Caldav request
630      * @throws IOException on error
631      */
632     public void sendOutbox(CaldavRequest request) throws IOException {
633         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
634         response.startMultistatus();
635         appendOutbox(response, request, null);
636         response.endMultistatus();
637         response.close();
638     }
639 
640     /**
641      * Send calendar response for request.
642      *
643      * @param request Caldav request
644      * @throws IOException on error
645      */
646     public void sendFolderOrItem(CaldavRequest request) throws IOException {
647         String folderPath = request.getFolderPath();
648         // process request before sending response to avoid sending headers twice on error
649         ExchangeSession.Folder folder = session.getFolder(folderPath);
650         List<ExchangeSession.Contact> contacts = null;
651         List<ExchangeSession.Event> events = null;
652         List<ExchangeSession.Folder> folderList = null;
653         if (request.getDepth() == 1) {
654             if (folder.isContact()) {
655                 contacts = session.getAllContacts(folderPath);
656             } else if (folder.isCalendar() || folder.isTask()) {
657                 events = session.getAllEvents(folderPath);
658                 if (!folderPath.startsWith("/public")) {
659                     folderList = session.getSubCalendarFolders(folderPath, false);
660                 }
661             }
662         }
663 
664         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
665         response.startMultistatus();
666         appendFolderOrItem(response, request, folder, null);
667         if (request.getDepth() == 1) {
668             if (folder.isContact()) {
669                 appendContactsResponses(response, request, contacts);
670             } else if (folder.isCalendar() || folder.isTask()) {
671                 appendEventsResponses(response, request, events);
672                 // Send sub folders for multi-calendar support under iCal, except for public folders
673                 if (folderList != null) {
674                     for (ExchangeSession.Folder subFolder : folderList) {
675                         appendFolderOrItem(response, request, subFolder, subFolder.folderPath.substring(subFolder.folderPath.indexOf('/') + 1));
676                     }
677                 }
678             }
679         }
680         response.endMultistatus();
681         response.close();
682     }
683 
684     /**
685      * Fake PROPPATCH response for request.
686      *
687      * @param request Caldav request
688      * @throws IOException on error
689      */
690     public void patchCalendar(CaldavRequest request) throws IOException {
691         String displayname = request.getProperty("displayname");
692         String folderPath = request.getFolderPath();
693         if (displayname != null) {
694             String targetPath = request.getParentFolderPath() + '/' + displayname;
695             if (!targetPath.equals(folderPath)) {
696                 session.moveFolder(folderPath, targetPath);
697             }
698         }
699         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
700         response.startMultistatus();
701         // ical calendar folder proppatch
702         if (request.hasProperty("calendar-color") || request.hasProperty("calendar-order")) {
703             response.startPropstat();
704             if (request.hasProperty("calendar-color")) {
705                 response.appendProperty("x1:calendar-color", "x1=\"http://apple.com/ns/ical/\"", null);
706             }
707             if (request.hasProperty("calendar-order")) {
708                 response.appendProperty("x1:calendar-order", "x1=\"http://apple.com/ns/ical/\"", null);
709             }
710             response.endPropStatOK();
711         }
712         response.endMultistatus();
713         response.close();
714     }
715 
716     protected String getEventFileNameFromPath(String path) {
717         int index = path.lastIndexOf('/');
718         if (index < 0) {
719             return null;
720         } else {
721             return StringUtil.xmlDecode(path.substring(index + 1));
722         }
723     }
724 
725     /**
726      * Report items listed in request.
727      *
728      * @param request Caldav request
729      * @throws IOException on error
730      */
731     public void reportItems(CaldavRequest request) throws IOException {
732         String folderPath = request.getFolderPath();
733         List<ExchangeSession.Event> events;
734         List<String> notFound = new ArrayList<String>();
735 
736         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
737         response.startMultistatus();
738         if (request.isMultiGet()) {
739             int count = 0;
740             int total = request.getHrefs().size();
741             for (String href : request.getHrefs()) {
742                 DavGatewayTray.debug(new BundleMessage("LOG_REPORT_ITEM", ++count, total));
743                 DavGatewayTray.switchIcon();
744                 String eventName = getEventFileNameFromPath(href);
745                 try {
746                     // ignore cases for Sunbird
747                     if (eventName != null && eventName.length() > 0
748                             && !"inbox".equals(eventName) && !"calendar".equals(eventName)) {
749                         ExchangeSession.Item item;
750                         try {
751                             item = session.getItem(folderPath, eventName);
752                         } catch (HttpNotFoundException e) {
753                             // workaround for Lightning bug
754                             if (request.isBrokenLightning() && eventName.indexOf('%') >= 0) {
755                                 item = session.getItem(folderPath, URIUtil.decode(StringUtil.encodePlusSign(eventName)));
756                             } else {
757                                 throw e;
758                             }
759 
760                         }
761                         if (!eventName.equals(item.getName())) {
762                             DavGatewayTray.warn(new BundleMessage("LOG_MESSAGE", "wrong item name requested "+eventName+" received "+item.getName()));
763                         }
764                         appendItemResponse(response, request, item);
765                     }
766                 } catch (SocketException e) {
767                     // rethrow SocketException (client closed connection)
768                     throw e;
769                 } catch (Exception e) {
770                     wireLogger.debug(e.getMessage(), e);
771                     DavGatewayTray.warn(new BundleMessage("LOG_ITEM_NOT_AVAILABLE", eventName, href));
772                     notFound.add(href);
773                 }
774             }
775         } else if (request.isPath(1, "users") && request.isPath(3, "inbox")) {
776             events = session.getEventMessages(request.getFolderPath());
777             appendEventsResponses(response, request, events);
778         } else {
779             ExchangeSession.Folder folder = session.getFolder(folderPath);
780             if (folder.isContact()) {
781                 List<ExchangeSession.Contact> contacts = session.getAllContacts(folderPath);
782                 appendContactsResponses(response, request, contacts);
783             } else {
784                 if (request.vTodoOnly) {
785                     events = session.searchTasksOnly(request.getFolderPath());
786                 } else if (request.vEventOnly) {
787                     events = session.searchEventsOnly(request.getFolderPath(), request.timeRangeStart, request.timeRangeEnd);
788                 } else {
789                     events = session.searchEvents(request.getFolderPath(), request.timeRangeStart, request.timeRangeEnd);
790                 }
791                 appendEventsResponses(response, request, events);
792             }
793         }
794 
795         // send not found events errors
796         for (String href : notFound) {
797             response.startResponse(encodePath(request, href));
798             response.appendPropstatNotFound();
799             response.endResponse();
800         }
801         response.endMultistatus();
802         response.close();
803     }
804 
805     /**
806      * Send principals folder.
807      *
808      * @param request Caldav request
809      * @throws IOException on error
810      */
811     public void sendPrincipalsFolder(CaldavRequest request) throws IOException {
812         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
813         response.startMultistatus();
814         response.startResponse(encodePath(request, request.getPath()));
815         response.startPropstat();
816 
817         if (request.hasProperty("current-user-principal")) {
818             response.appendHrefProperty("D:current-user-principal", encodePath(request, "/principals/users/" + session.getEmail()));
819         }
820         response.endPropStatOK();
821         response.endResponse();
822         response.endMultistatus();
823         response.close();
824     }
825 
826     /**
827      * Send user response for request.
828      *
829      * @param request Caldav request
830      * @throws IOException on error
831      */
832     public void sendUserRoot(CaldavRequest request) throws IOException {
833         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
834         response.startMultistatus();
835         response.startResponse(encodePath(request, request.getPath()));
836         response.startPropstat();
837 
838         if (request.hasProperty("resourcetype")) {
839             response.appendProperty("D:resourcetype", "<D:collection/>");
840         }
841         if (request.hasProperty("displayname")) {
842             response.appendProperty("D:displayname", request.getLastPath());
843         }
844         if (request.hasProperty("getctag")) {
845             ExchangeSession.Folder rootFolder = session.getFolder("");
846             response.appendProperty("CS:getctag", "CS=\"http://calendarserver.org/ns/\"",
847                     IOUtil.encodeBase64AsString(rootFolder.ctag));
848         }
849         response.endPropStatOK();
850         if (request.getDepth() == 1) {
851             appendInbox(response, request, "inbox");
852             appendOutbox(response, request, "outbox");
853             appendFolderOrItem(response, request, session.getFolder(request.getFolderPath("calendar")), "calendar");
854             appendFolderOrItem(response, request, session.getFolder(request.getFolderPath("contacts")), "contacts");
855         }
856         response.endResponse();
857         response.endMultistatus();
858         response.close();
859     }
860 
861     /**
862      * Send caldav response for / request.
863      *
864      * @param request Caldav request
865      * @throws IOException on error
866      */
867     public void sendRoot(CaldavRequest request) throws IOException {
868         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
869         response.startMultistatus();
870         response.startResponse("/");
871         response.startPropstat();
872 
873         if (request.hasProperty("principal-collection-set")) {
874             response.appendHrefProperty("D:principal-collection-set", "/principals/users/");
875         }
876         if (request.hasProperty("displayname")) {
877             response.appendProperty("D:displayname", "ROOT");
878         }
879         if (request.hasProperty("resourcetype")) {
880             response.appendProperty("D:resourcetype", "<D:collection/>");
881         }
882         if (request.hasProperty("current-user-principal")) {
883             response.appendHrefProperty("D:current-user-principal", encodePath(request, "/principals/users/" + session.getEmail()));
884         }
885         response.endPropStatOK();
886         response.endResponse();
887         if (request.depth == 1) {
888             // iPhone workaround: send calendar subfolder
889             response.startResponse("/users/" + session.getEmail() + "/calendar");
890             response.startPropstat();
891             if (request.hasProperty("resourcetype")) {
892                 response.appendProperty("D:resourcetype", "<D:collection/>" +
893                         "<C:calendar xmlns:C=\"urn:ietf:params:xml:ns:caldav\"/>");
894             }
895             if (request.hasProperty("displayname")) {
896                 response.appendProperty("D:displayname", session.getEmail());
897             }
898             if (request.hasProperty("supported-calendar-component-set")) {
899                 response.appendProperty("C:supported-calendar-component-set", "<C:comp name=\"VEVENT\"/>");
900             }
901             response.endPropStatOK();
902             response.endResponse();
903 
904             response.startResponse("/users");
905             response.startPropstat();
906             if (request.hasProperty("displayname")) {
907                 response.appendProperty("D:displayname", "users");
908             }
909             if (request.hasProperty("resourcetype")) {
910                 response.appendProperty("D:resourcetype", "<D:collection/>");
911             }
912             response.endPropStatOK();
913             response.endResponse();
914 
915             response.startResponse("/principals");
916             response.startPropstat();
917             if (request.hasProperty("displayname")) {
918                 response.appendProperty("D:displayname", "principals");
919             }
920             if (request.hasProperty("resourcetype")) {
921                 response.appendProperty("D:resourcetype", "<D:collection/>");
922             }
923             response.endPropStatOK();
924             response.endResponse();
925         }
926         response.endMultistatus();
927         response.close();
928     }
929 
930     /**
931      * Send caldav response for /directory/ request.
932      *
933      * @param request Caldav request
934      * @throws IOException on error
935      */
936     public void sendDirectory(CaldavRequest request) throws IOException {
937         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
938         response.startMultistatus();
939         response.startResponse("/directory/");
940         response.startPropstat();
941         if (request.hasProperty("current-user-privilege-set")) {
942             response.appendProperty("D:current-user-privilege-set", "<D:privilege><D:read/></D:privilege>");
943         }
944         response.endPropStatOK();
945         response.endResponse();
946         response.endMultistatus();
947         response.close();
948     }
949 
950     /**
951      * Send caldav response for /.well-known/ request.
952      *
953      * @throws IOException on error
954      */
955     public void sendWellKnown() throws IOException {
956         HashMap<String, String> headers = new HashMap<String, String>();
957         headers.put("Location", "/");
958         sendHttpResponse(HttpStatus.SC_MOVED_PERMANENTLY, headers);
959     }
960 
961     /**
962      * Send Caldav principal response.
963      *
964      * @param request   Caldav request
965      * @param prefix    principal prefix (users or public)
966      * @param principal principal name (email address for users)
967      * @throws IOException on error
968      */
969     public void sendPrincipal(CaldavRequest request, String prefix, String principal) throws IOException {
970         // actual principal is email address
971         String actualPrincipal = principal;
972         if ("users".equals(prefix) &&
973                 (principal.equalsIgnoreCase(session.getAlias()) || (principal.equalsIgnoreCase(session.getAliasFromLogin())))) {
974             actualPrincipal = session.getEmail();
975         }
976 
977         CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS);
978         response.startMultistatus();
979         response.startResponse(encodePath(request, "/principals/" + prefix + '/' + principal));
980         response.startPropstat();
981 
982         if (request.hasProperty("principal-URL") && request.isIcal5()) {
983             response.appendHrefProperty("D:principal-URL", encodePath(request, "/principals/" + prefix + '/' + actualPrincipal));
984         }
985 
986 
987         if (request.hasProperty("calendar-home-set")) {
988             if ("users".equals(prefix)) {
989                 response.appendHrefProperty("C:calendar-home-set", encodePath(request, "/users/" + actualPrincipal + "/calendar/"));
990             } else {
991                 response.appendHrefProperty("C:calendar-home-set", encodePath(request, '/' + prefix + '/' + actualPrincipal));
992             }
993         }
994 
995         if (request.hasProperty("calendar-user-address-set") && "users".equals(prefix)) {
996             response.appendHrefProperty("C:calendar-user-address-set", "mailto:" + actualPrincipal);
997         }
998 
999         if (request.hasProperty("addressbook-home-set")) {
1000             if (request.isUserAgent("Address%20Book") || request.isUserAgent("Darwin")) {
1001                 response.appendHrefProperty("E:addressbook-home-set", encodePath(request, '/' + prefix + '/' + actualPrincipal + '/'));
1002             } else if ("users".equals(prefix)) {
1003                 response.appendHrefProperty("E:addressbook-home-set", encodePath(request, "/users/" + actualPrincipal + "/contacts/"));
1004             } else {
1005                 response.appendHrefProperty("E:addressbook-home-set", encodePath(request, '/' + prefix + '/' + actualPrincipal + '/'));
1006             }
1007         }
1008 
1009         if ("users".equals(prefix)) {
1010             if (request.hasProperty("schedule-inbox-URL")) {
1011                 response.appendHrefProperty("C:schedule-inbox-URL", encodePath(request, "/users/" + actualPrincipal + "/inbox/"));
1012             }
1013 
1014             if (request.hasProperty("schedule-outbox-URL")) {
1015                 response.appendHrefProperty("C:schedule-outbox-URL", encodePath(request, "/users/" + actualPrincipal + "/outbox/"));
1016             }
1017         } else {
1018             // public calendar, send root href as inbox url (always empty) for Lightning
1019             if (request.isLightning() && request.hasProperty("schedule-inbox-URL")) {
1020                 response.appendHrefProperty("C:schedule-inbox-URL", "/");
1021             }
1022             // send user outbox
1023             if (request.hasProperty("schedule-outbox-URL")) {
1024                 response.appendHrefProperty("C:schedule-outbox-URL", encodePath(request, "/users/" + session.getEmail() + "/outbox/"));
1025             }
1026         }
1027 
1028         if (request.hasProperty("displayname")) {
1029             response.appendProperty("D:displayname", actualPrincipal);
1030         }
1031         if (request.hasProperty("resourcetype")) {
1032             response.appendProperty("D:resourcetype", "<D:collection/><D:principal/>");
1033         }
1034         if (request.hasProperty("supported-report-set")) {
1035             response.appendProperty("D:supported-report-set", "<D:supported-report><D:report><C:calendar-multiget/></D:report></D:supported-report>");
1036         }
1037         response.endPropStatOK();
1038         response.endResponse();
1039         response.endMultistatus();
1040         response.close();
1041     }
1042 
1043     /**
1044      * Send free busy response for body request.
1045      *
1046      * @param body request body
1047      * @throws IOException on error
1048      */
1049     public void sendFreeBusy(String body) throws IOException {
1050         HashMap<String, String> valueMap = new HashMap<String, String>();
1051         ArrayList<String> attendees = new ArrayList<String>();
1052         HashMap<String, String> attendeeKeyMap = new HashMap<String, String>();
1053         ICSBufferedReader reader = new ICSBufferedReader(new StringReader(body));
1054         String line;
1055         String key;
1056         while ((line = reader.readLine()) != null) {
1057             int index = line.indexOf(':');
1058             if (index <= 0) {
1059                 throw new DavMailException("EXCEPTION_INVALID_REQUEST", body);
1060             }
1061             String fullkey = line.substring(0, index);
1062             String value = line.substring(index + 1);
1063             int semicolonIndex = fullkey.indexOf(';');
1064             if (semicolonIndex > 0) {
1065                 key = fullkey.substring(0, semicolonIndex);
1066             } else {
1067                 key = fullkey;
1068             }
1069             if ("ATTENDEE".equals(key)) {
1070                 attendees.add(value);
1071                 attendeeKeyMap.put(value, fullkey);
1072             } else {
1073                 valueMap.put(key, value);
1074             }
1075         }
1076         // get freebusy for each attendee
1077         HashMap<String, ExchangeSession.FreeBusy> freeBusyMap = new HashMap<String, ExchangeSession.FreeBusy>();
1078         for (String attendee : attendees) {
1079             ExchangeSession.FreeBusy freeBusy = session.getFreebusy(attendee, valueMap.get("DTSTART"), valueMap.get("DTEND"));
1080             if (freeBusy != null) {
1081                 freeBusyMap.put(attendee, freeBusy);
1082             }
1083         }
1084         CaldavResponse response = new CaldavResponse(HttpStatus.SC_OK);
1085         response.startScheduleResponse();
1086 
1087         for (Map.Entry<String, ExchangeSession.FreeBusy> entry : freeBusyMap.entrySet()) {
1088             String attendee = entry.getKey();
1089             response.startRecipientResponse(attendee);
1090 
1091             StringBuilder ics = new StringBuilder();
1092             ics.append("BEGIN:VCALENDAR").append((char) 13).append((char) 10)
1093                     .append("VERSION:2.0").append((char) 13).append((char) 10)
1094                     .append("PRODID:-//davmail.sf.net/NONSGML DavMail Calendar V1.1//EN").append((char) 13).append((char) 10)
1095                     .append("METHOD:REPLY").append((char) 13).append((char) 10)
1096                     .append("BEGIN:VFREEBUSY").append((char) 13).append((char) 10)
1097                     .append("DTSTAMP:").append(valueMap.get("DTSTAMP")).append("").append((char) 13).append((char) 10)
1098                     .append("ORGANIZER:").append(valueMap.get("ORGANIZER")).append("").append((char) 13).append((char) 10)
1099                     .append("DTSTART:").append(valueMap.get("DTSTART")).append("").append((char) 13).append((char) 10)
1100                     .append("DTEND:").append(valueMap.get("DTEND")).append("").append((char) 13).append((char) 10)
1101                     .append("UID:").append(valueMap.get("UID")).append("").append((char) 13).append((char) 10)
1102                     .append(attendeeKeyMap.get(attendee)).append(':').append(attendee).append("").append((char) 13).append((char) 10);
1103             entry.getValue().appendTo(ics);
1104             ics.append("END:VFREEBUSY").append((char) 13).append((char) 10)
1105                     .append("END:VCALENDAR");
1106             response.appendCalendarData(ics.toString());
1107             response.endRecipientResponse();
1108 
1109         }
1110         response.endScheduleResponse();
1111         response.close();
1112 
1113     }
1114 
1115 
1116     /**
1117      * Send Http error response for exception
1118      *
1119      * @param e exception
1120      * @throws IOException on error
1121      */
1122     public void sendErr(Exception e) throws IOException {
1123         String message = e.getMessage();
1124         if (message == null) {
1125             message = e.toString();
1126         }
1127         if (e instanceof HttpNotFoundException) {
1128             sendErr(HttpStatus.SC_NOT_FOUND, message);
1129         } else if (e instanceof HttpPreconditionFailedException) {
1130             sendErr(HttpStatus.SC_PRECONDITION_FAILED, message);
1131         } else {
1132             // workaround for Lightning bug: sleep for 1 second
1133             try {
1134                 Thread.sleep(1000);
1135             } catch (InterruptedException ie) {
1136                 // ignore
1137             }
1138             sendErr(HttpStatus.SC_SERVICE_UNAVAILABLE, message);
1139         }
1140     }
1141 
1142     /**
1143      * Send 400 bad response for unsupported request.
1144      *
1145      * @param request Caldav request
1146      * @throws IOException on error
1147      */
1148     public void sendUnsupported(CaldavRequest request) throws IOException {
1149         BundleMessage message = new BundleMessage("LOG_UNSUPPORTED_REQUEST", request);
1150         DavGatewayTray.error(message);
1151         sendErr(HttpStatus.SC_BAD_REQUEST, message.format());
1152     }
1153 
1154     /**
1155      * Send Http error status and message.
1156      *
1157      * @param status  Http status
1158      * @param message error messagee
1159      * @throws IOException on error
1160      */
1161     public void sendErr(int status, String message) throws IOException {
1162         sendHttpResponse(status, null, "text/plain;charset=UTF-8", message, false);
1163     }
1164 
1165     /**
1166      * Send OPTIONS response.
1167      *
1168      * @throws IOException on error
1169      */
1170     public void sendOptions() throws IOException {
1171         HashMap<String, String> headers = new HashMap<String, String>();
1172         headers.put("Allow", "OPTIONS, PROPFIND, HEAD, GET, REPORT, PROPPATCH, PUT, DELETE, POST");
1173         sendHttpResponse(HttpStatus.SC_OK, headers);
1174     }
1175 
1176     /**
1177      * Send 401 Unauthorized response.
1178      *
1179      * @throws IOException on error
1180      */
1181     public void sendUnauthorized() throws IOException {
1182         HashMap<String, String> headers = new HashMap<String, String>();
1183         headers.put("WWW-Authenticate", "Basic realm=\"" + BundleMessage.format("UI_DAVMAIL_GATEWAY") + '\"');
1184         sendHttpResponse(HttpStatus.SC_UNAUTHORIZED, headers, null, (byte[]) null, true);
1185     }
1186 
1187     /**
1188      * Send Http response with given status.
1189      *
1190      * @param status Http status
1191      * @throws IOException on error
1192      */
1193     public void sendHttpResponse(int status) throws IOException {
1194         sendHttpResponse(status, null, null, (byte[]) null, true);
1195     }
1196 
1197     /**
1198      * Send Http response with given status and headers.
1199      *
1200      * @param status  Http status
1201      * @param headers Http headers
1202      * @throws IOException on error
1203      */
1204     public void sendHttpResponse(int status, Map<String, String> headers) throws IOException {
1205         sendHttpResponse(status, headers, null, (byte[]) null, true);
1206     }
1207 
1208     /**
1209      * Send Http response with given status in chunked mode.
1210      *
1211      * @param status      Http status
1212      * @param contentType MIME content type
1213      * @throws IOException on error
1214      */
1215     public void sendChunkedHttpResponse(int status, String contentType) throws IOException {
1216         HashMap<String, String> headers = new HashMap<String, String>();
1217         headers.put("Transfer-Encoding", "chunked");
1218         sendHttpResponse(status, headers, contentType, (byte[]) null, true);
1219     }
1220 
1221     /**
1222      * Send Http response with given status, headers, content type and content.
1223      * Close connection if keepAlive is false
1224      *
1225      * @param status      Http status
1226      * @param headers     Http headers
1227      * @param contentType MIME content type
1228      * @param content     response body as string
1229      * @param keepAlive   keep connection open
1230      * @throws IOException on error
1231      */
1232     public void sendHttpResponse(int status, Map<String, String> headers, String contentType, String content, boolean keepAlive) throws IOException {
1233         sendHttpResponse(status, headers, contentType, content.getBytes("UTF-8"), keepAlive);
1234     }
1235 
1236     /**
1237      * Send Http response with given status, headers, content type and content.
1238      * Close connection if keepAlive is false
1239      *
1240      * @param status      Http status
1241      * @param headers     Http headers
1242      * @param contentType MIME content type
1243      * @param content     response body as byte array
1244      * @param keepAlive   keep connection open
1245      * @throws IOException on error
1246      */
1247     public void sendHttpResponse(int status, Map<String, String> headers, String contentType, byte[] content, boolean keepAlive) throws IOException {
1248         sendClient("HTTP/1.1 " + status + ' ' + HttpStatus.getStatusText(status));
1249         if (status != HttpStatus.SC_UNAUTHORIZED) {
1250             sendClient("Server: DavMail Gateway " + DavGateway.getCurrentVersion());
1251             sendClient("DAV: 1, calendar-access, calendar-schedule, calendarserver-private-events, addressbook");
1252             SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH);
1253             // force GMT timezone
1254             formatter.setTimeZone(ExchangeSession.GMT_TIMEZONE);
1255             String now = formatter.format(new Date());
1256             sendClient("Date: " + now);
1257             sendClient("Expires: " + now);
1258             sendClient("Cache-Control: private, max-age=0");
1259         }
1260         if (headers != null) {
1261             for (Map.Entry<String, String> header : headers.entrySet()) {
1262                 sendClient(header.getKey() + ": " + header.getValue());
1263             }
1264         }
1265         if (contentType != null) {
1266             sendClient("Content-Type: " + contentType);
1267         }
1268         closed = closed || !keepAlive;
1269         sendClient("Connection: " + (closed ? "close" : "keep-alive"));
1270         if (content != null && content.length > 0) {
1271             sendClient("Content-Length: " + content.length);
1272         } else if (headers == null || !"chunked".equals(headers.get("Transfer-Encoding"))) {
1273             sendClient("Content-Length: 0");
1274         }
1275         sendClient("");
1276         if (content != null && content.length > 0) {
1277             // full debug trace
1278             if (wireLogger.isDebugEnabled()) {
1279                 wireLogger.debug("> " + new String(content, "UTF-8"));
1280             }
1281             sendClient(content);
1282         }
1283     }
1284 
1285     /**
1286      * Decode HTTP credentials
1287      *
1288      * @param authorization http authorization header value
1289      * @throws IOException if invalid credentials
1290      */
1291     protected void decodeCredentials(String authorization) throws IOException {
1292         int index = authorization.indexOf(' ');
1293         if (index > 0) {
1294             String mode = authorization.substring(0, index).toLowerCase();
1295             if (!"basic".equals(mode)) {
1296                 throw new DavMailException("EXCEPTION_UNSUPPORTED_AUTHORIZATION_MODE", mode);
1297             }
1298             String encodedCredentials = authorization.substring(index + 1);
1299             String decodedCredentials = IOUtil.decodeBase64AsString(encodedCredentials);
1300             index = decodedCredentials.indexOf(':');
1301             if (index > 0) {
1302                 userName = decodedCredentials.substring(0, index);
1303                 password = decodedCredentials.substring(index + 1);
1304             } else {
1305                 throw new DavMailException("EXCEPTION_INVALID_CREDENTIALS");
1306             }
1307         } else {
1308             throw new DavMailException("EXCEPTION_INVALID_CREDENTIALS");
1309         }
1310 
1311     }
1312 
1313     protected static class CaldavRequest {
1314         protected final String command;
1315         protected final String path;
1316         protected final String[] pathElements;
1317         protected final Map<String, String> headers;
1318         protected int depth;
1319         protected final String body;
1320         protected final HashMap<String, String> properties = new HashMap<String, String>();
1321         protected HashSet<String> hrefs;
1322         protected boolean isMultiGet;
1323         protected String timeRangeStart;
1324         protected String timeRangeEnd;
1325         protected boolean vTodoOnly;
1326         protected boolean vEventOnly;
1327 
1328         protected CaldavRequest(String command, String path, Map<String, String> headers, String body) throws IOException {
1329             this.command = command;
1330             this.path = path.replaceAll("//", "/");
1331             pathElements = this.path.split("/");
1332             this.headers = headers;
1333             buildDepth();
1334             this.body = body;
1335 
1336             if (isPropFind() || isReport() || isMkCalendar() || isPropPatch()) {
1337                 parseXmlBody();
1338             }
1339         }
1340 
1341         public boolean isOptions() {
1342             return "OPTIONS".equals(command);
1343         }
1344 
1345         public boolean isPropFind() {
1346             return "PROPFIND".equals(command);
1347         }
1348 
1349         public boolean isPropPatch() {
1350             return "PROPPATCH".equals(command);
1351         }
1352 
1353         public boolean isReport() {
1354             return "REPORT".equals(command);
1355         }
1356 
1357         public boolean isGet() {
1358             return "GET".equals(command);
1359         }
1360 
1361         public boolean isHead() {
1362             return "HEAD".equals(command);
1363         }
1364 
1365         public boolean isPut() {
1366             return "PUT".equals(command);
1367         }
1368 
1369         public boolean isPost() {
1370             return "POST".equals(command);
1371         }
1372 
1373         public boolean isDelete() {
1374             return "DELETE".equals(command);
1375         }
1376 
1377         public boolean isMkCalendar() {
1378             return "MKCALENDAR".equals(command);
1379         }
1380 
1381         public boolean isMove() {
1382             return "MOVE".equals(command);
1383         }
1384 
1385         /**
1386          * Check if this request is a folder request.
1387          *
1388          * @return true if this is a folder (not event) request
1389          */
1390         public boolean isFolder() {
1391             return path.endsWith("/") || isPropFind() || isReport() || isPropPatch() || isOptions() || isPost();
1392         }
1393 
1394         public boolean isRoot() {
1395             return (pathElements.length == 0 || pathElements.length == 1);
1396         }
1397 
1398         public boolean isPathLength(int length) {
1399             return pathElements.length == length;
1400         }
1401 
1402         public int getPathLength() {
1403             return pathElements.length;
1404         }
1405 
1406         public String getPath() {
1407             return path;
1408         }
1409 
1410         public String getPath(String subFolder) {
1411             String folderPath;
1412             if (subFolder == null || subFolder.length() == 0) {
1413                 folderPath = path;
1414             } else if (path.endsWith("/")) {
1415                 folderPath = path + subFolder;
1416             } else {
1417                 folderPath = path + '/' + subFolder;
1418             }
1419             if (folderPath.endsWith("/")) {
1420                 return folderPath;
1421             } else {
1422                 return folderPath + '/';
1423             }
1424         }
1425 
1426         /**
1427          * Check if path element at index is value
1428          *
1429          * @param index path element index
1430          * @param value path value
1431          * @return true if path element at index is value
1432          */
1433         public boolean isPath(int index, String value) {
1434             return value != null && value.equals(getPathElement(index));
1435         }
1436 
1437         protected String getPathElement(int index) {
1438             if (index < pathElements.length) {
1439                 return pathElements[index];
1440             } else {
1441                 return null;
1442             }
1443         }
1444 
1445         public String getLastPath() {
1446             return getPathElement(getPathLength() - 1);
1447         }
1448 
1449         protected boolean isBrokenHrefEncoding() {
1450             return isUserAgent("DAVKit/3") || isUserAgent("eM Client/3") || isBrokenLightning();
1451         }
1452 
1453         protected boolean isBrokenLightning() {
1454             return isUserAgent("Lightning/1.0b2");
1455         }
1456 
1457         protected boolean isLightning() {
1458             return isUserAgent("Lightning/");
1459         }
1460 
1461         protected boolean isIcal5() {
1462             return isUserAgent("CoreDAV/") || isUserAgent("iOS/")
1463                     // iCal 6
1464                     || isUserAgent("Mac OS X/10.8");
1465         }
1466 
1467         protected boolean isUserAgent(String key) {
1468             String userAgent = headers.get("user-agent");
1469             return userAgent != null && userAgent.indexOf(key) >= 0;
1470         }
1471 
1472         public boolean isFreeBusy() {
1473             return body != null && body.indexOf("VFREEBUSY") >= 0;
1474         }
1475 
1476         protected void buildDepth() {
1477             String depthValue = headers.get("depth");
1478             if ("infinity".equalsIgnoreCase(depthValue)) {
1479                 depth = Integer.MAX_VALUE;
1480             } else if (depthValue != null) {
1481                 try {
1482                     depth = Integer.valueOf(depthValue);
1483                 } catch (NumberFormatException e) {
1484                     DavGatewayTray.warn(new BundleMessage("LOG_INVALID_DEPTH", depthValue));
1485                 }
1486             }
1487         }
1488 
1489         public int getDepth() {
1490             return depth;
1491         }
1492 
1493         public String getBody() {
1494             return body;
1495         }
1496 
1497         public String getHeader(String headerName) {
1498             return headers.get(headerName);
1499         }
1500 
1501         protected void parseXmlBody() throws IOException {
1502             if (body == null) {
1503                 throw new DavMailException("EXCEPTION_INVALID_CALDAV_REQUEST", "Missing body");
1504             }
1505             XMLStreamReader streamReader = null;
1506             try {
1507                 streamReader = XMLStreamUtil.createXMLStreamReader(body);
1508                 while (streamReader.hasNext()) {
1509                     streamReader.next();
1510                     if (XMLStreamUtil.isStartTag(streamReader)) {
1511                         String tagLocalName = streamReader.getLocalName();
1512                         if ("prop".equals(tagLocalName)) {
1513                             handleProp(streamReader);
1514                         } else if ("calendar-multiget".equals(tagLocalName)
1515                                 || "addressbook-multiget".equals(tagLocalName)) {
1516                             isMultiGet = true;
1517                         } else if ("comp-filter".equals(tagLocalName)) {
1518                             handleCompFilter(streamReader);
1519                         } else if ("href".equals(tagLocalName)) {
1520                             if (hrefs == null) {
1521                                 hrefs = new HashSet<String>();
1522                             }
1523                             if (isBrokenHrefEncoding()) {
1524                                 hrefs.add(streamReader.getElementText());
1525                             } else {
1526                                 hrefs.add(URIUtil.decode(StringUtil.encodePlusSign(streamReader.getElementText())));
1527                             }
1528                         }
1529                     }
1530                 }
1531             } catch (XMLStreamException e) {
1532                 throw new DavMailException("EXCEPTION_INVALID_CALDAV_REQUEST", e.getMessage());
1533             } finally {
1534                 try {
1535                     if (streamReader != null) {
1536                         streamReader.close();
1537                     }
1538                 } catch (XMLStreamException e) {
1539                     DavGatewayTray.error(e);
1540                 }
1541             }
1542         }
1543 
1544         protected boolean isEndTag(XMLStreamReader reader, String tagLocalName) {
1545             return (reader.getEventType() == XMLStreamConstants.END_ELEMENT) && (reader.getLocalName().equals(tagLocalName));
1546         }
1547 
1548         public void handleCompFilter(XMLStreamReader reader) throws XMLStreamException {
1549             while (reader.hasNext() && !isEndTag(reader, "comp-filter")) {
1550                 reader.next();
1551                 if (XMLStreamUtil.isStartTag(reader, "comp-filter")) {
1552                     String name = reader.getAttributeValue(null, "name");
1553                     if ("VEVENT".equals(name)) {
1554                         vEventOnly = true;
1555                     } else if ("VTODO".equals(name)) {
1556                         vTodoOnly = true;
1557                     }
1558                 } else if (XMLStreamUtil.isStartTag(reader, "time-range")) {
1559                     timeRangeStart = reader.getAttributeValue(null, "start");
1560                     timeRangeEnd = reader.getAttributeValue(null, "end");
1561                 }
1562             }
1563         }
1564 
1565         public void handleProp(XMLStreamReader reader) throws XMLStreamException {
1566             while (reader.hasNext() && !isEndTag(reader, "prop")) {
1567                 reader.next();
1568                 if (XMLStreamUtil.isStartTag(reader)) {
1569                     String tagLocalName = reader.getLocalName();
1570                     String tagText = null;
1571                     if ("displayname".equals(tagLocalName) || reader.hasText()) {
1572                         tagText = XMLStreamUtil.getElementText(reader);
1573                     }
1574                     properties.put(tagLocalName, tagText);
1575                 }
1576             }
1577         }
1578 
1579         public boolean hasProperty(String propertyName) {
1580             return properties.containsKey(propertyName);
1581         }
1582 
1583         public String getProperty(String propertyName) {
1584             return properties.get(propertyName);
1585         }
1586 
1587         public boolean isMultiGet() {
1588             return isMultiGet && hrefs != null;
1589         }
1590 
1591         public Set<String> getHrefs() {
1592             return hrefs;
1593         }
1594 
1595         @Override
1596         public String toString() {
1597             return command + ' ' + path + " Depth: " + depth + '\n' + body;
1598         }
1599 
1600         /**
1601          * Get request folder path.
1602          *
1603          * @return exchange folder path
1604          */
1605         public String getFolderPath() {
1606             return getFolderPath(null);
1607         }
1608 
1609         public String getParentFolderPath() {
1610             int endIndex;
1611             if (isFolder()) {
1612                 endIndex = getPathLength() - 1;
1613             } else {
1614                 endIndex = getPathLength() - 2;
1615             }
1616             return getFolderPath(endIndex, null);
1617         }
1618 
1619         /**
1620          * Get request folder path with subFolder.
1621          *
1622          * @param subFolder sub folder path
1623          * @return folder path
1624          */
1625         public String getFolderPath(String subFolder) {
1626             int endIndex;
1627             if (isFolder()) {
1628                 endIndex = getPathLength();
1629             } else {
1630                 endIndex = getPathLength() - 1;
1631             }
1632             return getFolderPath(endIndex, subFolder);
1633         }
1634 
1635         protected String getFolderPath(int endIndex, String subFolder) {
1636 
1637             StringBuilder calendarPath = new StringBuilder();
1638             for (int i = 0; i < endIndex; i++) {
1639                 if (getPathElement(i).length() > 0) {
1640                     calendarPath.append('/').append(getPathElement(i));
1641                 }
1642             }
1643             if (subFolder != null && subFolder.length() > 0) {
1644                 calendarPath.append('/').append(subFolder);
1645             }
1646             if (this.isUserAgent("Address%20Book") || this.isUserAgent("Darwin")) {
1647                 /* WARNING - This is a kludge -
1648                  * If your public folder address book path has spaces, then Address Book app just ignores that account
1649                  * This kludge allows you to specify the path in which spaces are encoded as ___
1650                  * It'll make Address book to not ignore the account and communicate with DavMail.
1651                  * Here we replace the ___ in the path with spaces. Be warned if your actual address book path has ___ 
1652                  * it'll fail.
1653                  */
1654                 String result = calendarPath.toString();
1655                 // replace unsupported spaces
1656                 if (result.indexOf(' ') >= 0) {
1657                     result = result.replaceAll("___", " ");
1658                 }
1659                 // replace /addressbook suffix on public folders
1660                 if (result.startsWith("/public")) {
1661                     result = result.replaceAll("/addressbook", "");
1662                 }
1663 
1664                 return result;
1665             } else {
1666                 return calendarPath.toString();
1667             }
1668         }
1669     }
1670 
1671     /**
1672      * Http chunked response.
1673      */
1674     protected class ChunkedResponse {
1675         Writer writer;
1676 
1677         protected ChunkedResponse(int status, String contentType) throws IOException {
1678             writer = new OutputStreamWriter(new BufferedOutputStream(new OutputStream() {
1679                 @Override
1680                 public void write(byte[] data, int offset, int length) throws IOException {
1681                     sendClient(Integer.toHexString(length));
1682                     sendClient(data, offset, length);
1683                     if (wireLogger.isDebugEnabled()) {
1684                         StringBuilder logBuffer = new StringBuilder("> ");
1685                         logBuffer.append(new String(data, offset, length, "UTF-8"));
1686                         wireLogger.debug(logBuffer.toString());
1687                     }
1688                     sendClient("");
1689                 }
1690 
1691                 @Override
1692                 public void write(int b) throws IOException {
1693                     throw new UnsupportedOperationException();
1694                 }
1695 
1696                 @Override
1697                 public void close() throws IOException {
1698                     sendClient("0");
1699                     sendClient("");
1700                 }
1701             }), "UTF-8");
1702             sendChunkedHttpResponse(status, contentType);
1703         }
1704 
1705         public void append(String data) throws IOException {
1706             writer.write(data);
1707         }
1708 
1709         public void close() throws IOException {
1710             writer.close();
1711         }
1712     }
1713 
1714     /**
1715      * Caldav response wrapper, content sent in chunked mode to avoid timeout
1716      */
1717     protected class CaldavResponse extends ChunkedResponse {
1718 
1719         protected CaldavResponse(int status) throws IOException {
1720             super(status, "text/xml;charset=UTF-8");
1721             writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1722         }
1723 
1724 
1725         public void startMultistatus() throws IOException {
1726             writer.write("<D:multistatus xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:E=\"urn:ietf:params:xml:ns:carddav\">");
1727         }
1728 
1729         public void startResponse(String href) throws IOException {
1730             writer.write("<D:response>");
1731             writer.write("<D:href>");
1732             writer.write(StringUtil.xmlEncode(href));
1733             writer.write("</D:href>");
1734         }
1735 
1736         public void startPropstat() throws IOException {
1737             writer.write("<D:propstat>");
1738             writer.write("<D:prop>");
1739         }
1740 
1741         public void appendCalendarData(String ics) throws IOException {
1742             if (ics != null && ics.length() > 0) {
1743                 writer.write("<C:calendar-data xmlns:C=\"urn:ietf:params:xml:ns:caldav\"");
1744                 writer.write(" C:content-type=\"text/calendar\" C:version=\"2.0\">");
1745                 writer.write(StringUtil.xmlEncode(ics));
1746                 writer.write("</C:calendar-data>");
1747             }
1748         }
1749 
1750         public void appendContactData(String vcard) throws IOException {
1751             if (vcard != null && vcard.length() > 0) {
1752                 writer.write("<E:address-data>");
1753                 writer.write(StringUtil.xmlEncode(vcard));
1754                 writer.write("</E:address-data>");
1755             }
1756         }
1757 
1758         public void appendHrefProperty(String propertyName, String propertyValue) throws IOException {
1759             appendProperty(propertyName, null, "<D:href>" + StringUtil.xmlEncode(propertyValue) + "</D:href>");
1760         }
1761 
1762         public void appendProperty(String propertyName) throws IOException {
1763             appendProperty(propertyName, null);
1764         }
1765 
1766         public void appendProperty(String propertyName, String propertyValue) throws IOException {
1767             appendProperty(propertyName, null, propertyValue);
1768         }
1769 
1770         public void appendProperty(String propertyName, String namespace, String propertyValue) throws IOException {
1771             if (propertyValue != null) {
1772                 writer.write('<');
1773                 writer.write(propertyName);
1774                 if (namespace != null) {
1775                     writer.write("  xmlns:");
1776                     writer.write(namespace);
1777                 }
1778                 writer.write('>');
1779                 writer.write(propertyValue);
1780                 writer.write("</");
1781                 writer.write(propertyName);
1782                 writer.write('>');
1783             } else {
1784                 writer.write('<');
1785                 writer.write(propertyName);
1786                 if (namespace != null) {
1787                     writer.write("  xmlns:");
1788                     writer.write(namespace);
1789                 }
1790                 writer.write("/>");
1791             }
1792         }
1793 
1794         public void endPropStatOK() throws IOException {
1795             writer.write("</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>");
1796         }
1797 
1798         public void appendPropstatNotFound() throws IOException {
1799             writer.write("<D:propstat><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>");
1800         }
1801 
1802         public void endResponse() throws IOException {
1803             writer.write("</D:response>");
1804         }
1805 
1806         public void endMultistatus() throws IOException {
1807             writer.write("</D:multistatus>");
1808         }
1809 
1810         public void startScheduleResponse() throws IOException {
1811             writer.write("<C:schedule-response xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">");
1812         }
1813 
1814         public void startRecipientResponse(String recipient) throws IOException {
1815             writer.write("<C:response><C:recipient><D:href>");
1816             writer.write(recipient);
1817             writer.write("</D:href></C:recipient><C:request-status>2.0;Success</C:request-status>");
1818         }
1819 
1820         public void endRecipientResponse() throws IOException {
1821             writer.write("</C:response>");
1822         }
1823 
1824         public void endScheduleResponse() throws IOException {
1825             writer.write("</C:schedule-response>");
1826         }
1827 
1828     }
1829 }
1830