Skip to content

Commit b1594d8

Browse files
committed
feat: add url signing and test cases
1 parent 55d2dd1 commit b1594d8

File tree

5 files changed

+351
-79
lines changed

5 files changed

+351
-79
lines changed

src/lib/crypto-utils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Simple synchronous crypto utilities for ImageKit SDK
3+
*
4+
* This module provides HMAC-SHA1 functionality using Node.js crypto module.
5+
* URL signing is only supported in Node.js runtime.
6+
*/
7+
8+
import { ImageKitError } from '../core/error';
9+
10+
/**
11+
* Creates an HMAC-SHA1 hash using Node.js crypto module
12+
*
13+
* @param key - The secret key for HMAC generation
14+
* @param data - The data to be signed
15+
* @returns Hex-encoded HMAC-SHA1 hash
16+
* @throws ImageKitError if crypto module is not available or operation fails
17+
*/
18+
export function createHmacSha1(key: string, data: string): string {
19+
let crypto: any;
20+
21+
try {
22+
crypto = require('crypto');
23+
} catch (err) {
24+
throw new ImageKitError(
25+
'URL signing requires Node.js crypto module which is not available in this runtime. ' +
26+
'Please use Node.js environment for URL signing functionality.',
27+
);
28+
}
29+
30+
try {
31+
return crypto.createHmac('sha1', key).update(data, 'utf8').digest('hex');
32+
} catch (error) {
33+
throw new ImageKitError(
34+
`Failed to generate HMAC-SHA1 signature: ${error instanceof Error ? error.message : 'Unknown error'}`,
35+
);
36+
}
37+
}

src/lib/transformation-utils.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const supportedTransforms: { [key: string]: string } = {
5454
aiChangeBackground: 'e-changebg',
5555
aiRemoveBackground: 'e-bgremove',
5656
aiRemoveBackgroundExternal: 'e-removedotbg',
57+
aiEdit: 'e-edit',
5758
contrastStretch: 'e-contrast',
5859
shadow: 'e-shadow',
5960
sharpen: 'e-sharpen',
@@ -86,9 +87,6 @@ export const supportedTransforms: { [key: string]: string } = {
8687

8788
// Raw pass-through
8889
raw: 'raw',
89-
90-
// Additional missing mappings from JS SDK
91-
aiEdit: 'e-edit',
9290
};
9391

9492
export default {

src/resources/helper.ts

Lines changed: 154 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,127 @@ import type {
1313
SolidColorOverlay,
1414
} from './shared';
1515
import transformationUtils, { safeBtoa } from '../lib/transformation-utils';
16+
import { createHmacSha1 } from '../lib/crypto-utils';
1617

1718
const TRANSFORMATION_PARAMETER = 'tr';
19+
const SIGNATURE_PARAMETER = 'ik-s';
20+
const TIMESTAMP_PARAMETER = 'ik-t';
21+
const DEFAULT_TIMESTAMP = 9999999999;
1822
const SIMPLE_OVERLAY_PATH_REGEX = new RegExp('^[a-zA-Z0-9-._/ ]*$');
1923
const SIMPLE_OVERLAY_TEXT_REGEX = new RegExp('^[a-zA-Z0-9-._ ]*$');
2024

25+
export class Helper extends APIResource {
26+
constructor(client: ImageKit) {
27+
super(client);
28+
}
29+
30+
/**
31+
* Builds a source URL with the given options.
32+
*
33+
* @param opts - The options for building the source URL.
34+
* @returns The constructed source URL.
35+
*/
36+
buildSrc(opts: SrcOptions): string {
37+
opts.urlEndpoint = opts.urlEndpoint || '';
38+
opts.src = opts.src || '';
39+
opts.transformationPosition = opts.transformationPosition || 'query';
40+
41+
if (!opts.src) {
42+
return '';
43+
}
44+
45+
const isAbsoluteURL = opts.src.startsWith('http://') || opts.src.startsWith('https://');
46+
47+
var urlObj, isSrcParameterUsedForURL, urlEndpointPattern;
48+
49+
try {
50+
if (!isAbsoluteURL) {
51+
urlEndpointPattern = new URL(opts.urlEndpoint).pathname;
52+
urlObj = new URL(pathJoin([opts.urlEndpoint.replace(urlEndpointPattern, ''), opts.src]));
53+
} else {
54+
urlObj = new URL(opts.src!);
55+
isSrcParameterUsedForURL = true;
56+
}
57+
} catch (e) {
58+
return '';
59+
}
60+
61+
for (var i in opts.queryParameters) {
62+
urlObj.searchParams.append(i, String(opts.queryParameters[i]));
63+
}
64+
65+
var transformationString = this.buildTransformationString(opts.transformation);
66+
67+
if (transformationString && transformationString.length) {
68+
if (!transformationUtils.addAsQueryParameter(opts) && !isSrcParameterUsedForURL) {
69+
urlObj.pathname = pathJoin([
70+
TRANSFORMATION_PARAMETER + transformationUtils.getChainTransformDelimiter() + transformationString,
71+
urlObj.pathname,
72+
]);
73+
}
74+
}
75+
76+
if (urlEndpointPattern) {
77+
urlObj.pathname = pathJoin([urlEndpointPattern, urlObj.pathname]);
78+
} else {
79+
urlObj.pathname = pathJoin([urlObj.pathname]);
80+
}
81+
82+
// First, build the complete URL with transformations
83+
let finalUrl = urlObj.href;
84+
85+
// Add transformation parameter manually to avoid URL encoding
86+
// URLSearchParams.set() would encode commas and colons in transformation string,
87+
// It would work correctly but not very readable e.g., "w-300,h-400" is better than "w-300%2Ch-400"
88+
if (transformationString && transformationString.length) {
89+
if (transformationUtils.addAsQueryParameter(opts) || isSrcParameterUsedForURL) {
90+
const separator = urlObj.searchParams.toString() ? '&' : '?';
91+
finalUrl = `${finalUrl}${separator}${TRANSFORMATION_PARAMETER}=${transformationString}`;
92+
}
93+
}
94+
95+
// Then sign the URL if needed
96+
if (opts.signed === true || (opts.expiresIn && opts.expiresIn > 0)) {
97+
const expiryTimestamp = getSignatureTimestamp(opts.expiresIn);
98+
99+
const urlSignature = getSignature({
100+
privateKey: this._client.privateAPIKey,
101+
url: finalUrl,
102+
urlEndpoint: opts.urlEndpoint,
103+
expiryTimestamp,
104+
});
105+
106+
// Add signature parameters to the final URL
107+
// Use URL object to properly determine if we need ? or & separator
108+
const finalUrlObj = new URL(finalUrl);
109+
const hasExistingParams = finalUrlObj.searchParams.toString().length > 0;
110+
const separator = hasExistingParams ? '&' : '?';
111+
let signedUrl = finalUrl;
112+
113+
if (expiryTimestamp && expiryTimestamp !== DEFAULT_TIMESTAMP) {
114+
signedUrl += `${separator}${TIMESTAMP_PARAMETER}=${expiryTimestamp}`;
115+
signedUrl += `&${SIGNATURE_PARAMETER}=${urlSignature}`;
116+
} else {
117+
signedUrl += `${separator}${SIGNATURE_PARAMETER}=${urlSignature}`;
118+
}
119+
120+
return signedUrl;
121+
}
122+
123+
return finalUrl;
124+
}
125+
126+
/**
127+
* Builds a transformation string from the given transformations.
128+
*
129+
* @param transformation - The transformations to apply.
130+
* @returns The constructed transformation string.
131+
*/
132+
buildTransformationString(transformation: Transformation[] | undefined): string {
133+
return buildTransformationString(transformation);
134+
}
135+
}
136+
21137
function removeTrailingSlash(str: string): string {
22138
if (typeof str == 'string' && str[str.length - 1] == '/') {
23139
str = str.substring(0, str.length - 1);
@@ -231,7 +347,7 @@ function buildTransformationString(transformation: Transformation[] | undefined)
231347
} else if (key === 'raw') {
232348
parsedTransformStep.push(currentTransform[key] as string);
233349
} else {
234-
if (transformKey === 'di') {
350+
if (transformKey === 'di' || transformKey === 'ff') {
235351
value = removeTrailingSlash(removeLeadingSlash((value as string) || ''));
236352
value = value.replace(/\//g, '@@');
237353
}
@@ -256,84 +372,46 @@ function buildTransformationString(transformation: Transformation[] | undefined)
256372
return parsedTransforms.join(transformationUtils.getChainTransformDelimiter());
257373
}
258374

259-
export class Helper extends APIResource {
260-
constructor(client: ImageKit) {
261-
super(client);
262-
}
375+
/**
376+
* Calculates the expiry timestamp for URL signing
377+
*
378+
* @param seconds - Number of seconds from now when the URL should expire
379+
* @returns Unix timestamp for expiry, or DEFAULT_TIMESTAMP if invalid/not provided
380+
*/
381+
function getSignatureTimestamp(seconds: number | undefined): number {
382+
if (!seconds || seconds <= 0) return DEFAULT_TIMESTAMP;
263383

264-
/**
265-
* Builds a source URL with the given options.
266-
*
267-
* @param opts - The options for building the source URL.
268-
* @returns The constructed source URL.
269-
*/
270-
buildSrc(opts: SrcOptions): string {
271-
opts.urlEndpoint = opts.urlEndpoint || '';
272-
opts.src = opts.src || '';
273-
opts.transformationPosition = opts.transformationPosition || 'query';
384+
const sec = parseInt(String(seconds), 10);
385+
if (!sec || isNaN(sec)) return DEFAULT_TIMESTAMP;
274386

275-
if (!opts.src) {
276-
return '';
277-
}
278-
279-
const isAbsoluteURL = opts.src.startsWith('http://') || opts.src.startsWith('https://');
280-
281-
var urlObj, isSrcParameterUsedForURL, urlEndpointPattern;
282-
283-
try {
284-
if (!isAbsoluteURL) {
285-
urlEndpointPattern = new URL(opts.urlEndpoint).pathname;
286-
urlObj = new URL(pathJoin([opts.urlEndpoint.replace(urlEndpointPattern, ''), opts.src]));
287-
} else {
288-
urlObj = new URL(opts.src!);
289-
isSrcParameterUsedForURL = true;
290-
}
291-
} catch (e) {
292-
return '';
293-
}
294-
295-
for (var i in opts.queryParameters) {
296-
urlObj.searchParams.append(i, String(opts.queryParameters[i]));
297-
}
298-
299-
var transformationString = this.buildTransformationString(opts.transformation);
300-
301-
if (transformationString && transformationString.length) {
302-
if (!transformationUtils.addAsQueryParameter(opts) && !isSrcParameterUsedForURL) {
303-
urlObj.pathname = pathJoin([
304-
TRANSFORMATION_PARAMETER + transformationUtils.getChainTransformDelimiter() + transformationString,
305-
urlObj.pathname,
306-
]);
307-
}
308-
}
309-
310-
if (urlEndpointPattern) {
311-
urlObj.pathname = pathJoin([urlEndpointPattern, urlObj.pathname]);
312-
} else {
313-
urlObj.pathname = pathJoin([urlObj.pathname]);
314-
}
387+
const currentTimestamp = Math.floor(new Date().getTime() / 1000);
388+
return currentTimestamp + sec;
389+
}
315390

316-
if (transformationString && transformationString.length) {
317-
if (transformationUtils.addAsQueryParameter(opts) || isSrcParameterUsedForURL) {
318-
if (urlObj.searchParams.toString() !== '') {
319-
// In 12 node.js .size was not there. So, we need to check if it is an object or not.
320-
return `${urlObj.href}&${TRANSFORMATION_PARAMETER}=${transformationString}`;
321-
} else {
322-
return `${urlObj.href}?${TRANSFORMATION_PARAMETER}=${transformationString}`;
323-
}
324-
}
325-
}
391+
/**
392+
* Generates an HMAC-SHA1 signature for URL signing
393+
*
394+
* @param opts - Options containing private key, URL, endpoint, and expiry timestamp
395+
* @returns Hex-encoded signature, or empty string if required params missing
396+
*/
397+
function getSignature(opts: {
398+
privateKey: string;
399+
url: string;
400+
urlEndpoint: string;
401+
expiryTimestamp: number;
402+
}): string {
403+
if (!opts.privateKey || !opts.url || !opts.urlEndpoint) return '';
404+
405+
// Create the string to sign: relative path + expiry timestamp
406+
const stringToSign =
407+
opts.url.replace(addTrailingSlash(opts.urlEndpoint), '') + String(opts.expiryTimestamp);
408+
409+
return createHmacSha1(opts.privateKey, stringToSign);
410+
}
326411

327-
return urlObj.href;
328-
}
329-
330-
/**
331-
* Builds a transformation string from the given transformations.
332-
*
333-
* @param transformation - The transformations to apply.
334-
* @returns The constructed transformation string.
335-
*/
336-
buildTransformationString(transformation: Transformation[] | undefined): string {
337-
return buildTransformationString(transformation);
412+
function addTrailingSlash(str: string): string {
413+
if (typeof str === 'string' && str[str.length - 1] !== '/') {
414+
str = str + '/';
338415
}
416+
return str;
339417
}

tests/url-generation/overlay.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,30 @@ describe('Overlay encoding test cases', function () {
414414
expect(url).toBe(`https://ik.imagekit.io/demo/tr:l-text,i-Manu,l-end/medium_cafe_B1iTdD0C.jpg`);
415415
});
416416

417+
it('Handle slash in fontFamily in case of custom fonts', function () {
418+
const url = client.helper.buildSrc({
419+
transformationPosition: 'path',
420+
urlEndpoint: 'https://ik.imagekit.io/demo',
421+
src: '/medium_cafe_B1iTdD0C.jpg',
422+
transformation: [
423+
{
424+
overlay: {
425+
type: 'text',
426+
text: 'Manu',
427+
transformation: [
428+
{
429+
fontFamily: 'nested-path/Poppins-Regular_Q15GrYWmL.ttf',
430+
},
431+
],
432+
},
433+
},
434+
],
435+
});
436+
expect(url).toBe(
437+
`https://ik.imagekit.io/demo/tr:l-text,i-Manu,ff-nested-path@@Poppins-Regular_Q15GrYWmL.ttf,l-end/medium_cafe_B1iTdD0C.jpg`,
438+
);
439+
});
440+
417441
it('Simple text overlay with spaces and other safe characters, should use i instead of ie', function () {
418442
const url = client.helper.buildSrc({
419443
transformationPosition: 'path',

0 commit comments

Comments
 (0)