Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions cloudinary-sfmc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,28 @@ https://localhost:3000/web-image?cloudName=<cloud_name>&apiKey=<api_key>
**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=<cloud_name>&apiKey=<api_key>&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.
19 changes: 16 additions & 3 deletions cloudinary-sfmc/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down Expand Up @@ -92,6 +99,7 @@ const App = () => {
setState: setState,
state: state,
assetSelector: openMlw,
previewServerUrl: parms.get('previewServerUrl'),
cld: cld
};

Expand All @@ -103,7 +111,12 @@ const App = () => {
path="/mlw"
render={(props) => (
<Suspense fallback={<div>Loading....</div>}>
<MediaLib {...props} cnf={cldConf} ver={ver} />
<MediaLib
{...props}
cnf={cldConf}
ver={ver}
previewServerUrl={parms.get('previewServerUrl')}
/>
</Suspense>
)}
/>
Expand Down
20 changes: 15 additions & 5 deletions cloudinary-sfmc/src/ImageContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,7 +193,8 @@ export default function ImageContextProvider({
imageLink,
{ width: width, height: height },
setTransformationError,
errorImageUrl
errorImageUrl,
asset && asset.embargoPreviewUrl
);
if (previewHtml) {
setPreviewCnt(previewHtml);
Expand All @@ -206,7 +215,8 @@ export default function ImageContextProvider({
height,
width,
errorImageUrl,
setTransformationError
setTransformationError,
asset
]);

useEffect(() => {
Expand Down
16 changes: 14 additions & 2 deletions cloudinary-sfmc/src/MediaLib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions cloudinary-sfmc/src/WebImage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions cloudinary-sfmc/src/__tests__/mediaLibValidators.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
56 changes: 52 additions & 4 deletions cloudinary-sfmc/src/mediaLibValidators.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
},
Expand Down
19 changes: 19 additions & 0 deletions preview-server/.env.example
Original file line number Diff line number Diff line change
@@ -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
Loading