/*
* Copyright (c) 2014 the original author or authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.werval.util;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static io.werval.util.IllegalArguments.ensureNotEmpty;
import static io.werval.util.Strings.EMPTY;
import static io.werval.util.Strings.SPACE;
import static io.werval.util.Strings.isEmpty;
/**
* Generate short unique string secret identities from positive numbers.
* <p>
* See <a href="http://hashids.org/">hashids.org</a>.
* This is a Java port of the <a href="https://github.com/ivanakimov/hashids.js">javascript version</a>.
* <p>
* The Hashids's {@literal salt} is used as a secret to generate unique strings using a given {@literal alphabet}.
* Generated strings can have a {@literal minimumLength}.
* <p>
* If you use this to obfuscates identities, do not expose your {@literal salt}, {@literal alphabet} nor
* {@literal separators} to a client, client-side is not safe.
* <p>
* Only positive numbers are supported.
* All methods in this class will throw an {@link IllegalArgumentException} if a negative number is given.
* If you want to use negative numbers you'll have to handle prepending {@literal -} to the hash string yourself and
* would be limited to single number hashes.
* <p>
* Here is sample code to handle negative numbers prepending {@literal -} to them:
* <pre>
* Hashids hashids = new Hashids( "this is your salt" );
* long number = -1234567890;
* String enc = ( Math.abs( number ) != number ? "-" : "" ) + hashids.encodeToString( Math.abs( number ) );
* long dec = enc.startsWith( "-" ) ? -hashids.decodeLongs( enc.substring( 1 ) )[0] : hashids.decodeLongs( enc )[0];
* </pre>
* Note that this isn't true Hashids anymore and that this is limited to single number hashes.
* <p>
* Hashids instances are thread-safe.
*
* @navassoc 1 create * Hashid
*/
public final class Hashids
{
/**
* Default alphabet.
* <p>
* {@literal abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890}
*/
public static final String DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
/**
* Default separators.
* <p>
* Used to prevent the generation of strings that contains rude english words.
* <p>
* {@literal cfhistuCFHISTU}
*/
public static final String DEFAULT_SEPARATORS = "cfhistuCFHISTU";
/**
* Maximum number value.
* <p>
* This is {@literal 9_007_199_254_740_991}, or {@literal 2^53-1}, or {@literal Number.MAX_VALUE-1} in Javascript.
* This limit is mandatory in order to ensure interoperability.
*/
public static final long MAX_NUMBER_VALUE = 9_007_199_254_740_992L - 1;
/**
* Hashids Builder.
* <p>
* Immutable, reusable builder.
* Each method return a new builder instance.
* <p>
* Defaults are no salt, {@link #DEFAULT_ALPHABET}, no minimum length and {@link #DEFAULT_SEPARATORS}.
*
* @navassoc 1 create * Hashids
*/
public static final class Builder
{
private final String salt;
private final String alphabet;
private final String separators;
private final int minimumLength;
/**
* Create a new Hashids builder.
*/
public Builder()
{
this.salt = EMPTY;
this.alphabet = DEFAULT_ALPHABET;
this.separators = DEFAULT_SEPARATORS;
this.minimumLength = 0;
}
private Builder( String salt, String alphabet, String separators, int minimumLength )
{
this.salt = salt;
this.alphabet = alphabet;
this.separators = separators;
this.minimumLength = minimumLength;
}
public Builder salt( String salt )
{
return new Builder( salt, alphabet, separators, minimumLength );
}
public Builder alphabet( String alphabet )
{
return new Builder( salt, alphabet, separators, minimumLength );
}
public Builder separators( String separators )
{
return new Builder( salt, alphabet, separators, minimumLength );
}
public Builder minimumLength( int minimumLength )
{
return new Builder( salt, alphabet, separators, minimumLength );
}
public Hashids build()
{
return new Hashids( salt, minimumLength, alphabet, separators );
}
}
private static final int MIN_ALPHABET_LENGTH = 16;
private static final double SEP_DIV = 3.5;
private static final int GUARD_DIV = 12;
private final String salt;
private final int minimumLength;
private final String alphabet;
private final String separators;
private final String guards;
public Hashids( String salt )
{
this( salt, 0 );
}
public Hashids( String salt, int minimumLength )
{
this( salt, minimumLength, DEFAULT_ALPHABET );
}
public Hashids( String salt, String alphabet )
{
this( salt, 0, alphabet );
}
public Hashids( String salt, int minimumLength, String alphabet )
{
this( salt, minimumLength, alphabet, DEFAULT_SEPARATORS );
}
public Hashids( String salt, int minimumLength, String alphabet, String separators )
{
ensureNotEmpty( "alphabet", alphabet );
this.salt = salt == null ? EMPTY : salt;
this.minimumLength = minimumLength < 0 ? 0 : minimumLength;
String uniqueAlphabet = EMPTY;
for( int idx = 0; idx < alphabet.length(); idx++ )
{
if( !uniqueAlphabet.contains( EMPTY + alphabet.charAt( idx ) ) )
{
uniqueAlphabet += EMPTY + alphabet.charAt( idx );
}
}
alphabet = uniqueAlphabet;
if( alphabet.length() < MIN_ALPHABET_LENGTH )
{
throw new IllegalArgumentException(
"Alphabet must contain at least " + MIN_ALPHABET_LENGTH + " unique characters"
);
}
if( alphabet.contains( SPACE ) )
{
throw new IllegalArgumentException( "Alphabet cannot contains spaces" );
}
// separators should contain only characters present in alphabet;
// alphabet should not contains separators
String seps = separators == null ? EMPTY : separators;
for( int sepIdx = 0; sepIdx < seps.length(); sepIdx++ )
{
int alphaIdx = alphabet.indexOf( seps.charAt( sepIdx ) );
if( alphaIdx == -1 )
{
seps = seps.substring( 0, sepIdx ) + SPACE + seps.substring( sepIdx + 1 );
}
else
{
alphabet = alphabet.substring( 0, alphaIdx ) + SPACE + alphabet.substring( alphaIdx + 1 );
}
}
alphabet = alphabet.replaceAll( "\\s+", EMPTY );
seps = seps.replaceAll( "\\s+", EMPTY );
seps = consistentShuffle( seps, this.salt );
if( isEmpty( seps ) || ( alphabet.length() / seps.length() ) > SEP_DIV )
{
int sepsLen = (int) Math.ceil( alphabet.length() / SEP_DIV );
if( sepsLen == 1 )
{
sepsLen++;
}
if( sepsLen > seps.length() )
{
int diff = sepsLen - seps.length();
seps += alphabet.substring( 0, diff );
alphabet = alphabet.substring( diff );
}
else
{
seps = seps.substring( 0, sepsLen );
}
}
alphabet = consistentShuffle( alphabet, this.salt );
// round up using double cast
// int guardCount = (int) Math.ceil( (double) ( alphabet.length() / GUARD_DIV ) );
int guardCount = (int) Math.ceil( alphabet.length() / GUARD_DIV );
if( alphabet.length() < 3 )
{
guards = seps.substring( 0, guardCount );
seps = seps.substring( guardCount );
}
else
{
guards = alphabet.substring( 0, guardCount );
alphabet = alphabet.substring( guardCount );
}
this.alphabet = alphabet;
this.separators = seps;
}
/**
* Encrypt number(s).
*
* @param numbers The number(s) to encrypt
*
* @return The Hashid instance with both number(s) and the encrypted string
*/
public Hashid encode( long... numbers )
{
if( numbers.length == 0 )
{
return Hashid.EMPTY;
}
return doEncode( numbers );
}
/**
* Encrypt number(s) to string.
*
* @param numbers The number(s) to encrypt
*
* @return The encrypted string
*/
public String encodeToString( long... numbers )
{
if( numbers.length == 0 )
{
return EMPTY;
}
return encode( numbers ).toString();
}
/**
* Encrypt number(s) to string.
*
* @param numbers The number(s) to encrypt
*
* @return The encrypted string
*/
public String encodeToString( int... numbers )
{
if( numbers.length == 0 )
{
return EMPTY;
}
long[] longs = new long[ numbers.length ];
for( int idx = 0; idx < numbers.length; idx++ )
{
longs[idx] = numbers[idx];
}
return doEncode( longs ).toString();
}
/**
* Encrypt hexa string to string.
*
* @param hexa The hexa string to encrypt
*
* @return The encrypted string
*/
public String encodeToString( String hexa )
{
if( !hexa.matches( "^[0-9a-fA-F]+$" ) )
{
throw new IllegalArgumentException( String.format( "%s is not a hex string", hexa ) );
}
Matcher matcher = Pattern.compile( "[\\w\\W]{1,12}" ).matcher( hexa );
List<Long> matched = new ArrayList<>();
while( matcher.find() )
{
matched.add( Long.parseLong( "1" + matcher.group(), 16 ) );
}
return doEncode( toArray( matched ) ).toString();
}
/**
* Decrypt string.
*
* @param hash The encrypted string
*
* @return The Hashid instance with both hash and the decrypted number(s)
*/
public Hashid decode( String hash )
{
if( isEmpty( hash ) )
{
return Hashid.EMPTY;
}
return doDecode( hash, alphabet );
}
/**
* Decrypt string to longs.
*
* @param hash The encrypted string
*
* @return The decrypted longs
*/
public long[] decodeLongs( String hash )
{
if( isEmpty( hash ) )
{
return new long[ 0 ];
}
return doDecode( hash, alphabet ).longs();
}
/**
* Decrypt string to integers.
*
* @param hash The encrypted string
*
* @return The decrypted integers
*
* @throws IllegalArgumentException if decoded number is out of integer range, shouldn't you be using longs instead?
*/
public int[] decodeInts( String hash )
{
if( isEmpty( hash ) )
{
return new int[ 0 ];
}
long[] numbers = doDecode( hash, alphabet ).longs();
int[] ints = new int[ numbers.length ];
for( int idx = 0; idx < numbers.length; idx++ )
{
long number = numbers[idx];
if( number < Integer.MIN_VALUE || number > Integer.MAX_VALUE )
{
throw new IllegalArgumentException( "Number out of range" );
}
ints[idx] = (int) number;
}
return ints;
}
/**
* Decrypt string to numbers as hex.
*
* @param hash The encrypted string
*
* @return The decrypted numbers as hex
*/
public String decodeHex( String hash )
{
StringBuilder sb = new StringBuilder();
long[] numbers = decodeLongs( hash );
for( long number : numbers )
{
sb.append( Long.toHexString( number ).substring( 1 ) );
}
return sb.toString();
}
private Hashid doEncode( long... numbers )
{
int numberHashInt = 0;
for( int idx = 0; idx < numbers.length; idx++ )
{
if( numbers[idx] < 0 || numbers[idx] > MAX_NUMBER_VALUE )
{
throw new IllegalArgumentException( "Number out of range" );
}
numberHashInt += numbers[idx] % ( idx + 100 );
}
String decodeAlphabet = alphabet;
final char lotery = decodeAlphabet.toCharArray()[numberHashInt % decodeAlphabet.length()];
String result = lotery + EMPTY;
String buffer;
int sepsIdx, guardIdx;
for( int idx = 0; idx < numbers.length; idx++ )
{
long num = numbers[idx];
buffer = lotery + salt + decodeAlphabet;
decodeAlphabet = consistentShuffle( decodeAlphabet, buffer.substring( 0, decodeAlphabet.length() ) );
final String last = hash( num, decodeAlphabet );
result += last;
if( idx + 1 < numbers.length )
{
num %= ( (int) last.toCharArray()[0] + idx );
sepsIdx = (int) ( num % separators.length() );
result += separators.toCharArray()[sepsIdx];
}
}
if( result.length() < minimumLength )
{
guardIdx = ( numberHashInt + (int) ( result.toCharArray()[0] ) ) % guards.length();
char guard = guards.toCharArray()[guardIdx];
result = guard + result;
if( result.length() < minimumLength )
{
guardIdx = ( numberHashInt + (int) ( result.toCharArray()[2] ) ) % guards.length();
guard = guards.toCharArray()[guardIdx];
result += guard;
}
}
final int halfLen = decodeAlphabet.length() / 2;
while( result.length() < minimumLength )
{
decodeAlphabet = consistentShuffle( decodeAlphabet, decodeAlphabet );
result = decodeAlphabet.substring( halfLen ) + result + decodeAlphabet.substring( 0, halfLen );
final int excess = result.length() - minimumLength;
if( excess > 0 )
{
int startPos = excess / 2;
result = result.substring( startPos, startPos + minimumLength );
}
}
return new Hashid( numbers, result );
}
private Hashid doDecode( String hash, String alphabet )
{
int idx = 0;
String[] hashArray = hash.replaceAll( "[" + guards + "]", SPACE ).split( SPACE );
if( hashArray.length == 3 || hashArray.length == 2 )
{
idx = 1;
}
String hashBreakdown = hashArray[idx];
final char lottery = hashBreakdown.toCharArray()[0];
hashBreakdown = hashBreakdown.substring( 1 );
hashBreakdown = hashBreakdown.replaceAll( "[" + separators + "]", SPACE );
hashArray = hashBreakdown.split( SPACE );
final List<Long> result = new ArrayList<>();
String buffer;
for( String subHash : hashArray )
{
buffer = lottery + salt + alphabet;
alphabet = consistentShuffle( alphabet, buffer.substring( 0, alphabet.length() ) );
result.add( unhash( subHash, alphabet ) );
}
long[] resultArray = toArray( result );
if( !doEncode( resultArray ).toString().equals( hash ) )
{
throw new IllegalArgumentException( String.format( "%s is not a valid hashid", hash ) );
}
return new Hashid( resultArray, hash );
}
private String consistentShuffle( String alphabet, String salt )
{
if( salt.length() <= 0 )
{
return alphabet;
}
final char[] saltChars = salt.toCharArray();
int ascVal, j;
char tmp;
for( int idx = alphabet.length() - 1, v = 0, p = 0; idx > 0; idx--, v++ )
{
v %= salt.length();
ascVal = (int) saltChars[v];
p += ascVal;
j = ( ascVal + v + p ) % idx;
tmp = alphabet.charAt( j );
alphabet = alphabet.substring( 0, j ) + alphabet.charAt( idx ) + alphabet.substring( j + 1 );
alphabet = alphabet.substring( 0, idx ) + tmp + alphabet.substring( idx + 1 );
}
return alphabet;
}
private String hash( long input, String alphabet )
{
String hash = EMPTY;
final int alphabetLen = alphabet.length();
final char[] alphabetChars = alphabet.toCharArray();
do
{
hash = alphabetChars[(int) ( input % alphabetLen )] + hash;
input /= alphabetLen;
}
while( input > 0 );
return hash;
}
private Long unhash( String input, String alphabet )
{
long number = 0;
long pos;
final char[] inputChars = input.toCharArray();
for( int idx = 0; idx < input.length(); idx++ )
{
pos = alphabet.indexOf( inputChars[idx] );
number += pos * Math.pow( alphabet.length(), input.length() - idx - 1 );
}
return number;
}
private long[] toArray( List<Long> longs )
{
final long[] result = new long[ longs.size() ];
int idx = 0;
for( Long aLong : longs )
{
result[idx++] = aLong;
}
return result;
}
}