From 226d36f691803801fcc15aa014aefa9e57959d50 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 16 May 2024 14:24:32 +0300 Subject: [PATCH 1/5] feat: add `secureUploadsSignatureResolver` option --- abstract/CTX.js | 2 + abstract/SecureUploadsManager.js | 63 +++++++++++++++++++++++++++ abstract/UploaderBlock.js | 19 +++++--- blocks/Config/Config.js | 4 +- blocks/Config/initialConfig.js | 1 + blocks/Config/normalizeConfigValue.js | 29 +++++++++--- blocks/FileItem/FileItem.js | 2 +- demo/secure-uploads.html | 43 ++++++++++++++++++ types/exported.d.ts | 9 +++- 9 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 abstract/SecureUploadsManager.js create mode 100644 demo/secure-uploads.html diff --git a/abstract/CTX.js b/abstract/CTX.js index 231be2b50..02daf1011 100644 --- a/abstract/CTX.js +++ b/abstract/CTX.js @@ -35,4 +35,6 @@ export const uploaderBlockCtx = (fnCtx) => ({ '*groupInfo': null, /** @type {Set} */ '*uploadTrigger': new Set(), + /** @type {import('./SecureUploadsManager.js').SecureUploadsManager | null} */ + '*secureUploadsManager': null, }); diff --git a/abstract/SecureUploadsManager.js b/abstract/SecureUploadsManager.js new file mode 100644 index 000000000..52ca10e4e --- /dev/null +++ b/abstract/SecureUploadsManager.js @@ -0,0 +1,63 @@ +// @ts-check + +/** @param {import('../types').SecureUploadsSignatureAndExpire} secureToken */ +const isSecureTokenExpired = (secureToken) => { + const { secureExpire } = secureToken; + return Date.now() > Number(secureExpire); +}; + +export class SecureUploadsManager { + /** + * @private + * @type {import('./UploaderBlock.js').UploaderBlock} + */ + _block; + /** + * @private + * @type {import('../types').SecureUploadsSignatureAndExpire | null} + */ + _secureToken = null; + + /** @param {import('./UploaderBlock.js').UploaderBlock} block */ + constructor(block) { + this._block = block; + } + + /** @returns {Promise} */ + async getSecureToken() { + const { secureSignature, secureExpire, secureUploadsSignatureResolver } = this._block.cfg; + + if ((secureSignature || secureExpire) && secureUploadsSignatureResolver) { + console.warn( + 'Both secureSignature/secureExpire and secureUploadsSignatureResolver are set. secureUploadsSignatureResolver will be used.', + ); + } + + if (secureUploadsSignatureResolver) { + this._block.debugPrint('Secure signature resolver is set. Fetching secure token...'); + + if (!this._secureToken || isSecureTokenExpired(this._secureToken)) { + const result = await secureUploadsSignatureResolver(); + this._block.debugPrint('Secure token fetched:', result); + + this._secureToken = result ?? null; + } + + return this._secureToken; + } + + if (secureSignature && secureExpire) { + this._block.debugPrint('Secure signature and expire are set. Using them...', { + secureSignature, + secureExpire, + }); + + return { + secureSignature, + secureExpire, + }; + } + + return null; + } +} diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 29f20b528..be4955d74 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -21,6 +21,7 @@ import { TypedCollection } from './TypedCollection.js'; import { buildOutputCollectionState } from './buildOutputCollectionState.js'; import { uploadEntrySchema } from './uploadEntrySchema.js'; import { parseCdnUrl } from '../utils/parseCdnUrl.js'; +import { SecureUploadsManager } from './SecureUploadsManager.js'; export class UploaderBlock extends ActivityBlock { couldBeCtxOwner = false; isCtxOwner = false; @@ -184,6 +185,10 @@ export class UploaderBlock extends ActivityBlock { if (this.__initialUploadMetadata) { this.$['*uploadMetadata'] = this.__initialUploadMetadata; } + + if (!this.$['*secureUploadsManager']) { + this.$['*secureUploadsManager'] = new SecureUploadsManager(this); + } } // TODO: Probably we should not allow user to override `source` property @@ -582,7 +587,7 @@ export class UploaderBlock extends ActivityBlock { * @param {import('../types').OutputCollectionState} collectionState */ async _createGroup(collectionState) { - const uploadClientOptions = this.getUploadClientOptions(); + const uploadClientOptions = await this.getUploadClientOptions(); const uuidList = collectionState.allEntries.map((entry) => { return entry.uuid + (entry.cdnUrlModifiers ? `/${entry.cdnUrlModifiers}` : ''); }); @@ -822,8 +827,12 @@ export class UploaderBlock extends ActivityBlock { return configValue; } - /** @returns {import('@uploadcare/upload-client').FileFromOptions} */ - getUploadClientOptions() { + /** @returns {Promise} */ + async getUploadClientOptions() { + /** @type {SecureUploadsManager} */ + const secureUploadsManager = this.$['*secureUploadsManager']; + const secureToken = await secureUploadsManager.getSecureToken(); + let options = { store: this.cfg.store, publicKey: this.cfg.pubkey, @@ -831,8 +840,8 @@ export class UploaderBlock extends ActivityBlock { baseURL: this.cfg.baseUrl, userAgent: customUserAgent, integration: this.cfg.userAgentIntegration, - secureSignature: this.cfg.secureSignature, - secureExpire: this.cfg.secureExpire, + secureSignature: secureToken?.secureSignature, + secureExpire: secureToken?.secureExpire, retryThrottledRequestMaxTimes: this.cfg.retryThrottledRequestMaxTimes, multipartMinFileSize: this.cfg.multipartMinFileSize, multipartChunkSize: this.cfg.multipartChunkSize, diff --git a/blocks/Config/Config.js b/blocks/Config/Config.js index 8bae53a04..1e8a80d37 100644 --- a/blocks/Config/Config.js +++ b/blocks/Config/Config.js @@ -13,9 +13,9 @@ const allConfigKeys = /** @type {(keyof import('../../types').ConfigType)[]} */ /** * Config keys that can't be passed as attribute (because they are object or function) * - * @type {(keyof import('../../types').ConfigComplexType)[]} + * @type {['metadata', 'localeDefinitionOverride', 'secureUploadsSignatureResolver']} */ -const complexConfigKeys = ['metadata', 'localeDefinitionOverride']; +export const complexConfigKeys = ['metadata', 'localeDefinitionOverride', 'secureUploadsSignatureResolver']; /** @type {(key: keyof import('../../types').ConfigType) => key is keyof import('../../types').ConfigComplexType} */ const isComplexKey = (key) => complexConfigKeys.includes(key); diff --git a/blocks/Config/initialConfig.js b/blocks/Config/initialConfig.js index 3c396ebca..b0ee12374 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -60,4 +60,5 @@ export const initialConfig = { metadata: null, localeName: 'en', localeDefinitionOverride: null, + secureUploadsSignatureResolver: null, }; diff --git a/blocks/Config/normalizeConfigValue.js b/blocks/Config/normalizeConfigValue.js index 360727118..281f8a45c 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -48,13 +48,30 @@ const asMetadata = (value) => { throw new Error('Invalid metadata value. Must be an object or function.'); }; -/** @param {unknown} value */ -const asLocaleDefinitionOverride = (value) => { +/** + * @template {{}} T + * @param {unknown} value + * @returns {T} + */ +const asObject = (value) => { if (typeof value === 'object') { - return /** @type {import('../../types').LocaleDefinitionOverride} */ (value); + return /** @type {T} */ (value); + } + + throw new Error('Invalid value. Must be an object.'); +}; + +/** + * @template {Function} T + * @param {unknown} value + * @returns {T} + */ +const asFunction = (value) => { + if (typeof value === 'function') { + return /** @type {T} */ (value); } - throw new Error('Invalid localeDefinitionOverride value. Must be an object.'); + throw new Error('Invalid value. Must be a function.'); }; /** @@ -116,7 +133,9 @@ const mapping = { localeName: asString, metadata: asMetadata, - localeDefinitionOverride: asLocaleDefinitionOverride, + localeDefinitionOverride: /** @type {typeof asObject} */ (asObject), + secureUploadsSignatureResolver: + /** @type {typeof asFunction} */ (asFunction), }; /** diff --git a/blocks/FileItem/FileItem.js b/blocks/FileItem/FileItem.js index 57a23b420..d9ca7ad3c 100644 --- a/blocks/FileItem/FileItem.js +++ b/blocks/FileItem/FileItem.js @@ -366,7 +366,7 @@ export class FileItem extends UploaderBlock { } const fileInput = file || entry.getValue('externalUrl') || entry.getValue('uuid'); - const baseUploadClientOptions = this.getUploadClientOptions(); + const baseUploadClientOptions = await this.getUploadClientOptions(); /** @type {import('@uploadcare/upload-client').FileFromOptions} */ const uploadClientOptions = { ...baseUploadClientOptions, diff --git a/demo/secure-uploads.html b/demo/secure-uploads.html new file mode 100644 index 000000000..75d002fe2 --- /dev/null +++ b/demo/secure-uploads.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/types/exported.d.ts b/types/exported.d.ts index e6e2e7e3b..2419b7ea4 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -1,4 +1,5 @@ import { LocaleDefinition } from '../abstract/localeRegistry'; +import { complexConfigKeys } from '../blocks/Config/Config'; export type UploadError = import('@uploadcare/upload-client').UploadError; export type UploadcareFile = import('@uploadcare/upload-client').UploadcareFile; @@ -7,6 +8,9 @@ export type UploadcareGroup = import('@uploadcare/upload-client').UploadcareGrou export type Metadata = import('@uploadcare/upload-client').Metadata; export type MetadataCallback = (fileEntry: OutputFileEntry) => Promise | Metadata; export type LocaleDefinitionOverride = Record; +export type SecureUploadsSignatureAndExpire = { secureSignature: string; secureExpire: string }; +export type SecureUploadsSignatureResolver = () => Promise; + export type ConfigType = { pubkey: string; multiple: boolean; @@ -51,12 +55,13 @@ export type ConfigType = { userAgentIntegration: string; debug: boolean; localeName: string; - + // Complex types metadata: Metadata | MetadataCallback | null; localeDefinitionOverride: LocaleDefinitionOverride | null; + secureUploadsSignatureResolver: SecureUploadsSignatureResolver | null; }; -export type ConfigComplexType = Pick; +export type ConfigComplexType = Pick; export type ConfigPlainType = Omit; export type ConfigAttributesType = KebabCaseKeys & LowerCaseKeys; From c7cfcd00563dc377b0368f1e2adc0ad973bcb20b Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 16 May 2024 14:26:36 +0300 Subject: [PATCH 2/5] feat: add `secureDeliveryProxyUrlResolver` option --- abstract/Block.js | 28 ++++++++++----- blocks/Config/Config.js | 9 +++-- blocks/Config/initialConfig.js | 1 + blocks/Config/normalizeConfigValue.js | 3 ++ .../secure-delivery-proxy-url-resolver.html | 35 +++++++++++++++++++ .../secure-delivery-proxy-url-template.html | 34 ++++++++++++++++++ .../preview-proxy/secure-delivery-proxy.js | 0 types/exported.d.ts | 7 +++- 8 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 demo/preview-proxy/secure-delivery-proxy-url-resolver.html create mode 100644 demo/preview-proxy/secure-delivery-proxy-url-template.html rename secure-delivery-proxy.js => demo/preview-proxy/secure-delivery-proxy.js (100%) diff --git a/abstract/Block.js b/abstract/Block.js index 3940b37a2..e60d1818a 100644 --- a/abstract/Block.js +++ b/abstract/Block.js @@ -11,6 +11,7 @@ import { LocaleManager, localeStateKey } from './LocaleManager.js'; import { l10nProcessor } from './l10nProcessor.js'; import { sharedConfigKey } from './sharedConfigKey.js'; import { initialConfig } from '../blocks/Config/initialConfig.js'; +import { extractFilename, extractOperations, extractUuid } from '../utils/cdn-utils.js'; const TAG_PREFIX = 'lr-'; @@ -242,15 +243,26 @@ export class Block extends BaseComponent { * @returns {String} */ proxyUrl(url) { - let previewProxy = this.cfg.secureDeliveryProxy; - if (!previewProxy) { - return url; + if (this.cfg.secureDeliveryProxy && this.cfg.secureDeliveryProxyUrlResolver) { + console.warn( + 'Both secureDeliveryProxy and secureDeliveryProxyUrlResolver are set. Using secureDeliveryProxyUrlResolver.', + ); } - return applyTemplateData( - previewProxy, - { previewUrl: url }, - { transform: (value) => window.encodeURIComponent(value) }, - ); + if (this.cfg.secureDeliveryProxyUrlResolver) { + return this.cfg.secureDeliveryProxyUrlResolver(url, { + uuid: extractUuid(url), + cdnUrlModifiers: extractOperations(url), + fileName: extractFilename(url), + }); + } + if (this.cfg.secureDeliveryProxy) { + return applyTemplateData( + this.cfg.secureDeliveryProxy, + { previewUrl: url }, + { transform: (value) => window.encodeURIComponent(value) }, + ); + } + return url; } /** @returns {import('../types').ConfigType} } */ diff --git a/blocks/Config/Config.js b/blocks/Config/Config.js index 1e8a80d37..921b62ccc 100644 --- a/blocks/Config/Config.js +++ b/blocks/Config/Config.js @@ -13,9 +13,14 @@ const allConfigKeys = /** @type {(keyof import('../../types').ConfigType)[]} */ /** * Config keys that can't be passed as attribute (because they are object or function) * - * @type {['metadata', 'localeDefinitionOverride', 'secureUploadsSignatureResolver']} + * @type {['metadata', 'localeDefinitionOverride', 'secureUploadsSignatureResolver', 'secureDeliveryProxyUrlResolver']} */ -export const complexConfigKeys = ['metadata', 'localeDefinitionOverride', 'secureUploadsSignatureResolver']; +export const complexConfigKeys = [ + 'metadata', + 'localeDefinitionOverride', + 'secureUploadsSignatureResolver', + 'secureDeliveryProxyUrlResolver', +]; /** @type {(key: keyof import('../../types').ConfigType) => key is keyof import('../../types').ConfigComplexType} */ const isComplexKey = (key) => complexConfigKeys.includes(key); diff --git a/blocks/Config/initialConfig.js b/blocks/Config/initialConfig.js index b0ee12374..64baed07d 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -61,4 +61,5 @@ export const initialConfig = { localeName: 'en', localeDefinitionOverride: null, secureUploadsSignatureResolver: null, + secureDeliveryProxyUrlResolver: null, }; diff --git a/blocks/Config/normalizeConfigValue.js b/blocks/Config/normalizeConfigValue.js index 281f8a45c..d9534b9d1 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -136,6 +136,9 @@ const mapping = { localeDefinitionOverride: /** @type {typeof asObject} */ (asObject), secureUploadsSignatureResolver: /** @type {typeof asFunction} */ (asFunction), + secureDeliveryProxyUrlResolver: + /** @type {typeof asFunction} */ (asFunction), + }; /** diff --git a/demo/preview-proxy/secure-delivery-proxy-url-resolver.html b/demo/preview-proxy/secure-delivery-proxy-url-resolver.html new file mode 100644 index 000000000..71a8fb2d2 --- /dev/null +++ b/demo/preview-proxy/secure-delivery-proxy-url-resolver.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/demo/preview-proxy/secure-delivery-proxy-url-template.html b/demo/preview-proxy/secure-delivery-proxy-url-template.html new file mode 100644 index 000000000..1a9a42603 --- /dev/null +++ b/demo/preview-proxy/secure-delivery-proxy-url-template.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + diff --git a/secure-delivery-proxy.js b/demo/preview-proxy/secure-delivery-proxy.js similarity index 100% rename from secure-delivery-proxy.js rename to demo/preview-proxy/secure-delivery-proxy.js diff --git a/types/exported.d.ts b/types/exported.d.ts index 2419b7ea4..62b66b594 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -8,6 +8,10 @@ export type UploadcareGroup = import('@uploadcare/upload-client').UploadcareGrou export type Metadata = import('@uploadcare/upload-client').Metadata; export type MetadataCallback = (fileEntry: OutputFileEntry) => Promise | Metadata; export type LocaleDefinitionOverride = Record; +export type SecureDeliveryProxyUrlResolver = ( + previewUrl: string, + { uuid: string, cdnUrlModifiers: string, fileName: string }, +) => string; export type SecureUploadsSignatureAndExpire = { secureSignature: string; secureExpire: string }; export type SecureUploadsSignatureResolver = () => Promise; @@ -60,8 +64,9 @@ export type ConfigType = { metadata: Metadata | MetadataCallback | null; localeDefinitionOverride: LocaleDefinitionOverride | null; secureUploadsSignatureResolver: SecureUploadsSignatureResolver | null; + secureDeliveryProxyUrlResolver: SecureDeliveryProxyUrlResolver | null; }; -export type ConfigComplexType = Pick; +export type ConfigComplexType = Pick; export type ConfigPlainType = Omit; export type ConfigAttributesType = KebabCaseKeys & LowerCaseKeys; From add7aadc53407225df3aaa6afb741c4ce9efff55 Mon Sep 17 00:00:00 2001 From: Aleksandr Grenishin Date: Fri, 17 May 2024 15:13:51 +0300 Subject: [PATCH 3/5] Update abstract/Block.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- abstract/Block.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/abstract/Block.js b/abstract/Block.js index e60d1818a..2aaca734e 100644 --- a/abstract/Block.js +++ b/abstract/Block.js @@ -244,9 +244,7 @@ export class Block extends BaseComponent { */ proxyUrl(url) { if (this.cfg.secureDeliveryProxy && this.cfg.secureDeliveryProxyUrlResolver) { - console.warn( - 'Both secureDeliveryProxy and secureDeliveryProxyUrlResolver are set. Using secureDeliveryProxyUrlResolver.', - ); + console.warn('Both secureDeliveryProxy and secureDeliveryProxyUrlResolver are set. The secureDeliveryProxyUrlResolver will be used.'); } if (this.cfg.secureDeliveryProxyUrlResolver) { return this.cfg.secureDeliveryProxyUrlResolver(url, { From 0574d8bb07504a3dfb27b8e49741fe58ee78c7ea Mon Sep 17 00:00:00 2001 From: nd0ut Date: Fri, 17 May 2024 16:01:37 +0300 Subject: [PATCH 4/5] refactor --- abstract/Block.js | 10 +++++--- abstract/SecureUploadsManager.js | 41 +++++++++++++++++++++--------- demo/secure-uploads.html | 41 ++++++++++++++++++++++-------- types/exported.d.ts | 2 +- utils/cdn-utils.js | 22 ++++++++++++---- utils/cdn-utils.test.js | 14 ++++++++++ utils/isSecureTokenExpired.js | 17 +++++++++++++ utils/isSecureTokenExpired.test.js | 32 +++++++++++++++++++++++ 8 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 utils/isSecureTokenExpired.js create mode 100644 utils/isSecureTokenExpired.test.js diff --git a/abstract/Block.js b/abstract/Block.js index 2aaca734e..1546c8a5c 100644 --- a/abstract/Block.js +++ b/abstract/Block.js @@ -1,7 +1,9 @@ // @ts-check import { BaseComponent, Data } from '@symbiotejs/symbiote'; +import { initialConfig } from '../blocks/Config/initialConfig.js'; import { EventEmitter } from '../blocks/UploadCtxProvider/EventEmitter.js'; import { WindowHeightTracker } from '../utils/WindowHeightTracker.js'; +import { extractFilename, extractCdnUrlModifiers, extractUuid } from '../utils/cdn-utils.js'; import { getLocaleDirection } from '../utils/getLocaleDirection.js'; import { getPluralForm } from '../utils/getPluralForm.js'; import { applyTemplateData, getPluralObjects } from '../utils/template-utils.js'; @@ -10,8 +12,6 @@ import { blockCtx } from './CTX.js'; import { LocaleManager, localeStateKey } from './LocaleManager.js'; import { l10nProcessor } from './l10nProcessor.js'; import { sharedConfigKey } from './sharedConfigKey.js'; -import { initialConfig } from '../blocks/Config/initialConfig.js'; -import { extractFilename, extractOperations, extractUuid } from '../utils/cdn-utils.js'; const TAG_PREFIX = 'lr-'; @@ -244,12 +244,14 @@ export class Block extends BaseComponent { */ proxyUrl(url) { if (this.cfg.secureDeliveryProxy && this.cfg.secureDeliveryProxyUrlResolver) { - console.warn('Both secureDeliveryProxy and secureDeliveryProxyUrlResolver are set. The secureDeliveryProxyUrlResolver will be used.'); + console.warn( + 'Both secureDeliveryProxy and secureDeliveryProxyUrlResolver are set. The secureDeliveryProxyUrlResolver will be used.', + ); } if (this.cfg.secureDeliveryProxyUrlResolver) { return this.cfg.secureDeliveryProxyUrlResolver(url, { uuid: extractUuid(url), - cdnUrlModifiers: extractOperations(url), + cdnUrlModifiers: extractCdnUrlModifiers(url), fileName: extractFilename(url), }); } diff --git a/abstract/SecureUploadsManager.js b/abstract/SecureUploadsManager.js index 52ca10e4e..1493ef440 100644 --- a/abstract/SecureUploadsManager.js +++ b/abstract/SecureUploadsManager.js @@ -1,10 +1,6 @@ // @ts-check -/** @param {import('../types').SecureUploadsSignatureAndExpire} secureToken */ -const isSecureTokenExpired = (secureToken) => { - const { secureExpire } = secureToken; - return Date.now() > Number(secureExpire); -}; +import { isSecureTokenExpired } from '../utils/isSecureTokenExpired.js'; export class SecureUploadsManager { /** @@ -23,6 +19,14 @@ export class SecureUploadsManager { this._block = block; } + /** + * @private + * @param {unknown[]} args + */ + _debugPrint(...args) { + this._block.debugPrint('[secure-uploads]', ...args); + } + /** @returns {Promise} */ async getSecureToken() { const { secureSignature, secureExpire, secureUploadsSignatureResolver } = this._block.cfg; @@ -34,20 +38,33 @@ export class SecureUploadsManager { } if (secureUploadsSignatureResolver) { - this._block.debugPrint('Secure signature resolver is set. Fetching secure token...'); - if (!this._secureToken || isSecureTokenExpired(this._secureToken)) { - const result = await secureUploadsSignatureResolver(); - this._block.debugPrint('Secure token fetched:', result); - - this._secureToken = result ?? null; + if (!this._secureToken) { + this._debugPrint('Secure signature is not set yet.'); + } else { + this._debugPrint('Secure signature is expired. Resolving a new one...'); + } + try { + const result = await secureUploadsSignatureResolver(); + if (!result) { + this._debugPrint('Secure signature resolver returned nothing.'); + this._secureToken = null; + } else if (!result.secureSignature || !result.secureExpire) { + console.error('Secure signature resolver returned an invalid result:', result); + } else { + this._debugPrint('Secure signature resolved:', result); + this._secureToken = result; + } + } catch (err) { + console.error('Secure signature resolving failed. Falling back to the previous one.', err); + } } return this._secureToken; } if (secureSignature && secureExpire) { - this._block.debugPrint('Secure signature and expire are set. Using them...', { + this._debugPrint('Secure signature and expire are set. Using them...', { secureSignature, secureExpire, }); diff --git a/demo/secure-uploads.html b/demo/secure-uploads.html index 75d002fe2..105e96d15 100644 --- a/demo/secure-uploads.html +++ b/demo/secure-uploads.html @@ -23,21 +23,42 @@ LR.registerBlocks(LR); + const msToUnixTimestamp = (ms) => Math.floor(ms / 1000).toString(); + + const getSecureExpire = (options) => { + if ('expire' in options) { + return msToUnixTimestamp(new Date(options.expire).getTime()); + } + + return msToUnixTimestamp(Date.now() + options.lifetime); + }; + + export const generateSecureSignature = async (secret, options) => { + const encoder = new TextEncoder(); + const data = encoder.encode(getSecureExpire(options)); + const key = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: { name: 'SHA-256' } }, + false, + ['sign'], + ); + const signature = await window.crypto.subtle.sign('HMAC', key, data); + const secureSignature = Array.from(new Uint8Array(signature)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return { secureSignature, secureExpire: getSecureExpire(options) }; + }; + const config = document.querySelector('lr-config'); config.secureUploadsSignatureResolver = async () => { - return { - secureSignature: '41d3c05a1d9bfd4493186a5fd78f02719b6cb4363f55a52e235729a1649737b7', - secureExpire: '1715857665', - }; + const token = await generateSecureSignature('', { lifetime: 60 * 1000 }); + console.log('Generating secure signature...', token); + return token }; - + diff --git a/types/exported.d.ts b/types/exported.d.ts index b71967068..0e50d329c 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -10,7 +10,7 @@ export type MetadataCallback = (fileEntry: OutputFileEntry) => Promise export type LocaleDefinitionOverride = Record; export type SecureDeliveryProxyUrlResolver = ( previewUrl: string, - { uuid: string, cdnUrlModifiers: string, fileName: string }, + urlParts: { uuid: string; cdnUrlModifiers: string; fileName: string }, ) => string; export type SecureUploadsSignatureAndExpire = { secureSignature: string; secureExpire: string }; export type SecureUploadsSignatureResolver = () => Promise; diff --git a/utils/cdn-utils.js b/utils/cdn-utils.js index deebb064a..98b412fa2 100644 --- a/utils/cdn-utils.js +++ b/utils/cdn-utils.js @@ -85,19 +85,31 @@ export function extractUuid(cdnUrl) { } /** - * Extract UUID from CDN URL + * Extract operations string from CDN URL * * @param {string} cdnUrl - * @returns {string[]} + * @returns {string} */ -export function extractOperations(cdnUrl) { +export function extractCdnUrlModifiers(cdnUrl) { let withoutFilename = trimFilename(cdnUrl); let url = new URL(withoutFilename); let operationsMarker = url.pathname.indexOf('/-/'); if (operationsMarker === -1) { - return []; + return ''; } - let operationsStr = url.pathname.substring(operationsMarker); + let operationsStr = url.pathname.substring(operationsMarker).slice(1); + + return operationsStr; +} + +/** + * Extract UUID from CDN URL + * + * @param {string} cdnUrl + * @returns {string[]} + */ +export function extractOperations(cdnUrl) { + let operationsStr = extractCdnUrlModifiers(cdnUrl); return operationsStr .split('/-/') diff --git a/utils/cdn-utils.test.js b/utils/cdn-utils.test.js index 205eec30a..193059289 100644 --- a/utils/cdn-utils.test.js +++ b/utils/cdn-utils.test.js @@ -9,6 +9,7 @@ import { trimFilename, extractUuid, extractOperations, + extractCdnUrlModifiers, } from './cdn-utils.js'; const falsyValues = ['', undefined, null, false, true, 0, 10]; @@ -211,3 +212,16 @@ describe('cdn-utils/extractOperations', () => { ]); }); }); + +describe('cdn-utils/extractCdnUrlModifiers', () => { + it('should extract operations string from cdn url', () => { + expect(extractCdnUrlModifiers('https://ucarecdn.com/:uuid/')).to.eql(''); + expect(extractCdnUrlModifiers('https://ucarecdn.com/:uuid/image.jpeg')).to.eql(''); + expect( + extractCdnUrlModifiers('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/-/resize/100x/image.jpeg'), + ).to.eql('-/resize/100x/'); + expect( + extractCdnUrlModifiers('https://domain.ucr.io:8080/-/resize/100x/https://domain.com/image.jpg?q=1#hash'), + ).to.eql('-/resize/100x/'); + }); +}); diff --git a/utils/isSecureTokenExpired.js b/utils/isSecureTokenExpired.js new file mode 100644 index 000000000..358c4a2e3 --- /dev/null +++ b/utils/isSecureTokenExpired.js @@ -0,0 +1,17 @@ +/** @param {number} ms */ +const msToUnixTimestamp = (ms) => Math.floor(ms / 1000); + +/** + * Check if secure token is expired. It uses a threshold of 10 seconds by default. i.e. if the token is not expired yet + * but will expire in the next 10 seconds, it will return false. + * + * @param {import('../types').SecureUploadsSignatureAndExpire} secureToken + * @param {{ threshold?: number }} [options] + */ +export const isSecureTokenExpired = (secureToken, { threshold } = { threshold: 10 * 1000 }) => { + const { secureExpire } = secureToken; + const nowUnix = msToUnixTimestamp(Date.now()); + const expireUnix = Number(secureExpire); + const thresholdUnix = msToUnixTimestamp(threshold); + return nowUnix + thresholdUnix >= expireUnix; +}; diff --git a/utils/isSecureTokenExpired.test.js b/utils/isSecureTokenExpired.test.js new file mode 100644 index 000000000..7cbb3ecd4 --- /dev/null +++ b/utils/isSecureTokenExpired.test.js @@ -0,0 +1,32 @@ +import { isSecureTokenExpired } from './isSecureTokenExpired'; +import { expect } from '@esm-bundle/chai'; +import * as sinon from 'sinon'; + +const DATE_NOW = 60 * 1000; + +describe('isSecureTokenExpired', () => { + let clock; + beforeEach(() => { + clock = sinon.useFakeTimers(DATE_NOW); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should return true if the token is expired', () => { + expect(isSecureTokenExpired({ secureExpire: '0', secureSignature: '' })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '59', secureSignature: '' })).to.equal(true); + }); + + it('should return true if the token will expire in the next 10 seconds', () => { + expect(isSecureTokenExpired({ secureExpire: '60', secureSignature: '' })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '61', secureSignature: '' })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '70', secureSignature: '' })).to.equal(true); + }); + + it("should return false if the token is not expired and won't expire in next 10 seconds", () => { + expect(isSecureTokenExpired({ secureExpire: '71', secureSignature: '' })).to.equal(false); + expect(isSecureTokenExpired({ secureExpire: '80', secureSignature: '' })).to.equal(false); + }); +}); From 0d9205d0391f7c703069ac77efd62b0709bde626 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 23 May 2024 14:47:58 +0300 Subject: [PATCH 5/5] feat: add `secureUploadsExpireThreshold` option --- abstract/SecureUploadsManager.js | 9 ++++++++- abstract/UploaderBlock.js | 2 +- blocks/Config/initialConfig.js | 1 + blocks/Config/normalizeConfigValue.js | 1 + types/exported.d.ts | 3 ++- utils/isSecureTokenExpired.js | 4 ++-- utils/isSecureTokenExpired.test.js | 15 ++++++++------- 7 files changed, 23 insertions(+), 12 deletions(-) diff --git a/abstract/SecureUploadsManager.js b/abstract/SecureUploadsManager.js index 1493ef440..1d6c3b56a 100644 --- a/abstract/SecureUploadsManager.js +++ b/abstract/SecureUploadsManager.js @@ -38,7 +38,10 @@ export class SecureUploadsManager { } if (secureUploadsSignatureResolver) { - if (!this._secureToken || isSecureTokenExpired(this._secureToken)) { + if ( + !this._secureToken || + isSecureTokenExpired(this._secureToken, { threshold: this._block.cfg.secureUploadsExpireThreshold }) + ) { if (!this._secureToken) { this._debugPrint('Secure signature is not set yet.'); } else { @@ -53,6 +56,10 @@ export class SecureUploadsManager { console.error('Secure signature resolver returned an invalid result:', result); } else { this._debugPrint('Secure signature resolved:', result); + this._debugPrint( + 'Secure signature will expire in', + new Date(Number(result.secureExpire) * 1000).toISOString(), + ); this._secureToken = result; } } catch (err) { diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index be4955d74..8079bd24d 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -831,7 +831,7 @@ export class UploaderBlock extends ActivityBlock { async getUploadClientOptions() { /** @type {SecureUploadsManager} */ const secureUploadsManager = this.$['*secureUploadsManager']; - const secureToken = await secureUploadsManager.getSecureToken(); + const secureToken = await secureUploadsManager.getSecureToken().catch(() => null); let options = { store: this.cfg.store, diff --git a/blocks/Config/initialConfig.js b/blocks/Config/initialConfig.js index 7062c8744..ae5216ed4 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -60,6 +60,7 @@ export const initialConfig = { metadata: null, localeName: 'en', localeDefinitionOverride: null, + secureUploadsExpireThreshold: 10 * 60 * 1000, secureUploadsSignatureResolver: null, secureDeliveryProxyUrlResolver: null, iconHrefResolver: null, diff --git a/blocks/Config/normalizeConfigValue.js b/blocks/Config/normalizeConfigValue.js index 92c98e460..34e20eb90 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -133,6 +133,7 @@ const mapping = { localeName: asString, metadata: asMetadata, + secureUploadsExpireThreshold: asNumber, localeDefinitionOverride: /** @type {typeof asObject} */ (asObject), secureUploadsSignatureResolver: /** @type {typeof asFunction} */ (asFunction), diff --git a/types/exported.d.ts b/types/exported.d.ts index 0e50d329c..dc11650a6 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -60,7 +60,8 @@ export type ConfigType = { userAgentIntegration: string; debug: boolean; localeName: string; - + secureUploadsExpireThreshold: number; + // Complex types metadata: Metadata | MetadataCallback | null; localeDefinitionOverride: LocaleDefinitionOverride | null; diff --git a/utils/isSecureTokenExpired.js b/utils/isSecureTokenExpired.js index 358c4a2e3..db7c3d3f1 100644 --- a/utils/isSecureTokenExpired.js +++ b/utils/isSecureTokenExpired.js @@ -6,9 +6,9 @@ const msToUnixTimestamp = (ms) => Math.floor(ms / 1000); * but will expire in the next 10 seconds, it will return false. * * @param {import('../types').SecureUploadsSignatureAndExpire} secureToken - * @param {{ threshold?: number }} [options] + * @param {{ threshold?: number }} options */ -export const isSecureTokenExpired = (secureToken, { threshold } = { threshold: 10 * 1000 }) => { +export const isSecureTokenExpired = (secureToken, { threshold }) => { const { secureExpire } = secureToken; const nowUnix = msToUnixTimestamp(Date.now()); const expireUnix = Number(secureExpire); diff --git a/utils/isSecureTokenExpired.test.js b/utils/isSecureTokenExpired.test.js index 7cbb3ecd4..1431864d2 100644 --- a/utils/isSecureTokenExpired.test.js +++ b/utils/isSecureTokenExpired.test.js @@ -3,6 +3,7 @@ import { expect } from '@esm-bundle/chai'; import * as sinon from 'sinon'; const DATE_NOW = 60 * 1000; +const THRESHOLD = 10 * 1000; describe('isSecureTokenExpired', () => { let clock; @@ -15,18 +16,18 @@ describe('isSecureTokenExpired', () => { }); it('should return true if the token is expired', () => { - expect(isSecureTokenExpired({ secureExpire: '0', secureSignature: '' })).to.equal(true); - expect(isSecureTokenExpired({ secureExpire: '59', secureSignature: '' })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '0', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '59', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(true); }); it('should return true if the token will expire in the next 10 seconds', () => { - expect(isSecureTokenExpired({ secureExpire: '60', secureSignature: '' })).to.equal(true); - expect(isSecureTokenExpired({ secureExpire: '61', secureSignature: '' })).to.equal(true); - expect(isSecureTokenExpired({ secureExpire: '70', secureSignature: '' })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '60', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '61', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(true); + expect(isSecureTokenExpired({ secureExpire: '70', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(true); }); it("should return false if the token is not expired and won't expire in next 10 seconds", () => { - expect(isSecureTokenExpired({ secureExpire: '71', secureSignature: '' })).to.equal(false); - expect(isSecureTokenExpired({ secureExpire: '80', secureSignature: '' })).to.equal(false); + expect(isSecureTokenExpired({ secureExpire: '71', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(false); + expect(isSecureTokenExpired({ secureExpire: '80', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(false); }); });