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
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
],
"homepage": "https://github.com/RingierIMU/image-service-php",
"require": {
"php": ">= 7.3"
"php": ">= 7.3",
"ext-json": "*",
"ext-zlib": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
Expand Down
16 changes: 12 additions & 4 deletions src/ImageResize.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function buildUrl(

unset($imageParams['s']);
ksort($imageParams);
$imageParamsPath = $imageParams ? base64_encode(json_encode($imageParams)) : '';
$imageParamsPath = $imageParams ? UrlCodec::compress(self::jsonEncode($imageParams)) : '';

$imagePathPostfix = implode(
'/',
Expand Down Expand Up @@ -101,7 +101,7 @@ public function buildTemplateUrl(

unset($imageParams['s']);
ksort($imageParams);
$imageParamsPath = $imageParams ? base64_encode(json_encode($imageParams)) : '';
$imageParamsPath = $imageParams ? UrlCodec::compress(self::jsonEncode($imageParams)) : '';

$imageHost = rtrim($this->imageServiceUrl, '/');
$imagePathPostfix = implode(
Expand All @@ -113,8 +113,8 @@ function ($item) {
array_merge(
['-' . $imageParamsPath],
[
base64_encode(
json_encode(
UrlCodec::compress(
self::jsonEncode(
array_replace_recursive(
[
'template' => null,
Expand Down Expand Up @@ -147,4 +147,12 @@ function ($item) {
. '/'
. $imagePathPostfix;
}

protected static function jsonEncode(array $params): string
{
return json_encode(
$params,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
);
}
}
240 changes: 240 additions & 0 deletions src/UrlCodec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
<?php

namespace RingierIMU\ImageService;

use InvalidArgumentException;

/**
* Compresses text into a compact URL-safe token and back.
*
* Token format: '_' + version + unpadded URL-safe base64 (RFC 4648 §5) of the
* raw DEFLATE compressed text:
* _0 no preset dictionary
* _1 preset dictionary v1
* Every output character is an RFC 3986 unreserved character, so tokens pass
* through urlencode() unchanged.
*
* Tokens without the '_' prefix are decoded as legacy standard base64, so
* URLs generated before compression was introduced keep working.
*
* 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
* dictionary under the next version prefix instead.
*/
class UrlCodec
{
protected const COMPRESSED_PREFIX = '_';

protected const VERSION_PLAIN = '0';

protected const VERSION_DICTIONARY_V1 = '1';

protected const DICTIONARY_V1 = ''
. 'backgrounds/'
. 'overlays/'
. 'placeholders/'
. '.svg'
. '.webp'
. '","cornerOverlayColor":"'
. '","cornerStripColor":"'
. '","brandWebsite":"'
. '","brandWebsiteColor":"'
. '","brandWidth":"'
. '","companyColor":"'
. '","titleColor":"'
. '","subtitleColor":"'
. '","subtitleBorderColor":"'
. '","ctaColor":"'
. '","ctaBackgroundColor":"'
. '","priceColor":"'
. '","priceBackgroundColor":"'
. '","priceBackgroundOpacity":"'
. '","jobTitleColor":"'
. '","jobFunctionColor":"'
. '","salaryColor":"'
. '","salaryIconColor":"'
. '","locationColor":"'
. '","locationIconColor":"'
. '","workTypeColor":"'
. '","workTypeIconColor":"'
. '","infoValue1Color":"'
. '","infoValue2Color":"'
. '","infoValue3Color":"'
. '","infoLabel1Color":"'
. '","infoLabel2Color":"'
. '","infoLabel3Color":"'
. '","backgroundGradientFrom":"'
. '","backgroundGradientTo":"'
. '","backgroundGradientColor":"'
. '","sideGradientFrom":"'
. '","sideGradientTo":"'
. '","footerBackgroundColor":"'
. '","brandBackgroundColor":"'
. '","brandBackgroundStripeColor":"'
. '","fontFamily":"Roboto'
. '#ffffff'
. '#000000'
. 'transparent'
. 'aimg.brightermonday.co.ke/'
. 'imo-static-public-prod/'
. 'imo-original-imgs-prod/imagini_agentii_originale/'
. 'mx-files-prod-ritdu-rsmg/entity-list/'
. 'horizon-files-staging/'
. 'sdc-bn-production/'
. 'cde-bn-production/client-8/'
. 'user-img-gh-jobs-prod/userid'
. 'advertiser-img-gh-jobs-prod/dealer-images/advid'
. 'prod-property-core-backend-media-brk/'
. 'prod-property-core-backend-media-rad/'
. 'category-rectangle-2022-10'
. 'category-rectangle-2025-08'
. 'category-square'
. 'job-square-04-2022'
. 'job-square'
. 'business'
. '","titleWithoutSubtitle1":"'
. '","titleWithoutSubtitle2":"'
. '","title1Large":"'
. '","title3_1":"'
. '","title3_2":"'
. '","title3_3":"'
. '","location2_1":"'
. '","location2_2":"'
. '","subtitle1":"'
. '","subtitle2":"'
. '","company":"'
. '","title":"'
. 'Internship & Graduate'
. 'Part Time'
. 'Contract'
. 'Unspecified'
. 'Confidential'
. 'GHS '
. 'Management & Business Development'
. 'Accounting, Auditing & Finance'
. 'Engineering & Technology'
. 'Admin & Office'
. 'Sales'
. 'Accra & Tema Region'
. 'Kumasi & Ashanti Region'
. ' Region'
. 'Dakar | Temps plein'
. 'Nairobi | Full time'
. 'Full Time'
. 'SUPR. TEREN '
. 'CIRCULAR'
. 'parter'
. 'SEMIDECOMANDAT'
. 'CASĂ'
. 'APARTAMENT'
. 'CAMERĂ'
. 'BĂI'
. 'de închiriat'
. 'de vânzare'
. '.000 €'
. '0 €'
. 'Recrutement ouvert'
. 'Now Hiring'
. 'shared-folder-prod/2022-10/square/'
. 'shared-folder-prod/2024-09/rectangle/'
. 'shared-folder-prod/2025-09/rectangle/'
. 'horizon-files-prod/og-template/job-square-04-2022/'
. '/ed/picture/'
. '/pi/logo/'
. '/ed/logo/'
. '.jpeg"'
. '.png"'
. '"embed":'
. '"resize":{'
. '{"fit":"'
. ',"w":'
. ',"h":'
. '{"dpr":'
. '"ow":"'
. '"oi":'
. '{"or":180}'
. '{"or":270}'
. '{"or":90}'
. '{"or":0}'
. '"images":[]}'
. '"},"logo":{"file":"s3://'
. '"},"background":{"file":"s3://'
. '{"template":"business","replacements":{"title":"'
. '{"template":"job-09-2024","replacements":{"jobTitle":"","jobFunction":"","salary":"","location":"","workType":""},"images":{"background":{"file":"s3://'
. '-dark'
. '{"template":"cde-prop-meta-carousel-06-2025-light","replacements":{"title1Regular":"APARTAMENT","title2":"de vânzare","subtitle":"","price":"","infoValue1":"","infoLabel1":"CAMERE","infoValue2":"","infoLabel2":"SUPRAFAȚĂ","infoValue3":"","infoLabel3":"ETAJ"},"images":{"background":{"file":"s3://prod-property-core-backend-media-imo/'
. '{"template":"job-04-2022","replacements":{"title1":"","title2":"","subtitle":"","cta":"Now Hiring"},"images":{"background":{"file":"s3://horizon-files-prod/og-template/job-04-2022/';

public static function compress(string $text): string
{
$context = deflate_init(
ZLIB_ENCODING_RAW,
[
'level' => 9,
'dictionary' => self::DICTIONARY_V1,
]
);

return self::COMPRESSED_PREFIX
. self::VERSION_DICTIONARY_V1
. self::base64UrlEncode(deflate_add($context, $text, ZLIB_FINISH));
}

public static function decompress(string $token): string
{
if (mb_substr($token, 0, 1) !== self::COMPRESSED_PREFIX) {
$text = base64_decode($token, true);

if ($text === false) {
throw new InvalidArgumentException('Invalid legacy base64 token');
}

return $text;
}

switch (mb_substr($token, 1, 1)) {
case self::VERSION_PLAIN:
$options = [];
break;
case self::VERSION_DICTIONARY_V1:
$options = ['dictionary' => self::DICTIONARY_V1];
break;
default:
throw new InvalidArgumentException('Unknown token version');
}

$context = inflate_init(ZLIB_ENCODING_RAW, $options);
$text = @inflate_add(
$context,
self::base64UrlDecode(mb_substr($token, 2)),
ZLIB_FINISH
);

if ($text === false) {
throw new InvalidArgumentException('Invalid DEFLATE payload in token');
}

return $text;
}

protected static function base64UrlEncode(string $bytes): string
{
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

protected static function base64UrlDecode(string $encoded): string
{
$base64 = strtr($encoded, '-_', '+/');
$bytes = base64_decode(
str_pad($base64, (int) ceil(mb_strlen($base64) / 4) * 4, '='),
true
);

if ($bytes === false) {
throw new InvalidArgumentException('Invalid base64url payload in token');
}

return $bytes;
}
}
57 changes: 56 additions & 1 deletion tests/ImageResizeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PHPUnit\Framework\TestCase;
use RingierIMU\ImageService\ImageResize;
use RingierIMU\ImageService\UrlCodec;

final class ImageResizeTest extends TestCase
{
Expand Down Expand Up @@ -39,6 +40,37 @@ public function testResizeUrl(): void
);
}

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

$url = $imageResize->buildUrl(
'hz',
'pi',
'listing-thumb-360w',
'horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
[
'w' => 360,
'h' => 240,
'fit' => 'crop',
]
);

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

$paramsSegment = explode('/', $url)[7];
$this->assertEquals(
'{"fit":"crop","h":240,"w":360}',
UrlCodec::decompress(mb_substr($paramsSegment, 1))
);
}

public function testTemplateUrl(): void
{
$imageResize = new ImageResize(
Expand Down Expand Up @@ -70,8 +102,31 @@ public function testTemplateUrl(): void
);

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

$this->assertLessThan(1024, mb_strlen($url));

$templateSegment = explode('/', $url)[9];
$this->assertEquals(
[
'template' => 'job-04-2022',
'replacements' => [
'title1' => 'Title line 1',
'title2' => 'Title line 2',
'subtitle' => 'Subtitle line',
],
'images' => [
'logo' => [
'file' => 's3://horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
],
'background' => [
'file' => 's3://horizon-files-prod/pi/picture/qk4mz7j/e6be465dd23e0ef571fcdbd3f46bfe1c8486cd52.jpg',
],
],
],
json_decode(UrlCodec::decompress($templateSegment), true)
);
}
}
Loading
Loading