diff --git a/README.md b/README.md index 78c819e..d83f4f1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ function Example() { - **Components** (`src/components`) — building blocks such as `CldAssetSelector`, `CldMediaEditor`, `ImageScale`, `OverlayEditor`, `VideoSelector`, `ColorPicker`, and standard form inputs (`Select`, `Slider`, `Checkbox`, `RadioButtons`, etc). - **Containers** (`src/containers`) — `FormPanel`, which composes components into configurable forms driven by a `formConfig` schema. - **`cloudinary-sfmc/`** — a standalone demo app (Media Library UI) showing the components wired together end-to-end. +- **`preview-server/`** — a small standalone backend that signs short-lived preview URLs for embargoed (Access Control-restricted) assets, so they can be previewed in the content-block editor before the embargo lifts. See [preview-server/README.md](preview-server/README.md). ## Development diff --git a/cloudinary-sfmc/README.md b/cloudinary-sfmc/README.md index 9b0a694..867519b 100644 --- a/cloudinary-sfmc/README.md +++ b/cloudinary-sfmc/README.md @@ -15,3 +15,28 @@ https://localhost:3000/web-image?cloudName=&apiKey= **Note:** Use a development or sandbox cloud for local testing — not a production cloud. The `cloudName` and `apiKey` are passed as query parameters and are visible in browser history, dev-server logs, and referrer headers. + +### Testing embargoed-asset previews + +To exercise the embargo preview flow (see [`../preview-server`](../preview-server)), +run that service locally and pass its URL as an extra query param: + +``` +http://localhost:3000/web-image?cloudName=&apiKey=&previewServerUrl=http://localhost:8787 +``` + +**Use `http://`, not `https://`, for this.** `preview-server` is a plain HTTP +service; if the page itself is loaded over HTTPS (this app's default `yarn +start` sets `HTTPS=true`), Chrome silently upgrades the `fetch()` call to +`preview-server` to `https://` before sending it, which fails outright against +a plain HTTP server (`ERR_SSL_PROTOCOL_ERROR`) rather than falling back to +HTTP. Start the dev server with HTTPS disabled for this test instead of the +default `yarn start`: + +```bash +HTTPS=false node ../node_modules/react-scripts/bin/react-scripts.js start +``` + +Then pick an asset in your sandbox cloud that has a `token` Access Control rule +(Cloudinary's embargo pattern). Without `previewServerUrl` set, embargoed assets +still show the pre-existing "This asset is restricted" block. diff --git a/cloudinary-sfmc/src/App.js b/cloudinary-sfmc/src/App.js index 86b5a81..6a9745b 100644 --- a/cloudinary-sfmc/src/App.js +++ b/cloudinary-sfmc/src/App.js @@ -48,13 +48,20 @@ const App = () => { if (window.newTab && !window.newTab.closed) { window.newTab.focus(); } else { + // Each previous "Choose Image" click (once its popup was closed) left + // its own 'message' listener attached here — they were never removed, + // only ever added to. A single 'ready' from a new popup would then be + // answered by every accumulated listener, so the popup received + // multiple 'open' messages and re-ran its validators once per message. + if (window.newTabMessageHandler) { + window.removeEventListener('message', window.newTabMessageHandler); + } window.newTab = window.open( document.location.origin + '/mlw' + document.location.search, '_blank' ); const messageHandler = onMessage({ setAsset, showOpts, source }); - - window.removeEventListener('message', messageHandler); + window.newTabMessageHandler = messageHandler; window.addEventListener('message', messageHandler); } }; @@ -92,6 +99,7 @@ const App = () => { setState: setState, state: state, assetSelector: openMlw, + previewServerUrl: parms.get('previewServerUrl'), cld: cld }; @@ -103,7 +111,12 @@ const App = () => { path="/mlw" render={(props) => ( Loading....}> - + )} /> diff --git a/cloudinary-sfmc/src/ImageContext.js b/cloudinary-sfmc/src/ImageContext.js index be0dde3..98d79bd 100644 --- a/cloudinary-sfmc/src/ImageContext.js +++ b/cloudinary-sfmc/src/ImageContext.js @@ -22,14 +22,22 @@ async function buildContent( imageLink, scale, setTransformationError, - errorImageUrl + errorImageUrl, + embargoPreviewUrl ) { let content = {}; let html; if (urls.imageUrl) { + // For an embargoed asset the plain public url.imageUrl 401s until the + // embargo lifts, by design — polling it would never succeed. Poll and + // render the on-canvas preview against the short-lived signed preview + // URL instead, but keep saving the plain public URL as the actual + // content block HTML: that's the URL that needs to work once this is + // actually sent, long after any preview token has expired. + const previewSourceUrl = embargoPreviewUrl || urls.imageUrl; try { - await pollImageReady(urls.imageUrl, 20, 4); - content.previewHtml = buildHtml(urls.imageUrl, cld, alt, imageAlignment, scale, null, null); + await pollImageReady(previewSourceUrl, 20, 4); + content.previewHtml = buildHtml(previewSourceUrl, cld, alt, imageAlignment, scale, null, null); html = buildHtml( urls.imageUrl, cld, @@ -185,7 +193,8 @@ export default function ImageContextProvider({ imageLink, { width: width, height: height }, setTransformationError, - errorImageUrl + errorImageUrl, + asset && asset.embargoPreviewUrl ); if (previewHtml) { setPreviewCnt(previewHtml); @@ -206,7 +215,8 @@ export default function ImageContextProvider({ height, width, errorImageUrl, - setTransformationError + setTransformationError, + asset ]); useEffect(() => { diff --git a/cloudinary-sfmc/src/MediaLib.js b/cloudinary-sfmc/src/MediaLib.js index 9e89f23..f5e8bd6 100644 --- a/cloudinary-sfmc/src/MediaLib.js +++ b/cloudinary-sfmc/src/MediaLib.js @@ -7,7 +7,8 @@ import './MediaLib.css'; MediaLib.propTypes = { cnf: types.object, - ver: types.object + ver: types.object, + previewServerUrl: types.string }; export default function MediaLib(props) { @@ -29,6 +30,12 @@ export default function MediaLib(props) { } let data = event.data; if (data.messageType === 'open') { + // Reset rather than append: `validators` is a plain array that + // outlives a single 'open' message for as long as this popup stays + // open, so processing 'open' more than once (e.g. a stray duplicate + // postMessage from the opener) must not leave stale entries from a + // previous open queued up alongside the new ones. + validators.length = 0; if (data.validators && data.validators.length > 0) { data.validators.forEach((v) => { if (v in assetValidatitors) { @@ -104,7 +111,12 @@ export default function MediaLib(props) { noDimensions: `Something seems to be wrong with this ${assetType.current}` }; let asset = data.assets[0]; - const args = { asset: asset, sizeLimit: sizeLimit.current, rightType: assetType.current }; + const args = { + asset: asset, + sizeLimit: sizeLimit.current, + rightType: assetType.current, + previewServerUrl: props.previewServerUrl + }; const validatorsRes = await Promise.allSettled(validators.map((v) => v(args))); let errorBanners = []; validatorsRes.forEach((res) => { diff --git a/cloudinary-sfmc/src/WebImage.js b/cloudinary-sfmc/src/WebImage.js index 3432e26..eb75924 100644 --- a/cloudinary-sfmc/src/WebImage.js +++ b/cloudinary-sfmc/src/WebImage.js @@ -20,6 +20,7 @@ export default function WebImage({ cnf }) { type: 'imageSelect', name: 'imageSelect', cloudName: cnf.cldConf.cloud_name, + previewServerUrl: cnf.previewServerUrl, selectState: imageState.imageSelect || {}, buttonLabel: 'selectImage', analytics: cnf.ver, diff --git a/cloudinary-sfmc/src/__tests__/mediaLibValidators.test.js b/cloudinary-sfmc/src/__tests__/mediaLibValidators.test.js new file mode 100644 index 0000000..a81228c --- /dev/null +++ b/cloudinary-sfmc/src/__tests__/mediaLibValidators.test.js @@ -0,0 +1,111 @@ +import validators from '../mediaLibValidators'; +import { enableFetchMocks } from 'jest-fetch-mock'; +enableFetchMocks(); + +describe('isNotRestricted', () => { + beforeEach(() => { + fetch.resetMocks(); + }); + + it('resolves when the plain secure_url is reachable', async () => { + fetch.mockResponseOnce('', { status: 200 }); + const asset = { secure_url: 'https://res.cloudinary.com/demo/image/upload/pub.jpg' }; + await expect( + validators.isNotRestricted({ asset, previewServerUrl: 'https://preview.example.com' }) + ).resolves.toBeUndefined(); + expect(fetch.mock.calls.length).toBe(1); + }); + + it('rejects when unreachable and no preview server is configured', async () => { + fetch.mockResponseOnce('', { status: 401 }); + const asset = { secure_url: 'https://res.cloudinary.com/demo/image/upload/priv.jpg' }; + await expect(validators.isNotRestricted({ asset })).rejects.toBe('restricted'); + }); + + it('resolves with a signed embargo preview url when the asset is genuinely embargoed', async () => { + fetch.mockResponseOnce('', { status: 401 }); // the plain HEAD check + fetch.mockResponseOnce( + JSON.stringify({ + url: 'https://res.cloudinary.com/demo/image/upload/signed-token/pub.jpg', + accessControl: { access_type: 'token', start: '2026-12-01T00:00:00Z' } + }), + { status: 200 } + ); + const asset = { + public_id: 'pub', + resource_type: 'image', + type: 'upload', + secure_url: 'https://res.cloudinary.com/demo/image/upload/pub.jpg' + }; + await expect( + validators.isNotRestricted({ asset, previewServerUrl: 'https://preview.example.com/' }) + ).resolves.toBeUndefined(); + + expect(fetch.mock.calls[1][0]).toBe('https://preview.example.com/api/embargo-preview-url'); + expect(JSON.parse(fetch.mock.calls[1][1].body)).toEqual({ + publicId: 'pub', + resourceType: 'image', + deliveryType: 'upload' + }); + expect(asset.embargoPreviewUrl).toBe('https://res.cloudinary.com/demo/image/upload/signed-token/pub.jpg'); + expect(asset.accessControl).toEqual({ access_type: 'token', start: '2026-12-01T00:00:00Z' }); + }); + + it('rejects when the preview server refuses (asset is not actually restricted)', async () => { + fetch.mockResponseOnce('', { status: 401 }); + fetch.mockResponseOnce(JSON.stringify({ error: 'not_token_restricted' }), { status: 403 }); + const asset = { + public_id: 'pub', + resource_type: 'image', + type: 'upload', + secure_url: 'https://res.cloudinary.com/demo/image/upload/pub.jpg' + }; + await expect( + validators.isNotRestricted({ asset, previewServerUrl: 'https://preview.example.com' }) + ).rejects.toBe('restricted'); + }); + + it('rejects when the preview server call itself errors', async () => { + fetch.mockRejectOnce(new Error('network down')); + const asset = { + public_id: 'pub', + resource_type: 'image', + type: 'upload', + secure_url: 'https://res.cloudinary.com/demo/image/upload/pub.jpg' + }; + await expect( + validators.isNotRestricted({ asset, previewServerUrl: 'https://preview.example.com' }) + ).rejects.toBe('restricted'); + }); + + it('rejects (rather than throwing/unhandled-rejecting) when fetch itself throws synchronously', async () => { + // Some browser extensions patch window.fetch and throw a plain + // TypeError('Failed to fetch') instead of returning a rejected promise. + fetch.mockImplementationOnce(() => { + throw new TypeError('Failed to fetch'); + }); + fetch.mockImplementationOnce(() => { + throw new TypeError('Failed to fetch'); + }); + const asset = { + public_id: 'pub', + resource_type: 'image', + type: 'upload', + secure_url: 'https://res.cloudinary.com/demo/image/upload/pub.jpg' + }; + await expect( + validators.isNotRestricted({ asset, previewServerUrl: 'https://preview.example.com' }) + ).rejects.toBe('restricted'); + }); +}); + +describe('other validators are unaffected', () => { + it('isNotOverSizeLimit still works', async () => { + await expect( + validators.isNotOverSizeLimit({ asset: { bytes: 1024 }, sizeLimit: 10 }) + ).resolves.toBeUndefined(); + await expect( + validators.isNotOverSizeLimit({ asset: { bytes: 100 * 1024 * 1024 }, sizeLimit: 10 }) + ).rejects.toBe('tooBig'); + }); +}); diff --git a/cloudinary-sfmc/src/mediaLibValidators.js b/cloudinary-sfmc/src/mediaLibValidators.js index 6c63d8d..215b475 100644 --- a/cloudinary-sfmc/src/mediaLibValidators.js +++ b/cloudinary-sfmc/src/mediaLibValidators.js @@ -1,17 +1,65 @@ +// If the plain, unauthenticated URL isn't reachable, ask the embargo preview +// server whether that's because the asset is genuinely Cloudinary Access +// Control-restricted (our embargo case) before giving up. When it is, we +// still resolve — the asset stays selectable — but attach a short-lived +// signed preview URL (and the access_control rule, for messaging) onto the +// asset object so the editor can render a real preview while the asset +// isn't publicly reachable yet. +// A minimal wrapper around window.fetch that always returns a promise that +// rejects rather than throwing synchronously. Some browser extensions patch +// window.fetch and can throw a plain TypeError('Failed to fetch') outright +// instead of returning a rejected promise — when that happens inside a +// .then()/.catch() callback (as it does below), it turns into an unhandled +// promise rejection instead of being caught by the surrounding .catch(). +function safeFetch(...fetchArgs) { + try { + return fetch(...fetchArgs); + } catch (err) { + return Promise.reject(err); + } +} + +function tryEmbargoPreview(asset, previewServerUrl, resolve, reject) { + if (!previewServerUrl) { + reject('restricted'); + return; + } + safeFetch(`${previewServerUrl.replace(/\/+$/, '')}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publicId: asset.public_id, + resourceType: asset.resource_type, + deliveryType: asset.type + }) + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) { + reject('restricted'); + return; + } + asset.embargoPreviewUrl = data.url; + asset.accessControl = data.accessControl; + resolve(); + }) + .catch(() => reject('restricted')); +} + export default { isNotRestricted: (args) => { - const { asset } = args; + const { asset, previewServerUrl } = args; return new Promise((resolve, reject) => { - fetch(asset.secure_url, { method: 'HEAD' }) + safeFetch(asset.secure_url, { method: 'HEAD' }) .then((res) => { if (res.ok) { resolve(); } else { - reject('restricted'); + tryEmbargoPreview(asset, previewServerUrl, resolve, reject); } }) .catch(() => { - reject('restricted'); + tryEmbargoPreview(asset, previewServerUrl, resolve, reject); }); }); }, diff --git a/preview-server/.env.example b/preview-server/.env.example new file mode 100644 index 0000000..8996470 --- /dev/null +++ b/preview-server/.env.example @@ -0,0 +1,19 @@ +CLOUDINARY_CLOUD_NAME= +CLOUDINARY_API_KEY= +CLOUDINARY_API_SECRET= + +# Cloudinary Access Control Key (from Cloudinary Support). Signs short-lived +# preview URLs for token-restricted (embargoed) assets. Distinct from the +# account's API key/secret above — never reused for anything else. +CLOUDINARY_ACCESS_CONTROL_KEY= + +# Comma-separated list of origins allowed to call this service — the +# deployed SFMC content-builder app's origin(s). Leave empty only for local +# development; an empty list allows any origin. +ALLOWED_ORIGINS=https://sfmc-contentbuilder.cloudinary.com + +# How long a signed preview URL stays valid. Keep this short: it only needs +# to survive one editor session, never the lifetime of a sent email. +PREVIEW_TTL_SECONDS=300 + +PORT=8787 diff --git a/preview-server/README.md b/preview-server/README.md new file mode 100644 index 0000000..c1a4cf8 --- /dev/null +++ b/preview-server/README.md @@ -0,0 +1,80 @@ +# SFMC embargo preview server + +A small standalone Express service used only for one thing: signing +short-lived preview URLs for **embargoed** Cloudinary assets (assets with a +`token` Access Control rule that automatically flips to `anonymous`/public at +a scheduled date), so an SFMC content-block author can preview them before +the embargo lifts. + +This exists because `cloudinary-sfmc/` is a pure client-side app — it has no +access to any Cloudinary secret. Signing an authenticated delivery URL needs +one (the **Access Control Key**, provisioned by Cloudinary Support — distinct +from the account's API key/secret), which must never reach the browser +bundle. Hence a separate service. + +## What it does + +`POST /api/embargo-preview-url` with `{ publicId, resourceType, deliveryType }`: + +1. Looks the asset up via the Cloudinary Admin API (server-to-server, using + the account's own API key/secret) to read its real `access_control` rules + — the client's claim about an asset is never trusted directly. +2. If the asset doesn't actually carry a `token` access-control rule, refuses + with `403 not_token_restricted`. This endpoint only ever signs genuinely + embargoed assets. +3. Otherwise returns a signed, short-lived (`PREVIEW_TTL_SECONDS`, default + 300s) authenticated delivery URL built with the official Cloudinary SDK + and the Access Control Key, plus the matching `access_control` rule (so + the caller can show the marketer when the embargo actually lifts). + +The **final** email HTML is never built from this signed URL — only the +plain, normal Cloudinary delivery URL is saved as content, so it starts +working the moment the embargo lifts and doesn't depend on this service or +a token that will have long since expired. + +## Configuration + +Copy `.env.example` to `.env` and fill in: + +- `CLOUDINARY_CLOUD_NAME` / `CLOUDINARY_API_KEY` / `CLOUDINARY_API_SECRET` — + used only for the read-only Admin API lookup. +- `CLOUDINARY_ACCESS_CONTROL_KEY` — from Cloudinary Support. Keep this out of + any client-facing config; it never leaves this service. +- `ALLOWED_ORIGINS` — comma-separated list of origins allowed to call this + service (the deployed `cloudinary-sfmc` app's origin(s)). Leave empty only + for local development. +- `PREVIEW_TTL_SECONDS` — how long a signed preview URL stays valid. Keep + this short: it only needs to survive one editor session. + +## Running it + +```bash +yarn install +yarn dev # node --watch index.js +# or +yarn start +``` + +## Testing + +```bash +yarn test # node --test, uses a fake cloudinary client — no network calls +``` + +## Deploying + +This is a plain Express app — deploy it anywhere that runs Node 20+ (Render, +a Lambda + API Gateway, etc.). Point the `cloudinary-sfmc` app at it via the +`previewServerUrl` query parameter (see the root README). + +## Known limitations / follow-ups + +- Video-to-GIF (`Video2Gif.js`) still hard-blocks restricted source videos; + this service and the validator change only cover the Image content block. +- A stale signed preview URL (past its TTL) is refreshed lazily: `CldAssetSelector` + re-requests a fresh one the moment the browser fails to load the current + one (``), which covers reopening a previously-saved content + block after the token has expired. It does **not** cover an already-open + block sitting idle past the TTL without any reload — there's no background + timer re-signing it while mounted. The saved/sent HTML is unaffected either + way, since it never used the signed URL. diff --git a/preview-server/app.js b/preview-server/app.js new file mode 100644 index 0000000..41e2c89 --- /dev/null +++ b/preview-server/app.js @@ -0,0 +1,142 @@ +import express from 'express'; +import cors from 'cors'; + +const DEFAULT_PREVIEW_TTL_SECONDS = 300; + +// True when a Cloudinary asset's `access_control` rules include a `token` +// entry — i.e. the asset is currently Cloudinary Access Control-restricted. +// This is the embargo pattern: `token` now, flips to `anonymous`/public on +// its own at the rule's `start` date. We treat any other reason an asset +// might be unreachable (deleted, wrong resource type, network error) as out +// of scope for this endpoint and refuse to sign it. +export function isTokenRestricted(resource) { + return ( + Array.isArray(resource?.access_control) && + resource.access_control.some((rule) => rule?.access_type === 'token') + ); +} + +function tokenRule(resource) { + return resource.access_control.find((rule) => rule?.access_type === 'token'); +} + +/** + * @param {object} deps + * @param {object} deps.cloudinary - an initialized `cloudinary` v2 SDK instance + * (already `.config()`-ured with cloud_name/api_key/api_secret so + * `cloudinary.api.resource` can look assets up). + * @param {string} deps.accessControlKey - the Cloudinary Access Control Key + * used to sign preview URLs. Distinct from the account's API secret. + * @param {string[]} [deps.allowedOrigins] - origins allowed to call this + * service (the deployed SFMC content-builder app's origin(s)). + * @param {number} [deps.previewTtlSeconds] - how long a signed preview URL + * stays valid. + */ +export function createApp({ + cloudinary, + accessControlKey, + allowedOrigins = [], + previewTtlSeconds = DEFAULT_PREVIEW_TTL_SECONDS +}) { + if (!cloudinary) { + throw new Error('createApp requires a configured cloudinary SDK instance'); + } + + const app = express(); + app.use(express.json()); + app.use( + cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.length === 0 || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + // eslint-disable-next-line no-console + console.error( + `Rejected request from origin "${origin}" — not in ALLOWED_ORIGINS (${allowedOrigins.join(', ') || ''})` + ); + callback(new Error('Origin not allowed')); + } + } + }) + ); + + app.use((req, res, next) => { + // eslint-disable-next-line no-console + console.log(`${req.method} ${req.path} from origin "${req.headers.origin || ''}"`); + next(); + }); + + app.get('/healthz', (req, res) => { + res.json({ ok: true }); + }); + + app.post('/api/embargo-preview-url', async (req, res) => { + if (!accessControlKey) { + res.status(500).json({ error: 'server_misconfigured', message: 'Access control key is not configured' }); + return; + } + + const { publicId, resourceType = 'image', deliveryType = 'upload' } = req.body || {}; + if (!publicId) { + res.status(400).json({ error: 'invalid_request', message: 'publicId is required' }); + return; + } + + let resource; + try { + resource = await cloudinary.api.resource(publicId, { + resource_type: resourceType, + type: deliveryType + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`Cloudinary resource lookup failed for ${publicId}:`, err.message); + res.status(404).json({ error: 'asset_not_found', message: err.message }); + return; + } + + if (!isTokenRestricted(resource)) { + // Not a real embargo case — refuse rather than hand back a signed URL + // for an asset that doesn't need one. Callers should fall back to + // whatever normal "this asset is unavailable" handling they already + // have for this response. + res.status(403).json({ error: 'not_token_restricted', message: 'Asset is not embargoed' }); + return; + } + + let url; + try { + url = cloudinary.url(publicId, { + resource_type: resourceType, + type: deliveryType, + sign_url: true, + auth_token: { key: accessControlKey, duration: previewTtlSeconds }, + secure: true + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Failed to sign embargo preview url:', err); + res.status(500).json({ error: 'sign_failed', message: err.message }); + return; + } + + res.json({ + url, + expiresInSeconds: previewTtlSeconds, + accessControl: tokenRule(resource) + }); + }); + + // eslint-disable-next-line no-unused-vars + app.use((err, req, res, next) => { + if (err && err.message === 'Origin not allowed') { + res.status(403).json({ error: 'origin_not_allowed' }); + return; + } + // eslint-disable-next-line no-console + console.error('Unhandled error in preview server:', err); + res.status(500).json({ error: 'internal_error', message: err.message }); + }); + + return app; +} diff --git a/preview-server/index.js b/preview-server/index.js new file mode 100644 index 0000000..fc3a661 --- /dev/null +++ b/preview-server/index.js @@ -0,0 +1,36 @@ +import 'dotenv/config'; +import { v2 as cloudinary } from 'cloudinary'; +import { createApp } from './app.js'; + +// Defensive trim: a stray trailing space or newline picked up when +// copy-pasting a credential into .env silently corrupts an HMAC key while +// still looking correct when printed — this rules that class of bug out +// entirely rather than relying on the value being pasted cleanly. +function trimmedEnv(name) { + const value = process.env[name]; + return typeof value === 'string' ? value.trim() : value; +} + +cloudinary.config({ + cloud_name: trimmedEnv('CLOUDINARY_CLOUD_NAME'), + api_key: trimmedEnv('CLOUDINARY_API_KEY'), + api_secret: trimmedEnv('CLOUDINARY_API_SECRET') +}); + +const allowedOrigins = (process.env.ALLOWED_ORIGINS || '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + +const app = createApp({ + cloudinary, + accessControlKey: trimmedEnv('CLOUDINARY_ACCESS_CONTROL_KEY'), + allowedOrigins, + previewTtlSeconds: process.env.PREVIEW_TTL_SECONDS ? Number(process.env.PREVIEW_TTL_SECONDS) : undefined +}); + +const port = process.env.PORT || 8787; +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`Embargo preview server listening on port ${port}`); +}); diff --git a/preview-server/package.json b/preview-server/package.json new file mode 100644 index 0000000..d3f2dbd --- /dev/null +++ b/preview-server/package.json @@ -0,0 +1,21 @@ +{ + "name": "sfmc-embargo-preview-server", + "version": "1.0.0", + "private": true, + "description": "Signs short-lived preview URLs (via a Cloudinary Access Control Key) for embargoed assets so SFMC content-block editors can preview them before the embargo lifts.", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js", + "test": "node --test" + }, + "dependencies": { + "cloudinary": "^2.11.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1" + } +} diff --git a/preview-server/test/app.test.js b/preview-server/test/app.test.js new file mode 100644 index 0000000..a9bb514 --- /dev/null +++ b/preview-server/test/app.test.js @@ -0,0 +1,137 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, isTokenRestricted } from '../app.js'; + +function fakeCloudinary({ resource, urlResult = 'https://res.cloudinary.com/demo/image/upload/signed/test' } = {}) { + return { + api: { + resource: async () => { + if (resource instanceof Error) throw resource; + return resource; + } + }, + url: () => urlResult + }; +} + +async function withServer(app, fn) { + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address(); + try { + await fn(`http://127.0.0.1:${port}`); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +test('isTokenRestricted is true only when a token access_control rule is present', () => { + assert.equal(isTokenRestricted({ access_control: [{ access_type: 'token' }] }), true); + assert.equal(isTokenRestricted({ access_control: [{ access_type: 'anonymous' }] }), false); + assert.equal(isTokenRestricted({ access_control: [] }), false); + assert.equal(isTokenRestricted({}), false); +}); + +test('POST /api/embargo-preview-url returns a signed url for a token-restricted asset', async () => { + const resource = { + public_id: 'demo/hero', + access_control: [{ access_type: 'token', start: '2026-12-01T00:00:00Z' }] + }; + const app = createApp({ + cloudinary: fakeCloudinary({ resource }), + accessControlKey: 'test-key' + }); + + await withServer(app, async (base) => { + const res = await fetch(`${base}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicId: 'demo/hero' }) + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.match(body.url, /^https:\/\/res\.cloudinary\.com\//); + assert.equal(body.accessControl.access_type, 'token'); + }); +}); + +test('POST /api/embargo-preview-url refuses assets that are not token-restricted', async () => { + const resource = { public_id: 'demo/public-asset', access_control: [] }; + const app = createApp({ + cloudinary: fakeCloudinary({ resource }), + accessControlKey: 'test-key' + }); + + await withServer(app, async (base) => { + const res = await fetch(`${base}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicId: 'demo/public-asset' }) + }); + assert.equal(res.status, 403); + const body = await res.json(); + assert.equal(body.error, 'not_token_restricted'); + }); +}); + +test('POST /api/embargo-preview-url requires a publicId', async () => { + const app = createApp({ cloudinary: fakeCloudinary({}), accessControlKey: 'test-key' }); + + await withServer(app, async (base) => { + const res = await fetch(`${base}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + assert.equal(res.status, 400); + }); +}); + +test('POST /api/embargo-preview-url 500s clearly when the access control key is missing', async () => { + const app = createApp({ cloudinary: fakeCloudinary({}), accessControlKey: undefined }); + + await withServer(app, async (base) => { + const res = await fetch(`${base}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicId: 'demo/hero' }) + }); + assert.equal(res.status, 500); + const body = await res.json(); + assert.equal(body.error, 'server_misconfigured'); + }); +}); + +test('POST /api/embargo-preview-url 404s when the asset lookup fails', async () => { + const app = createApp({ + cloudinary: fakeCloudinary({ resource: new Error('not found') }), + accessControlKey: 'test-key' + }); + + await withServer(app, async (base) => { + const res = await fetch(`${base}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicId: 'demo/missing' }) + }); + assert.equal(res.status, 404); + }); +}); + +test('rejects requests from an origin not on the allowlist', async () => { + const resource = { public_id: 'demo/hero', access_control: [{ access_type: 'token' }] }; + const app = createApp({ + cloudinary: fakeCloudinary({ resource }), + accessControlKey: 'test-key', + allowedOrigins: ['https://sfmc-contentbuilder.cloudinary.com'] + }); + + await withServer(app, async (base) => { + const res = await fetch(`${base}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: 'https://evil.example.com' }, + body: JSON.stringify({ publicId: 'demo/hero' }) + }); + assert.equal(res.status, 403); + }); +}); diff --git a/preview-server/yarn.lock b/preview-server/yarn.lock new file mode 100644 index 0000000..67c7f93 --- /dev/null +++ b/preview-server/yarn.lock @@ -0,0 +1,516 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + +body-parser@^2.2.1: + version "2.3.0" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" + integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== + dependencies: + bytes "^3.1.2" + content-type "^2.0.0" + debug "^4.4.3" + http-errors "^2.0.1" + iconv-lite "^0.7.2" + on-finished "^2.4.1" + qs "^6.15.2" + raw-body "^3.0.2" + type-is "^2.1.0" + +bytes@^3.1.2, bytes@~3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + +cloudinary@^2.11.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/cloudinary/-/cloudinary-2.11.0.tgz#232b65574eb5428313d33971c9aeb9895d535cd1" + integrity sha512-o2pAkDDqrzPFE/mn9GCfFjBIGQmPs++Ts6rtkFl4vY528DNHiMovKWjH16HBjeGa9iwI7eAad24aWN2emT0aww== + dependencies: + lodash "^4.17.23" + +content-disposition@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" + integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== + +content-type@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +content-type@^2.0.0, content-type@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.1.0.tgz#d9389c43c0a8cf6a355db464d21e07092a40493a" + integrity sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag== + +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + +cookie@^0.7.1: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cors@^2.8.6: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + +debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +depd@^2.0.0, depd@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +dotenv@^17.4.2: + version "17.4.2" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-17.4.2.tgz#c07e54a746e11eba021dd9e1047ced5afdc1c034" + integrity sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw== + +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +encodeurl@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" + integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== + +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b" + integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw== + dependencies: + es-errors "^1.3.0" + +escape-html@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +etag@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" + integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== + +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" + +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + +hasown@^2.0.2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" + integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== + dependencies: + depd "~2.0.0" + inherits "~2.0.4" + setprototypeof "~1.2.0" + statuses "~2.0.2" + toidentifier "~1.0.1" + +iconv-lite@^0.7.2, iconv-lite@~0.7.0: + version "0.7.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +inherits@~2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + +is-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== + +lodash@^4.17.23: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + +media-typer@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8" + integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ== + +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + +mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + +mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +negotiator@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.1.0.tgz#16e003d0db4ac24fd9df168edf871625deeae3df" + integrity sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg== + dependencies: + content-type "^2.1.0" + +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + +on-finished@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +parseurl@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-to-regexp@^8.0.0: + version "8.4.2" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" + integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== + +proxy-addr@^2.0.7: + version "2.0.8" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.8.tgz#624e760fd8ab06b1b8320c2f8506d4422cf8e932" + integrity sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + +qs@^6.14.0, qs@^6.15.2: + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== + dependencies: + es-define-property "^1.0.1" + side-channel "^1.1.1" + +range-parser@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== + +raw-body@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" + +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + +setprototypeof@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" + integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== + +toidentifier@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +type-is@^2.0.1, type-is@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" + integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== + dependencies: + content-type "^2.0.0" + media-typer "^1.1.0" + mime-types "^3.0.0" + +unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +vary@^1, vary@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== diff --git a/src/components/CldAssetSelector/CldAssetSelector.js b/src/components/CldAssetSelector/CldAssetSelector.js index 6efe2b6..6f15791 100644 --- a/src/components/CldAssetSelector/CldAssetSelector.js +++ b/src/components/CldAssetSelector/CldAssetSelector.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import types from 'prop-types'; import t from '../../services/i18n'; import { withFormsy } from 'formsy-react'; @@ -8,6 +8,7 @@ import cx from 'classnames'; import { IoIosFolder, IoIosTrash } from 'react-icons/io'; import { FormPanelContext } from '../../containers/FormPanel/FormPanelContext'; +import Banner from '../Banner'; // PropTypes // https://reactjs.org/docs/typechecking-with-proptypes.html#proptypes @@ -16,9 +17,22 @@ CldAssetSelector.propTypes = { value: types.object, setValue: types.any, description: types.string, - openCldAssetSelector: types.any + openCldAssetSelector: types.any, + previewServerUrl: types.string }; +// Mirrors window.fetch but never throws synchronously — some browser +// extensions patch fetch and can throw a plain TypeError instead of +// returning a rejected promise, which would otherwise escape a .catch() +// attached after the call. +function safeFetch(...fetchArgs) { + try { + return fetch(...fetchArgs); + } catch (err) { + return Promise.reject(err); + } +} + export default function CldAssetSelector({ buttonLabel = 'Choose Image', value = null, @@ -30,10 +44,14 @@ export default function CldAssetSelector({ // Local state const [previewUrl, setPreviewUrl] = useState(null); const [mlTransformations, setMlTransformations] = useState(null); + // Guards against retrying forever if a freshly-signed preview URL somehow + // also fails to load — one refresh attempt per selected asset. + const refreshAttempted = useRef(false); // Lifecycle updates useEffect(() => { - if (props.value && props.value.secure_url) { + refreshAttempted.current = false; + if (props.value && (props.value.secure_url || props.value.embargoPreviewUrl)) { let trns = { height: 300, crop: 'thumb', @@ -44,7 +62,11 @@ export default function CldAssetSelector({ setMlTransformations(props.value.derived[0].raw_transformation); Object.assign(trns, { raw_transformation: props.value.derived[0].raw_transformation }); } - setPreviewUrl(cld.url(props.value.public_id, trns)); + // An embargoed asset isn't publicly reachable yet, so `cld.url(...)` + // would just 401. Use the short-lived signed preview URL the + // isNotRestricted validator attached instead, so the author can still + // see what they picked. + setPreviewUrl(props.value.embargoPreviewUrl || cld.url(props.value.public_id, trns)); if (props.setValue) { props.setValue(props.value); } @@ -60,12 +82,69 @@ export default function CldAssetSelector({ } } - function handlePreviewError(event) { - let target = event.currentTarget; + function showBrokenImagePlaceholder(target) { target.src = 'https://product-assets-res.cloudinary.com/image/upload/w_80,co_rgb:c23834,e_colorize:100,f_png/l_text:Arial_60_bold_text_align_center_line_spacing_8:INVALID%0ATRANSFORMATION,co_rgb:06060b,y_90,w_280/b_rgb:F3F2F2,c_lpad,w_300,h_260/PageDesigner/warning.png'; } + // A previously-signed embargo preview URL is only valid for a few minutes + // (see preview-server's PREVIEW_TTL_SECONDS). Reopening this content block + // later, after that window has passed, is the normal way this preview + // goes stale — so on load failure for an asset we know is embargoed, try + // once to get a fresh signed URL instead of just showing "broken image". + function refreshEmbargoPreview(target) { + safeFetch(`${props.previewServerUrl.replace(/\/+$/, '')}/api/embargo-preview-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publicId: props.value.public_id, + resourceType: props.value.resource_type, + deliveryType: props.value.type + }) + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) { + showBrokenImagePlaceholder(target); + return; + } + const refreshed = { + ...props.value, + embargoPreviewUrl: data.url, + accessControl: data.accessControl + }; + setPreviewUrl(data.url); + if (props.setValue) { + props.setValue(refreshed); + } + }) + .catch(() => showBrokenImagePlaceholder(target)); + } + + function handlePreviewError(event) { + const target = event.currentTarget; + if ( + props.value && + props.value.accessControl && + props.previewServerUrl && + !refreshAttempted.current + ) { + refreshAttempted.current = true; + refreshEmbargoPreview(target); + return; + } + showBrokenImagePlaceholder(target); + } + + function embargoLiftDate() { + const start = props.value && props.value.accessControl && props.value.accessControl.start; + if (!start) { + return null; + } + const date = new Date(start); + return Number.isNaN(date.getTime()) ? null : date.toLocaleString(); + } + // Render return (
@@ -113,6 +192,16 @@ export default function CldAssetSelector({ {props.description &&
{props.description}
}
)} + {props.value && props.value.embargoPreviewUrl && ( + + )} {mlTransformations && (