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 HttpResponseException)) {
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         try (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         }
1131         // get freebusy for each attendee
1132         HashMap<String, ExchangeSession.FreeBusy> freeBusyMap = new HashMap<>();
1133         for (String attendee : attendees) {
1134             ExchangeSession.FreeBusy freeBusy = session.getFreebusy(attendee, valueMap.get("DTSTART"), valueMap.get("DTEND"));
1135             if (freeBusy != null) {
1136                 freeBusyMap.put(attendee, freeBusy);
1137             }
1138         }
1139         CaldavResponse response = new CaldavResponse(HttpStatus.SC_OK);
1140         response.startScheduleResponse();
1141 
1142         for (Map.Entry<String, ExchangeSession.FreeBusy> entry : freeBusyMap.entrySet()) {
1143             String attendee = entry.getKey();
1144             response.startRecipientResponse(attendee);
1145 
1146             StringBuilder ics = new StringBuilder();
1147             ics.append("BEGIN:VCALENDAR").append((char) 13).append((char) 10)
1148                     .append("VERSION:2.0").append((char) 13).append((char) 10)
1149                     .append("PRODID:-//davmail.sf.net/NONSGML DavMail Calendar V1.1//EN").append((char) 13).append((char) 10)
1150                     .append("METHOD:REPLY").append((char) 13).append((char) 10)
1151                     .append("BEGIN:VFREEBUSY").append((char) 13).append((char) 10)
1152                     .append("DTSTAMP:").append(valueMap.get("DTSTAMP")).append((char) 13).append((char) 10)
1153                     .append("ORGANIZER:").append(valueMap.get("ORGANIZER")).append((char) 13).append((char) 10)
1154                     .append("DTSTART:").append(valueMap.get("DTSTART")).append((char) 13).append((char) 10)
1155                     .append("DTEND:").append(valueMap.get("DTEND")).append((char) 13).append((char) 10)
1156                     .append("UID:").append(valueMap.get("UID")).append((char) 13).append((char) 10)
1157                     .append(attendeeKeyMap.get(attendee)).append(':').append(attendee).append((char) 13).append((char) 10);
1158             entry.getValue().appendTo(ics);
1159             ics.append("END:VFREEBUSY").append((char) 13).append((char) 10)
1160                     .append("END:VCALENDAR");
1161             response.appendCalendarData(ics.toString());
1162             response.endRecipientResponse();
1163 
1164         }
1165         response.endScheduleResponse();
1166         response.close();
1167 
1168     }
1169 
1170 
1171     /**
1172      * Send Http error response for exception
1173      *
1174      * @param e exception
1175      * @throws IOException on error
1176      */
1177     public void sendErr(Exception e) throws IOException {
1178         String message = e.getMessage();
1179         if (message == null) {
1180             message = e.toString();
1181         }
1182         if (e instanceof HttpResponseException) {
1183             sendErr(((HttpResponseException)e).getStatusCode(), ((HttpResponseException)e).getReasonPhrase());
1184         } else {
1185             // workaround for Lightning bug: sleep for 1 second
1186             try {
1187                 Thread.sleep(1000);
1188             } catch (InterruptedException ie) {
1189                 Thread.currentThread().interrupt();
1190             }
1191             sendErr(HttpStatus.SC_SERVICE_UNAVAILABLE, message);
1192         }
1193     }
1194 
1195     /**
1196      * Send 404 not found for unknown request.
1197      *
1198      * @param request Caldav request
1199      * @throws IOException on error
1200      */
1201     public void sendNotFound(CaldavRequest request) throws IOException {
1202         BundleMessage message = new BundleMessage("LOG_UNSUPPORTED_REQUEST", request);
1203         DavGatewayTray.warn(message);
1204         sendErr(HttpStatus.SC_NOT_FOUND, message.format());
1205     }
1206 
1207     /**
1208      * Send Http error status and message.
1209      *
1210      * @param status  Http status
1211      * @param message error messagee
1212      * @throws IOException on error
1213      */
1214     public void sendErr(int status, String message) throws IOException {
1215         sendHttpResponse(status, null, "text/plain;charset=UTF-8", message, false);
1216     }
1217 
1218     /**
1219      * Send OPTIONS response.
1220      *
1221      * @throws IOException on error
1222      */
1223     public void sendOptions() throws IOException {
1224         HashMap<String, String> headers = new HashMap<>();
1225         headers.put("Allow", "OPTIONS, PROPFIND, HEAD, GET, REPORT, PROPPATCH, PUT, DELETE, POST");
1226         sendHttpResponse(HttpStatus.SC_OK, headers);
1227     }
1228 
1229     /**
1230      * Send 401 Unauthorized response.
1231      *
1232      * @throws IOException on error
1233      */
1234     public void sendUnauthorized() throws IOException {
1235         HashMap<String, String> headers = new HashMap<>();
1236         headers.put("WWW-Authenticate", "Basic realm=\"" + BundleMessage.format("UI_DAVMAIL_GATEWAY") + '\"');
1237         sendHttpResponse(HttpStatus.SC_UNAUTHORIZED, headers, null, (byte[]) null, true);
1238     }
1239 
1240     /**
1241      * Send Http response with given status.
1242      *
1243      * @param status Http status
1244      * @throws IOException on error
1245      */
1246     public void sendHttpResponse(int status) throws IOException {
1247         sendHttpResponse(status, null, null, (byte[]) null, true);
1248     }
1249 
1250     /**
1251      * Send Http response with given status and headers.
1252      *
1253      * @param status  Http status
1254      * @param headers Http headers
1255      * @throws IOException on error
1256      */
1257     public void sendHttpResponse(int status, Map<String, String> headers) throws IOException {
1258         sendHttpResponse(status, headers, null, (byte[]) null, true);
1259     }
1260 
1261     /**
1262      * Send Http response with given status in chunked mode.
1263      *
1264      * @param status      Http status
1265      * @param contentType MIME content type
1266      * @throws IOException on error
1267      */
1268     public void sendChunkedHttpResponse(int status, String contentType) throws IOException {
1269         HashMap<String, String> headers = new HashMap<>();
1270         headers.put("Transfer-Encoding", "chunked");
1271         sendHttpResponse(status, headers, contentType, (byte[]) null, true);
1272     }
1273 
1274     /**
1275      * Send Http response with given status, headers, content type and content.
1276      * Close connection if keepAlive is false
1277      *
1278      * @param status      Http status
1279      * @param headers     Http headers
1280      * @param contentType MIME content type
1281      * @param content     response body as string
1282      * @param keepAlive   keep connection open
1283      * @throws IOException on error
1284      */
1285     public void sendHttpResponse(int status, Map<String, String> headers, String contentType, String content, boolean keepAlive) throws IOException {
1286         sendHttpResponse(status, headers, contentType, content.getBytes(StandardCharsets.UTF_8), keepAlive);
1287     }
1288 
1289     /**
1290      * Send Http response with given status, headers, content type and content.
1291      * Close connection if keepAlive is false
1292      *
1293      * @param status      Http status
1294      * @param headers     Http headers
1295      * @param contentType MIME content type
1296      * @param content     response body as byte array
1297      * @param keepAlive   keep connection open
1298      * @throws IOException on error
1299      */
1300     public void sendHttpResponse(int status, Map<String, String> headers, String contentType, byte[] content, boolean keepAlive) throws IOException {
1301         sendClient("HTTP/1.1 " + status + ' ' + EnglishReasonPhraseCatalog.INSTANCE.getReason(status, Locale.ENGLISH));
1302         if (status != HttpStatus.SC_UNAUTHORIZED) {
1303             sendClient("Server: DavMail Gateway " + DavGateway.getCurrentVersion());
1304             String scheduleMode;
1305             // enable automatic scheduling over EWS, can be disabled
1306             if (Settings.getBooleanProperty("davmail.caldavAutoSchedule", true)
1307                     && !(session instanceof DavExchangeSession)) {
1308                 scheduleMode = "calendar-auto-schedule";
1309             } else {
1310                 scheduleMode = "calendar-schedule";
1311             }
1312             sendClient("DAV: 1, calendar-access, " + scheduleMode + ", calendarserver-private-events, addressbook");
1313             SimpleDateFormat formatter = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH);
1314             // force GMT timezone
1315             formatter.setTimeZone(ExchangeSession.GMT_TIMEZONE);
1316             String now = formatter.format(new Date());
1317             sendClient("Date: " + now);
1318             sendClient("Expires: " + now);
1319             sendClient("Cache-Control: private, max-age=0");
1320         }
1321         if (headers != null) {
1322             for (Map.Entry<String, String> header : headers.entrySet()) {
1323                 sendClient(header.getKey() + ": " + header.getValue());
1324             }
1325         }
1326         if (contentType != null) {
1327             sendClient("Content-Type: " + contentType);
1328         }
1329         closed = closed || !keepAlive;
1330         sendClient("Connection: " + (closed ? "close" : "keep-alive"));
1331         if (content != null && content.length > 0) {
1332             sendClient("Content-Length: " + content.length);
1333         } else if (headers == null || !"chunked".equals(headers.get("Transfer-Encoding"))) {
1334             sendClient("Content-Length: 0");
1335         }
1336         sendClient("");
1337         if (content != null && content.length > 0) {
1338             // full debug trace
1339             if (wireLogger.isDebugEnabled()) {
1340                 wireLogger.debug("> " + new String(content, StandardCharsets.UTF_8));
1341             }
1342             sendClient(content);
1343         }
1344     }
1345 
1346     /**
1347      * Decode HTTP credentials
1348      *
1349      * @param authorization http authorization header value
1350      * @throws IOException if invalid credentials
1351      */
1352     protected void decodeCredentials(String authorization) throws IOException {
1353         int index = authorization.indexOf(' ');
1354         if (index > 0) {
1355             String mode = authorization.substring(0, index).toLowerCase();
1356             if (!"basic".equals(mode)) {
1357                 throw new DavMailException("EXCEPTION_UNSUPPORTED_AUTHORIZATION_MODE", mode);
1358             }
1359             String encodedCredentials = authorization.substring(index + 1);
1360             String decodedCredentials = IOUtil.decodeBase64AsString(encodedCredentials);
1361             index = decodedCredentials.indexOf(':');
1362             if (index > 0) {
1363                 userName = decodedCredentials.substring(0, index);
1364                 password = decodedCredentials.substring(index + 1);
1365             } else {
1366                 throw new DavMailException("EXCEPTION_INVALID_CREDENTIALS");
1367             }
1368         } else {
1369             throw new DavMailException("EXCEPTION_INVALID_CREDENTIALS");
1370         }
1371 
1372     }
1373 
1374     public static class CaldavRequest {
1375         protected final String command;
1376         protected final String path;
1377         protected final String[] pathElements;
1378         protected final Map<String, String> headers;
1379         protected int depth;
1380         protected final String body;
1381         protected final HashMap<String, String> properties = new HashMap<>();
1382         protected HashSet<String> hrefs;
1383         protected boolean isMultiGet;
1384         protected String timeRangeStart;
1385         protected String timeRangeEnd;
1386         protected boolean vTodoOnly;
1387         protected boolean vEventOnly;
1388 
1389         protected CaldavRequest(String command, String path, Map<String, String> headers, String body) throws IOException {
1390             this.command = command;
1391             this.path = path.replaceAll("//", "/");
1392             pathElements = this.path.split("/");
1393             this.headers = headers;
1394             buildDepth();
1395             this.body = body;
1396 
1397             if (isPropFind() || isReport() || isMkCalendar() || isPropPatch()) {
1398                 parseXmlBody();
1399             }
1400         }
1401 
1402         public boolean isOptions() {
1403             return "OPTIONS".equals(command);
1404         }
1405 
1406         public boolean isPropFind() {
1407             return "PROPFIND".equals(command);
1408         }
1409 
1410         public boolean isPropPatch() {
1411             return "PROPPATCH".equals(command);
1412         }
1413 
1414         public boolean isReport() {
1415             return "REPORT".equals(command);
1416         }
1417 
1418         public boolean isGet() {
1419             return "GET".equals(command);
1420         }
1421 
1422         public boolean isHead() {
1423             return "HEAD".equals(command);
1424         }
1425 
1426         public boolean isPut() {
1427             return "PUT".equals(command);
1428         }
1429 
1430         public boolean isPost() {
1431             return "POST".equals(command);
1432         }
1433 
1434         public boolean isDelete() {
1435             return "DELETE".equals(command);
1436         }
1437 
1438         public boolean isMkCalendar() {
1439             return "MKCALENDAR".equals(command);
1440         }
1441 
1442         public boolean isMove() {
1443             return "MOVE".equals(command);
1444         }
1445 
1446         /**
1447          * Check if this request is a folder request.
1448          *
1449          * @return true if this is a folder (not event) request
1450          */
1451         public boolean isFolder() {
1452             return path.endsWith("/") || isPropFind() || isReport() || isPropPatch() || isOptions() || isPost();
1453         }
1454 
1455         public boolean isRoot() {
1456             return (pathElements.length == 0 || pathElements.length == 1);
1457         }
1458 
1459         public boolean isPathLength(int length) {
1460             return pathElements.length == length;
1461         }
1462 
1463         public int getPathLength() {
1464             return pathElements.length;
1465         }
1466 
1467         public String getPath() {
1468             return path;
1469         }
1470 
1471         public String getPath(String subFolder) {
1472             String folderPath;
1473             if (subFolder == null || subFolder.isEmpty()) {
1474                 folderPath = path;
1475             } else if (path.endsWith("/")) {
1476                 folderPath = path + subFolder;
1477             } else {
1478                 folderPath = path + '/' + subFolder;
1479             }
1480             if (folderPath.endsWith("/")) {
1481                 return folderPath;
1482             } else {
1483                 return folderPath + '/';
1484             }
1485         }
1486 
1487         /**
1488          * Check if path element at index is value
1489          *
1490          * @param index path element index
1491          * @param value path value
1492          * @return true if path element at index is value
1493          */
1494         public boolean isPath(int index, String value) {
1495             return value != null && value.equals(getPathElement(index));
1496         }
1497 
1498         protected String getPathElement(int index) {
1499             if (index < pathElements.length) {
1500                 return pathElements[index];
1501             } else {
1502                 return null;
1503             }
1504         }
1505 
1506         public String getLastPath() {
1507             return getPathElement(getPathLength() - 1);
1508         }
1509 
1510         protected boolean isBrokenHrefEncoding() {
1511             return isUserAgent("DAVKit/3") || isUserAgent("eM Client/3") || isBrokenLightning();
1512         }
1513 
1514         protected boolean isBrokenLightning() {
1515             return isUserAgent("Lightning/1.0b2");
1516         }
1517 
1518         protected boolean isLightning() {
1519             return isUserAgent("Lightning/") || isUserAgent("Thunderbird/");
1520         }
1521 
1522         protected boolean isIcal5() {
1523             return isUserAgent("CoreDAV/") || isUserAgent("iOS/")
1524                     // iCal 6
1525                     || isUserAgent("Mac OS X/10.8");
1526         }
1527 
1528         protected boolean isUserAgent(String key) {
1529             String userAgent = headers.get("user-agent");
1530             return userAgent != null && userAgent.contains(key);
1531         }
1532 
1533         public boolean isFreeBusy() {
1534             return body != null && body.contains("VFREEBUSY");
1535         }
1536 
1537         protected void buildDepth() {
1538             String depthValue = headers.get("depth");
1539             if ("infinity".equalsIgnoreCase(depthValue)) {
1540                 depth = Integer.MAX_VALUE;
1541             } else if (depthValue != null) {
1542                 try {
1543                     depth = Integer.parseInt(depthValue);
1544                 } catch (NumberFormatException e) {
1545                     DavGatewayTray.warn(new BundleMessage("LOG_INVALID_DEPTH", depthValue));
1546                 }
1547             }
1548         }
1549 
1550         public int getDepth() {
1551             return depth;
1552         }
1553 
1554         public String getBody() {
1555             return body;
1556         }
1557 
1558         public String getHeader(String headerName) {
1559             return headers.get(headerName);
1560         }
1561 
1562         protected void parseXmlBody() throws IOException {
1563             if (body == null) {
1564                 throw new DavMailException("EXCEPTION_INVALID_CALDAV_REQUEST", "Missing body");
1565             }
1566             XMLStreamReader streamReader = null;
1567             try {
1568                 streamReader = XMLStreamUtil.createXMLStreamReader(body);
1569                 while (streamReader.hasNext()) {
1570                     streamReader.next();
1571                     if (XMLStreamUtil.isStartTag(streamReader)) {
1572                         String tagLocalName = streamReader.getLocalName();
1573                         if ("prop".equals(tagLocalName)) {
1574                             handleProp(streamReader);
1575                         } else if ("calendar-multiget".equals(tagLocalName)
1576                                 || "addressbook-multiget".equals(tagLocalName)) {
1577                             isMultiGet = true;
1578                         } else if ("comp-filter".equals(tagLocalName)) {
1579                             handleCompFilter(streamReader);
1580                         } else if ("href".equals(tagLocalName)) {
1581                             if (hrefs == null) {
1582                                 hrefs = new HashSet<>();
1583                             }
1584                             if (isBrokenHrefEncoding()) {
1585                                 hrefs.add(streamReader.getElementText());
1586                             } else {
1587                                 hrefs.add(URIUtil.decode(StringUtil.encodePlusSign(streamReader.getElementText())));
1588                             }
1589                         }
1590                     }
1591                 }
1592             } catch (XMLStreamException e) {
1593                 throw new DavMailException("EXCEPTION_INVALID_CALDAV_REQUEST", e.getMessage());
1594             } finally {
1595                 try {
1596                     if (streamReader != null) {
1597                         streamReader.close();
1598                     }
1599                 } catch (XMLStreamException e) {
1600                     DavGatewayTray.error(e);
1601                 }
1602             }
1603         }
1604 
1605         public void handleCompFilter(XMLStreamReader reader) throws XMLStreamException {
1606             while (reader.hasNext() && !XMLStreamUtil.isEndTag(reader, "comp-filter")) {
1607                 reader.next();
1608                 if (XMLStreamUtil.isStartTag(reader, "comp-filter")) {
1609                     String name = reader.getAttributeValue(null, "name");
1610                     if ("VEVENT".equals(name)) {
1611                         vEventOnly = true;
1612                     } else if ("VTODO".equals(name)) {
1613                         vTodoOnly = true;
1614                     }
1615                 } else if (XMLStreamUtil.isStartTag(reader, "time-range")) {
1616                     timeRangeStart = reader.getAttributeValue(null, "start");
1617                     timeRangeEnd = reader.getAttributeValue(null, "end");
1618                 }
1619             }
1620         }
1621 
1622         public void handleProp(XMLStreamReader reader) throws XMLStreamException {
1623             while (reader.hasNext() && !XMLStreamUtil.isEndTag(reader, "prop")) {
1624                 reader.next();
1625                 if (XMLStreamUtil.isStartTag(reader)) {
1626                     String tagLocalName = reader.getLocalName();
1627                     String tagText = null;
1628                     if ("displayname".equals(tagLocalName) || reader.hasText()) {
1629                         tagText = XMLStreamUtil.getElementText(reader);
1630                     }
1631                     properties.put(tagLocalName, tagText);
1632                 }
1633             }
1634         }
1635 
1636         public boolean hasProperty(String propertyName) {
1637             return properties.containsKey(propertyName);
1638         }
1639 
1640         public String getProperty(String propertyName) {
1641             return properties.get(propertyName);
1642         }
1643 
1644         public boolean isMultiGet() {
1645             return isMultiGet && hrefs != null;
1646         }
1647 
1648         public Set<String> getHrefs() {
1649             return hrefs;
1650         }
1651 
1652         @Override
1653         public String toString() {
1654             return command + ' ' + path + " Depth: " + depth + '\n' + body;
1655         }
1656 
1657         /**
1658          * Get request folder path.
1659          *
1660          * @return exchange folder path
1661          */
1662         public String getFolderPath() {
1663             return getFolderPath(null);
1664         }
1665 
1666         public String getParentFolderPath() {
1667             int endIndex;
1668             if (isFolder()) {
1669                 endIndex = getPathLength() - 1;
1670             } else {
1671                 endIndex = getPathLength() - 2;
1672             }
1673             return getFolderPath(endIndex, null);
1674         }
1675 
1676         /**
1677          * Get request folder path with subFolder.
1678          *
1679          * @param subFolder sub folder path
1680          * @return folder path
1681          */
1682         public String getFolderPath(String subFolder) {
1683             int endIndex;
1684             if (isFolder()) {
1685                 endIndex = getPathLength();
1686             } else {
1687                 endIndex = getPathLength() - 1;
1688             }
1689             return getFolderPath(endIndex, subFolder);
1690         }
1691 
1692         protected String getFolderPath(int endIndex, String subFolder) {
1693 
1694             StringBuilder calendarPath = new StringBuilder();
1695             for (int i = 0; i < endIndex; i++) {
1696                 if (!getPathElement(i).isEmpty()) {
1697                     calendarPath.append('/').append(getPathElement(i));
1698                 }
1699             }
1700             if (subFolder != null && !subFolder.isEmpty()) {
1701                 calendarPath.append('/').append(subFolder);
1702             }
1703             if (this.isUserAgent("Address%20Book") || this.isUserAgent("Darwin")) {
1704                 /* WARNING - This is a kludge -
1705                  * If your public folder address book path has spaces, then Address Book app just ignores that account
1706                  * This kludge allows you to specify the path in which spaces are encoded as ___
1707                  * It'll make Address book to not ignore the account and communicate with DavMail.
1708                  * Here we replace the ___ in the path with spaces. Be warned if your actual address book path has ___
1709                  * it'll fail.
1710                  */
1711                 String result = calendarPath.toString();
1712                 // replace unsupported spaces
1713                 if (result.indexOf(' ') >= 0) {
1714                     result = result.replaceAll("___", " ");
1715                 }
1716                 // replace /addressbook suffix on public folders
1717                 if (result.startsWith("/public")) {
1718                     result = result.replaceAll("/addressbook", "");
1719                 }
1720 
1721                 return result;
1722             } else {
1723                 return calendarPath.toString();
1724             }
1725         }
1726     }
1727 
1728     /**
1729      * Http chunked response.
1730      */
1731     protected class ChunkedResponse {
1732         Writer writer;
1733 
1734         protected ChunkedResponse(int status, String contentType) throws IOException {
1735             writer = new OutputStreamWriter(new BufferedOutputStream(new OutputStream() {
1736                 @Override
1737                 public void write(byte[] data, int offset, int length) throws IOException {
1738                     sendClient(Integer.toHexString(length));
1739                     sendClient(data, offset, length);
1740                     if (wireLogger.isDebugEnabled()) {
1741                         wireLogger.debug("> " + new String(data, offset, length, StandardCharsets.UTF_8));
1742                     }
1743                     sendClient("");
1744                 }
1745 
1746                 @Override
1747                 public void write(int b) {
1748                     throw new UnsupportedOperationException();
1749                 }
1750 
1751                 @Override
1752                 public void close() throws IOException {
1753                     sendClient("0");
1754                     sendClient("");
1755                 }
1756             }), StandardCharsets.UTF_8);
1757             sendChunkedHttpResponse(status, contentType);
1758         }
1759 
1760         public void append(String data) throws IOException {
1761             writer.write(data);
1762         }
1763 
1764         public void close() throws IOException {
1765             writer.close();
1766         }
1767     }
1768 
1769     /**
1770      * Caldav response wrapper, content sent in chunked mode to avoid timeout
1771      */
1772     public class CaldavResponse extends ChunkedResponse {
1773 
1774         protected CaldavResponse(int status) throws IOException {
1775             super(status, "text/xml;charset=UTF-8");
1776             writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1777         }
1778 
1779 
1780         public void startMultistatus() throws IOException {
1781             writer.write("<D:multistatus xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\" xmlns:E=\"urn:ietf:params:xml:ns:carddav\">");
1782         }
1783 
1784         public void startResponse(String href) throws IOException {
1785             writer.write("<D:response>");
1786             writer.write("<D:href>");
1787             writer.write(StringUtil.xmlEncode(href));
1788             writer.write("</D:href>");
1789         }
1790 
1791         public void startPropstat() throws IOException {
1792             writer.write("<D:propstat>");
1793             writer.write("<D:prop>");
1794         }
1795 
1796         public void appendCalendarData(String ics) throws IOException {
1797             if (ics != null && !ics.isEmpty()) {
1798                 writer.write("<C:calendar-data xmlns:C=\"urn:ietf:params:xml:ns:caldav\"");
1799                 writer.write(" C:content-type=\"text/calendar\" C:version=\"2.0\">");
1800                 writer.write(StringUtil.xmlEncode(ics));
1801                 writer.write("</C:calendar-data>");
1802             }
1803         }
1804 
1805         public void appendContactData(String vcard) throws IOException {
1806             if (vcard != null && !vcard.isEmpty()) {
1807                 writer.write("<E:address-data>");
1808                 writer.write(StringUtil.xmlEncode(vcard));
1809                 writer.write("</E:address-data>");
1810             }
1811         }
1812 
1813         public void appendHrefProperty(String propertyName, String propertyValue) throws IOException {
1814             appendProperty(propertyName, null, "<D:href>" + StringUtil.xmlEncode(propertyValue) + "</D:href>");
1815         }
1816 
1817         public void appendProperty(String propertyName) throws IOException {
1818             appendProperty(propertyName, null);
1819         }
1820 
1821         public void appendProperty(String propertyName, String propertyValue) throws IOException {
1822             appendProperty(propertyName, null, propertyValue);
1823         }
1824 
1825         public void appendProperty(String propertyName, String namespace, String propertyValue) throws IOException {
1826             if (propertyValue != null) {
1827                 startTag(propertyName, namespace);
1828                 writer.write('>');
1829                 writer.write(propertyValue);
1830                 writer.write("</");
1831                 writer.write(propertyName);
1832                 writer.write('>');
1833             } else {
1834                 startTag(propertyName, namespace);
1835                 writer.write("/>");
1836             }
1837         }
1838 
1839         private void startTag(String propertyName, String namespace) throws IOException {
1840             writer.write('<');
1841             writer.write(propertyName);
1842             if (namespace != null) {
1843                 writer.write(" xmlns:");
1844                 writer.write(namespace);
1845             }
1846         }
1847 
1848         public void endPropStatOK() throws IOException {
1849             writer.write("</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>");
1850         }
1851 
1852         public void appendPropstatNotFound() throws IOException {
1853             writer.write("<D:propstat><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>");
1854         }
1855 
1856         public void endResponse() throws IOException {
1857             writer.write("</D:response>");
1858         }
1859 
1860         public void endMultistatus() throws IOException {
1861             writer.write("</D:multistatus>");
1862         }
1863 
1864         public void startScheduleResponse() throws IOException {
1865             writer.write("<C:schedule-response xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">");
1866         }
1867 
1868         public void startRecipientResponse(String recipient) throws IOException {
1869             writer.write("<C:response><C:recipient><D:href>");
1870             writer.write(recipient);
1871             writer.write("</D:href></C:recipient><C:request-status>2.0;Success</C:request-status>");
1872         }
1873 
1874         public void endRecipientResponse() throws IOException {
1875             writer.write("</C:response>");
1876         }
1877 
1878         public void endScheduleResponse() throws IOException {
1879             writer.write("</C:schedule-response>");
1880         }
1881 
1882     }
1883 }
1884