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