From 9e7eba9f290c935cd56569421de88b5adfac65d8 Mon Sep 17 00:00:00 2001 From: "Eli Kent [SSW]" <69125238+kulesy@users.noreply.github.com> Date: Mon, 11 May 2026 16:52:20 +1000 Subject: [PATCH] feat(tinacms): pass branch on all TinaMediaStore cloud calls (#6765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #6503. This PR makes media operations branch-aware end-to-end: 1. **Forward the branch on every cloud media call.** `TinaMediaStore`'s `upload_url`, `list`, and `delete` requests now carry `?branch=`, so once an app opts into branch-aware media those operations are scoped to the editor's current branch instead of always hitting production. 2. **Fix staging URL handling for multi-segment branches.** `@tinacms/graphql`'s media URL resolver now formats staging URLs as `/__staging//__file/` instead of `/__staging//`. The previous form broke for branches containing `/` (e.g. `feat/my-branch`) because CloudFront decodes paths before downstream components see them — the S3 write key (with a literal `%2F`) wouldn't match the decoded read path. The `__file` delimiter lets the branch contribute its natural `/` segments while still marking where the file path begins. 3. **Refresh from the server after upload.** The media manager now drops its locally-constructed entries and re-fetches the list, since the server is the source of truth for the canonical `src` URL (including the staging-branch path when applicable). Constructing it on the client would mean mirroring the server's branch-aware path logic plus the per-stage CDN host, which is fragile to keep in sync. ### Branch-forwarding details - `persist_cloud()` — appends `?branch=` to the `upload_url` request, then returns `[]` so the media manager's refresh path runs (see point 3 above) - `list()` cloud branch — appends `&branch=` to the list URL - `delete()` cloud branch — appends `?branch=` to the delete URL `this.api.branch` is already URL-encoded by [`Client.setBranch()`](https://github.com/tinacms/tinacms/blob/main/packages/tinacms/src/internalClient/index.ts#L167). A new private `encodedBranchParam()` helper decodes then re-encodes at the use site so the value is always single-encoded, even if the URL containing it is later re-processed. The local-mode code paths (`persist_local`, the local-mode list branch, the local-mode delete branch) are untouched. Also reserves an optional `rename?(from: string, to: string): Promise` hook on the `MediaStore` interface as a future extension point — no implementation in `TinaMediaStore`, no surfacing in `MediaManager`. ## Backward compatibility Older assets-api versions ignore unknown query parameters, so this can ship before [tinacms/tinacloud#3330](https://github.com/tinacms/tinacloud/issues/3330) lands. When the flag isn't enabled on an app, the server's [`isBranchRequest()`](https://github.com/tinacms/tinacloud/blob/main/js/assets-api/src/api/koa-admin.ts#L16) helper falls through to production behaviour — and that fall-through is regression-tested for all three endpoints by [tinacms/tinacloud#3472](https://github.com/tinacms/tinacloud/pull/3472). ## Test plan - [x] `pnpm --filter tinacms build` — clean - [x] `pnpm --filter tinacms test` — 321 passed, 5 skipped (no regressions) - [x] Manual diff inspection: each of the three cloud URLs now ends with `?branch=…` or `&branch=…` exactly once; local-mode branches are untouched - [x] Encoding round-trip: `feat/my-branch` (stored as `feat%2Fmy-branch`) decodes to `feat/my-branch` and re-encodes back to `feat%2Fmy-branch` --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jack Pettit [SSW] <57518417+JackDevAU@users.noreply.github.com> --- .../tinacms-pass-branch-on-media-calls.md | 18 ++ .../graphql/src/resolver/media-utils.test.ts | 8 +- .../graphql/src/resolver/media-utils.ts | 15 +- .../components/media/media-manager.tsx | 13 +- .../toolkit/core/media-store.default.test.ts | 234 ++++++++++++++++++ .../src/toolkit/core/media-store.default.ts | 232 +++++++++++------ packages/tinacms/src/toolkit/core/media.ts | 9 + 7 files changed, 436 insertions(+), 93 deletions(-) create mode 100644 .changeset/tinacms-pass-branch-on-media-calls.md create mode 100644 packages/tinacms/src/toolkit/core/media-store.default.test.ts diff --git a/.changeset/tinacms-pass-branch-on-media-calls.md b/.changeset/tinacms-pass-branch-on-media-calls.md new file mode 100644 index 0000000000..b5e0447c47 --- /dev/null +++ b/.changeset/tinacms-pass-branch-on-media-calls.md @@ -0,0 +1,18 @@ +--- +"tinacms": minor +"@tinacms/graphql": minor +--- + +Forward the editor's current branch to the TinaCloud assets-api on every cloud media call, and fix staging URL handling for multi-segment branches + +`TinaMediaStore` now appends `?branch=` to its `upload_url`, `list`, and `delete` requests so that — once the assets-api opts an app into branch-aware media — uploads, listings, and deletions are scoped to the branch the editor is on, instead of always hitting the production branch. The branch is read from `Client.branch` (already URL-encoded) and decoded then re-encoded at the use site to avoid double-encoding. + +The query parameter is ignored by assets-api versions that do not parse it, so this change is safe to deploy ahead of the server-side rollout. Local mode is unaffected. + +`@tinacms/graphql`'s media URL resolver now formats staging URLs as `/__staging//__file/` instead of `/__staging//`. The previous form broke for branches containing `/` (e.g. `feat/my-branch`) because CloudFront decodes paths before downstream components see them, so the S3 write key (with a literal `%2F`) wouldn't match the decoded read path. The `__file` delimiter lets the branch contribute its natural `/` segments while still marking where the file path begins. + +Note: staging URLs produced by `@tinacms/graphql@2.3.0`–`2.3.1` use the old format and will not round-trip through this version's `resolveMediaCloudToRelative`. Branch-aware media is gated server-side and has not been enabled for any tenant yet, so no persisted data is expected to be affected — but if you turned it on for testing, regenerate the affected field values from the editor after upgrading. + +After a successful cloud upload `TinaMediaStore.persist()` now resolves its return value from the assets-api `list` endpoint instead of constructing each `Media.src` locally — the server is the source of truth for the canonical URL (including the staging-branch path and per-stage CDN host). The `MediaStore.persist()` contract is preserved, so the returned items still flow through the media manager and the image-field drop handler. + +Also reserves an optional `rename?(from, to)` hook on the `MediaStore` interface as a future extension point — no implementation yet. diff --git a/packages/@tinacms/graphql/src/resolver/media-utils.test.ts b/packages/@tinacms/graphql/src/resolver/media-utils.test.ts index 4a3e8a5cd3..074a918231 100644 --- a/packages/@tinacms/graphql/src/resolver/media-utils.test.ts +++ b/packages/@tinacms/graphql/src/resolver/media-utils.test.ts @@ -138,7 +138,7 @@ describe('resolveMedia', () => { schema ); expect(resolvedURL).toEqual( - `https://${assetsHost}/${clientId}/__staging/feat%2Fx/llama.png` + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/llama.png` ); }); @@ -179,8 +179,8 @@ describe('resolveMedia', () => { schema ); expect(resolved).toEqual([ - `https://${assetsHost}/${clientId}/__staging/feat%2Fx/a.png`, - `https://${assetsHost}/${clientId}/__staging/feat%2Fx/b.png`, + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/a.png`, + `https://${assetsHost}/${clientId}/__staging/feat/x/__file/b.png`, ]); }); @@ -196,7 +196,7 @@ describe('resolveMedia', () => { mediaBranch: 'main', }; - const stagingURL = `https://${assetsHost}/${clientId}/__staging/feat%2Fx/llama.png`; + const stagingURL = `https://${assetsHost}/${clientId}/__staging/feat/x/__file/llama.png`; const resolvedURL = resolveMediaCloudToRelative(stagingURL, config, schema); expect(resolvedURL).toEqual(relativeURL); }); diff --git a/packages/@tinacms/graphql/src/resolver/media-utils.ts b/packages/@tinacms/graphql/src/resolver/media-utils.ts index e2c04c7b85..641075ea27 100644 --- a/packages/@tinacms/graphql/src/resolver/media-utils.ts +++ b/packages/@tinacms/graphql/src/resolver/media-utils.ts @@ -91,16 +91,25 @@ export const resolveMediaRelativeToCloud = ( } }; +// Branches may contain `/` (e.g. `feat/my-branch`). Storing them URL-encoded +// would break the CDN read path because CloudFront decodes paths before +// downstream components see them — the write would have `%2F` literal in the +// S3 key but every read would look for the decoded `/` form. Instead we let +// the branch contribute its natural `/` segments and use a `__file` prefix to +// delimit where the (possibly multi-segment) branch ends and the file path +// begins. const stagingPrefix = (config: { branch?: string; mediaBranch?: string; }): string => config.branch && config.branch !== config.mediaBranch - ? `/__staging/${encodeURIComponent(config.branch)}` + ? `/__staging/${config.branch}/__file` : ''; -// Matches `/__staging//…` and captures everything after the branch segment. -const STAGING_SEGMENT = /^\/__staging\/[^/]+(\/.*)$/; +// Matches `/__staging//__file/` and +// captures everything after the `__file` segment. Non-greedy so the branch +// can span multiple `/` segments. +const STAGING_SEGMENT = /^\/__staging\/.+?\/__file(\/.*)$/; const stripStagingPrefix = (path: string): string => { const match = path.match(STAGING_SEGMENT); diff --git a/packages/tinacms/src/toolkit/components/media/media-manager.tsx b/packages/tinacms/src/toolkit/components/media/media-manager.tsx index fc9d2e0791..b7246d2a2b 100644 --- a/packages/tinacms/src/toolkit/components/media/media-manager.tsx +++ b/packages/tinacms/src/toolkit/components/media/media-manager.tsx @@ -13,11 +13,6 @@ import React, { useEffect, useState, forwardRef, useRef } from 'react'; import { createContext, useContext } from 'react'; import * as dropzone from 'react-dropzone'; import type { FileError } from 'react-dropzone'; -import { captureEvent } from '../../../lib/posthog/posthogProvider'; -import { - MediaManagerContentUploadedEvent, - MediaManagerContentDeletedEvent, -} from '../../../lib/posthog/posthog'; import { BiArrowToBottom, BiCloudUpload, @@ -30,9 +25,14 @@ import { } from 'react-icons/bi'; import { BiFile } from 'react-icons/bi'; import { IoMdRefresh } from 'react-icons/io'; +import { + MediaManagerContentDeletedEvent, + MediaManagerContentUploadedEvent, +} from '../../../lib/posthog/posthog'; +import { captureEvent } from '../../../lib/posthog/posthogProvider'; import { Breadcrumb } from './breadcrumb'; import { CopyField } from './copy-field'; -import { checkerboardStyle, GridMediaItem, ListMediaItem } from './media-item'; +import { GridMediaItem, ListMediaItem, checkerboardStyle } from './media-item'; import { DeleteModal, NewFolderModal } from './modal'; import { DEFAULT_MEDIA_UPLOAD_TYPES, @@ -312,6 +312,7 @@ export function MediaPicker({ ); }); } + // if there are media items, set the first one as active and prepend all the items to the list if (mediaItems.length !== 0) { const extensions = [ diff --git a/packages/tinacms/src/toolkit/core/media-store.default.test.ts b/packages/tinacms/src/toolkit/core/media-store.default.test.ts new file mode 100644 index 0000000000..b5e78e85ea --- /dev/null +++ b/packages/tinacms/src/toolkit/core/media-store.default.test.ts @@ -0,0 +1,234 @@ +import type { Media, MediaUploadOptions } from './media'; +import { TinaMediaStore } from './media-store.default'; + +type FetchWithTokenMock = ReturnType; + +const makeJsonResponse = (status: number, body: unknown) => + ({ + ok: status >= 200 && status < 300, + status, + json: vi.fn().mockResolvedValue(body), + }) as any; + +const buildStore = ({ + branch = 'main', + isLocalMode = false, + authenticated = true, +}: { + branch?: string | undefined; + isLocalMode?: boolean; + authenticated?: boolean; +} = {}) => { + const fetchWithToken: FetchWithTokenMock = vi.fn(); + const authProvider = { + fetchWithToken, + isAuthenticated: vi.fn().mockResolvedValue(authenticated), + }; + const api = { + branch, + clientId: 'test-client', + contentApiUrl: + 'https://content.tinajs.io/1.1/content/test-client/github/main', + assetsApiUrl: 'https://assets.tinajs.io', + isLocalMode, + authProvider, + options: {}, + getRequestStatus: vi.fn().mockResolvedValue({ error: false }), + schema: { schema: { config: { media: { tina: {} } } } }, + }; + const cms = { api: { tina: api } } as any; + const store = new TinaMediaStore(cms); + return { store, fetchWithToken, api }; +}; + +describe('TinaMediaStore — branch query param', () => { + describe('list()', () => { + it('appends single-encoded branch for a simple branch', async () => { + const { store, fetchWithToken } = buildStore({ branch: 'main' }); + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { files: [], directories: [], cursor: 0 }) + ); + + await store.list({ directory: '', thumbnailSizes: [] } as any); + + const calledUrl = fetchWithToken.mock.calls[0][0]; + expect(calledUrl).toContain('&branch=main'); + expect(calledUrl).not.toContain('branch=undefined'); + }); + + it('single-encodes a branch containing `/` (already encoded on the Client)', async () => { + // `Client.setBranch('feat/x')` stores `'feat%2Fx'`; list() should + // forward exactly `branch=feat%2Fx`, never the double-encoded form. + const { store, fetchWithToken } = buildStore({ branch: 'feat%2Fx' }); + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { files: [], directories: [], cursor: 0 }) + ); + + await store.list({ directory: '', thumbnailSizes: [] } as any); + + const calledUrl = fetchWithToken.mock.calls[0][0]; + expect(calledUrl).toContain('&branch=feat%2Fx'); + expect(calledUrl).not.toContain('feat%252Fx'); + }); + + it('omits the branch param when branch is the literal string "undefined"', async () => { + // `Client.setBranch(undefined)` runs `encodeURIComponent(undefined)`, + // which returns the literal 9-char string "undefined". We must not + // forward that to the assets-api as a real branch. + const { store, fetchWithToken } = buildStore({ branch: 'undefined' }); + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { files: [], directories: [], cursor: 0 }) + ); + + await store.list({ directory: '', thumbnailSizes: [] } as any); + + const calledUrl = fetchWithToken.mock.calls[0][0]; + expect(calledUrl).not.toContain('branch='); + }); + + it('omits the branch param when branch is empty', async () => { + const { store, fetchWithToken } = buildStore({ branch: '' }); + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { files: [], directories: [], cursor: 0 }) + ); + + await store.list({ directory: '', thumbnailSizes: [] } as any); + + const calledUrl = fetchWithToken.mock.calls[0][0]; + expect(calledUrl).not.toContain('branch='); + }); + }); + + describe('delete()', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('appends branch as the first query param', async () => { + const { store, fetchWithToken } = buildStore({ branch: 'feat%2Fx' }); + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { requestId: 'req-1' }) + ); + + const deletePromise = store.delete({ + directory: 'images', + filename: 'a.png', + } as Media); + await vi.advanceTimersByTimeAsync(1100); + await deletePromise; + + const calledUrl = fetchWithToken.mock.calls[0][0]; + expect(calledUrl).toContain('/images/a.png?branch=feat%2Fx'); + }); + + it('omits the branch param when branch is unset', async () => { + const { store, fetchWithToken } = buildStore({ branch: '' }); + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { requestId: 'req-1' }) + ); + + const deletePromise = store.delete({ + directory: 'images', + filename: 'a.png', + } as Media); + await vi.advanceTimersByTimeAsync(1100); + await deletePromise; + + const calledUrl = fetchWithToken.mock.calls[0][0]; + expect(calledUrl).toContain('/images/a.png'); + expect(calledUrl).not.toContain('branch='); + }); + }); + + describe('persist()', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + const stubFetchGlobal = (status: number, body: unknown) => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + text: vi.fn().mockResolvedValue(''), + json: vi.fn().mockResolvedValue(body), + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + }; + + it('forwards the encoded branch on the upload_url request and resolves canonical entries via list()', async () => { + const { store, fetchWithToken } = buildStore({ branch: 'feat%2Fx' }); + // 1) upload_url response + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { + signedUrl: 'https://s3.example/signed', + requestId: 'req-1', + }) + ); + // 2) the post-upload list() call inside fetchUploadedEntries + fetchWithToken.mockResolvedValueOnce( + makeJsonResponse(200, { + files: [ + { + filename: 'llama.png', + src: 'https://assets.tina.io/test-client/__staging/feat/x/__file/uploads/llama.png', + }, + ], + directories: [], + cursor: 0, + }) + ); + stubFetchGlobal(200, {}); + + const uploads: MediaUploadOptions[] = [ + { + directory: 'uploads', + file: new File(['x'], 'llama.png', { type: 'image/png' }), + }, + ]; + + const persistPromise = store.persist(uploads); + // Advance past the 1s polling sleep inside the upload loop. + await vi.advanceTimersByTimeAsync(1100); + const result = await persistPromise; + + // First call is upload_url with branch query. + const uploadUrl = fetchWithToken.mock.calls[0][0]; + expect(uploadUrl).toContain( + '/upload_url/uploads/llama.png?branch=feat%2Fx' + ); + + // Result contains the canonical entry from the list endpoint, not a + // locally-constructed `https://assets.tina.io//` URL. + expect(result).toHaveLength(1); + expect(result[0].filename).toBe('llama.png'); + expect(result[0].src).toBe( + 'https://assets.tina.io/test-client/__staging/feat/x/__file/uploads/llama.png' + ); + }); + + it('returns [] when not authenticated, without making upload calls', async () => { + const { store, fetchWithToken } = buildStore({ authenticated: false }); + + const uploads: MediaUploadOptions[] = [ + { + directory: 'uploads', + file: new File(['x'], 'llama.png', { type: 'image/png' }), + }, + ]; + + const result = await store.persist(uploads); + expect(result).toEqual([]); + expect(fetchWithToken).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/tinacms/src/toolkit/core/media-store.default.ts b/packages/tinacms/src/toolkit/core/media-store.default.ts index c0970a5ebb..fc42a45e8d 100644 --- a/packages/tinacms/src/toolkit/core/media-store.default.ts +++ b/packages/tinacms/src/toolkit/core/media-store.default.ts @@ -1,15 +1,15 @@ +import { DEFAULT_MEDIA_UPLOAD_TYPES } from '@toolkit/components/media/utils'; +import type { Client } from '../../internalClient'; +import { CMS } from './cms'; import { + E_BAD_ROUTE, + E_UNAUTHORIZED, Media, - MediaStore, - MediaUploadOptions, MediaList, MediaListOptions, - E_UNAUTHORIZED, - E_BAD_ROUTE, + MediaStore, + MediaUploadOptions, } from './media'; -import { CMS } from './cms'; -import { DEFAULT_MEDIA_UPLOAD_TYPES } from '@toolkit/components/media/utils'; -import type { Client } from '../../internalClient'; const s3ErrorRegex = /.*(.+)<\/Code>.*(.+)<\/Message>.*/; @@ -108,94 +108,161 @@ export class TinaMediaStore implements MediaStore { // allow up to 100MB uploads maxSize = 100 * 1024 * 1024; + /** + * Returns the current branch as a single-encoded query-param value, or + * an empty string when no branch is set. + * + * `this.api.branch` is already URL-encoded by `Client.setBranch()`, so we + * decode then re-encode here to defend against double-encoding when this + * value is concatenated into a URL. + * + * `Client.setBranch()` runs the constructor's `options.branch` through + * `encodeURIComponent` without a guard, so an unset `options.branch` + * lands here as the literal string `"undefined"`. We treat that and the + * empty case as no-branch so we don't send `?branch=undefined` to the + * assets-api (which would route the call to a non-existent staging path). + */ + private encodedBranchParam(): string { + if (!this.api.branch) return ''; + const decoded = decodeURIComponent(this.api.branch); + if (!decoded || decoded === 'undefined') return ''; + return encodeURIComponent(decoded); + } + private async persist_cloud(media: MediaUploadOptions[]): Promise { - const newFiles: Media[] = []; + if (!(await this.isAuthenticated())) { + return []; + } - if (await this.isAuthenticated()) { - for (const item of media) { - let directory = item.directory; - if (directory?.endsWith('/')) { - directory = directory.substr(0, directory.length - 1); - } - const path = `${ - directory && directory !== '/' - ? `${directory}/${item.file.name}` - : item.file.name - }`; - const res = await this.api.authProvider.fetchWithToken( - `${this.url}/upload_url/${path}`, - { method: 'GET' } - ); + const encodedBranch = this.encodedBranchParam(); + const branchQuery = encodedBranch ? `?branch=${encodedBranch}` : ''; - if (res.status === 412) { - const { message = 'Unexpected error generating upload url' } = - await res.json(); - throw new Error(message); - } + for (const item of media) { + let directory = item.directory; + if (directory?.endsWith('/')) { + directory = directory.substr(0, directory.length - 1); + } + const path = `${ + directory && directory !== '/' + ? `${directory}/${item.file.name}` + : item.file.name + }`; + const res = await this.api.authProvider.fetchWithToken( + `${this.url}/upload_url/${path}${branchQuery}`, + { method: 'GET' } + ); - const { signedUrl, requestId } = await res.json(); - if (!signedUrl) { - throw new Error('Unexpected error generating upload url'); + if (res.status === 412) { + const { message = 'Unexpected error generating upload url' } = + await res.json(); + throw new Error(message); + } + + const { signedUrl, requestId } = await res.json(); + if (!signedUrl) { + throw new Error('Unexpected error generating upload url'); + } + + const uploadRes = await this.fetchFunction(signedUrl, { + method: 'PUT', + body: item.file, + headers: { + 'Content-Type': item.file.type || 'application/octet-stream', + 'Content-Length': String(item.file.size), + }, + }); + + if (!uploadRes.ok) { + const xmlRes = await uploadRes.text(); + const matches = s3ErrorRegex.exec(xmlRes); + console.error(xmlRes); + if (!matches) { + throw new Error('Unexpected error uploading media asset'); + } else { + throw new Error(`Upload error: '${matches[2]}'`); } + } - const uploadRes = await this.fetchFunction(signedUrl, { - method: 'PUT', - body: item.file, - headers: { - 'Content-Type': item.file.type || 'application/octet-stream', - 'Content-Length': String(item.file.size), - }, - }); + const updateStartTime = Date.now(); + while (true) { + // sleep for 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)); - if (!uploadRes.ok) { - const xmlRes = await uploadRes.text(); - const matches = s3ErrorRegex.exec(xmlRes); - console.error(xmlRes); - if (!matches) { - throw new Error('Unexpected error uploading media asset'); + const { error, message } = await this.api.getRequestStatus(requestId); + if (error !== undefined) { + if (error) { + throw new Error(message); } else { - throw new Error(`Upload error: '${matches[2]}'`); + // success + break; } } - const updateStartTime = Date.now(); - while (true) { - // sleep for 1 second - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const { error, message } = await this.api.getRequestStatus(requestId); - if (error !== undefined) { - if (error) { - throw new Error(message); - } else { - // success - break; - } - } - - if (Date.now() - updateStartTime > 30000) { - throw new Error('Time out waiting for upload to complete'); - } + if (Date.now() - updateStartTime > 30000) { + throw new Error('Time out waiting for upload to complete'); } + } + } + + return this.fetchUploadedEntries(media); + } - const src = `https://assets.tina.io/${this.api.clientId}/${path}`; + /** + * Resolves the just-uploaded items to canonical `Media` entries by hitting + * the assets-api `list` endpoint, which is the source of truth for the + * `src` URL — including the staging-branch path + * (`__staging//__file/...`) and the per-stage CDN host. + * Constructing those URLs on the client would mirror server-side branch + * routing and CDN-host logic, which is fragile to keep in sync. + * + * Best-effort: items not found within the first page of their directory + * (e.g. very large directories) are omitted from the result rather than + * throwing — the upload itself already succeeded. + */ + private async fetchUploadedEntries( + media: MediaUploadOptions[] + ): Promise { + const byDirectory = new Map(); + for (const item of media) { + let dir = item.directory || ''; + while (dir.endsWith('/')) dir = dir.slice(0, -1); + const bucket = byDirectory.get(dir) ?? []; + bucket.push(item); + byDirectory.set(dir, bucket); + } - newFiles.push({ - directory: item.directory, - filename: item.file.name, - id: item.file.name, - type: 'file', - thumbnails: { - '75x75': src, - '400x400': src, - '1000x1000': src, - }, - src, + const thumbnailSizes = [ + { w: 75, h: 75 }, + { w: 400, h: 400 }, + { w: 1000, h: 1000 }, + ]; + + const results: Media[] = []; + for (const [directory, items] of byDirectory) { + let listed: MediaList; + try { + listed = await this.list({ + directory, + limit: Math.max(100, items.length * 4), + thumbnailSizes, }); + } catch (err) { + console.error('Failed to fetch canonical media entries:', err); + continue; } - } - return newFiles; + const found = new Map(); + for (const entry of listed.items) { + if (entry.type === 'file') { + found.set(entry.filename, entry); + } + } + for (const item of items) { + const entry = found.get(item.file.name); + if (entry) results.push(entry); + } + } + return results; } private async persist_local(media: MediaUploadOptions[]): Promise { @@ -337,10 +404,13 @@ export class TinaMediaStore implements MediaStore { let res; if (!this.isLocal) { + const encodedBranch = this.encodedBranchParam(); res = await this.api.authProvider.fetchWithToken( `${this.url}/list/${options.directory || ''}?limit=${ options.limit || 20 - }${options.offset ? `&cursor=${options.offset}` : ''}` + }${options.offset ? `&cursor=${options.offset}` : ''}${ + encodedBranch ? `&branch=${encodedBranch}` : '' + }` ); if (res.status == 401) { @@ -410,8 +480,10 @@ export class TinaMediaStore implements MediaStore { }`; if (!this.isLocal) { if (await this.isAuthenticated()) { + const encodedBranch = this.encodedBranchParam(); + const branchQuery = encodedBranch ? `?branch=${encodedBranch}` : ''; const res = await this.api.authProvider.fetchWithToken( - `${this.url}/${path}`, + `${this.url}/${path}${branchQuery}`, { method: 'DELETE', } diff --git a/packages/tinacms/src/toolkit/core/media.ts b/packages/tinacms/src/toolkit/core/media.ts index 61113eb331..b8d62df573 100644 --- a/packages/tinacms/src/toolkit/core/media.ts +++ b/packages/tinacms/src/toolkit/core/media.ts @@ -91,6 +91,15 @@ export interface MediaStore { */ list(options?: MediaListOptions): Promise; + /** + * Reserved hook for renaming a media object in the store. + * + * Not yet implemented in `TinaMediaStore` or surfaced in `MediaManager` — + * declared here as an extension point so stores can begin to opt in once + * the corresponding assets-api endpoint is built. + */ + rename?(from: string, to: string): Promise; + /** * Indicates that uploads and deletions are not supported *