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