├── LICENSE ├── composer.json └── src ├── Math ├── BCMath.php ├── Gmp.php └── MathInterface.php ├── Sqids.php └── SqidsInterface.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present Sqids maintainers. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sqids/sqids", 3 | "description": "Generate short YouTube-looking IDs from numbers", 4 | "license": "MIT", 5 | "keywords": [ 6 | "sqids", 7 | "hashids", 8 | "generate", 9 | "encode", 10 | "decode", 11 | "sqids", 12 | "ids" 13 | ], 14 | "homepage": "https://sqids.org/php", 15 | "require": { 16 | "php": "^8.1" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^10.5|^11.2" 20 | }, 21 | "suggest": { 22 | "ext-bcmath": "Required to use BC Math arbitrary precision mathematics (*).", 23 | "ext-gmp": "Required to use GNU multiple precision mathematics (*)." 24 | }, 25 | "minimum-stability": "dev", 26 | "prefer-stable": true, 27 | "autoload": { 28 | "psr-4": { 29 | "Sqids\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "Sqids\\Tests\\": "tests/" 35 | } 36 | }, 37 | "config": { 38 | "preferred-install": "dist" 39 | }, 40 | "extra": { 41 | "branch-alias": { 42 | "dev-main": "1.0-dev" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Math/BCMath.php: -------------------------------------------------------------------------------- 1 | 0; 39 | } 40 | 41 | public function intval($a) 42 | { 43 | return intval($a); 44 | } 45 | 46 | public function strval($a) 47 | { 48 | return $a; 49 | } 50 | 51 | public function get($a) 52 | { 53 | return $a; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Math/Gmp.php: -------------------------------------------------------------------------------- 1 | 0; 39 | } 40 | 41 | public function intval($a) 42 | { 43 | return gmp_intval($a); 44 | } 45 | 46 | public function strval($a) 47 | { 48 | return gmp_strval($a); 49 | } 50 | 51 | public function get($a) 52 | { 53 | return gmp_init($a); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Math/MathInterface.php: -------------------------------------------------------------------------------- 1 | '[i1]', 236 | 'o' => '[o0]', 237 | 'l' => '[l1]', 238 | ]; 239 | 240 | protected MathInterface $math; 241 | 242 | protected ?string $blocklistRegex; 243 | 244 | /** @throws InvalidArgumentException */ 245 | public function __construct( 246 | protected string $alphabet = self::DEFAULT_ALPHABET, 247 | protected int $minLength = self::DEFAULT_MIN_LENGTH, 248 | protected array $blocklist = self::DEFAULT_BLOCKLIST, 249 | ) { 250 | $this->math = $this->getMathExtension(); 251 | 252 | if ($alphabet == '') { 253 | $alphabet = self::DEFAULT_ALPHABET; 254 | } 255 | 256 | if (mb_strlen($alphabet) != strlen($alphabet)) { 257 | throw new InvalidArgumentException('Alphabet cannot contain multibyte characters'); 258 | } 259 | 260 | if (strlen($alphabet) < 3) { 261 | throw new InvalidArgumentException('Alphabet length must be at least 3'); 262 | } 263 | 264 | if (preg_match('/(.).*\1/', $alphabet)) { 265 | throw new InvalidArgumentException('Alphabet must contain unique characters'); 266 | } 267 | 268 | if ($minLength < 0 || $minLength > self::MIN_LENGTH_LIMIT) { 269 | throw new InvalidArgumentException('Minimum length has to be between 0 and ' . self::MIN_LENGTH_LIMIT); 270 | } 271 | 272 | $this->blocklistRegex = $this->buildBlocklistRegex(); 273 | $this->alphabet = $this->shuffle($alphabet); 274 | } 275 | 276 | /** 277 | * Encodes an array of unsigned integers into an ID 278 | * 279 | * These are the cases where encoding might fail: 280 | * - One of the numbers passed is smaller than 0 or greater than `maxValue()` 281 | * - An n-number of attempts has been made to re-generated the ID, where n is alphabet length + 1 282 | * 283 | * @param array $numbers Non-negative integers to encode into an ID 284 | * @return string Generated ID 285 | */ 286 | public function encode(array $numbers): string 287 | { 288 | if (count($numbers) == 0) { 289 | return ''; 290 | } 291 | 292 | foreach ($numbers as $n) { 293 | if ($n < 0 || $n > self::maxValue()) { 294 | throw new InvalidArgumentException( 295 | 'Encoding supports numbers between 0 and ' . self::maxValue(), 296 | ); 297 | } 298 | } 299 | 300 | return $this->encodeNumbers($numbers); 301 | } 302 | 303 | /** 304 | * Internal function that encodes an array of unsigned integers into an ID 305 | * 306 | * @param array $numbers Non-negative integers to encode into an ID 307 | * @param int $increment An internal number used to modify the `offset` variable in order to re-generate the ID 308 | * @return string Generated ID 309 | */ 310 | protected function encodeNumbers(array $numbers, int $increment = 0): string 311 | { 312 | if ($increment > strlen($this->alphabet)) { 313 | throw new InvalidArgumentException('Reached max attempts to re-generate the ID'); 314 | } 315 | 316 | $offset = count($numbers); 317 | foreach ($numbers as $i => $v) { 318 | $offset += ord($this->alphabet[$v % strlen($this->alphabet)]) + $i; 319 | } 320 | $offset %= strlen($this->alphabet); 321 | $offset = ($offset + $increment) % strlen($this->alphabet); 322 | 323 | $alphabet = substr($this->alphabet, $offset) . substr($this->alphabet, 0, $offset); 324 | $prefix = $alphabet[0]; 325 | $alphabet = strrev($alphabet); 326 | $id = $prefix; 327 | 328 | for ($i = 0; $i != count($numbers); $i++) { 329 | $num = $numbers[$i]; 330 | 331 | $id .= $this->toId($num, substr($alphabet, 1)); 332 | if ($i < count($numbers) - 1) { 333 | $id .= $alphabet[0]; 334 | $alphabet = $this->shuffle($alphabet); 335 | } 336 | } 337 | 338 | if ($this->minLength > strlen($id)) { 339 | $id .= $alphabet[0]; 340 | 341 | while ($this->minLength - strlen($id) > 0) { 342 | $alphabet = $this->shuffle($alphabet); 343 | $id .= substr($alphabet, 0, min($this->minLength - strlen($id), strlen($this->alphabet))); 344 | } 345 | } 346 | 347 | if ($this->isBlockedId($id)) { 348 | $id = $this->encodeNumbers($numbers, $increment + 1); 349 | } 350 | 351 | return $id; 352 | } 353 | 354 | /** 355 | * Decodes an ID back into an array of unsigned integers 356 | * 357 | * These are the cases where the return value might be an empty array: 358 | * - Empty ID / empty string 359 | * - Non-alphabet character is found within the ID 360 | * 361 | * @param string $id Encoded ID 362 | * @return array Array of unsigned integers 363 | */ 364 | public function decode(string $id): array 365 | { 366 | $ret = []; 367 | 368 | if ($id == '') { 369 | return $ret; 370 | } 371 | 372 | if (!preg_match('/^[' . preg_quote($this->alphabet, '/') . ']+$/', $id)) { 373 | return $ret; 374 | } 375 | 376 | $prefix = $id[0]; 377 | $offset = strpos($this->alphabet, $prefix); 378 | $alphabet = substr($this->alphabet, $offset) . substr($this->alphabet, 0, $offset); 379 | $alphabet = strrev($alphabet); 380 | $id = substr($id, 1); 381 | 382 | while (strlen($id) > 0) { 383 | $separator = $alphabet[0]; 384 | 385 | $chunks = explode($separator, $id, 2); 386 | if (array_key_exists(0, $chunks)) { 387 | if ($chunks[0] == '') { 388 | return $ret; 389 | } 390 | 391 | $ret[] = $this->toNumber($chunks[0], substr($alphabet, 1)); 392 | if (array_key_exists(1, $chunks)) { 393 | $alphabet = $this->shuffle($alphabet); 394 | } 395 | } 396 | 397 | $id = $chunks[1] ?? ''; 398 | } 399 | 400 | return $ret; 401 | } 402 | 403 | protected function shuffle(string $alphabet): string 404 | { 405 | for ($i = 0, $j = strlen($alphabet) - 1; $j > 0; $i++, $j--) { 406 | $r = ($i * $j + ord($alphabet[$i]) + ord($alphabet[$j])) % strlen($alphabet); 407 | [$alphabet[$i], $alphabet[$r]] = [$alphabet[$r], $alphabet[$i]]; 408 | } 409 | 410 | return $alphabet; 411 | } 412 | 413 | protected function toId(int $num, string $alphabet): string 414 | { 415 | $id = ''; 416 | do { 417 | $id = $alphabet[$this->math->intval($this->math->mod($num, strlen($alphabet)))] . $id; 418 | $num = $this->math->divide($num, strlen($alphabet)); 419 | } while ($this->math->greaterThan($num, 0)); 420 | 421 | return $id; 422 | } 423 | 424 | protected function toNumber(string $id, string $alphabet): int 425 | { 426 | $number = 0; 427 | for ($i = 0; $i < strlen($id); $i++) { 428 | $number = $this->math->add( 429 | $this->math->multiply($number, strlen($alphabet)), 430 | strpos($alphabet, $id[$i]), 431 | ); 432 | } 433 | 434 | return $this->math->intval($number); 435 | } 436 | 437 | protected function isBlockedId(string $id): bool 438 | { 439 | return $this->blocklistRegex !== null && preg_match($this->blocklistRegex, $id); 440 | } 441 | 442 | protected static function maxValue(): int 443 | { 444 | return PHP_INT_MAX; 445 | } 446 | 447 | /** 448 | * Get BC Math or GMP extension. 449 | * @throws \RuntimeException 450 | */ 451 | protected function getMathExtension(): MathInterface 452 | { 453 | if (extension_loaded('gmp')) { 454 | return new Gmp(); 455 | } 456 | 457 | if (extension_loaded('bcmath')) { 458 | return new BCMath(); 459 | } 460 | 461 | throw new RuntimeException('Missing math extension for Sqids, install either bcmath or gmp.'); 462 | } 463 | 464 | protected function buildBlocklistRegex(): ?string 465 | { 466 | $wordsMatchingExactly = []; 467 | $wordsMatchingBeginningOrEnd = []; 468 | $wordMatchingAnywhere = []; 469 | 470 | foreach ($this->blocklist as $word) { 471 | $word = (string) $word; 472 | if (strlen($word) <= 3) { 473 | $wordsMatchingExactly[] = preg_quote($word, '/'); 474 | } else { 475 | $word = preg_quote($word, '/'); 476 | $leet = strtr($word, self::LEET); 477 | if (!preg_match('/\d/', $word)) { 478 | $wordMatchingAnywhere[] = $word; 479 | } elseif ($leet === $word) { 480 | $wordsMatchingBeginningOrEnd[] = $word; 481 | } 482 | 483 | if ($leet !== $word) { 484 | $wordsMatchingBeginningOrEnd[] = $leet; 485 | } 486 | } 487 | } 488 | 489 | $regexParts = []; 490 | if ($wordsMatchingExactly) { 491 | $regexParts[] = '^(' . implode('|', $wordsMatchingExactly) . ')$'; 492 | } 493 | if ($wordsMatchingBeginningOrEnd) { 494 | $regexParts[] = '^(' . implode('|', $wordsMatchingBeginningOrEnd) . ')'; 495 | $regexParts[] = '(' . implode('|', $wordsMatchingBeginningOrEnd) . ')$'; 496 | } 497 | if ($wordMatchingAnywhere) { 498 | $regexParts[] = '(' . implode('|', $wordMatchingAnywhere) . ')'; 499 | } 500 | 501 | if ($regexParts) { 502 | return '/(' . implode('|', $regexParts) . ')/i'; 503 | } 504 | 505 | return null; 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /src/SqidsInterface.php: -------------------------------------------------------------------------------- 1 | $numbers 19 | * @return string 20 | */ 21 | public function encode(array $numbers): string; 22 | 23 | /** 24 | * Decode an ID back to integers. 25 | * @param string $id 26 | * @return array 27 | */ 28 | public function decode(string $id): array; 29 | } 30 | --------------------------------------------------------------------------------