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.util;
20  
21  import org.apache.commons.codec.DecoderException;
22  import org.apache.commons.codec.binary.Base64;
23  import org.apache.commons.codec.binary.Hex;
24  
25  import java.nio.charset.StandardCharsets;
26  import java.text.ParseException;
27  import java.text.SimpleDateFormat;
28  import java.util.ArrayList;
29  import java.util.Calendar;
30  import java.util.List;
31  import java.util.Set;
32  import java.util.regex.Pattern;
33  
34  /**
35   * Various string handling methods
36   */
37  public final class StringUtil {
38      private StringUtil() {
39      }
40  
41      /**
42       * Return the sub string between startDelimiter and endDelimiter or null.
43       *
44       * @param value          String value
45       * @param startDelimiter start delimiter
46       * @param endDelimiter   end delimiter
47       * @return token value
48       */
49      public static String getToken(String value, String startDelimiter, String endDelimiter) {
50          String token = null;
51          if (value != null) {
52              int startIndex = value.indexOf(startDelimiter);
53              if (startIndex >= 0) {
54                  startIndex += startDelimiter.length();
55                  int endIndex = value.indexOf(endDelimiter, startIndex);
56                  if (endIndex >= 0) {
57                      token = value.substring(startIndex, endIndex);
58                  }
59              }
60          }
61          return token;
62      }
63  
64      /**
65       * Return the sub string between startDelimiter and endDelimiter or null,
66       * look for last token in string.
67       *
68       * @param value          String value
69       * @param startDelimiter start delimiter
70       * @param endDelimiter   end delimiter
71       * @return token value
72       */
73      public static String getLastToken(String value, String startDelimiter, String endDelimiter) {
74          String token = null;
75          if (value != null) {
76              int startIndex = value.lastIndexOf(startDelimiter);
77              if (startIndex >= 0) {
78                  startIndex += startDelimiter.length();
79                  int endIndex = value.indexOf(endDelimiter, startIndex);
80                  if (endIndex >= 0) {
81                      token = value.substring(startIndex, endIndex);
82                  }
83              }
84          }
85          return token;
86      }
87  
88      /**
89       * Return the sub string between startDelimiter and endDelimiter with newToken.
90       *
91       * @param value          String value
92       * @param startDelimiter start delimiter
93       * @param endDelimiter   end delimiter
94       * @param newToken       new token value
95       * @return token value
96       */
97      public static String replaceToken(String value, String startDelimiter, String endDelimiter, String newToken) {
98          String result = null;
99          if (value != null) {
100             int startIndex = value.indexOf(startDelimiter);
101             if (startIndex >= 0) {
102                 startIndex += startDelimiter.length();
103                 int endIndex = value.indexOf(endDelimiter, startIndex);
104                 if (endIndex >= 0) {
105                     result = value.substring(0, startIndex) + newToken + value.substring(endIndex);
106                 }
107             }
108         }
109         return result;
110     }
111 
112     /**
113      * Join values with given separator.
114      *
115      * @param values    value set
116      * @param separator separator
117      * @return joined values
118      */
119     public static String join(Set<String> values, String separator) {
120         if (values != null && !values.isEmpty()) {
121             StringBuilder result = new StringBuilder();
122             for (String value : values) {
123                 if (result.length() > 0) {
124                     result.append(separator);
125                 }
126                 result.append(value);
127             }
128             return result.toString();
129         } else {
130             return null;
131         }
132     }
133 
134     static class PatternMap {
135         protected String match;
136         protected String value;
137         protected Pattern pattern;
138 
139         protected PatternMap(String match, String value) {
140             this.match = match;
141             this.value = value;
142             pattern = Pattern.compile(match);
143         }
144 
145         protected PatternMap(String match, String escapedMatch, String value) {
146             this.match = match;
147             this.value = value;
148             pattern = Pattern.compile(escapedMatch);
149         }
150 
151         protected PatternMap(String match, Pattern pattern, String value) {
152             this.match = match;
153             this.value = value;
154             this.pattern = pattern;
155         }
156 
157         protected String replaceAll(String string) {
158             if (string != null && string.contains(match)) {
159                 return pattern.matcher(string).replaceAll(value);
160             } else {
161                 return string;
162             }
163         }
164     }
165 
166     private static final Pattern AMP_PATTERN = Pattern.compile("&");
167     private static final Pattern PLUS_PATTERN = Pattern.compile("\\+");
168 
169     private static final Pattern QUOTE_PATTERN = Pattern.compile("\"");
170     private static final Pattern CR_PATTERN = Pattern.compile("\r");
171     private static final Pattern LF_PATTERN = Pattern.compile("\n");
172 
173     private static final List<PatternMap> URLENCODED_PATTERNS = new ArrayList<>();
174     static {
175         URLENCODED_PATTERNS.add(new PatternMap(String.valueOf((char) 0xF8FF), "_xF8FF_"));
176         URLENCODED_PATTERNS.add(new PatternMap("%26", "&"));
177         URLENCODED_PATTERNS.add(new PatternMap("%2B", "+"));
178         URLENCODED_PATTERNS.add(new PatternMap("%3A", ":"));
179         URLENCODED_PATTERNS.add(new PatternMap("%3B", ";"));
180         URLENCODED_PATTERNS.add(new PatternMap("%3C", "<"));
181         URLENCODED_PATTERNS.add(new PatternMap("%3E", ">"));
182         URLENCODED_PATTERNS.add(new PatternMap("%22", "\""));
183         URLENCODED_PATTERNS.add(new PatternMap("%23", "#"));
184         URLENCODED_PATTERNS.add(new PatternMap("%2A", "*"));
185         URLENCODED_PATTERNS.add(new PatternMap("%7C", "|"));
186         URLENCODED_PATTERNS.add(new PatternMap("%3F", "?"));
187         URLENCODED_PATTERNS.add(new PatternMap("%7E", "~"));
188 
189         // CRLF is replaced with LF in response
190         URLENCODED_PATTERNS.add(new PatternMap("\n", "_x000D__x000A_"));
191 
192         // last replace %
193         URLENCODED_PATTERNS.add(new PatternMap("%25", "%"));
194     }
195 
196     private static final List<PatternMap> URLENCODE_PATTERNS = new ArrayList<>();
197     static {
198         // first replace %
199         URLENCODE_PATTERNS.add(new PatternMap("%", "%25"));
200 
201         URLENCODE_PATTERNS.add(new PatternMap("_xF8FF_", String.valueOf((char) 0xF8FF)));
202         URLENCODE_PATTERNS.add(new PatternMap("&", AMP_PATTERN, "%26"));
203         URLENCODE_PATTERNS.add(new PatternMap("+", PLUS_PATTERN, "%2B"));
204         URLENCODE_PATTERNS.add(new PatternMap(":", "%3A"));
205         URLENCODE_PATTERNS.add(new PatternMap(";", "%3B"));
206         URLENCODE_PATTERNS.add(new PatternMap("<", "%3C"));
207         URLENCODE_PATTERNS.add(new PatternMap(">", "%3E"));
208         URLENCODE_PATTERNS.add(new PatternMap("\"", "%22"));
209         URLENCODE_PATTERNS.add(new PatternMap("#", "%23"));
210         URLENCODE_PATTERNS.add(new PatternMap("~", "%7E"));
211         URLENCODE_PATTERNS.add(new PatternMap("*", "\\*", "%2A"));
212         URLENCODE_PATTERNS.add(new PatternMap("|", "\\|", "%7C"));
213         URLENCODE_PATTERNS.add(new PatternMap("?", "\\?", "%3F"));
214 
215         URLENCODE_PATTERNS.add(new PatternMap("_x000D__x000A_", "\r\n"));
216 
217     }
218 
219     private static final List<PatternMap> XML_DECODE_PATTERNS = new ArrayList<>();
220     static {
221         XML_DECODE_PATTERNS.add(new PatternMap("&amp;", "&"));
222         XML_DECODE_PATTERNS.add(new PatternMap("&lt;", "<"));
223         XML_DECODE_PATTERNS.add(new PatternMap("&gt;", ">"));
224     }
225 
226     private static final List<PatternMap> XML_ENCODE_PATTERNS = new ArrayList<>();
227     static {
228         XML_ENCODE_PATTERNS.add(new PatternMap("&", AMP_PATTERN, "&amp;"));
229         XML_ENCODE_PATTERNS.add(new PatternMap("<", "&lt;"));
230         XML_ENCODE_PATTERNS.add(new PatternMap(">", "&gt;"));
231     }
232 
233     private static final Pattern SLASH_PATTERN = Pattern.compile("/");
234     private static final Pattern UNDERSCORE_PATTERN = Pattern.compile("_");
235     private static final Pattern DASH_PATTERN = Pattern.compile("-");
236 
237     // WebDav search parameter encode
238     private static final Pattern APOS_PATTERN = Pattern.compile("'");
239 
240     /**
241      * Xml encode content.
242      *
243      * @param name decoded name
244      * @return name encoded name
245      */
246     public static String xmlEncode(String name) {
247         String result = name;
248         if (result != null) {
249             for (PatternMap patternMap : XML_ENCODE_PATTERNS) {
250                 result = patternMap.replaceAll(result);
251             }
252         }
253         return result;
254     }
255 
256     /**
257      * Xml encode inside attribute.
258      *
259      * @param name decoded name
260      * @return name encoded name
261      */
262     public static String xmlEncodeAttribute(String name) {
263         String result = xmlEncode(name);
264         if (result != null) {
265             if (result.indexOf('"') >= 0) {
266                 result = QUOTE_PATTERN.matcher(result).replaceAll("&#x22;");
267             }
268             if (result.indexOf('\r') >= 0) {
269                 result = CR_PATTERN.matcher(result).replaceAll("&#x0D;");
270             }
271             if (result.indexOf('\n') >= 0) {
272                 result = LF_PATTERN.matcher(result).replaceAll("&#x0A;");
273             }
274         }
275         return result;
276     }
277 
278     /**
279      * Need to decode XML for iCal
280      *
281      * @param name encoded name
282      * @return name decoded name
283      */
284     public static String xmlDecode(String name) {
285         String result = name;
286         if (result != null) {
287             for (PatternMap patternMap : XML_DECODE_PATTERNS) {
288                 result = patternMap.replaceAll(result);
289             }
290         }
291         return result;
292     }
293 
294     /**
295      * Convert base64 value to hex.
296      *
297      * @param value base64 value
298      * @return hex value
299      */
300     @SuppressWarnings("unused")
301     public static String base64ToHex(String value) {
302         String hexValue = null;
303         if (value != null) {
304             hexValue = new String(Hex.encodeHex(Base64.decodeBase64(value.getBytes(StandardCharsets.UTF_8))));
305         }
306         return hexValue;
307     }
308 
309     /**
310      * Convert hex value to base64.
311      *
312      * @param value hex value
313      * @return base64 value
314      * @throws DecoderException on error
315      */
316     @SuppressWarnings("unused")
317     public static String hexToBase64(String value) throws DecoderException {
318         String base64Value = null;
319         if (value != null) {
320             base64Value = new String(Base64.encodeBase64(Hex.decodeHex(value.toCharArray())), StandardCharsets.UTF_8);
321         }
322         return base64Value;
323     }
324 
325     /**
326      * Encode item name to get actual value stored in urlcompname MAPI property.
327      *
328      * @param value decoded value
329      * @return urlcompname encoded value
330      */
331     public static String encodeUrlcompname(String value) {
332         String result = value;
333         if (result != null) {
334             for (PatternMap patternMap : URLENCODE_PATTERNS) {
335                 result = patternMap.replaceAll(result);
336             }
337         }
338         return result;
339     }
340 
341     /**
342      * Decode urlcompname to get item name.
343      *
344      * @param urlcompname encoded value
345      * @return decoded value
346      */
347     public static String decodeUrlcompname(String urlcompname) {
348         String result = urlcompname;
349         if (result != null) {
350             for (PatternMap patternMap : URLENCODED_PATTERNS) {
351                 result = patternMap.replaceAll(result);
352             }
353         }
354         return result;
355     }
356 
357     /**
358      * Urlencode plus sign in encoded href.
359      * '+' is decoded as ' ' by URIUtil.decode, the workaround is to force urlencoding to '%2B' first
360      *
361      * @param value encoded href
362      * @return encoded href
363      */
364     public static String encodePlusSign(String value) {
365         String result = value;
366         if (result.indexOf('+') >= 0) {
367             result = PLUS_PATTERN.matcher(result).replaceAll("%2B");
368         }
369         return result;
370     }
371 
372     /**
373      * Encode EWS base64 itemId to url compatible value.
374      *
375      * @param value base64 value
376      * @return url compatible value
377      */
378     public static String base64ToUrl(String value) {
379         String result = value;
380         if (result != null) {
381             if (result.indexOf('+') >= 0) {
382                 result = PLUS_PATTERN.matcher(result).replaceAll("-");
383             }
384             if (result.indexOf('/') >= 0) {
385                 result = SLASH_PATTERN.matcher(result).replaceAll("_");
386             }
387         }
388         return result;
389     }
390 
391     /**
392      * Encode EWS url compatible itemId back to base64 value.
393      *
394      * @param value url compatible value
395      * @return base64 value
396      */
397     public static String urlToBase64(String value) {
398         String result = value;
399         if (result.indexOf('-') >= 0) {
400             result = DASH_PATTERN.matcher(result).replaceAll("+");
401         }
402         if (result.indexOf('_') >= 0) {
403             result = UNDERSCORE_PATTERN.matcher(result).replaceAll("/");
404         }
405         return result;
406     }
407 
408     /**
409      * Encode quotes in Dav search parameter.
410      *
411      * @param value search parameter
412      * @return escaped value
413      */
414     public static String davSearchEncode(String value) {
415         return escapeQuotes(value);
416     }
417 
418     /**
419      * Escape quotes for DAV and graph filters.
420      * @param value input value
421      * @return quoted result
422      */
423     public static String escapeQuotes(String value) {
424         String result = value;
425         if (result != null && result.indexOf('\'') >= 0) {
426             result = APOS_PATTERN.matcher(result).replaceAll("''");
427         }
428         return result;
429     }
430 
431     /**
432      * Backslash escape double guotes in provided string.
433      *
434      * @param value input string
435      * @return escaped string
436      */
437     public static String escapeDoubleQuotes(String value) {
438         String result = value;
439         if (result != null && result.indexOf('"') >= 0) {
440             result = QUOTE_PATTERN.matcher(result).replaceAll("\\\\\"");
441         }
442         return result;
443     }
444 
445     /**
446      * Get allDay date value from Zulu timestamp.
447      *
448      * @param value Zulu datetime
449      * @return yyyyMMdd allDay date value
450      */
451     public static String convertZuluDateTimeToAllDay(String value) {
452         String result = value;
453         if (value != null && value.length() != 8) {
454             // try to convert datetime value to date value
455             try {
456                 Calendar calendar = Calendar.getInstance();
457                 SimpleDateFormat dateParser = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
458                 calendar.setTime(dateParser.parse(value));
459                 calendar.add(Calendar.HOUR_OF_DAY, 12);
460                 SimpleDateFormat dateFormatter = new SimpleDateFormat("yyyyMMdd");
461                 result = dateFormatter.format(calendar.getTime());
462             } catch (ParseException e) {
463                 // ignore
464             }
465         }
466         return result;
467     }
468 
469     /**
470      * Takes a quoted string according to IMAP rfc 3501 and returns its intrinsic (unquoted) string value.
471      *
472      * <p>RFC 3501 defines a quoted string in its ABNF as follows:</p>
473      * <pre>
474      *     quoted          = DQUOTE *QUOTED-CHAR DQUOTE
475      *     DQUOTE          = %x22
476      *                       ; " (Double Quote)
477      *     QUOTED-CHAR     = &lt;any TEXT-CHAR except quoted-specials&gt; /
478      *                       "\" quoted-specials / UTF8-2 / UTF8-3 / UTF8-4
479      *     TEXT-CHAR       = &lt;any CHAR except CR and LF&gt;
480      *     quoted-specials = DQUOTE / "\"
481      * </pre>
482      *
483      * @param quoted the quoted string or null
484      * @return the unquoted string, or null if-and-only-if `quoted` was null
485      * @throws IllegalArgumentException when the given quoted string is not valid
486      */
487     public static String parseQuotedImapString(String quoted) throws ParseException {
488         if (null == quoted) {
489             return null;
490         }
491         char[] quotedChars = quoted.toCharArray();
492         if (quotedChars.length < 2) {
493             // not a quoted string. even the empty string is, in it's quoted form, represented as: "\"\""
494             // (two double quotes)
495             throw new ParseException("Not a valid imap quoted string (too short): " + quoted, 0);
496         }
497         if ('"' != quotedChars[0]) {
498             throw new ParseException("Not a valid imap quoted string (does not start with double quote): " + quoted, 0);
499         }
500         // the result length is at least by 2 chars shorter than the input length, as at least the two enclosing
501         // double quotes are removed.
502         char[] resultChars = new char[quotedChars.length - 2];
503 
504         // 0 is the index of the first char in resultChars that is the actual result.
505         // resultPos is the index of the last byte in resultChars that is the actual result.
506         // resultChars MAY have additional bytes (when resultPos + 1 < resultChars.length), that are not part of the result.
507         int resultPos = 0;
508         // a very mechanical approach, looking at one character at a time
509         boolean backslashMode = false;
510         // iterate ignoring starting and ending quotes
511         for (int i = 1; i < quotedChars.length - 1; i++) {
512             if (backslashMode) {
513                 if ('\\' == quotedChars[i] || '"' == quotedChars[i]) {
514                     resultChars[resultPos++] = quotedChars[i];
515                     backslashMode = false;
516                 } else {
517                     throw new ParseException("Not a valid imap quoted string "
518                             + "(only '\"' and '\\' allowed after '\\') at index " + i + ": " + quoted, i);
519                 }
520             } else {
521                 if ('\\' == quotedChars[i]) {
522                     backslashMode = true;
523                 } else {
524                     resultChars[resultPos++] = quotedChars[i];
525                 }
526             }
527         }
528         if (backslashMode) {
529             throw new ParseException("Not a valid imap quoted string "
530                     + "(outer ending quote is backslashed): " + quoted, quotedChars.length - 1);
531         }
532         if ('"' != quotedChars[quotedChars.length - 1]) {
533             throw new ParseException("Not a valid imap quoted string "
534                     + "(does not end with double quotes): " + quoted, quotedChars.length - 1);
535         }
536         return new String(resultChars, 0, resultPos);
537     }
538 
539     /**
540      * Remove quotes if present on value.
541      *
542      * @param value input value
543      * @return unquoted string
544      */
545     public static String removeQuotes(String value) {
546         String result = value;
547         if (result != null) {
548             if (result.startsWith("\"") || result.startsWith("{") || result.startsWith("(")) {
549                 result = result.substring(1);
550             }
551             if (result.endsWith("\"") || result.endsWith("}") || result.endsWith(")")) {
552                 result = result.substring(0, result.length() - 1);
553             }
554         }
555         return result;
556     }
557 
558     /**
559      * Decode the folder name with _xF8FF_ or _x003E_ characters.
560      * @param folderName encoded folder name
561      * @return decoded folder name
562      */
563     public static String decodeFolderName(String folderName) {
564         if (folderName.contains("_xF8FF_")) {
565             return folderName.replace("_xF8FF_", "/");
566         }
567         if (folderName.contains("_x003E_")) {
568             return folderName.replace("_x003E_", ">");
569         }
570         return folderName;
571     }
572 
573     /**
574      * folderName may contain / or > characters, encode them.
575      * @param folderName folder name to encode
576      * @return encoded folder name
577      */
578     public static String encodeFolderName(String folderName) {
579         if (folderName.contains("/")) {
580             folderName = folderName.replace("/", "_xF8FF_");
581         }
582         if (folderName.contains(">")) {
583             folderName = folderName.replace(">", "_x003E_");
584         }
585         return folderName;
586     }
587 
588 }