001/* 002 * Copyright 2012 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.core.util; 017 018import java.io.UnsupportedEncodingException; 019import java.security.MessageDigest; 020import java.security.NoSuchAlgorithmException; 021import java.text.Normalizer; 022import java.util.ArrayList; 023import java.util.Collection; 024import java.util.Comparator; 025import java.util.Iterator; 026import java.util.List; 027import java.util.StringTokenizer; 028 029import org.apache.commons.codec.binary.Base64; 030import org.apache.commons.lang3.Strings; 031import org.slf4j.Logger; 032import org.slf4j.LoggerFactory; 033 034import org.ametys.runtime.i18n.I18nizableText; 035 036/** 037 * A collection of String management utility methods. 038 */ 039public final class StringUtils 040{ 041 private static final Logger __LOGGER = LoggerFactory.getLogger(StringUtils.class); 042 043 private static final long __DATA_SIZE_NEXT_LIMIT = 1024; 044 private static final List<String> __DATA_SIZE_KEYS = List.of( 045 "PLUGINS_CORE_UI_FORMAT_FILE_SIZE_NOT_ESCAPED_BYTES", 046 "PLUGINS_CORE_UI_FORMAT_FILE_SIZE_NOT_ESCAPED_KB", 047 "PLUGINS_CORE_UI_FORMAT_FILE_SIZE_NOT_ESCAPED_MB", 048 "PLUGINS_CORE_UI_FORMAT_FILE_SIZE_NOT_ESCAPED_GB", 049 "PLUGINS_CORE_UI_FORMAT_FILE_SIZE_NOT_ESCAPED_TB" 050 ); 051 052 private static final String[] __CSV_BEGIN_CHARS = {"=", "@", "+", "-", "\r", "\t"}; 053 private static final char __CSV_QUOTE = '"'; 054 private static final String __CSV_QUOTE_STR = String.valueOf(__CSV_QUOTE); 055 056 private StringUtils() 057 { 058 // empty private constructor 059 } 060 061 /** 062 * Extract String values from a comma seprated list. 063 * @param values the comma separated list 064 * @return a collection of String or an empty collection if string is null or empty. 065 */ 066 public static Collection<String> stringToCollection(String values) 067 { 068 Collection<String> result = new ArrayList<>(); 069 if (values != null && values.length() > 0) 070 { 071 // Explore the string list with a stringtokenizer with ','. 072 StringTokenizer stk = new StringTokenizer(values, ","); 073 074 while (stk.hasMoreTokens()) 075 { 076 // Don't forget to trim 077 result.add(stk.nextToken().trim()); 078 } 079 } 080 081 return result; 082 } 083 084 /** 085 * Extract String values from a comma seprated list. 086 * @param values the comma separated list 087 * @return an array of String 088 */ 089 public static String[] stringToStringArray(String values) 090 { 091 Collection<String> coll = stringToCollection(values); 092 return coll.toArray(new String[coll.size()]); 093 } 094 095 /** 096 * Generates a unique String key, based on System.currentTimeMillis() 097 * @return a unique String value 098 */ 099 public static String generateKey() 100 { 101 long value; 102 103 // Find a new value 104 synchronized (StringUtils.class) 105 { 106 value = System.currentTimeMillis(); 107 108 try 109 { 110 Thread.sleep(15); 111 } 112 catch (InterruptedException e) 113 { 114 // does nothing, continue 115 } 116 } 117 118 // Convert it to a string using radix 36 (more compact) 119 String longString = Long.toString(value, Character.MAX_RADIX); 120 121 return longString; 122 } 123 124 /** 125 * Encrypt a password by using first MD5 Hash and base64 encoding. 126 * @param password The password to be encrypted. 127 * @return The password encrypted or null if the MD5 is not supported 128 */ 129 public static String md5Base64(String password) 130 { 131 if (password == null) 132 { 133 return null; 134 } 135 136 MessageDigest md5; 137 try 138 { 139 md5 = MessageDigest.getInstance("MD5"); 140 } 141 catch (NoSuchAlgorithmException e) 142 { 143 // This error exception not be raised since MD5 is embedded in the JDK 144 __LOGGER.error("Cannot encode the password to md5Base64", e); 145 return null; 146 } 147 148 // MD5-hash the password. 149 md5.reset(); 150 try 151 { 152 md5.update(password.getBytes("UTF-8")); 153 } 154 catch (UnsupportedEncodingException e) 155 { 156 throw new IllegalStateException(e); 157 } 158 byte [] hash = md5.digest(); 159 160 // Base64-encode the result. 161 try 162 { 163 return new String(Base64.encodeBase64(hash), "UTF-8"); 164 } 165 catch (UnsupportedEncodingException e) 166 { 167 throw new IllegalStateException(e); 168 } 169 } 170 171 /** 172 * Normalize string. Pass to lower case and remove Unicode accents and diacritics 173 * @param value the value to normalize 174 * @return the normalized value 175 */ 176 public static String normalizeStringValue(String value) 177 { 178 return Normalizer.normalize(value.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", ""); 179 } 180 181 /** 182 * Transform a size to a readable size for data (bytes, KB, MB, etc.). 183 * @param size The size to transform 184 * @return An internationalized text with the size and the unit. 185 */ 186 public static I18nizableText toReadableDataSize(Long size) 187 { 188 if (size == 1L) 189 { 190 return _createReadatableDataSize(size, "PLUGINS_CORE_UI_FORMAT_FILE_SIZE_NOT_ESCAPED_BYTE"); 191 } 192 return _toReadableDataSize(size, __DATA_SIZE_KEYS.iterator()); 193 } 194 195 private static I18nizableText _toReadableDataSize(Long size, Iterator<String> keys) 196 { 197 String key = keys.next(); 198 if (!keys.hasNext() || size < __DATA_SIZE_NEXT_LIMIT) 199 { 200 return _createReadatableDataSize(size, key); 201 } 202 return _toReadableDataSize(size / __DATA_SIZE_NEXT_LIMIT, keys); 203 } 204 205 private static I18nizableText _createReadatableDataSize(Long size, String key) 206 { 207 return new I18nizableText("plugin.core-ui", key, List.of(size.toString())); 208 } 209 210 /** 211 * Returns a escaped {@code String} value for a CSV cell enclosed in double quotes. 212 * 213 * <p>Any double quote characters in the value are escaped with another double quote.</p> 214 * <p>If cell value contains a formula (ex: =SOMME(A0:A10)) it could be evaluated by CVS editor.<br> 215 * Use {@link #sanitizeCsv(String)} to avoid formula evaluation</p> 216 * 217 * <pre> 218 * null => "" 219 * =1+2 => "=1+2" 220 * =1+2'" ;,=1+2 => "=1+2'"" ;,=1+2" 221 * L'orem ipsut; sit amet, dolor => "L'orem ipsut; sit amet, dolor" 222 * =cmd|' /c Calc.exe'!'A1' => "=cmd|' /c Calc.exe'!'A1'" 223 * </pre> 224 * 225 * @param value the String value for CSV column. Can be null. 226 * @return the escaped value 227 */ 228 public static String escapeCsv(String value) 229 { 230 StringBuilder sb = new StringBuilder(); 231 232 sb.append(__CSV_QUOTE); 233 234 if (org.apache.commons.lang3.StringUtils.isNotEmpty(value)) 235 { 236 sb.append(Strings.CS.replace(value, __CSV_QUOTE_STR, __CSV_QUOTE_STR + __CSV_QUOTE_STR)); 237 } 238 239 sb.append(__CSV_QUOTE); 240 241 return sb.toString(); 242 } 243 244 /** 245 * Returns a sanitized {@code String} value for a CSV column enclosed in double quotes. 246 * 247 * <p>Any double quote characters in the value are escaped with another double quote.</p> 248 * 249 * <p>If the value starts with '=', '+', '-', '@', newline or TAB, is prepend with a single quote.</p> 250 * 251 * <pre> 252 * null => "" 253 * =1+2";=1+2 => "'=1+2"";=1+2" 254 * =1+2'" ;,=1+2 => "'=1+2'"" ;,=1+2" 255 * L'orem ipsut; sit amet, dolor => "L'orem ipsut; sit amet, dolor" 256 * =cmd|' /c Calc.exe'!'A1' => "'=cmd|' /c Calc.exe'!'A1'" 257 * </pre> 258 * 259 * @param value the untrusted String value for CSV column. Can be null. 260 * @return the escaped and trusted value 261 */ 262 public static String sanitizeCsv(String value) 263 { 264 StringBuilder sb = new StringBuilder(); 265 266 sb.append(__CSV_QUOTE); 267 268 if (org.apache.commons.lang3.StringUtils.isNotEmpty(value)) 269 { 270 if (Strings.CS.startsWithAny(value, __CSV_BEGIN_CHARS)) 271 { 272 sb.append("'"); 273 } 274 sb.append(Strings.CS.replace(value, __CSV_QUOTE_STR, __CSV_QUOTE_STR + __CSV_QUOTE_STR)); 275 } 276 277 sb.append(__CSV_QUOTE); 278 279 return sb.toString(); 280 } 281 282 /** 283 * Returns a sanitized {@code String} value for a XLS-HTML column (no double quotes enclosing). 284 * 285 * <p>If the value starts with '=', '+', '-', '@', newline or TAB, is prepend with a single quote.</p> 286 * 287 * <pre> 288 * null => StringUtils.EMPTY 289 * =1+2";=1+2 => '=1+2";=1+2 290 * =1+2'" ;,=1+2 => '=1+2'" ;,=1+2 291 * L'orem ipsut; sit amet, dolor => L'orem ipsut; sit amet, dolor 292 * =cmd|' /c Calc.exe'!'A1' => '=cmd|' /c Calc.exe'!'A1 293 * </pre> 294 * 295 * @param value the untrusted String value for HTML-XLS column. Can be null. 296 * @return the trusted value 297 */ 298 public static String sanitizeXlsHtml(String value) 299 { 300 if (org.apache.commons.lang3.StringUtils.isNotEmpty(value) && Strings.CS.startsWithAny(value, __CSV_BEGIN_CHARS)) 301 { 302 return "'" + value; 303 } 304 305 return org.apache.commons.lang3.StringUtils.defaultString(value); 306 } 307 308 /** 309 * Escape HTML special characters in a string. 310 * @param value the value to escape 311 * @return the escaped value 312 */ 313 public static String escapeHTML(String value) 314 { 315 return Strings.CS.replace(Strings.CS.replace(Strings.CS.replace(Strings.CS.replace(value, "&", "&"), "<", "<"), "\"", """), "'", "'"); 316 } 317 318 /** 319 * Compares two strings ignoring case and accents and honoring natural numbers ordering. 320 */ 321 public static class AlphanumComparator implements Comparator<String> 322 { 323 public int compare(String s1, String s2) 324 { 325 // Lowercase and replace accented characters with their non-accented equivalents 326 String normalizedS1 = Normalizer.normalize(s1.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 327 String normalizedS2 = Normalizer.normalize(s2.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 328 329 int s1Index = 0; 330 int s2Index = 0; 331 332 while (s1Index < normalizedS1.length() && s2Index < normalizedS2.length()) 333 { 334 char s1Char = normalizedS1.charAt(s1Index); 335 char s2Char = normalizedS2.charAt(s2Index); 336 337 if (Character.isDigit(s1Char) && Character.isDigit(s2Char)) 338 { 339 int s1Start = s1Index; 340 int s2Start = s2Index; 341 342 while (s1Index < normalizedS1.length() && Character.isDigit(normalizedS1.charAt(s1Index))) 343 { 344 s1Index++; 345 } 346 while (s2Index < normalizedS2.length() && Character.isDigit(normalizedS2.charAt(s2Index))) 347 { 348 s2Index++; 349 } 350 351 try 352 { 353 Long num1 = Long.parseLong(normalizedS1.substring(s1Start, s1Index)); 354 Long num2 = Long.parseLong(normalizedS2.substring(s2Start, s2Index)); 355 356 if (!num1.equals(num2)) 357 { 358 return num1.compareTo(num2); 359 } 360 } 361 catch (NumberFormatException e) 362 { 363 // Unable to parse as long (file name exceed Long.MAX_VALUE ?), compare inputs as String 364 return normalizedS1.compareTo(normalizedS2); 365 } 366 } 367 else 368 { 369 if (s1Char != s2Char) 370 { 371 return s1Char - s2Char; 372 } 373 s1Index++; 374 s2Index++; 375 } 376 } 377 return normalizedS1.length() - normalizedS2.length(); 378 } 379 } 380}