diff --git a/abstract/Block.js b/abstract/Block.js index 3940b37a2..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,7 +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'; 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. The secureDeliveryProxyUrlResolver will be used.', + ); } - return applyTemplateData( - previewProxy, - { previewUrl: url }, - { transform: (value) => window.encodeURIComponent(value) }, - ); + if (this.cfg.secureDeliveryProxyUrlResolver) { + return this.cfg.secureDeliveryProxyUrlResolver(url, { + uuid: extractUuid(url), + cdnUrlModifiers: extractCdnUrlModifiers(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/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..1d6c3b56a --- /dev/null +++ b/abstract/SecureUploadsManager.js @@ -0,0 +1,87 @@ +// @ts-check + +import { isSecureTokenExpired } from '../utils/isSecureTokenExpired.js'; + +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; + } + + /** + * @private + * @param {unknown[]} args + */ + _debugPrint(...args) { + this._block.debugPrint('[secure-uploads]', ...args); + } + + /** @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) { + if ( + !this._secureToken || + isSecureTokenExpired(this._secureToken, { threshold: this._block.cfg.secureUploadsExpireThreshold }) + ) { + 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._debugPrint( + 'Secure signature will expire in', + new Date(Number(result.secureExpire) * 1000).toISOString(), + ); + 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._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..8079bd24d 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().catch(() => null); + 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 0d00332e6..f9c1d0a74 100644 --- a/blocks/Config/Config.js +++ b/blocks/Config/Config.js @@ -13,9 +13,21 @@ 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', + * 'secureDeliveryProxyUrlResolver', + * 'iconHrefResolver', + * ]} */ -const complexConfigKeys = ['metadata', 'localeDefinitionOverride', 'iconHrefResolver']; +export const complexConfigKeys = [ + 'metadata', + 'localeDefinitionOverride', + 'secureUploadsSignatureResolver', + 'secureDeliveryProxyUrlResolver', + 'iconHrefResolver', +]; /** @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 50259226c..ae5216ed4 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -60,5 +60,8 @@ 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 da46087dc..34e20eb90 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -24,18 +24,6 @@ export const asBoolean = (value) => { if (value === 'false') return false; throw new Error(`Invalid boolean: "${value}"`); }; -/** - * @template {Function} T - * @param {unknown} value - * @returns {T} - */ -export const asFunction = (value) => { - if (typeof value === 'function') { - return /** @type {T} */ (value); - } - - throw new Error('Invalid function value. Must be a function.'); -}; /** @param {unknown} value */ const asStore = (value) => (value === 'auto' ? value : asBoolean(value)); @@ -60,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.'); }; /** @@ -128,7 +133,12 @@ const mapping = { localeName: asString, metadata: asMetadata, - localeDefinitionOverride: asLocaleDefinitionOverride, + secureUploadsExpireThreshold: asNumber, + localeDefinitionOverride: /** @type {typeof asObject} */ (asObject), + secureUploadsSignatureResolver: + /** @type {typeof asFunction} */ (asFunction), + secureDeliveryProxyUrlResolver: + /** @type {typeof asFunction} */ (asFunction), iconHrefResolver: /** @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/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/demo/secure-uploads.html b/demo/secure-uploads.html new file mode 100644 index 000000000..105e96d15 --- /dev/null +++ b/demo/secure-uploads.html @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + diff --git a/types/exported.d.ts b/types/exported.d.ts index f259fc0ea..dc11650a6 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,7 +8,14 @@ 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, + urlParts: { uuid: string; cdnUrlModifiers: string; fileName: string }, +) => string; +export type SecureUploadsSignatureAndExpire = { secureSignature: string; secureExpire: string }; +export type SecureUploadsSignatureResolver = () => Promise; export type IconHrefResolver = (iconName: string) => string; + export type ConfigType = { pubkey: string; multiple: boolean; @@ -52,13 +60,16 @@ export type ConfigType = { userAgentIntegration: string; debug: boolean; localeName: string; + secureUploadsExpireThreshold: number; // Complex types metadata: Metadata | MetadataCallback | null; localeDefinitionOverride: LocaleDefinitionOverride | null; + secureUploadsSignatureResolver: SecureUploadsSignatureResolver | null; + secureDeliveryProxyUrlResolver: SecureDeliveryProxyUrlResolver | null; iconHrefResolver: IconHrefResolver | null; }; -export type ConfigComplexType = Pick; +export type ConfigComplexType = Pick; export type ConfigPlainType = Omit; export type ConfigAttributesType = KebabCaseKeys & LowerCaseKeys; 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..db7c3d3f1 --- /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 }) => { + 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..1431864d2 --- /dev/null +++ b/utils/isSecureTokenExpired.test.js @@ -0,0 +1,33 @@ +import { isSecureTokenExpired } from './isSecureTokenExpired'; +import { expect } from '@esm-bundle/chai'; +import * as sinon from 'sinon'; + +const DATE_NOW = 60 * 1000; +const THRESHOLD = 10 * 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: '' }, { 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: '' }, { 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: '' }, { threshold: THRESHOLD })).to.equal(false); + expect(isSecureTokenExpired({ secureExpire: '80', secureSignature: '' }, { threshold: THRESHOLD })).to.equal(false); + }); +});