diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 577e7196..e79abc98 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -28,6 +28,14 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->length(2048) ->build(); + $builder + ->createField('longUrlHash', Types::BINARY) + ->columnName('long_url_hash') + ->length(32) + ->build(); + + $builder->addIndex(['long_url_hash'], 'IDX_long_url_hash'); + $shortCodeField = fieldWithUtf8Charset( $builder->createField('shortCode', Types::STRING), $emConfig, diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index d64f53aa..dcde05df 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -22,6 +22,8 @@ use function array_map; use function array_pad; use function array_reduce; use function explode; +use function hash; +use function hex2bin; use function implode; use function is_array; use function mb_strtolower; @@ -263,3 +265,12 @@ function geolocationFromRequest(ServerRequestInterface $request): Location|null return $geolocation; } + +/** + * Generate a SHA256 hash of a string and then convert it to binary + */ +function stringToBinHash(string $value): string +{ + // @phpstan-ignore-next-line return.type + return hex2bin(hash('sha256', $value)); +} diff --git a/module/Core/migrations/Version20260524105410.php b/module/Core/migrations/Version20260524105410.php new file mode 100644 index 00000000..ae9d8edc --- /dev/null +++ b/module/Core/migrations/Version20260524105410.php @@ -0,0 +1,47 @@ +getTable('short_urls'); + if ($shortUrls->hasColumn(self::COLUMN_NAME)) { + return; + } + + $shortUrls->addColumn(self::COLUMN_NAME, Types::BINARY, [ + 'length' => 32, + 'notnull' => false, // Temporarily nullable until values have been filled + ]); + $shortUrls->addIndex([self::COLUMN_NAME], self::INDEX_NAME); + } + + public function down(Schema $schema): void + { + $shortUrls = $schema->getTable('short_urls'); + $this->skipIf(!$shortUrls->hasColumn(self::COLUMN_NAME)); + + $shortUrls->dropIndex(self::INDEX_NAME); + $shortUrls->dropColumn(self::COLUMN_NAME); + } + + public function isTransactional(): bool + { + return !$this->connection->getDatabasePlatform() instanceof MySQLPlatform; + } +} diff --git a/module/Core/migrations/Version20260607082210.php b/module/Core/migrations/Version20260607082210.php new file mode 100644 index 00000000..8a35bd3d --- /dev/null +++ b/module/Core/migrations/Version20260607082210.php @@ -0,0 +1,46 @@ +connection->createQueryBuilder(); + $qb + ->select('id', 'original_url') + ->from('short_urls'); + + $result = $qb->executeQuery(); + while ($row = $result->fetchAssociative()) { + $updateQb = $this->connection->createQueryBuilder(); + $updateQb + ->update('short_urls') + ->set('long_url_hash', ':binHash') + ->where($updateQb->expr()->eq('id', ':id')) + ->setParameters([ + 'id' => $row['id'], + 'binHash' => hex2bin(hash('sha256', $row['original_url'])), + ]) + ->setMaxResults(1) + ->executeStatement(); + } + } + + public function isTransactional(): bool + { + return !$this->connection->getDatabasePlatform() instanceof MySQLPlatform; + } +} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index f9e16f18..81483d9f 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -30,10 +30,13 @@ use function array_map; use function count; use function Shlinkio\Shlink\Common\normalizeDate; use function Shlinkio\Shlink\Core\generateRandomShortCode; +use function Shlinkio\Shlink\Core\stringToBinHash; use function sprintf; class ShortUrl extends AbstractEntity { + private(set) string $longUrlHash; + /** * @param Collection $tags * @param Collection&Selectable $visits @@ -41,7 +44,13 @@ class ShortUrl extends AbstractEntity * @param Collection $redirectRules */ private function __construct( - private(set) string $longUrl, + private(set) string $longUrl { + get => $this->longUrl; + set(string $value) { + $this->longUrl = $value; + $this->longUrlHash = stringToBinHash($value); + } + }, private(set) string $shortCode, private(set) Chronos $dateCreated = new Chronos(), private(set) Collection $tags = new ArrayCollection(),