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