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, "&", "&amp;"), "<", "&lt;"), "\"", "&quot;"), "'", "&#39;");
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}