Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-nested-rich-text-getfieldgroup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tinacms": patch
---

Fix crash in `getFieldGroup` when editing deeply nested rich-text fields (3+ levels) with templates. The method used `findIndex` which always searched from the start of the path array, causing it to resolve the wrong "children"/"props" segments on recursive calls. Replaced with `indexOf` searching from the current position, and added a null guard for graceful fallback on malformed content.
11 changes: 11 additions & 0 deletions .changeset/strip-cloud-url-cross-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@tinacms/graphql": patch
---

Fix `resolveMediaCloudToRelative` so it strips any TinaCloud cloud URL on save, not only ones whose host matches `config.assetsHost`. The match condition is now host-agnostic: the `<clientId>/…` path prefix is the durable invariant; the host segment can vary across stages.

This unblocks multi-host setups (PR / stage / personal-dev TinaCloud stages) where the dashboard's default `MediaStore` inserts upload URLs with one host while content-api returns a different one as `assetsHost`. Previously the round-trip silently failed and absolute URLs got committed to the content repo. After this fix, content saves as a relative path regardless of which host the dashboard inserted, matching pre-existing content's format.

Also covers cross-stage content migration: an absolute URL written against one stage strips correctly when re-saved against another.

Closes [#6827](https://github.com/tinacms/tinacms/issues/6827).
94 changes: 94 additions & 0 deletions packages/@tinacms/graphql/src/resolver/media-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,100 @@ describe('resolveMedia', () => {
expect(resolvedURL).toEqual(relativeURL);
});

/**
* A cloud URL whose host doesn't match `config.assetsHost` (e.g. content
* uploaded against a different stage, or against the dashboard's hardcoded
* default) should still strip cleanly. The `<clientId>/…` path prefix is the
* invariant — the host segment can vary across stages.
*/
it('strips cloud URL whose host differs from config.assetsHost', () => {
const config: GraphQLConfig = {
useRelativeMedia: false,
assetsHost,
clientId,
};

const otherStageURL = `https://assets.tina.io/${clientId}/llama.png`;
const resolvedURL = resolveMediaCloudToRelative(
otherStageURL,
config,
schema
);
expect(resolvedURL).toEqual(relativeURL);
});

/**
* Array values containing cloud URLs from a mix of stages should each be
* stripped to a relative path regardless of which host they were uploaded
* against.
*/
it('strips array values with mixed cloud-URL hosts', () => {
const config: GraphQLConfig = {
useRelativeMedia: false,
assetsHost,
clientId,
};

const resolved = resolveMediaCloudToRelative(
[
`https://${assetsHost}/${clientId}/a.png`,
`https://assets.tina.io/${clientId}/b.png`,
`https://assets-other-stage.tinajs.dev/${clientId}/c.png`,
],
config,
schema
);
expect(resolved).toEqual([
'/uploads/a.png',
'/uploads/b.png',
'/uploads/c.png',
]);
});

/**
* Cross-host stripping should still respect the `__staging/{branch}` prefix,
* so editorial-branch content migrated between stages round-trips correctly.
*/
it('strips staging prefix from cross-host cloud URLs', () => {
const config: GraphQLConfig = {
useRelativeMedia: false,
assetsHost,
clientId,
branch: 'feat/x',
mediaBranch: 'main',
};

const otherStageStagingURL = `https://assets.tina.io/${clientId}/__staging/feat/x/__file/llama.png`;
const resolvedURL = resolveMediaCloudToRelative(
otherStageStagingURL,
config,
schema
);
expect(resolvedURL).toEqual(relativeURL);
});

/**
* URLs whose path doesn't begin with the configured client id (e.g. a
* non-TinaCloud asset URL or content from a different project) must be left
* alone.
*/
it('does not strip URLs that do not match the configured clientId', () => {
const config: GraphQLConfig = {
useRelativeMedia: false,
assetsHost,
clientId,
};

const otherClientURL =
'https://assets.tina.io/00000000-0000-0000-0000-000000000000/llama.png';
const resolvedURL = resolveMediaCloudToRelative(
otherClientURL,
config,
schema
);
expect(resolvedURL).toEqual(otherClientURL);
});

/**
* Missing `media: { tina: { ... }}` config should return the value, regardless of `useRelativeMedia`
*/
Expand Down
18 changes: 14 additions & 4 deletions packages/@tinacms/graphql/src/resolver/media-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,19 @@ export const resolveMediaCloudToRelative = (
}

if (hasTinaMediaConfig(schema) === true) {
const assetsURL = `https://${config.assetsHost}/${config.clientId}`;
const cleanMediaRoot = cleanUpSlashes(schema.config.media.tina.mediaRoot);
const cloudUrl = cloudUrlPattern(config.clientId);

if (typeof value === 'string' && value.includes(assetsURL)) {
if (typeof value === 'string' && cloudUrl.test(value)) {
return `${cleanMediaRoot}${stripStagingPrefix(
value.replace(assetsURL, '')
value.replace(cloudUrl, '')
)}`;
}
if (Array.isArray(value)) {
return value.map((v) => {
if (!v || typeof v !== 'string') return v;
const strippedURL = v.replace(assetsURL, '');
if (!cloudUrl.test(v)) return v;
const strippedURL = v.replace(cloudUrl, '');
return `${cleanMediaRoot}${stripStagingPrefix(strippedURL)}`;
});
}
Expand Down Expand Up @@ -116,6 +117,15 @@ const stripStagingPrefix = (path: string): string => {
return match ? match[1] : path;
};

const escapeRegExp = (s: string): string =>
s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

// Matches a TinaCloud cloud URL for the given client. The host segment varies
// across stages (e.g. `assets.tina.io`, `assets-{stage}.tinajs.dev`); the
// `<clientId>/…` path prefix is the durable invariant.
const cloudUrlPattern = (clientId: string): RegExp =>
new RegExp(`^https://[^/]+/${escapeRegExp(clientId)}`);

const cleanUpSlashes = (path: string): string => {
if (path) {
return `/${path.replace(/^\/+|\/+$/gm, '')}`;
Expand Down
Loading
Loading