Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 21 additions & 25 deletions src/ImageResize.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,6 @@ public function buildUrl(
} elseif (mb_substr($sourceImageUrl, 0, 8) === 'https://') {
$sourceImageUrl = mb_substr($sourceImageUrl, 8);
}
$sourceImageUrl = implode(
'/',
array_map(
'rawurlencode',
explode(
'/',
$sourceImageUrl
)
)
);

$imageHost = rtrim($this->imageServiceUrl, '/');

Expand All @@ -63,17 +53,11 @@ public function buildUrl(
'/',
[
urlencode('-' . $imageParamsPath),
trim($sourceImageUrl, '/'),
UrlCodec::compressSourcePath(trim($sourceImageUrl, '/')),
]
);

$signature = md5(
$this->imageServiceKey
. ':'
. $resizePath
. '/'
. $imagePathPostfix
);
$signature = $this->signature($resizePath . '/' . $imagePathPostfix);

return $imageHost
. '/'
Expand Down Expand Up @@ -131,13 +115,7 @@ function ($item) {
)
);

$signature = md5(
$this->imageServiceKey
. ':'
. $resizePath
. '/'
. $imagePathPostfix
);
$signature = $this->signature($resizePath . '/' . $imagePathPostfix);

return $imageHost
. '/'
Expand All @@ -148,6 +126,24 @@ function ($item) {
. $imagePathPostfix;
}

/**
* Unpadded URL-safe base64 of the raw md5 digest: 22 characters instead
* of the 32 hex ones. The image service accepts both encodings.
*
* @param string $signedPath
*/
protected function signature(string $signedPath): string
{
return UrlCodec::base64UrlEncode(
md5(
$this->imageServiceKey
. ':'
. $signedPath,
true
)
);
}

protected static function jsonEncode(array $params): string
{
return json_encode(
Expand Down
78 changes: 77 additions & 1 deletion src/UrlCodec.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
* Tokens without the '_' prefix are decoded as legacy standard base64, so
* URLs generated before compression was introduced keep working.
*
* Source image paths ('bucket/dir/file.jpg') use the same token format via
* compressSourcePath(): the whole path, or just its directory part with the
* filename kept plain, is emitted as a token whenever that is shorter than
* the rawurlencoded path. S3 bucket names cannot start with '_', so a '_'
* in the first path segment unambiguously marks a token.
*
* DICTIONARY_V1 is a wire-protocol contract shared byte-for-byte with the
* image service (image-service-lambda lib/url-codec.js). NEVER edit it:
* published URLs depend on it forever. To improve the vocabulary, add a new
Expand Down Expand Up @@ -181,6 +187,76 @@ public static function compress(string $text): string
. self::base64UrlEncode(deflate_add($context, $text, ZLIB_FINISH));
}

/**
* Encodes a source image path into its shortest URL form: plain
* rawurlencoded segments, a token for the directory part with the
* filename kept plain (visible for image SEO), or a token for the whole
* path. Ties prefer the more readable form. Deterministic per input, so
* URLs stay stable for caching.
*
* @param string $path
*/
public static function compressSourcePath(string $path): string
{
$best = implode(
'/',
array_map(
'rawurlencode',
explode('/', $path)
)
);

$slash = mb_strrpos($path, '/');

if ($slash !== false && $slash > 0) {
$dirsOnly = self::compress(mb_substr($path, 0, $slash))
. '/'
. rawurlencode(mb_substr($path, $slash + 1));

if (mb_strlen($dirsOnly) < mb_strlen($best)) {
$best = $dirsOnly;
}
}

$full = self::compress($path);

if (mb_strlen($full) < mb_strlen($best)) {
$best = $full;
}

return $best;
}

/**
* Decodes a source path produced by compressSourcePath() back to the
* raw path. Mirrors the image service (image-service-lambda
* lib/url-token-params.js): a '_' first segment is decompressed, any
* remaining segments are plain and rawurldecoded.
*
* @param string $path
*/
public static function decompressSourcePath(string $path): string
{
$segments = explode('/', $path);

if (mb_substr($segments[0], 0, 1) !== self::COMPRESSED_PREFIX) {
return implode(
'/',
array_map('rawurldecode', $segments)
);
}

$head = self::decompress(array_shift($segments));

return implode(
'/',
array_merge(
[$head],
array_map('rawurldecode', $segments)
)
);
}

public static function decompress(string $token): string
{
if (mb_substr($token, 0, 1) !== self::COMPRESSED_PREFIX) {
Expand Down Expand Up @@ -218,7 +294,7 @@ public static function decompress(string $token): string
return $text;
}

protected static function base64UrlEncode(string $bytes): string
public static function base64UrlEncode(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}
Expand Down
41 changes: 38 additions & 3 deletions tests/ImageResizeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,36 @@ public function testResizeUrl(): void
'horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
);

// directories compress into a token, the filename stays readable
$this->assertEquals(
'https://image-service.ringier.tech/hz/pi/listing-thumb-360w/a6c9235a796ac9a4d40a425cf9194e9d/-/horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
'https://image-service.ringier.tech/hz/pi/listing-thumb-360w/WtOn6QZtQBeHwqijej_Cyg/-/_1w6IUmDdh-bQw2yS3yjwLAA/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
$url
);

$this->assertEquals(
'horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
UrlCodec::decompressSourcePath(
implode('/', array_slice(explode('/', $url), 8))
)
);
}

public function testResizeUrlKeepsShortSourcePathsPlain(): void
{
$imageResize = new ImageResize(
'https://image-service.ringier.tech/',
'test-key',
);

$url = $imageResize->buildUrl(
'hz',
'pi',
'listing-thumb-360w',
'bucket/file.jpg',
);

$this->assertEquals(
'https://image-service.ringier.tech/hz/pi/listing-thumb-360w/vRiosggBpO5G9nOcRkk2-g/-/bucket/file.jpg',
$url
);
}
Expand All @@ -60,7 +88,7 @@ public function testResizeUrlWithParams(): void
);

$this->assertEquals(
'https://image-service.ringier.tech/hz/pi/listing-thumb-360w/69f69a5d4d68f7527396ae69d27817af/-_1g2WtZKDflMBZy8jEAJzPjM0MagE/horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
'https://image-service.ringier.tech/hz/pi/listing-thumb-360w/We1ryJtuobglqBVQUKoiMg/-_1g2WtZKDflMBZy8jEAJzPjM0MagE/_1w6IUmDdh-bQw2yS3yjwLAA/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
$url
);

Expand All @@ -69,6 +97,13 @@ public function testResizeUrlWithParams(): void
'{"fit":"crop","h":240,"w":360}',
UrlCodec::decompress(mb_substr($paramsSegment, 1))
);

$this->assertEquals(
'horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
UrlCodec::decompressSourcePath(
implode('/', array_slice(explode('/', $url), 8))
)
);
}

public function testTemplateUrl(): void
Expand Down Expand Up @@ -102,7 +137,7 @@ public function testTemplateUrl(): void
);

$this->assertEquals(
'https://image-service.ringier.tech/t/hz/pi/og-template-1200w-630h/082c612ebc6bb7375093509ce60c770e/-/_1I8fN4FSqkANM-gqGyO5HEjdC9QusGgNLovoCWxbE4n5ggQErPAqzTXKrzLP0U82SUk3MTFNSjIxTDVLTTM0N05JTklKM00zMktJSDZMtTCzMklNMjYBFSzr-TE0L-2prAQ/pretty-seo-filename.jpg',
'https://image-service.ringier.tech/t/hz/pi/og-template-1200w-630h/CCxhLrxrtzdQk1Cc5gx3Dg/-/_1I8fN4FSqkANM-gqGyO5HEjdC9QusGgNLovoCWxbE4n5ggQErPAqzTXKrzLP0U82SUk3MTFNSjIxTDVLTTM0N05JTklKM00zMktJSDZMtTCzMklNMjYBFSzr-TE0L-2prAQ/pretty-seo-filename.jpg',
$url
);

Expand Down
68 changes: 68 additions & 0 deletions tests/UrlCodecTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,74 @@ public function testDecompressesPlainTokensWithoutDictionary(): void
$this->assertSame($json, UrlCodec::decompress($token));
}

public function testSourcePathKeepsShortPathsPlain(): void
{
$this->assertSame(
'bucket/file.jpg',
UrlCodec::compressSourcePath('bucket/file.jpg')
);
}

public function testSourcePathRawurlencodesPlainSegments(): void
{
$this->assertSame(
'b/a%20c.jpg',
UrlCodec::compressSourcePath('b/a c.jpg')
);
}

public function testSourcePathCompressesDirectoriesKeepingFilenameVisible(): void
{
// high-entropy hash filenames stay plain; the dictionary-friendly
// directory part becomes the token
$path = 'prod-property-core-backend-media-imo/4185-1130/62/b2071156961e037f4e0925e75caaf8ec-gallery_image_large.jpg';

$encoded = UrlCodec::compressSourcePath($path);

$this->assertStringStartsWith('_1', $encoded);
$this->assertStringEndsWith('/b2071156961e037f4e0925e75caaf8ec-gallery_image_large.jpg', $encoded);
$this->assertLessThan(mb_strlen(rawurlencode($path)), mb_strlen($encoded));
$this->assertSame($path, UrlCodec::decompressSourcePath($encoded));
}

public function testSourcePathCompressesWholeDictionaryFriendlyPaths(): void
{
$path = 'horizon-files-prod/og-template/job-square-04-2022/background.png';

$encoded = UrlCodec::compressSourcePath($path);

$this->assertStringStartsWith('_1', $encoded);
$this->assertStringNotContainsString('/', $encoded);
$this->assertSame($path, UrlCodec::decompressSourcePath($encoded));
}

public function testSourcePathTokensAreUrlSafe(): void
{
$encoded = UrlCodec::compressSourcePath(
'prod-property-core-backend-media-imo/4185-1130/62/b2071156961e037f4e0925e75caaf8ec.jpg'
);

foreach (explode('/', $encoded) as $segment) {
$this->assertSame($segment, rawurlencode($segment));
}
}

public function testSourcePathRoundTripsSpecialCharacters(): void
{
$paths = [
'bucket/dir/imagine româna 100%+.jpg',
'prod-property-core-backend-media-brk/2023-11/apartament de vânzare.jpg',
'bucket/no-directories.png',
];

foreach ($paths as $path) {
$this->assertSame(
$path,
UrlCodec::decompressSourcePath(UrlCodec::compressSourcePath($path))
);
}
}

public function testDictionaryIsFrozen(): void
{
// wire-protocol contract with image-service-lambda lib/url-codec.js;
Expand Down
Loading