diff --git a/.agents/skills/create-database-migration/SKILL.md b/.agents/skills/create-database-migration/SKILL.md index e6bd516223f..ae3fd91d90f 100644 --- a/.agents/skills/create-database-migration/SKILL.md +++ b/.agents/skills/create-database-migration/SKILL.md @@ -12,6 +12,10 @@ accompanies it. ## Instructions +If you are adding a new table whose shape is still changing, consider marking it +as in development instead of writing a migration yet; see "New tables still in +development" in the guide. + 1. Create a new, empty migration file: `cd ghost/core && pnpm migrate:create `. IMPORTANT: do not create the migration file manually; always use this script to create the initial empty migration file. The slug must be kebab-case (e.g. `add-column-to-posts`). 2. The above command will create a new directory in `ghost/core/core/server/data/migrations/versions` if needed, create the empty migration file with the appropriate name, and bump the core and admin package versions to RC if this is the first migration after a release. 3. Update the migration file with the changes you want to make in the database, following the existing patterns in the codebase. Where appropriate, prefer to use the utility functions in `ghost/core/core/server/data/migrations/utils/*`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfdb1fb46e7..ecd4188ff2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -247,7 +247,7 @@ jobs: echo 'typecheck_projects_str=' >> "$GITHUB_OUTPUT" echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT" echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT" - echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT" + echo 'affected_playwright_matrix=[]' >> "$GITHUB_OUTPUT" echo 'publish_public_apps_matrix=[]' >> "$GITHUB_OUTPUT" exit 0 fi @@ -290,7 +290,12 @@ jobs: --withTarget test:acceptance \ --projects 'tag:playwright' \ --json) - echo "affected_playwright_projects=$PLAYWRIGHT_PROJECTS" >> "$GITHUB_OUTPUT" + # Split Admin across two runners; every other app keeps a single shard. + PLAYWRIGHT_MATRIX=$(jq -c '[.[] | . as $app | + (if $app == "@tryghost/admin" then 2 else 1 end) as $total | + range(1; $total + 1) | {app: $app, shardIndex: ., shardTotal: $total} + ]' <<< "$PLAYWRIGHT_PROJECTS") + echo "affected_playwright_matrix=$PLAYWRIGHT_MATRIX" >> "$GITHUB_OUTPUT" # Build the publish matrix for affected public apps. Matrix context # isn't available in a job-level `if:`, so the publish job can't filter @@ -305,7 +310,7 @@ jobs: affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }} typecheck_projects_str: ${{ steps.affected.outputs.typecheck_projects_str }} unit_test_projects_str: ${{ steps.affected.outputs.unit_test_projects_str }} - affected_playwright_projects: ${{ steps.affected.outputs.affected_playwright_projects }} + affected_playwright_matrix: ${{ steps.affected.outputs.affected_playwright_matrix }} publish_public_apps_matrix: ${{ steps.affected.outputs.publish_public_apps_matrix }} changed_i18n_apps: ${{ steps.affected.outputs.affected_i18n_projects != '' }} changed_core: ${{ steps.changed.outputs.core }} @@ -988,12 +993,12 @@ jobs: # are not starved on the default runner. See job_acceptance-tests above. runs-on: ${{ (github.repository_owner == 'TryGhost' && github.repository != 'TryGhost/Ghost' && matrix.app == '@tryghost/admin') && 'ubuntu-latest-4-cores' || 'ubuntu-latest' }} needs: [job_setup] - if: needs.job_setup.outputs.affected_playwright_projects != '[]' - name: App Playwright Acceptance Tests + if: needs.job_setup.outputs.affected_playwright_matrix != '[]' + name: App Playwright Acceptance Tests (${{ matrix.app }} ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) strategy: fail-fast: false matrix: - app: ${{ fromJSON(needs.job_setup.outputs.affected_playwright_projects) }} + include: ${{ fromJSON(needs.job_setup.outputs.affected_playwright_matrix) }} env: CI: true steps: @@ -1066,7 +1071,7 @@ jobs: run: pnpm exec playwright install-deps firefox - name: Run Playwright tests - run: pnpm nx run ${{ matrix.app }}:test:acceptance + run: pnpm nx run ${{ matrix.app }}:test:acceptance --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Get App Name if: always() @@ -1090,7 +1095,7 @@ jobs: if: always() && steps.app_name.outputs.has_report == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ steps.app_name.outputs.name }}-playwright-report + name: ${{ steps.app_name.outputs.name }}-playwright-report-${{ matrix.shardIndex }} path: ${{ steps.app_name.outputs.root }}/playwright-report retention-days: 30 @@ -1101,7 +1106,7 @@ jobs: if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ steps.app_name.outputs.name }}-failure-screenshots + name: ${{ steps.app_name.outputs.name }}-failure-screenshots-${{ matrix.shardIndex }} path: ${{ steps.app_name.outputs.root }}/**/__screenshots__/** if-no-files-found: ignore retention-days: 30 @@ -2388,7 +2393,9 @@ jobs: runs-on: ubuntu-slim steps: - name: Output needs - run: echo "${{ toJson(needs) }}" + env: + NEEDS_JSON: ${{ toJson(needs) }} + run: printf '%s\n' "$NEEDS_JSON" - name: Check if any required jobs failed or been cancelled if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/apps/admin-x-framework/test/unit/api/automated-emails.test.ts b/apps/admin-x-framework/test/unit/api/automated-emails.test.ts new file mode 100644 index 00000000000..2244af2a326 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/automated-emails.test.ts @@ -0,0 +1,58 @@ +import { act } from '@testing-library/react'; +import { renderHookWithProviders } from '../../../src/test/test-utils'; +import { + useAddAutomatedEmail, + useEditAutomatedEmail, + type AutomatedEmail, +} from '../../../src/api/automated-emails'; +import { withMockFetch } from '../../utils/mock-fetch'; + +const email: AutomatedEmail = { + id: 'email-id', + name: 'Free member welcome flow', + slug: 'member-welcome-email-free', + status: 'active', + subject: 'Welcome!', + lexical: null, + sender_name: null, + sender_email: null, + sender_reply_to: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: null, +}; + +describe('automated email APIs', () => { + describe('useAddAutomatedEmail', () => { + it('sends the creation payload', async () => { + await withMockFetch({ json: { automated_emails: [email] } }, async (mock) => { + const { result } = renderHookWithProviders(() => useAddAutomatedEmail()); + await act(async () => { + await result.current.mutateAsync(email); + }); + const request = mock.calls.find( + ([, options]: [unknown, RequestInit]) => options.method === 'POST', + ); + expect(request[0]).toBe('http://localhost:3000/ghost/api/admin/automated_emails/'); + expect(JSON.parse(request[1].body)).toEqual({ automated_emails: [email] }); + }); + }); + }); + + describe('useEditAutomatedEmail', () => { + it('sends the update payload', async () => { + await withMockFetch({ json: { automated_emails: [email] } }, async (mock) => { + const { result } = renderHookWithProviders(() => useEditAutomatedEmail()); + await act(async () => { + await result.current.mutateAsync(email); + }); + const request = mock.calls.find( + ([, options]: [unknown, RequestInit]) => options.method === 'PUT', + ); + expect(request[0]).toBe('http://localhost:3000/ghost/api/admin/automated_emails/email-id/'); + expect(JSON.parse(request[1].body)).toEqual({ + automated_emails: [email], + }); + }); + }); + }); +}); diff --git a/apps/admin/src/editor/editor-header.acceptance.test.tsx b/apps/admin/src/editor/editor-header.acceptance.test.tsx index 2532cdc3adb..1e4f57ab708 100644 --- a/apps/admin/src/editor/editor-header.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-header.acceptance.test.tsx @@ -508,6 +508,7 @@ describe('Editor header actions', () => { await expect(previewScreen.modal()).toHaveCount(0); await expect.element(publishScreen.options()).toBeVisible(); + await expect.element(publishScreen.previewButton()).toHaveFocus(); }); it('publishes from a preview opened by the header Preview button', async () => { @@ -614,25 +615,67 @@ describe('Editor header actions', () => { await editorScreen.previewButton().click(); await expect.element(previewScreen.modal()).toBeVisible(); + await expect + .poll(() => previewScreen.modal().element().contains(document.activeElement)) + .toBe(true); + await userEvent.keyboard('{Escape}'); await expect(previewScreen.modal()).toHaveCount(0); await expect.element(editorScreen.previewButton()).toHaveFocus(); }); - it('returns focus to the Publish button when the publish flow closes', async () => { + it.each(['Escape', 'Close button'] as const)( + 'returns focus to Publish after closing with %s and reopening', + async (closeWith) => { + publishChrome(); + fakeSavablePost(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.publishButton()).toBeEnabled(); + for (let opening = 0; opening < 2; opening += 1) { + await editorScreen.publishButton().click(); + await expect.element(publishScreen.options()).toBeVisible(); + await expect + .poll(() => publishScreen.root().element().contains(document.activeElement)) + .toBe(true); + + if (closeWith === 'Escape') { + await userEvent.keyboard('{Escape}'); + } else { + await publishScreen.closeButton().click(); + } + + await expect(publishScreen.root()).toHaveCount(0); + await expect.element(editorScreen.publishButton()).toHaveFocus(); + } + }, + ); + + it('returns focus to Publish when closed during the publish settings refresh', async () => { publishChrome(); fakeSavablePost(); await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); - await expect.element(editorScreen.publishButton()).toBeEnabled(); - await editorScreen.publishButton().click(); - await expect.element(publishScreen.options()).toBeVisible(); - await userEvent.keyboard('{Escape}'); + const refreshedSettings = deferred(); + const settingsApi = fakeAdminEndpoint('GET', /^\/settings\//, async () => { + await refreshedSettings.promise; + return settingsResponse({ labs: FLAG_ON.labs }); + }); + + try { + await editorScreen.publishButton().click(); + await expect.element(publishScreen.options()).toBeVisible(); + await expect.poll(() => settingsApi.requests.length).toBeGreaterThan(0); + await expect.element(editorScreen.publishButton()).toBeEnabled(); - await expect(publishScreen.root()).toHaveCount(0); - await expect.element(editorScreen.publishButton()).toHaveFocus(); + await userEvent.keyboard('{Escape}'); + await expect(publishScreen.root()).toHaveCount(0); + await expect.element(editorScreen.publishButton()).toHaveFocus(); + } finally { + refreshedSettings.resolve(); + } }); it('returns focus to the Unpublish button when the update flow closes', async () => { @@ -642,6 +685,9 @@ describe('Editor header actions', () => { await editorScreen.unpublishButton().click(); await expect.element(publishScreen.updateFlow()).toBeVisible(); + await expect + .poll(() => publishScreen.updateFlow().element().contains(document.activeElement)) + .toBe(true); await userEvent.keyboard('{Escape}'); diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 84dca2c709d..7fd1e37eb86 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -9,6 +9,7 @@ import { useState, } from 'react'; import { AdminLink } from '@/shared/admin-link'; +import { getPostListReturnUrl } from '@/posts/api'; import { NotFound } from '@/shared/not-found'; import { Navigate, useNavigate, useParams } from '@tryghost/admin-x-framework'; import { Button, LoadingIndicator } from '@tryghost/shade/components'; @@ -75,6 +76,9 @@ function EditorLoadError({ message, onRetry }: { message: string; onRetry: () => function EditorHeader({ postType, children }: { postType: PostType; children?: ReactNode }) { const listLabel = postType === 'page' ? 'Pages' : 'Posts'; + const reactOwnsList = useFeatureFlag('postsListReact'); + const resource = postType === 'page' ? 'pages' : 'posts'; + const listUrl = reactOwnsList ? getPostListReturnUrl(resource) : `/${resource}`; return ( - + {listLabel} diff --git a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx index bb3776945df..770d1d4fe7e 100644 --- a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx @@ -3,6 +3,7 @@ import { userEvent } from 'vitest/browser'; import { browseResponse, + currentRoute, currentUserResponse, fakeAdminEndpoint, fakeEditorChrome, @@ -15,6 +16,7 @@ import { submittedPost, tier, unsavedChangesGuarded, + withFastAutosave, withoutAutosave, } from '@test-utils/acceptance'; import { editorScreen } from '@/editor/editor.screen'; @@ -142,23 +144,84 @@ describe('Post settings access', () => { await expect(editorScreen.settingsTiersError()).toHaveCount(0); }); - it('refuses the first save until an explicitly selected tier access has a tier', async () => { + it('creates a new post with specific-tier access before a tier is picked, then sends the pair', async () => { editorChrome(); - const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, { - posts: [post({ id: NEW_POST_ID, visibility: 'public' })], + // A Public read carries every site tier, the free one included. + let created = post({ + id: NEW_POST_ID, + title: '(Untitled)', + status: 'draft', + visibility: 'public', + tiers: SITE_TIERS, + tags: [], + }); + const createApi = fakeAdminEndpoint('POST', /^\/posts\/\?/, ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + created = { ...created, ...submitted, id: NEW_POST_ID, updated_at: LOADED_AT }; + return { posts: [created] }; }); - await renderAdminApp('/editor/post', FLAG_ON); + fakeAdminEndpoint('GET', new RegExp(`^/posts/${NEW_POST_ID}/\\?`), () => ({ + posts: [created], + })); + const updateApi = fakeAdminEndpoint( + 'PUT', + new RegExp(`^/posts/${NEW_POST_ID}/\\?`), + ({ body }) => { + const submitted = (body as { posts: Partial[] }).posts[0]; + created = { + ...created, + ...submitted, + updated_at: new Date(Date.parse(created.updated_at) + 1000).toISOString(), + }; + return { posts: [created] }; + }, + ); + + await renderAdminApp('/editor/post', withFastAutosave({ labs: { editorReact: true } })); await openAccess(); await chooseVisibility('Specific tier(s)'); - await userEvent.keyboard('{Meta>}s{/Meta}'); + // The pair is staged and asks for a tier, but it does not save on its own: + // the write would carry nothing of it. await expect - .element(editorScreen.saveErrorBanner()) + .element(editorScreen.settingsTiersError()) .toHaveTextContent('Please select at least one tier.'); + await expect.element(editorScreen.settingsTier('Gold')).toBeVisible(); expect(createApi.requests).toHaveLength(0); - await expect.element(editorScreen.settingsVisibility()).toHaveTextContent('Specific tier(s)'); await expect.poll(unsavedChangesGuarded).toBe(true); + + await typeIntoBody('First words'); + + // The content creates the post with the pair left out of the write. + await expect.poll(() => createApi.requests.length, POLL).toBe(1); + expect(submittedPost(createApi)).not.toHaveProperty('visibility'); + expect(submittedPost(createApi)).not.toHaveProperty('tiers'); + await expect.poll(currentRoute, POLL).toBe(`/editor/post/${NEW_POST_ID}`); + + // The pair was never acknowledged, so the server's default visibility and + // the tiers its read carries do not replace the writer's choice. + await expect.element(editorScreen.settingsVisibility()).toHaveTextContent('Specific tier(s)'); + await expect + .element(editorScreen.settingsTiersError()) + .toHaveTextContent('Please select at least one tier.'); + await expect.element(editorScreen.settingsTiers()).toHaveAttribute('aria-invalid', 'true'); + await expect + .element(editorScreen.settingsTier('Gold')) + .toHaveAttribute('data-state', 'unchecked'); + // A save that follows the create leaves the pair out as well. + for (let index = 0; index < updateApi.requests.length; index += 1) { + expect(submittedPost(updateApi, index)).not.toHaveProperty('visibility'); + expect(submittedPost(updateApi, index)).not.toHaveProperty('tiers'); + } + + await editorScreen.settingsTier('Gold').click(); + + await expect(updateApi).toHaveSavedFields({ visibility: 'tiers', tiers: [{ id: GOLD.id }] }); + await expect(editorScreen.settingsTiersError()).toHaveCount(0); + expect(created).toMatchObject({ visibility: 'tiers', tiers: [{ id: GOLD.id }] }); + // Body typed after the create waits for the tier and lands with it. + await expect.poll(() => String(created.lexical ?? ''), POLL).toContain('First words'); }); it('sends the visibility and the tiers together once a tier is picked', async () => { diff --git a/apps/admin/src/editor/editor.acceptance.test.tsx b/apps/admin/src/editor/editor.acceptance.test.tsx index 0018a3148ce..c46dac60cad 100644 --- a/apps/admin/src/editor/editor.acceptance.test.tsx +++ b/apps/admin/src/editor/editor.acceptance.test.tsx @@ -1,7 +1,19 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; -import { fakeAdminEndpoint, fakeEditorChrome, post, renderAdminApp } from '@test-utils/acceptance'; +import { + currentRoute, + fakeAdminEndpoint, + fakeEditorChrome, + fakePages, + fakePosts, + fakePostsListScreen, + post, + renderAdminApp, + tag, +} from '@test-utils/acceptance'; import { editorScreen } from '@/editor/editor.screen'; +import { postsListScreen } from '@/posts/list/posts-list.screen'; +import { clearStickyPostFilters } from '@/posts/list/posts-sticky-filters'; const FLAG_ON = { labs: { editorReact: true } }; const FLAG_OFF = { labs: { editorReact: false } }; @@ -71,3 +83,47 @@ describe('Editor flag', () => { await expect(editorScreen.root()).toHaveCount(0); }); }); + +describe('Editor list breadcrumb', () => { + afterEach(clearStickyPostFilters); + + it.each([ + { postType: 'post', returnWith: 'breadcrumb' }, + { postType: 'page', returnWith: 'breadcrumb' }, + { postType: 'post', returnWith: 'back' }, + { postType: 'page', returnWith: 'back' }, + ] as const)( + 'returns a $postType to its filtered list with the same sort order using $returnWith', + async ({ postType, returnWith }) => { + const resource = postType === 'post' ? 'posts' : 'pages'; + const entry = post({ id: 'abc123', title: 'Engineering update', status: 'draft' }); + fakeEditorChrome(); + fakePostsListScreen(); + const listApi = (postType === 'post' ? fakePosts : fakePages)([entry]); + fakeAdminEndpoint('GET', new RegExp(`^/${resource}/abc123/\\?`), { [resource]: [entry] }); + fakeAdminEndpoint('GET', /^\/tags\/\?.*slug/, { + tags: [tag({ name: 'Engineering', slug: 'engineering' })], + }); + const listUrl = `/${resource}?type=draft&tag=engineering&order=title+asc`; + await renderAdminApp(listUrl, { labs: { editorReact: true, postsListReact: true } }); + + await expect.element(postsListScreen.filterBar()).toHaveTextContent('Engineering'); + await postsListScreen.listItems().first().click(); + await expect.element(editorScreen.root()).toBeVisible(); + await expect.element(editorScreen.backLink(postType)).toHaveAttribute('href', `#${listUrl}`); + if (returnWith === 'breadcrumb') { + await editorScreen.backLink(postType).click(); + } else { + window.history.back(); + } + + await expect.poll(currentRoute).toBe(listUrl); + await expect.element(postsListScreen.filterBar()).toHaveTextContent('Engineering'); + expect( + listApi.requests.some( + (request) => request.filter?.includes('tag:engineering') && request.order === 'title asc', + ), + ).toBe(true); + }, + ); +}); diff --git a/apps/admin/src/editor/engine/README.md b/apps/admin/src/editor/engine/README.md index 008acba732b..a899bff8e8d 100644 --- a/apps/admin/src/editor/engine/README.md +++ b/apps/admin/src/editor/engine/README.md @@ -128,13 +128,15 @@ successful save or accepted reload releases the collision. Other states: `subscribe()` suits `useSyncExternalStore`: emissions are deduplicated, listeners receive the emitted value, a throwing `onStateChange` port or subscriber is reported through `onListenerError` without interrupting the save, and a nested transition ends the outer pass so no listener sees an out-of-order state. +`onSaveFailed` is called once per request that ran and failed, after the failure state is published (queued work the failure dropped is not reported): with the command it ran, the error, whether the post carried a server id, and how long `execute` took (null when the request never reached it). A local validation hold on background work is not a failure; a frozen request is reported only once re-authentication is abandoned. A throwing port is reported through `onListenerError`. + ## Change tracker `change-tracker.ts` + `lexical-compare.ts`. Answers one question for the editor: does the live post differ from what is persisted, and why. Pure, React-free; the editor's hidden second Koenig instance supplies the baseline. ### State model -Three documents: **saved** (last persisted state, from load/refetch/acknowledged save), **baseline** (the hidden instance's post-load serialization — the document after Lexical's load-time transforms), **live** (the visible editor). Body verdict: dirty ⇔ live differs from saved **and** from baseline. Baseline readiness is separate from its value: `pending` (not reported yet), `ready` (a known document; `null`, `''`, and an empty root are all known-empty), `failed`. A live edit while pending is dirty (`BASELINE_PENDING`, fail closed); a failed baseline falls back to live-vs-saved (`BASELINE_FAILED`) and never disables body protection. Title, the ordered tag list, and the editable attributes contribute their own dirty bits. Stable reason codes identify each cause (nothing reports them): `POST_HAS_ERROR`, `POST_TAGS_DIVERGED`, `POST_TITLE_DIVERGED`, `SCRATCH_DIVERGED_FROM_SECONDARY`, `NEW_POST_HAS_CHANGED_ATTRIBUTES`, `POST_HAS_DIRTY_ATTRIBUTES`, `BASELINE_PENDING`, `BASELINE_FAILED`, `LEXICAL_PARSE_FAILED` (malformed or structurally invalid Lexical is dirty, never a thrown route blocker). +Three documents: **saved** (last persisted state, from load/refetch/acknowledged save), **baseline** (the hidden instance's post-load serialization — the document after Lexical's load-time transforms), **live** (the visible editor). Body verdict: dirty ⇔ live differs from saved **and** from baseline. Baseline readiness is separate from its value: `pending` (not reported yet), `ready` (a known document; `null`, `''`, and an empty root are all known-empty), `failed`. A live edit while pending is dirty (`BASELINE_PENDING`, fail closed); a failed baseline falls back to live-vs-saved (`BASELINE_FAILED`) and never disables body protection. Title, the ordered tag list, and the editable attributes contribute their own dirty bits. Stable reason codes identify each cause, which the session reports when a leave has to be confirmed: `POST_HAS_ERROR`, `POST_TAGS_DIVERGED`, `POST_TITLE_DIVERGED`, `SCRATCH_DIVERGED_FROM_SECONDARY`, `NEW_POST_HAS_CHANGED_ATTRIBUTES`, `POST_HAS_DIRTY_ATTRIBUTES`, `BASELINE_PENDING`, `BASELINE_FAILED`, `LEXICAL_PARSE_FAILED` (malformed or structurally invalid Lexical is dirty, never a thrown route blocker). ### API (id-first; events for another post are dropped) diff --git a/apps/admin/src/editor/engine/__test-utils__/engine-harness.ts b/apps/admin/src/editor/engine/__test-utils__/engine-harness.ts index 98c6cb53eb9..8134584be1f 100644 --- a/apps/admin/src/editor/engine/__test-utils__/engine-harness.ts +++ b/apps/admin/src/editor/engine/__test-utils__/engine-harness.ts @@ -7,6 +7,7 @@ import { type SaveEngine, type SaveEngineState, type SaveError, + type SaveFailure, type SaveOutcome, type SaveRequest, type SaveResult, @@ -57,7 +58,10 @@ export function dispatchAny(engine: SaveEngine, kind: DispatchIntent) { export function setup( overrides: Partial = {}, - ports: { autosaveDebounceMs?: () => number | undefined } = {}, + ports: { + autosaveDebounceMs?: () => number | undefined; + onSaveFailed?: (failure: SaveFailure) => void; + } = {}, ) { let snapshot = { ...BASE, ...overrides } as SaveSnapshot; const requests: SaveRequest[] = []; diff --git a/apps/admin/src/editor/engine/save-engine.reporting.test.ts b/apps/admin/src/editor/engine/save-engine.reporting.test.ts new file mode 100644 index 00000000000..4a1688beec2 --- /dev/null +++ b/apps/admin/src/editor/engine/save-engine.reporting.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSaveEngine, type SaveFailure, type SaveRequest } from './save-engine'; +import { + conflict, + flush, + hostLimit, + notFound, + setup, + sessionInvalid, + transport, + unknown, + validation, +} from './__test-utils__/engine-harness'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function reporting(overrides: Parameters[0] = {}) { + const failures: SaveFailure[] = []; + const h = setup(overrides, { onSaveFailed: (failure) => failures.push(failure) }); + return { ...h, failures }; +} + +describe('createSaveEngine onSaveFailed', () => { + it.each([transport, unknown, hostLimit, validation])( + 'reports a $kind failure from execute once, with the request and its timing', + async (error) => { + const h = reporting(); + void h.engine.dispatch('explicit'); + await flush(); + vi.advanceTimersByTime(2500); + + await h.fail(error); + + expect(h.failures).toEqual([ + { + command: { kind: 'explicit', requiresRevision: true, requiresReconfirmation: false }, + error, + persisted: true, + durationMs: 2500, + }, + ]); + }, + ); + + it('reports nothing for a save that succeeds', async () => { + const h = reporting(); + void h.engine.dispatch('explicit'); + + await h.succeed(); + + expect(h.failures).toEqual([]); + }); + + it('reports a rejected execute as the unknown failure it became', async () => { + const h = reporting(); + const cause = new Error('network down'); + void h.engine.dispatch('field'); + + await h.reject(cause); + + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ + command: { kind: 'field' }, + error: { kind: 'unknown', message: 'network down', cause }, + }); + }); + + it('reports a not-found with whether the post had an id', async () => { + const h = reporting({ id: null, updatedAt: null }); + void h.engine.dispatch('autosave'); + await vi.advanceTimersByTimeAsync(3000); + + await h.fail(notFound); + + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ error: notFound, persisted: false }); + }); + + it('reports a collision and not the queued saves it dropped', async () => { + const h = reporting(); + void h.engine.dispatch('field'); + await flush(); + void h.engine.dispatch('explicit'); + + await h.fail(conflict); + + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ command: { kind: 'field' }, error: conflict }); + }); + + it('reports an expired session only once re-authentication is abandoned', async () => { + const h = reporting(); + void h.engine.dispatch('explicit'); + await flush(); + vi.advanceTimersByTime(400); + await h.fail(sessionInvalid); + expect(h.engine.getState()).toEqual({ kind: 'reauth-pending', intent: 'explicit' }); + expect(h.failures).toEqual([]); + + // The report describes the request as it ran, not the post as it is now. + h.patch({ id: null, updatedAt: null }); + h.engine.reauthAbandoned(); + + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ + error: sessionInvalid, + persisted: true, + durationMs: 400, + }); + }); + + it('reports nothing when re-authentication succeeds and the save is retried', async () => { + const h = reporting(); + void h.engine.dispatch('explicit'); + await h.fail(sessionInvalid); + + h.engine.reauthSucceeded(); + await h.succeed(); + + expect(h.failures).toEqual([]); + }); + + it('reports a prepare failure without a duration, since no request was sent', async () => { + const h = reporting(); + h.prepare.mockResolvedValueOnce({ ok: false, error: unknown }); + + await h.engine.dispatch('explicit'); + + expect(h.execute).not.toHaveBeenCalled(); + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ error: unknown, durationMs: null }); + }); + + it('reports a local validation failure for an explicit save but not a held background save', async () => { + const h = reporting(); + h.prepare.mockResolvedValue({ ok: false, error: validation }); + + await expect(h.engine.dispatch('field')).resolves.toEqual({ + kind: 'blocked', + error: validation, + }); + expect(h.failures).toEqual([]); + + await h.engine.dispatch('explicit'); + + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ error: validation, durationMs: null }); + }); + + it('reports a snapshot that could not be read', async () => { + const h = reporting(); + const cause = new Error('snapshot exploded'); + h.throwNextSnapshot(cause); + + await h.engine.dispatch('explicit'); + + expect(h.failures).toHaveLength(1); + expect(h.failures[0]).toMatchObject({ + error: { kind: 'unknown', message: 'snapshot exploded', cause }, + persisted: true, + }); + }); + + it('routes a throwing reporter to onListenerError and still settles the save', async () => { + const h = setup( + {}, + { + onSaveFailed: () => { + throw new Error('reporter down'); + }, + }, + ); + const completion = h.engine.dispatch('explicit'); + + await h.fail(transport); + + await expect(completion).resolves.toEqual({ + kind: 'failed', + error: transport, + executedAs: 'explicit', + }); + expect(h.listenerErrors).toEqual([new Error('reporter down')]); + }); + + it('is optional', async () => { + const engine = createSaveEngine({ + getSnapshot: () => ({ + id: 'post-1', + updatedAt: '2026-09-02T11:00:00.000Z', + status: 'draft', + publishedAt: null, + title: 'Hello', + slug: 'hello', + isDirty: true, + changedSinceLastRevision: true, + version: 1, + }), + slug: { + settled: () => Promise.resolve(), + fromTitle: () => Promise.resolve({ slug: '', source: 'unchanged' }), + }, + prepare: (request: SaveRequest) => Promise.resolve({ ok: true, prepared: request }), + execute: () => Promise.resolve({ ok: false, error: transport }), + reconcile: () => {}, + }); + + await expect(engine.dispatch('explicit')).resolves.toMatchObject({ kind: 'failed' }); + }); +}); diff --git a/apps/admin/src/editor/engine/save-engine.ts b/apps/admin/src/editor/engine/save-engine.ts index 9f2cd669fd1..5a16c3ae49f 100644 --- a/apps/admin/src/editor/engine/save-engine.ts +++ b/apps/admin/src/editor/engine/save-engine.ts @@ -133,6 +133,16 @@ export type SaveOutcome = /** Local validation holds background work; other failures use the normal error handling. */ export type PrepareOutcome

= { ok: true; prepared: P } | { ok: false; error: SaveError }; +/** A request that settled as `failed`, once per request. */ +export interface SaveFailure { + readonly command: SaveCommand; + readonly error: SaveError; + /** Whether the post carried a server id when the request ran. */ + readonly persisted: boolean; + /** Milliseconds `execute` took before failing; null when the request never reached it. */ + readonly durationMs: number | null; +} + /** Unsaved content is independent of the commands currently allowed to execute. */ export interface PendingSave { blockedBy: SaveError | null; @@ -202,6 +212,8 @@ export interface SaveEnginePorts< onStateChange?: (state: SaveEngineState) => void; /** A throwing state callback or subscriber is reported here instead of interrupting the save. */ onListenerError?: (error: unknown) => void; + /** Called after a failed request has settled; a throw is reported like a listener's. */ + onSaveFailed?: (failure: SaveFailure) => void; } export interface SaveEngine { @@ -311,6 +323,8 @@ interface Timer { interface Frozen { slot: Slot; error: SaveError; + persisted: boolean; + durationMs: number | null; } const AUTOSAVE: SaveCommand = { @@ -582,12 +596,43 @@ export function createSaveEngine< : { kind: 'error', intent, error }; } - function failSlot(slot: Slot, error: SaveError): void { + function failSlot( + slot: Slot, + error: SaveError, + snapshot: S | null, + durationMs: number | null, + ): void { settle(slot.waiters, failed(error, slot.command.kind)); setState(failureState(slot.command.kind, error)); + reportFailure(slot, error, snapshot, durationMs); drain(); } + function reportFailure( + slot: Slot, + error: SaveError, + snapshot: S | null, + durationMs: number | null, + ): void { + report({ + command: slot.command, + error, + persisted: snapshot !== null && snapshot.id !== null, + durationMs, + }); + } + + function report(failure: SaveFailure): void { + if (!ports.onSaveFailed) { + return; + } + try { + ports.onSaveFailed(failure); + } catch (cause) { + reportListenerError(cause); + } + } + function blankToDefault(title: string): string { return title.trim() ? title : DEFAULT_TITLE; } @@ -655,7 +700,7 @@ export function createSaveEngine< try { snapshot = ports.getSnapshot(); } catch (cause) { - failSlot(slot, toSaveError(cause)); + failSlot(slot, toSaveError(cause), readSnapshot(), null); return; } const early = dropReason(slot, snapshot); @@ -671,6 +716,7 @@ export function createSaveEngine< setState(deriveState()); let outcome: SaveOutcome; + let executeStartedAt: number | null = null; try { await ports.slug.settled(); if (disposed) { @@ -717,6 +763,7 @@ export function createSaveEngine< ); if (!isBackgroundIntent(slot.command.kind)) { setState(failureState(slot.command.kind, preparation.error)); + reportFailure(slot, preparation.error, snapshot, null); } drain(); return; @@ -731,6 +778,7 @@ export function createSaveEngine< if (disposed) { return; } + executeStartedAt = Date.now(); outcome = await ports.execute(preparation.prepared, abort.signal); if (disposed) { return; @@ -760,14 +808,19 @@ export function createSaveEngine< drain(); return; } - handleError(slot, snapshot, outcome.error); + handleError( + slot, + snapshot, + outcome.error, + executeStartedAt === null ? null : Date.now() - executeStartedAt, + ); } - function handleError(slot: Slot, snapshot: S, error: SaveError): void { + function handleError(slot: Slot, snapshot: S, error: SaveError, durationMs: number | null): void { const intent = slot.command.kind; if (error.kind === 'session-invalid') { - frozen = { slot, error }; + frozen = { slot, error, persisted: snapshot.id !== null, durationMs }; setState({ kind: 'reauth-pending', intent }); return; } @@ -782,6 +835,7 @@ export function createSaveEngine< settle(dropWaiters, dropped('halted')); settle(slot.waiters, failed(error, intent)); setState({ kind: snapshot.id ? 'halted' : 'crashed' }); + reportFailure(slot, error, snapshot, durationMs); return; } @@ -797,6 +851,7 @@ export function createSaveEngine< settle(dropWaiters, dropped('conflict')); settle(slot.waiters, failed(error, intent)); setState({ kind: 'conflict', intent, error }); + reportFailure(slot, error, snapshot, durationMs); return; } @@ -807,7 +862,7 @@ export function createSaveEngine< ) { hold = { version: snapshot.version, source: 'server', error }; } - failSlot(slot, error); + failSlot(slot, error, snapshot, durationMs); } function captureCommand(kind: DispatchIntent, snapshot: S | null, options?: PublishOptions) { @@ -947,7 +1002,7 @@ export function createSaveEngine< if (!frozen || disposed) { return; } - const { slot, error } = frozen; + const { slot, error, persisted, durationMs } = frozen; frozen = null; settle(slot.waiters, failed(error, slot.command.kind)); const waiters: Waiter[] = []; @@ -960,6 +1015,7 @@ export function createSaveEngine< waiter.resolve(failed(error, waiter.command.kind)); } setState(failureState(slot.command.kind, error)); + report({ command: slot.command, error, persisted, durationMs }); } // A server document that no longer carries the rejected updated_at ends the diff --git a/apps/admin/src/editor/feature-image-caption.tsx b/apps/admin/src/editor/feature-image-caption.tsx index b1e773a1af8..d2d92096580 100644 --- a/apps/admin/src/editor/feature-image-caption.tsx +++ b/apps/admin/src/editor/feature-image-caption.tsx @@ -6,7 +6,7 @@ import { loadKoenig, } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from './card-config'; -import { reportKoenigError } from './report-error'; +import { reportKoenigError, reportKoenigRenderError } from './report-error'; export interface FeatureImageCaptionProps { /** Paragraph-wrapped caption HTML; the editor parses it as a document. */ @@ -76,7 +76,7 @@ export function FeatureImageCaption(props: FeatureImageCaptionProps) { return (

- + diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx index da44ac80c62..5c243f462f1 100644 --- a/apps/admin/src/editor/koenig-post-editor.tsx +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -9,7 +9,7 @@ import { } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from './card-config'; import { editorFileUploader } from './koenig-file-uploader'; -import { reportKoenigError } from './report-error'; +import { reportKoenigError, reportKoenigRenderError } from './report-error'; const NOOP = () => {}; @@ -99,7 +99,7 @@ export const KoenigPostEditor = memo(function KoenigPostEditor(props: KoenigPost return (
- + diff --git a/apps/admin/src/editor/publish/publish.screen.ts b/apps/admin/src/editor/publish/publish.screen.ts index e5a2d6508c8..c1458ff5b4c 100644 --- a/apps/admin/src/editor/publish/publish.screen.ts +++ b/apps/admin/src/editor/publish/publish.screen.ts @@ -37,6 +37,8 @@ const SETTINGS = { /** Publish and update flow locators and gestures for acceptance specs; no assertions. */ export const publishScreen = { root: () => page.getByTestId(publishFlowModal), + closeButton: () => + page.getByTestId(publishFlowModal).getByRole('button', { name: 'Close', exact: true }), options: () => page.getByTestId(publishFlowOptions), confirm: () => page.getByTestId(publishFlowConfirm), complete: () => page.getByTestId(publishFlowComplete), diff --git a/apps/admin/src/editor/publish/use-publish-inputs.component.test.tsx b/apps/admin/src/editor/publish/use-publish-inputs.component.test.tsx index acb0f3119e8..ee5107a1978 100644 --- a/apps/admin/src/editor/publish/use-publish-inputs.component.test.tsx +++ b/apps/admin/src/editor/publish/use-publish-inputs.component.test.tsx @@ -4,6 +4,7 @@ import { renderHook } from 'vitest-browser-react'; import { InAppProviders, fakeAdminEndpoint, newsletter } from '@test-utils/acceptance'; import { usePublishInputs } from '@/editor/publish/use-publish-inputs'; +import { useEditorSettings } from '@/editor/use-editor-settings'; const pagination = (pageNumber = 1, pages = 1) => ({ page: pageNumber, @@ -62,6 +63,76 @@ function fakeMemberCount(total: number, status = 200) { } describe('usePublishInputs', () => { + it('keeps valid inputs ready while settings refresh in the background', async () => { + fakeBoundaryInputs(); + fakeNewsletters(); + fakeMemberCount(20); + const hook = await renderHook( + () => ({ + ...usePublishInputs(), + refetchSettings: useEditorSettings().refetch, + }), + { wrapper: InAppProviders }, + ); + await expect.poll(() => hook.result.current.isReady).toBe(true); + + let releaseSettings: () => void = () => {}; + const settingsHeld = new Promise((resolve) => { + releaseSettings = resolve; + }); + const refreshedSettings = fakeAdminEndpoint('GET', /^\/settings\/\?/, async () => { + await settingsHeld; + return { + settings: [ + { key: 'members_signup_access', value: 'none' }, + { key: 'editor_default_email_recipients', value: 'visibility' }, + { key: 'timezone', value: 'Etc/UTC' }, + ], + }; + }); + + try { + await hook.act(() => { + void hook.result.current.refetchSettings(); + }); + await expect.poll(() => refreshedSettings.requests.length).toBe(1); + + expect(hook.result.current.isReady).toBe(true); + expect(hook.result.current.site.membersEnabled).toBe(true); + expect(hook.result.current.error).toBeNull(); + } finally { + releaseSettings(); + } + + await expect.poll(() => hook.result.current.site.membersEnabled).toBe(false); + expect(hook.result.current.isReady).toBe(true); + }); + + it.each(['failed', 'invalid'] as const)( + 'blocks previously ready inputs after a %s settings refresh', + async (response) => { + fakeBoundaryInputs(); + fakeNewsletters(); + fakeMemberCount(20); + const hook = await renderHook(() => usePublishInputs(), { wrapper: InAppProviders }); + await expect.poll(() => hook.result.current.isReady).toBe(true); + + const refreshedSettings = fakeAdminEndpoint( + 'GET', + /^\/settings\/\?/, + response === 'failed' + ? { errors: [{ message: 'Settings are offline' }] } + : { settings: [{ key: 'editor_default_email_recipients', value: 'invalid' }] }, + { status: response === 'failed' ? 500 : 200 }, + ); + await hook.act(() => hook.result.current.retry()); + + await expect.poll(() => refreshedSettings.requests.length).toBe(1); + await expect.poll(() => hook.result.current.error !== null).toBe(true); + expect(hook.result.current.isReady).toBe(false); + }, + ); + it('blocks on a member-count error and becomes ready after retry', async () => { const inputs = fakeBoundaryInputs(); fakeNewsletters(); diff --git a/apps/admin/src/editor/publish/use-publish-inputs.ts b/apps/admin/src/editor/publish/use-publish-inputs.ts index 75956c2d6c9..182ccda4c46 100644 --- a/apps/admin/src/editor/publish/use-publish-inputs.ts +++ b/apps/admin/src/editor/publish/use-publish-inputs.ts @@ -201,8 +201,9 @@ export function usePublishInputs(): PublishInputs { [settingsData, configData, newslettersData, currentUser, memberCount], ); const isLoading = + // Opening the flow refreshes settings for its limit checks. Keep validated + // cached settings usable so its Publish opener can receive focus on close. settingsQuery.isLoading || - settingsQuery.isFetching || configQuery.isLoading || configQuery.isFetching || newslettersQuery.isLoading || diff --git a/apps/admin/src/editor/report-error.test.ts b/apps/admin/src/editor/report-error.test.ts index 9dccdacd995..710964723b1 100644 --- a/apps/admin/src/editor/report-error.test.ts +++ b/apps/admin/src/editor/report-error.test.ts @@ -1,19 +1,57 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as Sentry from '@sentry/react'; -import { reportEditorError, reportKoenigError } from './report-error'; +import { APIError, ServerUnreachableError } from '@tryghost/admin-x-framework/errors'; +import type { SaveCommand, SaveError } from '@/editor/engine/save-engine'; +import type { EditorSaveFailure } from '@/editor/session/editor-session'; +import { + reportEditorError, + reportKoenigError, + reportKoenigRenderError, + reportLeaveConfirmation, + reportSaveFailure, + reportShownAlert, +} from './report-error'; -vi.mock('@sentry/react', () => ({ captureException: vi.fn() })); +vi.mock('@sentry/react', () => ({ captureException: vi.fn(), captureMessage: vi.fn() })); -describe('reportEditorError', () => { - beforeEach(() => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); +const FIELD: SaveCommand = { + kind: 'field', + requiresRevision: false, + requiresReconfirmation: false, +}; - afterEach(() => { - vi.restoreAllMocks(); - vi.mocked(Sentry.captureException).mockClear(); - }); +function failure(overrides: Partial = {}): EditorSaveFailure { + return { + command: FIELD, + error: { kind: 'unknown', message: 'Boom', cause: new Error('Boom') }, + persisted: true, + durationMs: 120, + postId: 'post-1', + status: 'draft', + ...overrides, + }; +} + +const TAGS = { + savePostTask: true, + post_type: 'post', + save_intent: 'field', + save_error_kind: 'unknown', + save_persisted: true, + save_status: 'draft', +}; + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(Sentry.captureException).mockClear(); + vi.mocked(Sentry.captureMessage).mockClear(); +}); + +describe('reportEditorError', () => { it('forwards the error to Sentry with the given context and logs it once', () => { const error = new Error('boom'); @@ -47,3 +85,276 @@ describe('reportEditorError', () => { }); }); }); + +describe('reportKoenigRenderError', () => { + it('tags a boundary crash as Lexical and keeps where in the tree it happened', () => { + window['@tryghost/koenig-lexical'] = { version: '1.2.3' }; + const error = new Error('render exploded'); + + reportKoenigRenderError(error, { componentStack: '\n at KoenigComposer' }); + + expect(Sentry.captureException).toHaveBeenCalledWith(error, { + tags: { lexical: true }, + contexts: { + koenig: { version: '1.2.3' }, + react: { componentStack: '\n at KoenigComposer' }, + }, + }); + }); +}); + +describe('reportSaveFailure', () => { + it('reports the failing request with what it was and which post it was for', () => { + const cause = new Error('Boom'); + + reportSaveFailure(failure({ error: { kind: 'unknown', message: 'Boom', cause } }), 'post'); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith(cause, { + tags: TAGS, + extra: { post_id: 'post-1', duration_ms: 120 }, + }); + // eslint-disable-next-line no-console + expect(console.error).toHaveBeenCalledWith(cause); + }); + + it('builds an error from the message when the failure has no cause', () => { + reportSaveFailure( + failure({ error: { kind: 'unknown', message: 'No record came back' } }), + 'page', + ); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('No record came back'), { + tags: { ...TAGS, post_type: 'page' }, + extra: { post_id: 'post-1', duration_ms: 120 }, + }); + }); + + it.each<[string, SaveCommand['kind']]>([ + ['an autosave', 'autosave'], + ['the timed cycle', 'timed'], + ['a leave save', 'leave'], + ['a publish', 'publish'], + ])('tags a failure from %s with its intent', (_label, kind) => { + reportSaveFailure(failure({ command: { ...FIELD, kind } }), 'post'); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(vi.mocked(Sentry.captureException).mock.calls[0][1]).toMatchObject({ + tags: { save_intent: kind }, + }); + }); + + it('reports a collision with its kind and the persisted status', () => { + const cause = new Error('Saving failed! Someone else is editing this post.'); + + reportSaveFailure( + failure({ + command: { ...FIELD, kind: 'explicit', requiresRevision: true }, + error: { kind: 'conflict', message: cause.message, cause }, + status: 'published', + }), + 'post', + ); + + expect(Sentry.captureException).toHaveBeenCalledWith(cause, { + tags: { + ...TAGS, + save_intent: 'explicit', + save_error_kind: 'conflict', + save_status: 'published', + }, + extra: { post_id: 'post-1', duration_ms: 120 }, + }); + }); + + it('reports an abandoned re-authentication as the session failure it was', () => { + const cause = new Error('Unauthorized'); + + reportSaveFailure( + failure({ error: { kind: 'session-invalid', message: 'Unauthorized', cause } }), + 'post', + ); + + expect(Sentry.captureException).toHaveBeenCalledWith(cause, { + tags: { ...TAGS, save_error_kind: 'session-invalid' }, + extra: { post_id: 'post-1', duration_ms: 120 }, + }); + }); + + it('reports a persisted post that is gone as a message carrying the post id', () => { + reportSaveFailure( + failure({ error: { kind: 'not-found', message: 'Post not found', cause: new Error('404') } }), + 'page', + ); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(Sentry.captureMessage).toHaveBeenCalledTimes(1); + expect(Sentry.captureMessage).toHaveBeenCalledWith('Attempted to edit deleted page', { + tags: { ...TAGS, post_type: 'page', save_error_kind: 'not-found' }, + extra: { post_id: 'post-1' }, + }); + }); + + it('reports a not-found on an unpersisted post as an exception', () => { + const cause = new Error('404'); + + reportSaveFailure( + failure({ + error: { kind: 'not-found', message: 'Post not found', cause }, + persisted: false, + postId: null, + }), + 'post', + ); + + expect(Sentry.captureMessage).not.toHaveBeenCalled(); + expect(Sentry.captureException).toHaveBeenCalledWith(cause, { + tags: { ...TAGS, save_error_kind: 'not-found', save_persisted: false }, + extra: { post_id: null, duration_ms: 120 }, + }); + }); + + it.each<[string, SaveError]>([ + ['a validation failure', { kind: 'validation', message: 'Title is too long' }], + ['a host limit', { kind: 'host-limit', message: 'Upgrade required' }], + [ + 'an unreachable server', + { kind: 'transport', message: 'Unreachable', cause: new ServerUnreachableError() }, + ], + ])('sends nothing for %s', (_label, error) => { + reportSaveFailure(failure({ error }), 'post'); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(Sentry.captureMessage).not.toHaveBeenCalled(); + // eslint-disable-next-line no-console + expect(console.error).not.toHaveBeenCalled(); + }); + + it('reports a failure that took more than two seconds with its timing as well', () => { + const cause = new ServerUnreachableError(); + + reportSaveFailure( + failure({ + command: { + kind: 'publish', + requiresRevision: true, + requiresReconfirmation: true, + target: { status: 'published', publishedAt: null, emailSegment: 'status:free' }, + }, + error: { kind: 'transport', message: 'Unreachable', cause }, + durationMs: 2001, + }), + 'post', + ); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).toHaveBeenCalledWith('Failed Lexical save took > 2s', { + tags: { + ...TAGS, + save_intent: 'publish', + save_error_kind: 'transport', + save_time: 3, + save_revision: true, + email_segment: 'status:free', + }, + extra: { post_id: 'post-1' }, + }); + }); + + it('omits the email segment tag from a slow failure that carried none', () => { + reportSaveFailure(failure({ durationMs: 2001 }), 'post'); + + expect(Sentry.captureException).toHaveBeenCalledTimes(2); + expect(vi.mocked(Sentry.captureException).mock.calls[0][1]).toStrictEqual({ + tags: { ...TAGS, save_time: 3, save_revision: false }, + extra: { post_id: 'post-1' }, + }); + }); + + it('sends no timing for a failure that never reached the transport', () => { + reportSaveFailure(failure({ durationMs: null }), 'post'); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + expect(vi.mocked(Sentry.captureException).mock.calls[0][1]).toMatchObject({ + extra: { post_id: 'post-1', duration_ms: null }, + }); + }); +}); + +describe('reportSaveFailure response tags', () => { + it('tags a failure the server answered with its status', () => { + const cause = new APIError(new Response(null, { status: 500 })); + + reportSaveFailure(failure({ error: { kind: 'unknown', message: 'Boom', cause } }), 'post'); + + expect(Sentry.captureException).toHaveBeenCalledWith(cause, { + tags: { ...TAGS, api_response_status: 500 }, + extra: { post_id: 'post-1', duration_ms: 120 }, + }); + }); + + it('carries no response tags for a failure that never got an answer', () => { + reportSaveFailure( + failure({ error: { kind: 'unknown', message: 'Boom', cause: new Error() } }), + 'post', + ); + + const context = vi.mocked(Sentry.captureException).mock.calls[0][1] as { tags: object }; + expect(Object.keys(context.tags)).not.toContain('api_response_status'); + expect(Object.keys(context.tags)).not.toContain('api_url'); + }); +}); + +describe('reportShownAlert', () => { + it('reports the banner text the writer read with the failure behind it', () => { + const cause = new APIError(new Response(null, { status: 409 })); + + reportShownAlert('Someone else is editing this post.', { + kind: 'conflict', + message: 'Saving failed!', + cause, + }); + + expect(Sentry.captureMessage).toHaveBeenCalledTimes(1); + expect(Sentry.captureMessage).toHaveBeenCalledWith('Someone else is editing this post.', { + tags: { + shown_to_user: true, + source: 'editor-banner', + save_error_kind: 'conflict', + api_response_status: 409, + }, + contexts: { + ghost: { + displayed_message: 'Someone else is editing this post.', + save_error_message: 'Saving failed!', + }, + }, + }); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); +}); + +describe('reportLeaveConfirmation', () => { + it('reports the leave prompt with why the post counted as unsaved', () => { + reportLeaveConfirmation( + { + postId: 'post-1', + status: 'draft', + engineState: 'error', + reasons: ['POST_HAS_ERROR', 'SCRATCH_DIVERGED_FROM_SECONDARY'], + }, + 'post', + ); + + expect(Sentry.captureMessage).toHaveBeenCalledTimes(1); + expect(Sentry.captureMessage).toHaveBeenCalledWith('showing leave editor modal', { + tags: { + post_type: 'post', + save_status: 'draft', + engine_state: 'error', + leave_reasons: 'POST_HAS_ERROR,SCRATCH_DIVERGED_FROM_SECONDARY', + }, + extra: { post_id: 'post-1', reasons: ['POST_HAS_ERROR', 'SCRATCH_DIVERGED_FROM_SECONDARY'] }, + }); + }); +}); diff --git a/apps/admin/src/editor/report-error.ts b/apps/admin/src/editor/report-error.ts index 954d119ddb1..bbebb4afb7b 100644 --- a/apps/admin/src/editor/report-error.ts +++ b/apps/admin/src/editor/report-error.ts @@ -1,10 +1,21 @@ import * as Sentry from '@sentry/react'; +import type { ErrorInfo } from 'react'; +import { APIError, ServerUnreachableError } from '@tryghost/admin-x-framework/errors'; +import type { PostType } from '@/editor/card-config'; +import type { SaveError } from '@/editor/engine/save-engine'; +import type { EditorLeaveConfirmation, EditorSaveFailure } from '@/editor/session/editor-session'; + +type TagValue = boolean | number | string; export interface EditorErrorContext { - tags?: Record; + tags?: Record; + extra?: Record; contexts?: Record>; } +/** A failed save is slow past this; Sentry gets a second event with its timing. */ +const SLOW_SAVE_MS = 2000; + /** * Reports an editor failure. Never rethrown: the editor recovers without losing * what the writer typed. @@ -23,3 +34,107 @@ export function reportKoenigError(error: unknown): void { contexts: { koenig: { version: window['@tryghost/koenig-lexical']?.version } }, }); } + +/** Reports a Koenig instance that crashed its error boundary, with where in the tree. */ +export function reportKoenigRenderError(error: unknown, info: ErrorInfo): void { + reportEditorError(error, { + tags: { lexical: true }, + contexts: { + koenig: { version: window['@tryghost/koenig-lexical']?.version }, + react: { componentStack: info.componentStack }, + }, + }); +} + +function definedTags(tags: Record): Record { + return Object.fromEntries( + Object.entries(tags).filter(([, value]) => value !== undefined), + ) as Record; +} + +/** The response behind a failure, when the transport answered at all. */ +function responseTags(error: SaveError): Record { + const response = error.cause instanceof APIError ? error.cause.response : undefined; + return { + api_response_status: response?.status, + // Sentry drops a tag value longer than 200 characters. + api_url: response?.url ? response.url.slice(0, 200) : undefined, + }; +} + +/** + * Reports a request that settled as failed. Validation, host limits and an + * unreachable server are the writer's or the host's to act on and are not + * reported; every other failure is, once, with what the request was. + */ +export function reportSaveFailure(failure: EditorSaveFailure, postType: PostType): void { + const { command, error, persisted, durationMs, postId, status } = failure; + const tags = definedTags({ + savePostTask: true, + post_type: postType, + save_intent: command.kind, + save_error_kind: error.kind, + save_persisted: persisted, + save_status: status, + ...responseTags(error), + }); + + if (durationMs !== null && durationMs > SLOW_SAVE_MS) { + Sentry.captureException('Failed Lexical save took > 2s', { + tags: definedTags({ + ...tags, + save_time: Math.ceil(durationMs / 1000), + save_revision: command.requiresRevision, + email_segment: command.target?.emailSegment, + }), + extra: { post_id: postId }, + }); + } + + if ( + error.kind === 'validation' || + error.kind === 'host-limit' || + error.cause instanceof ServerUnreachableError + ) { + return; + } + + if (error.kind === 'not-found' && persisted) { + Sentry.captureMessage(`Attempted to edit deleted ${postType}`, { + tags, + extra: { post_id: postId }, + }); + return; + } + + reportEditorError(error.cause ?? new Error(error.message), { + tags, + extra: { post_id: postId, duration_ms: durationMs }, + }); +} + +/** Reports an error banner the writer was shown, by the text they read. */ +export function reportShownAlert(message: string, error: SaveError): void { + Sentry.captureMessage(message, { + tags: definedTags({ + shown_to_user: true, + source: 'editor-banner', + save_error_kind: error.kind, + ...responseTags(error), + }), + contexts: { ghost: { displayed_message: message, save_error_message: error.message } }, + }); +} + +/** Reports a leave the writer had to confirm, with why the post counted as unsaved. */ +export function reportLeaveConfirmation(leave: EditorLeaveConfirmation, postType: PostType): void { + Sentry.captureMessage('showing leave editor modal', { + tags: { + post_type: postType, + save_status: leave.status, + engine_state: leave.engineState, + leave_reasons: leave.reasons.join(','), + }, + extra: { post_id: leave.postId, reasons: leave.reasons }, + }); +} diff --git a/apps/admin/src/editor/session/README.md b/apps/admin/src/editor/session/README.md index d30b250ddb7..ac5c8f4ce85 100644 --- a/apps/admin/src/editor/session/README.md +++ b/apps/admin/src/editor/session/README.md @@ -74,12 +74,19 @@ command in the runnable queue, so navigating away does not wait indefinitely. The live document remains the source of truth: Update enables, the post stays dirty, and leaving requires a save or confirmation. -All saves use the same preparation validator. An incomplete tier pairing, an -over-long meta/social field, an emptied author list, or a newly staged future -publish time holds a background save with a validation blocker. Body autosave, -title and image commits follow the same rule, including an already armed timer -or queued request. The editor explains why changes are waiting even when the -settings panel is closed. A saved future publish time is not itself invalid. +All saves use the same preparation validator. An incomplete tier pairing on a +post that exists, an over-long meta/social field, an emptied author list, or a +newly staged future publish time holds a background save with a validation +blocker. Body autosave, title and image commits follow the same rule, including +an already armed timer or queued request. The editor explains why changes are +waiting even when the settings panel is closed. A saved future publish time is +not itself invalid. + +A post the server has not created yet is not held to the tier rule. Its saves +go ahead with the incomplete pair left out of the write and of the submitted +projection, so the acknowledgement is not authoritative for either field: the +pair stays the writer's edit across the create, whatever visibility and tier +relations the server answered with, and the next complete pair sends both. An explicit save returns a validation failure promptly and shows the save error. Its content stays pending; its publish/schedule/email target is not retained for @@ -238,6 +245,29 @@ references are kept stable across engine events, so body edits need no new React snapshot while the rendered values stay the same. That makes the view suitable for `useSyncExternalStore` and lets it stand in for those values as a dependency. +## What the session reports + +Failures never reach the writer as thrown errors; the session reports them. Every +request that ran and failed is reported once, with the command it ran, the +error, whether the post already had a server id, the post's persisted status, +the id, and how long the request took. Queued work a failure dropped is not +reported on its own. An expired session is reported only when re-authentication +is abandoned, not when it is retried. A leave the writer has to +confirm is reported with the reason codes the tracker holds the post dirty for. +A draft disposed with a title but a slug still derived from the default title is +reported as an error. A throwing subscriber or slug listener is reported as an +error, and so is a slug edit the generator rejected. + +Sentry receives these through the editor's own reporter, with the response +status and URL when the transport answered. Validation failures, host limits and +an unreachable server are not sent: they are the writer's or the host's to act +on. A failed request that took more than two seconds is sent as a second event +with its timing. Every error banner the writer is shown — a failed save, a +collision, a deleted post — is also sent once as a message carrying the text +they read. A Koenig instance that crashes its error boundary is reported as a +Lexical failure. Sentry stays optional: without a DSN the calls are no-ops, and +an error is still logged to the console. + ## The autosave debounce The autosave debounce is a boot value: the session reads it from the config the diff --git a/apps/admin/src/editor/session/access-save.test.ts b/apps/admin/src/editor/session/access-save.test.ts index 202ad0e0c8f..0b9544ac263 100644 --- a/apps/admin/src/editor/session/access-save.test.ts +++ b/apps/admin/src/editor/session/access-save.test.ts @@ -4,11 +4,12 @@ import { record, serializedFields, sessionHarness, + type HarnessHooks, } from '@/editor/session/__test-utils__/session-harness'; const TIERS = [{ id: 'gold' }, { id: 'silver' }]; -function accessSession(visibility: string | null, tiers = TIERS) { +function accessSession(visibility: string | null, hooks: HarnessHooks = {}) { const saved = record({ id: 'post-id', uuid: 'post-uuid', @@ -17,7 +18,7 @@ function accessSession(visibility: string | null, tiers = TIERS) { slug: 'post', status: 'draft', visibility: visibility ?? 'paid', - tiers, + tiers: TIERS, lexical: null, updated_at: LOADED_AT, published_at: null, @@ -33,7 +34,7 @@ function accessSession(visibility: string | null, tiers = TIERS) { }, // Exercise the same serialization as the post/page transports: an unpaired // tier visibility disappears before the API sees it. - { applied: serializedFields, generateSlug: () => Promise.resolve('post') }, + { applied: serializedFields, generateSlug: () => Promise.resolve('post'), ...hooks }, ); } @@ -57,32 +58,95 @@ describe('saving post access', () => { }, ); - it.each([null, 'tiers'])( - 'refuses explicit saves and publishing with an empty tier selection (saved visibility: %s)', - async (visibility) => { - const { session, create, update } = accessSession(visibility); - session.patchFields({ visibility: 'tiers', tiers: [] }); - session.commitField(); - - for (const save of [session.dispatchExplicit, session.dispatchPublish]) { - expect(await save()).toMatchObject({ - kind: 'failed', - error: { kind: 'validation', message: 'Please select at least one tier.' }, - }); - } - expect(create).not.toHaveBeenCalled(); - expect(update).not.toHaveBeenCalled(); - expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [] }); - expect(session.hasUnsavedContent()).toBe(true); - - // Correcting the selection must recover from the validation failure. - session.patchFields({ tiers: [TIERS[0]] }); - expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); - expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [TIERS[0]] }); - expect(session.isDirty()).toBe(false); - session.dispose(); - }, - ); + it('refuses explicit saves and publishing with an empty tier selection on a saved post', async () => { + const { session, update } = accessSession('tiers'); + session.patchFields({ visibility: 'tiers', tiers: [] }); + session.commitField(); + + for (const save of [session.dispatchExplicit, session.dispatchPublish]) { + expect(await save()).toMatchObject({ + kind: 'failed', + error: { kind: 'validation', message: 'Please select at least one tier.' }, + }); + } + expect(update).not.toHaveBeenCalled(); + expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [] }); + expect(session.hasUnsavedContent()).toBe(true); + + // Correcting the selection must recover from the validation failure. + session.patchFields({ tiers: [TIERS[0]] }); + expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); + expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [TIERS[0]] }); + expect(session.isDirty()).toBe(false); + session.dispose(); + }); + + it('creates a new post with an empty tier selection and keeps the pair as the writer’s edit', async () => { + const { session, state, create, update } = accessSession(null); + session.patchFields({ visibility: 'tiers', tiers: [] }); + + expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0][0]).not.toHaveProperty('visibility'); + expect(create.mock.calls[0][0]).not.toHaveProperty('tiers'); + expect(state.acquiredIds).toEqual(['post-id']); + // The server answered with its default and the tiers a read carries; the + // pair was never sent, so neither replaces the writer's choice. + expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [] }); + expect(session.isDirty()).toBe(true); + + // The post exists now, so the same pair holds the next save. + expect(await session.dispatchExplicit()).toMatchObject({ + kind: 'failed', + error: { kind: 'validation', message: 'Please select at least one tier.' }, + }); + expect(update).not.toHaveBeenCalled(); + + session.patchFields({ tiers: [TIERS[0]] }); + expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); + expect(update.mock.calls[0][0]).toMatchObject({ visibility: 'tiers', tiers: [TIERS[0]] }); + expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [TIERS[0]] }); + expect(session.isDirty()).toBe(false); + session.dispose(); + }); + + it('refuses to publish a new post with an empty tier selection', async () => { + const { session, create } = accessSession(null); + session.patchFields({ visibility: 'tiers', tiers: [] }); + + expect(await session.dispatchPublish()).toMatchObject({ + kind: 'failed', + error: { kind: 'validation', message: 'Please select at least one tier.' }, + }); + expect(create).not.toHaveBeenCalled(); + + // A draft create leaves the pair out instead. + expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0][0]).not.toHaveProperty('visibility'); + session.dispose(); + }); + + it('sends a tier picked while the create is in flight together with the visibility', async () => { + const harness = accessSession(null, { + duringSave: () => harness.session.patchFields({ visibility: 'tiers', tiers: [TIERS[0]] }), + }); + const { session, create, update } = harness; + session.patchFields({ visibility: 'tiers', tiers: [] }); + + expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); + expect(create.mock.calls[0][0]).not.toHaveProperty('visibility'); + expect(create.mock.calls[0][0]).not.toHaveProperty('tiers'); + // The visibility was never sent, so the acknowledgement does not move it + // off the tier the writer picked in the meantime. + expect(session.getFields()).toMatchObject({ visibility: 'tiers', tiers: [TIERS[0]] }); + expect(session.isDirty()).toBe(true); + + expect(await session.dispatchExplicit()).toMatchObject({ kind: 'saved' }); + expect(update.mock.calls[0][0]).toMatchObject({ visibility: 'tiers', tiers: [TIERS[0]] }); + expect(session.isDirty()).toBe(false); + session.dispose(); + }); it('lets the server apply untouched access defaults on create', async () => { const { session, create } = accessSession(null); diff --git a/apps/admin/src/editor/session/editor-session.reporting.test.ts b/apps/admin/src/editor/session/editor-session.reporting.test.ts new file mode 100644 index 00000000000..5f500b5a4ea --- /dev/null +++ b/apps/admin/src/editor/session/editor-session.reporting.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SessionExpiredError } from '@tryghost/admin-x-framework/errors'; +import { DEFAULT_TITLE } from '@/editor/engine/save-engine'; +import { + body, + record, + sessionHarness, + updateCollision, +} from '@/editor/session/__test-utils__/session-harness'; +import type { EditorSaveFailure, EditorLeaveConfirmation } from './editor-session'; + +function reporting(...args: Parameters) { + const failures: EditorSaveFailure[] = []; + const leaves: EditorLeaveConfirmation[] = []; + const [options = {}, hooks] = args; + const harness = sessionHarness( + { + ...options, + onSaveFailed: (failure) => failures.push(failure), + onLeaveConfirmed: (leave) => leaves.push(leave), + }, + hooks, + ); + return { ...harness, failures, leaves }; +} + +describe('createEditorSession reporting', () => { + it('reports a failed update with the post id and its persisted status', async () => { + const collision = updateCollision(); + const { session, failures } = reporting( + { record: record({ status: 'published' }) }, + { failUpdateWith: collision }, + ); + session.patchTitle('Edited'); + + await session.dispatchExplicit(); + + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + command: { kind: 'explicit', requiresRevision: true, requiresReconfirmation: false }, + error: { kind: 'conflict', message: collision.message, cause: collision }, + persisted: true, + postId: 'abc123', + status: 'published', + }); + expect(typeof failures[0].durationMs).toBe('number'); + }); + + it('reports a create that answered with nothing as unpersisted', async () => { + const { session, failures, create } = reporting(); + create.mockResolvedValueOnce(undefined as never); + session.patchTitle('New'); + + await session.dispatchExplicit(); + + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + error: { kind: 'unknown', message: 'Couldn’t save this post.' }, + persisted: false, + postId: null, + status: 'draft', + }); + }); + + it('reports an expired session only once re-authentication is abandoned', async () => { + const { session, failures } = reporting( + { record: record() }, + { failUpdateWith: new SessionExpiredError(new Response(null, { status: 401 }), undefined) }, + ); + session.patchTitle('Edited'); + const completion = session.dispatchExplicit(); + await vi.waitFor(() => expect(session.getState().kind).toBe('reauth-pending')); + expect(failures).toEqual([]); + + session.reauthAbandoned(); + await completion; + + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ error: { kind: 'session-invalid' } }); + }); + + it('reports a leave the writer has to confirm with why the post is dirty', async () => { + const { session, leaves } = reporting( + { record: record({ status: 'published' }) }, + { failUpdateWith: updateCollision() }, + ); + session.setBaseline(record().lexical); + session.patchLexical(body('Hello and more')); + await session.dispatchExplicit(); + + expect(await session.leaveRequested()).toBe('confirm'); + + expect(leaves).toEqual([ + { + postId: 'abc123', + status: 'published', + engineState: 'conflict', + reasons: ['POST_HAS_ERROR', 'SCRATCH_DIVERGED_FROM_SECONDARY'], + }, + ]); + }); + + it('reports nothing for a leave decided after the session was disposed', async () => { + const { session, leaves } = reporting( + { record: record({ status: 'published' }) }, + { failUpdateWith: updateCollision() }, + ); + session.patchTitle('Edited'); + await session.dispatchExplicit(); + + const pending = session.leaveRequested(); + session.dispose(); + + expect(await pending).toBe('confirm'); + expect(leaves).toEqual([]); + }); + + it('routes a throwing leave reporter to onError and still answers', async () => { + const onError = vi.fn(); + const { session } = sessionHarness( + { + record: record({ status: 'published' }), + onError, + onLeaveConfirmed: () => { + throw new Error('reporter down'); + }, + }, + { failUpdateWith: updateCollision() }, + ); + session.patchTitle('Edited'); + await session.dispatchExplicit(); + onError.mockClear(); + + expect(await session.leaveRequested()).toBe('confirm'); + + expect(onError).toHaveBeenCalledWith(new Error('reporter down')); + }); + + it('reports nothing for a leave that loses nothing', async () => { + const { session, leaves } = reporting({ record: record() }); + + expect(await session.leaveRequested()).toBe('proceed'); + + expect(leaves).toEqual([]); + }); + + it('reports a draft disposed with a title but an untitled slug', () => { + const onError = vi.fn(); + const { session } = sessionHarness({ + record: record({ title: 'A real title', slug: 'untitled-2' }), + onError, + }); + + session.dispose(); + session.dispose(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Draft post has title set with untitled slug'), { + extra: { slug: 'untitled-2', title: 'A real title' }, + }); + }); + + it.each([ + ['the default title', { title: DEFAULT_TITLE, slug: 'untitled' }], + ['a matching slug', { title: 'A real title', slug: 'a-real-title' }], + ['a published post', { title: 'A real title', slug: 'untitled', status: 'published' as const }], + ])('reports nothing on dispose for %s', (_label, fields) => { + const onError = vi.fn(); + const { session } = sessionHarness({ record: record(fields), onError }); + + session.dispose(); + + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/editor/session/editor-session.ts b/apps/admin/src/editor/session/editor-session.ts index 7c4602e85a5..b6b94b47939 100644 --- a/apps/admin/src/editor/session/editor-session.ts +++ b/apps/admin/src/editor/session/editor-session.ts @@ -14,11 +14,13 @@ import { type SaveCompletion, type ScheduleOptions, type SaveEngineState, + type SaveFailure, type SaveOutcome, type SaveRequest, type SaveResult, } from '@/editor/engine/save-engine'; import type { + ChangeReasonCode, EditablePostPatch, EditablePostProjection, RestoredRevision, @@ -27,6 +29,7 @@ import type { import type { LexicalInput } from '@/editor/engine/lexical-compare'; import { pick } from '@/editor/engine/pick'; import type { PostWriteOptions } from '@tryghost/admin-x-framework/api/post-contract'; +import type { EditorErrorContext } from '@/editor/report-error'; import { toSaveError } from './error-mapping'; import { createSlugPort } from './slug-port'; import { buildSaveSnapshot, type EditorSaveSnapshot } from './snapshot'; @@ -38,6 +41,7 @@ import { identityFor, publishedAtInFuture, settingsFieldError, + tiersIncomplete, validatedFieldsOf, type EditorSettingsPatch, type EditorSettingsFields, @@ -55,6 +59,20 @@ export interface EditorSaveResult extends SaveResult { post: EditorRecord; } +/** A failed request with the post it was for: its server id, if any, and its persisted status. */ +export interface EditorSaveFailure extends SaveFailure { + readonly postId: string | null; + readonly status: PostStatus; +} + +/** A leave the writer has to confirm, with why the tracker holds the post dirty. */ +export interface EditorLeaveConfirmation { + readonly postId: string | null; + readonly status: PostStatus; + readonly engineState: SaveEngineState['kind']; + readonly reasons: ChangeReasonCode[]; +} + /** Fields the engine writes onto the request rather than reading from the live post. */ const AUTHORED_KEYS = ['title', 'slug'] as const; @@ -106,7 +124,11 @@ export interface EditorSessionOptions { transport: EditorSessionTransport; /** Called once the create acknowledges; the caller replaces the URL. */ onIdAcquired: (id: string) => void; - onError: (error: unknown) => void; + onError: (error: unknown, context?: EditorErrorContext) => void; + /** Called once per request that settled as failed. */ + onSaveFailed?: (failure: EditorSaveFailure) => void; + /** Called when a leave request answers `confirm`. */ + onLeaveConfirmed?: (leave: EditorLeaveConfirmation) => void; } /** The state React renders, published together after a session change. */ @@ -223,6 +245,8 @@ export function createEditorSession({ transport, onIdAcquired, onError, + onSaveFailed, + onLeaveConfirmed, }: EditorSessionOptions): EditorSession { let identity: PersistedIdentity = record ? { id: record.id, updatedAt: record.updated_at ?? '' } @@ -415,12 +439,13 @@ export function createEditorSession({ const stopSlugNotifications = machine.subscribe(notifyChanged); // The post validator runs before every save: an explicit tier selection needs a - // tier even on the first save, and an over-long field is not sent. + // tier once the post exists or leaves draft, and an over-long field is not sent. function requestInvalid( request: SaveRequest, projection: EditablePostPatch, ): string | null { - const invalid = settingsFieldError(validatedFieldsOf(live)); + const creatingDraft = request.snapshot.id === null && request.target.status === 'draft'; + const invalid = settingsFieldError(validatedFieldsOf(live), creatingDraft); if (invalid) { return invalid; } @@ -472,10 +497,17 @@ export function createEditorSession({ stageSettingsField(key, live, projection, payload); } } - // The write contract requires the pair even when only one field changed. - // Reads include tier relations for Public and Paid posts too, so switching - // to specific tiers can leave the relation IDs unchanged. - if (live.visibility === 'tiers' && ('visibility' in payload || 'tiers' in payload)) { + if (tiersIncomplete(live)) { + // The transport drops the unpaired pair (post-contract.ts). Kept out of the + // submitted projection too, or the ack rebases the held visibility away. + delete projection.visibility; + delete payload.visibility; + delete projection.tiers; + delete payload.tiers; + } else if (live.visibility === 'tiers' && ('visibility' in payload || 'tiers' in payload)) { + // The write contract requires the pair even when only one field changed. + // Reads include tier relations for Public and Paid posts too, so switching + // to specific tiers can leave the relation IDs unchanged. projection.visibility = live.visibility; payload.visibility = live.visibility; projection.tiers = live.tiers; @@ -562,13 +594,19 @@ export function createEditorSession({ // A matching refetch can make an unsubmitted edit look saved. Preserve // those edits through the rebase, whose fallback base is the latest saved // copy. Submitted fields already have a stable base in the request. - const unsubmittedEdits = Object.fromEntries( + const unsubmittedEdits: EditablePostPatch = Object.fromEntries( SETTINGS_FIELD_KEYS.filter( (key) => prepared.projection[key] === undefined && (writerEdits.get(key) ?? 0) > prepared.builtAtVersion, ).map((key) => [key, live[key]]), ); + // A pair left out of the write was never acknowledged; its empty tier list + // equals a new post's saved one, so the rebase would take the server's relations. + if (prepared.projection.visibility === undefined && tiersIncomplete(live)) { + unsubmittedEdits.visibility = live.visibility; + unsubmittedEdits.tiers = live.tiers; + } const acknowledged = projectionOf(result.post); tracker.saveAcknowledged(result.id, prepared.projection, acknowledged); tracker.setLive(result.id, unsubmittedEdits); @@ -614,6 +652,7 @@ export function createEditorSession({ notifyChanged(); }, onListenerError: onError, + onSaveFailed: (failure) => onSaveFailed?.({ ...failure, postId: identity.id, status }), }); // Seed the external-store snapshot before the session is handed to React. @@ -836,9 +875,38 @@ export function createEditorSession({ reauthSucceeded: () => engine.reauthSucceeded(), reauthAbandoned: () => engine.reauthAbandoned(), - leaveRequested: () => engine.leaveRequested(), + leaveRequested: async () => { + const decision = await engine.leaveRequested(); + if (decision === 'confirm' && !disposed) { + try { + onLeaveConfirmed?.({ + postId: identity.id, + status, + engineState: engine.getState().kind, + reasons: tracker.verdict().reasons.map((reason) => reason.code), + }); + } catch (error) { + onError(error); + } + } + return decision; + }, dispose: () => { + if (disposed) { + return; + } + // A draft leaving with a title but a slug still derived from the default title. + if ( + status === 'draft' && + live.slug.includes('untitled') && + live.title.trim() && + live.title !== DEFAULT_TITLE + ) { + onError(new Error('Draft post has title set with untitled slug'), { + extra: { slug: live.slug, title: live.title }, + }); + } disposed = true; pendingSlugEdits.clear(); stopSlugNotifications(); diff --git a/apps/admin/src/editor/session/session-banners.test.tsx b/apps/admin/src/editor/session/session-banners.test.tsx index f87ec9a3b19..169ca95ee06 100644 --- a/apps/admin/src/editor/session/session-banners.test.tsx +++ b/apps/admin/src/editor/session/session-banners.test.tsx @@ -1,10 +1,14 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { editorConflictReloadConfirm } from '@tryghost/test-data/selectors/editor'; import { toast } from 'sonner'; import type { PendingSave, SaveEngineState, SaveError } from '@/editor/engine/save-engine'; +import { reportShownAlert } from '@/editor/report-error'; import { SessionBanners } from './session-banners'; import type { ReloadOutcome } from './use-editor-session'; +vi.mock('@/editor/report-error', () => ({ reportShownAlert: vi.fn() })); + const noop = () => undefined; interface BannerOverrides { @@ -29,11 +33,8 @@ function renderBanners(state: SaveEngineState, overrides: BannerOverrides = {}) ); } -const CONFLICT: SaveEngineState = { - kind: 'conflict', - intent: 'autosave', - error: { kind: 'conflict', message: 'Someone else got there first.' }, -}; +const CONFLICT_ERROR: SaveError = { kind: 'conflict', message: 'Someone else got there first.' }; +const CONFLICT: SaveEngineState = { kind: 'conflict', intent: 'autosave', error: CONFLICT_ERROR }; function errored(error: Partial): SaveEngineState { return { @@ -228,3 +229,98 @@ describe('SessionBanners', () => { }, ); }); + +describe('SessionBanners reporting', () => { + beforeEach(() => { + vi.mocked(reportShownAlert).mockClear(); + }); + + it('reports a failed-save banner once, by the text shown, not per render', () => { + const error: SaveError = { kind: 'transport', message: 'Offline' }; + const state = errored(error); + const { rerender } = renderBanners(state); + rerender( + ''} + hasUnsavedContent={() => false} + state={state} + onDismissReauth={noop} + onReload={() => Promise.resolve('reloaded')} + onRetryReauth={noop} + onRetrySave={noop} + />, + ); + + expect(reportShownAlert).toHaveBeenCalledTimes(1); + expect(reportShownAlert).toHaveBeenCalledWith( + 'Couldn’t reach the server. Your changes are still here.', + error, + ); + }); + + it('reports a collision held on a pending save once across re-renders', () => { + const blockedBy: SaveError = { kind: 'conflict', message: 'Another writer changed this post.' }; + const { rerender } = renderBanners({ kind: 'idle' }, { pendingSave: { blockedBy } }); + // The session republishes a fresh pending-save wrapper around the same error. + rerender( + ''} + hasUnsavedContent={() => false} + pendingSave={{ blockedBy }} + state={{ kind: 'idle' }} + onDismissReauth={noop} + onReload={() => Promise.resolve('reloaded')} + onRetryReauth={noop} + onRetrySave={noop} + />, + ); + + expect(reportShownAlert).toHaveBeenCalledTimes(1); + expect(reportShownAlert).toHaveBeenCalledWith(expect.any(String), blockedBy); + }); + + it('reports a collision banner with the collision behind it', () => { + renderBanners(CONFLICT); + + expect(reportShownAlert).toHaveBeenCalledTimes(1); + expect(reportShownAlert).toHaveBeenCalledWith( + expect.stringContaining('Someone else is editing this post'), + CONFLICT_ERROR, + ); + }); + + it('reports a deleted-post banner as a not-found', () => { + renderBanners({ kind: 'halted' }); + + expect(reportShownAlert).toHaveBeenCalledTimes(1); + expect(reportShownAlert).toHaveBeenCalledWith( + expect.stringContaining('This post has been deleted'), + expect.objectContaining({ kind: 'not-found' }), + ); + }); + + it('reports the deleted-post banner a reload reveals', async () => { + renderBanners(CONFLICT, { onReload: () => Promise.resolve('gone') }); + fireEvent.click(screen.getByRole('button', { name: 'Reload' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('has been deleted')); + + expect(reportShownAlert).toHaveBeenCalledTimes(2); + expect(vi.mocked(reportShownAlert).mock.calls[1][0]).toContain('This post has been deleted'); + }); + + it.each<[string, SaveEngineState, PendingSave | undefined]>([ + ['idle', { kind: 'idle' }, undefined], + ['saving', { kind: 'saving', intent: 'autosave' }, undefined], + ['an expired session', { kind: 'reauth-pending', intent: 'explicit' }, undefined], + [ + 'a held validation', + { kind: 'idle' }, + { blockedBy: { kind: 'validation', message: 'At least one author is required.' } }, + ], + ])('reports nothing for %s', (_label, state, pendingSave) => { + renderBanners(state, { pendingSave }); + + expect(reportShownAlert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/editor/session/session-banners.tsx b/apps/admin/src/editor/session/session-banners.tsx index bd33e82c493..fa0536adbf5 100644 --- a/apps/admin/src/editor/session/session-banners.tsx +++ b/apps/admin/src/editor/session/session-banners.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { AlertDialog, @@ -21,6 +21,7 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PendingSave, SaveError, SaveEngineState } from '@/editor/engine/save-engine'; import { EDITOR_CONFIRM_DIALOG_LAYER } from '@/editor/layering'; +import { reportShownAlert } from '@/editor/report-error'; import type { ReloadOutcome } from './use-editor-session'; const SESSION_EXPIRED = 'Your session expired. Sign in again in a new tab, then retry.'; @@ -28,6 +29,8 @@ const CONFLICT = 'Someone else is editing this post. Reloading replaces what you have with their version, so copy your content first if you need it.'; const GONE = 'This post has been deleted. Copy your content and paste it into a new post to keep it.'; +// A halt carries no error of its own; the banner reports it as the not-found it is. +const NOT_FOUND: SaveError = { kind: 'not-found', message: GONE }; export interface SessionBannersProps { state: SaveEngineState; @@ -51,10 +54,20 @@ function saveErrorMessage(error: SaveError): string { } } +// Once per banner the writer reads, not per render of it. +function useShownAlert(message: string | null, error: SaveError | null): void { + useEffect(() => { + if (message !== null && error !== null) { + reportShownAlert(message, error); + } + }, [message, error]); +} + type ConflictBannerProps = Pick< SessionBannersProps, 'hasUnsavedContent' | 'contentText' | 'onReload' > & { + error: SaveError; deleted?: boolean; }; @@ -62,12 +75,14 @@ function ConflictBanner({ hasUnsavedContent, contentText, onReload, + error, deleted = false, }: ConflictBannerProps) { const [confirming, setConfirming] = useState(false); const [reloading, setReloading] = useState(false); const [reloadFoundDeleted, setReloadFoundDeleted] = useState(false); const gone = deleted || reloadFoundDeleted; + useShownAlert(gone ? GONE : CONFLICT, gone ? NOT_FOUND : error); const reload = async () => { setConfirming(false); @@ -164,6 +179,9 @@ export function SessionBanners({ onRetrySave, onReload, }: SessionBannersProps) { + const saveError = state.kind === 'error' ? state.error : null; + useShownAlert(saveError && saveErrorMessage(saveError), saveError); + if (state.kind === 'reauth-pending') { return ( diff --git a/apps/admin/src/editor/session/settings-fields.test.ts b/apps/admin/src/editor/session/settings-fields.test.ts index c8f889c2fee..4a6e60ceb5d 100644 --- a/apps/admin/src/editor/session/settings-fields.test.ts +++ b/apps/admin/src/editor/session/settings-fields.test.ts @@ -17,6 +17,7 @@ import { overLength, settingsFieldError, settingsFieldErrorFor, + tiersIncomplete, validatedFieldsOf, type ValidatedSettingsFieldKey, type ValidatedSettingsFields, @@ -59,65 +60,87 @@ describe('validatedFieldsOf', () => { const withoutMetaTitle = { ...validated }; delete (withoutMetaTitle as Partial).meta_title; - expect(settingsFieldError(validated)).toBe(META_TITLE_TOO_LONG); - expect(settingsFieldError(withoutMetaTitle)).toBeNull(); + expect(settingsFieldError(validated, false)).toBe(META_TITLE_TOO_LONG); + expect(settingsFieldError(withoutMetaTitle, false)).toBeNull(); }); }); describe('settingsFieldError', () => { it('passes fields that break no rule', () => { - expect(settingsFieldError(VALID)).toBeNull(); + expect(settingsFieldError(VALID, false)).toBeNull(); }); - it('refuses specific-tier access without a tier', () => { - expect(settingsFieldError({ ...VALID, visibility: 'tiers' })).toBe(TIERS_REQUIRED); + it('refuses specific-tier access without a tier on a post that exists', () => { + expect(settingsFieldError({ ...VALID, visibility: 'tiers' }, false)).toBe(TIERS_REQUIRED); }); - it('refuses a meta title past the column width', () => { - expect(settingsFieldError({ ...VALID, meta_title: 'a'.repeat(META_TITLE_MAX) })).toBeNull(); - expect(settingsFieldError({ ...VALID, meta_title: 'a'.repeat(META_TITLE_MAX + 1) })).toBe( + it('lets a new post keep specific-tier access without a tier', () => { + expect(settingsFieldError({ ...VALID, visibility: 'tiers' }, true)).toBeNull(); + // The pair is still incomplete: the section asks for a tier either way. + expect(tiersIncomplete({ ...VALID, visibility: 'tiers' })).toBe(true); + }); + + it('holds a new post to every other rule', () => { + expect(settingsFieldError({ ...VALID, meta_title: 'a'.repeat(META_TITLE_MAX + 1) }, true)).toBe( META_TITLE_TOO_LONG, ); }); + it('refuses a meta title past the column width', () => { + expect( + settingsFieldError({ ...VALID, meta_title: 'a'.repeat(META_TITLE_MAX) }, false), + ).toBeNull(); + expect( + settingsFieldError({ ...VALID, meta_title: 'a'.repeat(META_TITLE_MAX + 1) }, false), + ).toBe(META_TITLE_TOO_LONG); + }); + it('refuses a meta description past the column width', () => { expect( - settingsFieldError({ ...VALID, meta_description: 'a'.repeat(META_DESCRIPTION_MAX) }), + settingsFieldError({ ...VALID, meta_description: 'a'.repeat(META_DESCRIPTION_MAX) }, false), ).toBeNull(); expect( - settingsFieldError({ ...VALID, meta_description: 'a'.repeat(META_DESCRIPTION_MAX + 1) }), + settingsFieldError( + { ...VALID, meta_description: 'a'.repeat(META_DESCRIPTION_MAX + 1) }, + false, + ), ).toBe(META_DESCRIPTION_TOO_LONG); }); it('refuses a Facebook title past the column width', () => { - expect(settingsFieldError({ ...VALID, og_title: 'a'.repeat(OG_TITLE_MAX) })).toBeNull(); - expect(settingsFieldError({ ...VALID, og_title: 'a'.repeat(OG_TITLE_MAX + 1) })).toBe( + expect(settingsFieldError({ ...VALID, og_title: 'a'.repeat(OG_TITLE_MAX) }, false)).toBeNull(); + expect(settingsFieldError({ ...VALID, og_title: 'a'.repeat(OG_TITLE_MAX + 1) }, false)).toBe( OG_TITLE_TOO_LONG, ); }); it('refuses a Facebook description past the column width', () => { expect( - settingsFieldError({ ...VALID, og_description: 'a'.repeat(OG_DESCRIPTION_MAX) }), + settingsFieldError({ ...VALID, og_description: 'a'.repeat(OG_DESCRIPTION_MAX) }, false), ).toBeNull(); expect( - settingsFieldError({ ...VALID, og_description: 'a'.repeat(OG_DESCRIPTION_MAX + 1) }), + settingsFieldError({ ...VALID, og_description: 'a'.repeat(OG_DESCRIPTION_MAX + 1) }, false), ).toBe(OG_DESCRIPTION_TOO_LONG); }); it('refuses an X title past the column width', () => { - expect(settingsFieldError({ ...VALID, twitter_title: 'a'.repeat(X_TITLE_MAX) })).toBeNull(); - expect(settingsFieldError({ ...VALID, twitter_title: 'a'.repeat(X_TITLE_MAX + 1) })).toBe( - X_TITLE_TOO_LONG, - ); + expect( + settingsFieldError({ ...VALID, twitter_title: 'a'.repeat(X_TITLE_MAX) }, false), + ).toBeNull(); + expect( + settingsFieldError({ ...VALID, twitter_title: 'a'.repeat(X_TITLE_MAX + 1) }, false), + ).toBe(X_TITLE_TOO_LONG); }); it('refuses an X description past the column width', () => { expect( - settingsFieldError({ ...VALID, twitter_description: 'a'.repeat(X_DESCRIPTION_MAX) }), + settingsFieldError({ ...VALID, twitter_description: 'a'.repeat(X_DESCRIPTION_MAX) }, false), ).toBeNull(); expect( - settingsFieldError({ ...VALID, twitter_description: 'a'.repeat(X_DESCRIPTION_MAX + 1) }), + settingsFieldError( + { ...VALID, twitter_description: 'a'.repeat(X_DESCRIPTION_MAX + 1) }, + false, + ), ).toBe(X_DESCRIPTION_TOO_LONG); }); @@ -173,7 +196,7 @@ describe('settingsFieldErrorFor', () => { for (const [key, fields] of overLimit) { expect(settingsFieldErrorFor(key, fields)).not.toBeNull(); - expect(settingsFieldErrorFor(key, fields)).toBe(settingsFieldError(fields)); + expect(settingsFieldErrorFor(key, fields)).toBe(settingsFieldError(fields, false)); } }); }); diff --git a/apps/admin/src/editor/session/settings-fields.ts b/apps/admin/src/editor/session/settings-fields.ts index 2bc664ea8a7..bd4bf1f2d0d 100644 --- a/apps/admin/src/editor/session/settings-fields.ts +++ b/apps/admin/src/editor/session/settings-fields.ts @@ -93,7 +93,7 @@ export const OG_DESCRIPTION_TOO_LONG = `Facebook description cannot be longer th export const X_TITLE_TOO_LONG = `X title cannot be longer than ${X_TITLE_MAX} characters.`; export const X_DESCRIPTION_TOO_LONG = `X description cannot be longer than ${X_DESCRIPTION_MAX} characters.`; -/** `visibility: 'tiers'` with no tiers: the write contract drops the visibility. */ +/** `visibility: 'tiers'` with no tiers: the write contract drops the pair. */ export function tiersIncomplete( fields: Pick, ): boolean { @@ -172,9 +172,16 @@ export function settingsFieldErrorFor( return overLength(fields[key], max) ? message : null; } -/** The first rule the settings fields break, in the post validator's order. */ -export function settingsFieldError(fields: ValidatedSettingsFields): string | null { +/** + * The first rule the settings fields break, in the post validator's order. A + * post the server has not created yet is not held to the tier rule + * (validators/post.js `isNew`); its write leaves the pair out instead. + */ +export function settingsFieldError(fields: ValidatedSettingsFields, isNew: boolean): string | null { for (const key of VALIDATED_SETTINGS_FIELD_KEYS) { + if (isNew && key === 'tiers') { + continue; + } const error = settingsFieldErrorFor(key, fields); if (error) { return error; diff --git a/apps/admin/src/editor/session/use-editor-session.test.tsx b/apps/admin/src/editor/session/use-editor-session.test.tsx index 4c797cb07fc..562375382f0 100644 --- a/apps/admin/src/editor/session/use-editor-session.test.tsx +++ b/apps/admin/src/editor/session/use-editor-session.test.tsx @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReactNode } from 'react'; import { dispatchedIntents } from './__test-utils__/save-engine-spy'; import { record } from './__test-utils__/session-harness'; +import { reportLeaveConfirmation, reportSaveFailure } from '@/editor/report-error'; import type { EditorRecord } from './projection'; import { useEditorSession } from './use-editor-session'; @@ -22,6 +23,12 @@ vi.mock('@tryghost/admin-x-framework', () => ({ useLocation: () => ({ key: 'editor', state: null }), })); +vi.mock('@/editor/report-error', () => ({ + reportEditorError: vi.fn(), + reportLeaveConfirmation: vi.fn(), + reportSaveFailure: vi.fn(), +})); + // The real hooks hand back one stable function per mount; a fresh mock per // render would make the handle churn for a reason the hook does not own. const stable = vi.hoisted(() => ({ fetchApi: vi.fn(), generateSlug: vi.fn() })); @@ -163,3 +170,38 @@ describe('useEditorSession title blur', () => { ); }); }); + +describe('useEditorSession reporting', () => { + beforeEach(() => { + vi.mocked(reportSaveFailure).mockClear(); + vi.mocked(reportLeaveConfirmation).mockClear(); + }); + + it('reports a failed save for the post type the session edits', async () => { + const { result } = setup(); + act(() => result.current.bind.onTitleChange('A new title')); + + await act(() => result.current.saveExplicit()); + + expect(reportSaveFailure).toHaveBeenCalledTimes(1); + const [failure, postType] = vi.mocked(reportSaveFailure).mock.calls[0]; + expect(failure).toMatchObject({ postId: 'abc123', persisted: true, status: 'draft' }); + expect(postType).toBe('post'); + }); + + it('reports a leave the writer has to confirm', async () => { + const { result } = setup(); + act(() => result.current.bind.onTitleChange('A new title')); + await act(() => result.current.saveExplicit()); + + await act(async () => { + await expect(result.current.leaveRequested()).resolves.toBe('confirm'); + }); + + expect(reportLeaveConfirmation).toHaveBeenCalledTimes(1); + const [leave, postType] = vi.mocked(reportLeaveConfirmation).mock.calls[0]; + expect(leave).toMatchObject({ postId: 'abc123' }); + expect(leave.reasons).toContain('POST_HAS_ERROR'); + expect(postType).toBe('post'); + }); +}); diff --git a/apps/admin/src/editor/session/use-editor-session.ts b/apps/admin/src/editor/session/use-editor-session.ts index 4f5aca05af8..5fed71c2655 100644 --- a/apps/admin/src/editor/session/use-editor-session.ts +++ b/apps/admin/src/editor/session/use-editor-session.ts @@ -34,7 +34,11 @@ import { import type { RestoredRevision } from '@/editor/engine/change-tracker'; import type { LexicalInput } from '@/editor/engine/lexical-compare'; import type { PostType } from '@/editor/card-config'; -import { reportEditorError } from '@/editor/report-error'; +import { + reportEditorError, + reportLeaveConfirmation, + reportSaveFailure, +} from '@/editor/report-error'; import { contentToText } from './content-text'; import { createEditorSession, @@ -207,6 +211,8 @@ export function useEditorSession({ autosaveDebounceMs: () => autosaveDebounceMs.current, onIdAcquired: setPersistedId, onError: reportEditorError, + onSaveFailed: (failure) => reportSaveFailure(failure, postType), + onLeaveConfirmed: (leave) => reportLeaveConfirmation(leave, postType), transport: { create: async (payload: EditorCreatePayload) => { const current = transport.current; diff --git a/apps/admin/src/editor/settings/README.md b/apps/admin/src/editor/settings/README.md index f58280d11d6..bcbc4201e69 100644 --- a/apps/admin/src/editor/settings/README.md +++ b/apps/admin/src/editor/settings/README.md @@ -191,12 +191,16 @@ selection, and a tier ID without type metadata is preserved. A failed tier lookup shows an error and a Retry action in place of the list. An empty tier selection is staged like any other edit but never sent: the -section asks for at least one tier, and while the pairing is incomplete no field -save runs and a save the writer asks for is refused with the same message. -Because the pairing is staged rather than held in the panel, it survives closing -the sidebar, enables Update and is what the leave guard asks about. A create -with untouched access settings still uses the server default; an explicit tier -selection must include a tier even on the first save. +section asks for at least one tier. On a post that exists, no field save runs +while the pairing is incomplete and a save the writer asks for is refused with +the same message. Because the pairing is staged rather than held in the panel, +it survives closing the sidebar, enables Update and is what the leave guard asks +about. A post the server has not created yet is not held to the rule: the +section stages the incomplete pair without a save of its own, content saves go +ahead with the pair left out, and the pair stays the writer's edit across the +create, so the section keeps asking for a tier and the first tier picked sends +visibility and tiers together. A create with untouched access settings uses the +server default. When either access field changes to specific tiers, the save submits both visibility and the tier list, including tier IDs the writer never touched. A diff --git a/apps/admin/src/editor/settings/access-section.tsx b/apps/admin/src/editor/settings/access-section.tsx index c50287c9536..e2f419f92e6 100644 --- a/apps/admin/src/editor/settings/access-section.tsx +++ b/apps/admin/src/editor/settings/access-section.tsx @@ -21,8 +21,12 @@ import type { PostType } from '@/editor/card-config'; import { PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { useEditorSettings } from '@/editor/use-editor-settings'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import { TIERS_REQUIRED, tiersIncomplete } from '@/editor/session/settings-fields'; -import type { EditorSettingsPort } from './editor-settings-port'; +import { + TIERS_REQUIRED, + tiersIncomplete, + type EditorSettingsFields, +} from '@/editor/session/settings-fields'; +import { type EditorSettingsPort, isNewPost } from './editor-settings-port'; import { SectionLoadError } from './section-load-error'; import { SettingsSection } from './settings-section'; import { @@ -132,9 +136,19 @@ export function AccessSection({ session, postType }: AccessSectionProps) { }, [fetchNextPage, hasNextPage, isFetchingNextPage, tiersFailed]); const options = hasNextPage ? [] : tierOptions(tiersData?.tiers); + // An incomplete pair is left out of every write. On a post the server has not + // created yet it is staged without a save of its own: that write would carry nothing. + const editAccess = (patch: Pick) => { + if (tiersIncomplete(patch) && isNewPost(session)) { + session.stageSettings(patch); + return; + } + session.editSettings(patch); + }; + // Leaving `tiers` clears the tiers it granted, as the tier pickers do. const changeVisibility = (next: string) => - session.editSettings({ + editAccess({ visibility: next, tiers: next === 'tiers' ? postTiers(session.settings.tiers) : [], }); @@ -144,7 +158,7 @@ export function AccessSection({ session, postType }: AccessSectionProps) { if (!next.delete(id)) { next.add(id); } - session.editSettings({ visibility: 'tiers', tiers: tiersFromSelection(options, next) }); + editAccess({ visibility: 'tiers', tiers: tiersFromSelection(options, next) }); }; return ( diff --git a/apps/admin/src/editor/settings/editor-settings-port.ts b/apps/admin/src/editor/settings/editor-settings-port.ts index 3a014a286b7..754d7333089 100644 --- a/apps/admin/src/editor/settings/editor-settings-port.ts +++ b/apps/admin/src/editor/settings/editor-settings-port.ts @@ -23,6 +23,13 @@ export type EditorSettingsPort = Pick< bind: Pick; }; +/** A post the server has not created yet: nothing loaded and no id acquired. */ +export function isNewPost( + session: Pick, +): boolean { + return !session.loadedRecord && session.createdId === null; +} + /** The port's identity tracks the members it carries, not the render that produced it. */ export function useEditorSettingsPort(session: EditorSessionHandle): EditorSettingsPort { const { title, excerpt, onExcerptChange } = session.bind; diff --git a/apps/admin/src/editor/settings/revision-preview.tsx b/apps/admin/src/editor/settings/revision-preview.tsx index b1e10db7f12..a0d874ebb5e 100644 --- a/apps/admin/src/editor/settings/revision-preview.tsx +++ b/apps/admin/src/editor/settings/revision-preview.tsx @@ -17,7 +17,7 @@ import { } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from '@/editor/card-config'; import { editorFileUploader } from '@/editor/koenig-file-uploader'; -import { reportKoenigError } from '@/editor/report-error'; +import { reportKoenigError, reportKoenigRenderError } from '@/editor/report-error'; import type { RevisionEntry } from './post-history'; /** The part of Lexical's editor the loader's minimal instance type leaves out. */ @@ -95,7 +95,7 @@ export function RevisionPreview({ return (
- + diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index cd8cf7ca013..554c33ae5e3 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -72,6 +72,43 @@ let useEmberFeatureFlag: typeof import('./ember-bridge').useEmberFeatureFlag; let useSidebarVisibility: typeof import('./ember-bridge').useSidebarVisibility; let useEmberRouting: typeof import('./ember-bridge').useEmberRouting; +describe('syncEmberPostListQueryParams', () => { + baseTest('delivers filters when Ember loads after the React list', async () => { + vi.useFakeTimers(); + const { syncEmberPostListQueryParams } = await import('./ember-bridge'); + const stop = syncEmberPostListQueryParams('posts', { tag: 'news', order: 'title asc' }); + const mock = createMockStateBridge(); + const setPostListQueryParams = vi.fn(); + mock.stateBridge.setPostListQueryParams = setPostListQueryParams; + window.EmberBridge = { state: mock.stateBridge }; + + await vi.advanceTimersByTimeAsync(100); + + expect(setPostListQueryParams).toHaveBeenCalledWith('posts', { + tag: 'news', + order: 'title asc', + }); + stop(); + }); + + baseTest('cancels stale pending filters when the list changes', async () => { + vi.useFakeTimers(); + const { syncEmberPostListQueryParams } = await import('./ember-bridge'); + const stop = syncEmberPostListQueryParams('posts', { tag: 'news' }); + stop(); + const mock = createMockStateBridge(); + const setPostListQueryParams = vi.fn(); + mock.stateBridge.setPostListQueryParams = setPostListQueryParams; + window.EmberBridge = { state: mock.stateBridge }; + const stopCurrent = syncEmberPostListQueryParams('posts', {}); + + await vi.advanceTimersByTimeAsync(100); + + expect(setPostListQueryParams).toHaveBeenCalledExactlyOnceWith('posts', {}); + stopCurrent(); + }); +}); + beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index e4fb6fa3ed2..5a1c7a09707 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -28,6 +28,7 @@ export interface StateBridge { preloadAdminThemeStylesheet?: () => Promise; applyAdminThemePreference?: (mode: AdminThemeMode) => Promise | void; navigateToBillingSubRoute?: (subRoute: string) => void; + setPostListQueryParams?: (resource: 'posts' | 'pages', params: Record) => void; on( event: K, callback: (event: StateBridgeEventMap[K]) => void, @@ -329,6 +330,16 @@ export function navigateEmberBillingSubRoute(subRoute: string): boolean { return true; } +/** Keep the Ember editor's breadcrumb in sync with the React list. */ +export function syncEmberPostListQueryParams( + resource: 'posts' | 'pages', + params: Record, +): () => void { + return waitForStateBridge((stateBridge) => { + stateBridge.setPostListQueryParams?.(resource, params); + }); +} + /** * React -> Ember handlers for the FrameworkProvider. Feature flag overrides * wait for Ember to load; mutation handlers no-op when the bridge is absent. diff --git a/apps/admin/src/ember-bridge/index.ts b/apps/admin/src/ember-bridge/index.ts index 707fde8177a..ebff744fca3 100644 --- a/apps/admin/src/ember-bridge/index.ts +++ b/apps/admin/src/ember-bridge/index.ts @@ -16,6 +16,7 @@ export { preloadEmberAdminThemeStylesheet, applyEmberAdminThemePreference, navigateEmberBillingSubRoute, + syncEmberPostListQueryParams, emberMutationHandlers, } from './ember-bridge'; export type { diff --git a/apps/admin/src/layout/admin7-design.acceptance.test.tsx b/apps/admin/src/layout/admin7-design.acceptance.test.tsx index 8b8066334cb..f61ef27fe4f 100644 --- a/apps/admin/src/layout/admin7-design.acceptance.test.tsx +++ b/apps/admin/src/layout/admin7-design.acceptance.test.tsx @@ -16,7 +16,7 @@ it.each<{ { name: 'flag absent', route: '/members', labs: {}, enabled: false }, { name: 'flag disabled', route: '/members', labs: { admin7Pill: false }, enabled: false }, { name: 'flag enabled', route: '/members', labs: { admin7Pill: true }, enabled: true }, - { name: 'Ember route excluded', route: '/site', labs: { admin7Pill: true }, enabled: false }, + { name: 'Ember route excluded', route: '/restore', labs: { admin7Pill: true }, enabled: false }, { name: 'Ember editor excluded', route: '/editor/post/new', diff --git a/apps/admin/src/layout/app-sidebar/use-post-navigation.ts b/apps/admin/src/layout/app-sidebar/use-post-navigation.ts index 0a298c27b77..b80580b909b 100644 --- a/apps/admin/src/layout/app-sidebar/use-post-navigation.ts +++ b/apps/admin/src/layout/app-sidebar/use-post-navigation.ts @@ -72,9 +72,10 @@ function useReactPostNavigation(route: PostResource): PostNavigation { const allViews = [...defaultViews, ...customViews]; return { - // Sticky filters: returns you to the filters you last had, unless - // you are already here or those filters are just a view. - mainUrl: getStickyPostFilterUrl(route, location.pathname, viewFilters), + // Posts opens the full list, matching Members. Editor breadcrumbs + // separately preserve the last list's filters. + mainUrl: + route === 'posts' ? route : getStickyPostFilterUrl(route, location.pathname, viewFilters), // Ember highlights the parent only when no view underneath is. isMainActive: location.pathname === `/${route}` && !allViews.some((view) => view.isActive), defaultViews, @@ -99,7 +100,7 @@ function useEmberPostNavigation(route: PostResource): PostNavigation { : []; return { - mainUrl: routing.getRouteUrl(route), + mainUrl: route === 'posts' ? route : routing.getRouteUrl(route), isMainActive: routing.isRouteActive(route), defaultViews, customViews: sharedViews.map((view) => { diff --git a/apps/admin/src/layout/sidebar.acceptance.test.tsx b/apps/admin/src/layout/sidebar.acceptance.test.tsx index bfb111fc261..bf1f8698b7b 100644 --- a/apps/admin/src/layout/sidebar.acceptance.test.tsx +++ b/apps/admin/src/layout/sidebar.acceptance.test.tsx @@ -8,6 +8,8 @@ import { currentRoute, fakeAdminEndpoint, fakeEndpoint, + fakePosts, + fakePostsListScreen, fakeTags, renderAdminApp, currentUserResponse, @@ -15,6 +17,8 @@ import { type RenderAdminAppOptions, } from '@test-utils/acceptance'; import { sidebarScreen } from './sidebar.screen'; +import { postsListScreen } from '@/posts/list/posts-list.screen'; +import { clearStickyPostFilters } from '@/posts/list/posts-sticky-filters'; // The site fixture's URL roots the ActivityPub API (see use-activity-pub-queries.ts). const UNREAD_COUNT_URL = 'http://test.com/.ghost/activitypub/v1/notifications/unread/count'; @@ -53,6 +57,7 @@ function installStaleEmberRoute(activeRoute: 'members-activity' | 'pages' | 'pos afterEach(() => { delete window.EmberBridge; + clearStickyPostFilters(); }); describe('Sidebar navigation', () => { @@ -112,6 +117,8 @@ describe('Sidebar navigation', () => { it('keeps the boot loader visible until React commits its mount marker', async () => { await renderAdminApp('/site'); + // `/site` is a lazy route, so the shell commits after its chunk loads. + await expect.element(sidebarScreen.shellNav()).toBeVisible(); const marker = document.querySelector('[data-react-admin-mounted]')!; const emberApp = document.getElementById('ember-app')!; @@ -186,6 +193,38 @@ describe('Sidebar navigation', () => { await expect.poll(currentRoute).toBe('/pages'); }); + it.each([false, true])( + 'clears Posts filters and sorting after leaving the list: %s', + async (leaveList) => { + fakePostsListScreen(); + fakePosts([]); + await renderAdminApp('/posts?tag=news&order=title+asc', { labs: { postsListReact: true } }); + await expect.element(postsListScreen.filterBar()).toHaveTextContent('Unknown tag'); + + if (leaveList) { + await sidebarScreen.navLink('Tags').click(); + await expect.poll(currentRoute).toBe('/tags'); + } + + await expect.element(sidebarScreen.navLink('Posts')).toHaveAttribute('href', '#/posts'); + await sidebarScreen.navLink('Posts').click(); + + await expect.poll(currentRoute).toBe('/posts'); + await expect.element(postsListScreen.filterBar()).not.toHaveTextContent('Unknown tag'); + }, + ); + + it('keeps Posts submenu filters and clears them with the main link', async () => { + fakePostsListScreen(); + fakePosts([]); + await renderAdminApp('/posts', { labs: { postsListReact: true } }); + + await sidebarScreen.navLink('Drafts').click(); + await expect.poll(currentRoute).toBe('/posts?type=draft'); + await sidebarScreen.navLink('Posts').click(); + await expect.poll(currentRoute).toBe('/posts'); + }); + it.each([ { label: 'Posts', route: '/posts', emberRoute: 'posts' }, { label: 'Pages', route: '/pages', emberRoute: 'pages' }, diff --git a/apps/admin/src/migrate/api.ts b/apps/admin/src/migrate/api.ts new file mode 100644 index 00000000000..3ccf91006c9 --- /dev/null +++ b/apps/admin/src/migrate/api.ts @@ -0,0 +1,8 @@ +/** + * Public surface of the migrate domain, consumed by the admin shell + * (apps/admin/src/routes.tsx). Everything else in this domain is internal. + */ + +// Lazy entry, not a component re-export: the shell mounts it behind `lazy()`, +// so a static re-export would pull the chunk into the shell bundle. +export const lazyMigrateScreen = () => import('./migrate'); diff --git a/apps/admin/src/migrate/migrate.acceptance.test.tsx b/apps/admin/src/migrate/migrate.acceptance.test.tsx new file mode 100644 index 00000000000..44df16c5dd5 --- /dev/null +++ b/apps/admin/src/migrate/migrate.acceptance.test.tsx @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, onTestFinished } from 'vitest'; +import { page } from 'vitest/browser'; +import { + allowUnhandledRequests, + configResponse, + currentRoute, + fakeFrameOrigin, + fakeIntegrations, + fakeUsers, + renderAdminApp, + settingsResponse, + staffRole, + staffUser, +} from '@test-utils/acceptance'; +import type { Integration } from '@tryghost/admin-x-framework/api/integrations'; +import { sidebarScreen } from '@/layout/sidebar.screen'; +import { migrateScreen } from './migrate.screen'; + +const MIGRATE_ORIGIN = 'https://migrate.ghost.org'; + +// Stands in for the migration app: reports each load with its own id, echoes +// pings with that id, relays every other message back to Admin's window, then +// runs `script`. +function migrateStandIn(script = '') { + return ``; +} + +interface StandInMessage { + loaded?: string; + loadId?: string; + pong?: string; + received?: { request: string; response: Record }; +} + +function standInMessages(): StandInMessage[] { + const messages: StandInMessage[] = []; + const listener = (event: MessageEvent) => { + const data = event.data; + if ( + event.origin === MIGRATE_ORIGIN && + data && + ('loaded' in data || 'pong' in data || 'received' in data) + ) { + messages.push(data); + } + }; + window.addEventListener('message', listener); + onTestFinished(() => window.removeEventListener('message', listener)); + return messages; +} + +function selfServeMigration(secret: string): Integration { + const created = '2024-01-01T00:00:00.000Z'; + return { + id: 'ssm-id', + type: 'core', + slug: 'self-serve-migration', + name: 'Self-Serve Migration Integration', + icon_image: null, + description: null, + created_at: created, + updated_at: created, + api_keys: [ + { + id: 'ssm-key-id', + type: 'admin', + secret, + role_id: 'role-id', + integration_id: 'ssm-id', + user_id: null, + last_seen_at: null, + last_seen_version: null, + created_at: created, + updated_at: created, + }, + ], + }; +} + +const siteOwner = () => + staffUser({ email: 'owner@example.com', roles: [staffRole({ name: 'Owner' })] }); + +const externalNavigation = (): unknown => + JSON.parse(document.body.dataset.externalNavigate ?? 'null'); + +describe('Migrate', () => { + // The recorded handoff lives on the host page, which outlives a single test. + beforeEach(() => { + delete document.body.dataset.externalNavigate; + }); + + it('opens the migration app for the chosen platform', async () => { + await fakeFrameOrigin(MIGRATE_ORIGIN, migrateStandIn()); + const messages = standInMessages(); + await renderAdminApp('/migrate/substack'); + + await expect.poll(() => messages[0]?.loaded).toBe(`${MIGRATE_ORIGIN}/?platform=substack`); + await expect.element(sidebarScreen.shellNav()).not.toBeInTheDocument(); + }); + + it('sends the migration app its credentials when asked', async () => { + const users = fakeUsers([siteOwner()]); + fakeIntegrations([selfServeMigration('ssm-secret')]); + await fakeFrameOrigin( + MIGRATE_ORIGIN, + migrateStandIn(`parent.postMessage({ request: 'apiUrl' }, '*');`), + ); + const messages = standInMessages(); + await renderAdminApp('/migrate', { + boot: { + browseSettings: { + response: settingsResponse({ + settings: { + stripe_connect_account_id: 'acct_123', + stripe_connect_publishable_key: 'pk_live_123', + stripe_connect_livemode: true, + }, + }), + }, + }, + }); + + await expect + .poll(() => messages.find((message) => message.received)?.received) + .toEqual({ + request: 'initialData', + response: { + apiUrl: `${window.location.origin}/ghost`, + apiKey: 'ssm-secret', + stripe: true, + csvContentImporter: false, + ghostVersion: String(configResponse().config.version).split('.').slice(0, 2).join('.'), + ownerEmail: 'owner@example.com', + }, + }); + await expect(users).toHaveSentFilter("roles.name:'Owner'"); + }); + + it('returns to migration settings with an error when the credentials cannot be loaded', async () => { + // The settings app owns its request graph; this spec asserts the handoff. + allowUnhandledRequests(); + fakeUsers([siteOwner()]); + fakeIntegrations([]); + await fakeFrameOrigin( + MIGRATE_ORIGIN, + migrateStandIn(`parent.postMessage({ request: 'apiUrl' }, '*');`), + ); + await renderAdminApp('/migrate'); + + await expect.poll(currentRoute).toBe('/settings/migration'); + await expect + .element(page.getByText('Error initialising migration. Please try again later.')) + .toBeVisible(); + }); + + it('follows migration routes without reloading the app', async () => { + await fakeFrameOrigin( + MIGRATE_ORIGIN, + migrateStandIn(`parent.postMessage({ route: '/migrate/beehiiv' }, '*');`), + ); + const messages = standInMessages(); + await renderAdminApp('/migrate/substack'); + await expect.poll(currentRoute).toBe('/migrate/beehiiv'); + + const frame = migrateScreen.frame().element() as HTMLIFrameElement; + frame.contentWindow?.postMessage({ ping: true }, MIGRATE_ORIGIN); + + await expect + .poll(() => messages.find((message) => message.pong)?.pong) + .toBe(messages[0]?.loadId); + expect(messages.filter((message) => message.loaded)).toHaveLength(1); + }); + + it('hands routes Ember owns to Ember', async () => { + await fakeFrameOrigin( + MIGRATE_ORIGIN, + migrateStandIn(`parent.postMessage({ route: '/pro' }, '*');`), + ); + await renderAdminApp('/migrate'); + + await expect.poll(externalNavigation).toMatchObject({ route: '/pro', isExternal: true }); + }); + + it('ignores messages from other origins', async () => { + await fakeFrameOrigin(MIGRATE_ORIGIN, migrateStandIn()); + const messages = standInMessages(); + await renderAdminApp('/migrate'); + await expect.poll(() => messages.length).toBeGreaterThan(0); + + // Handled in order, so honouring the second would supersede the first. + window.dispatchEvent( + new MessageEvent('message', { origin: MIGRATE_ORIGIN, data: { route: '/migrate/beehiiv' } }), + ); + window.dispatchEvent( + new MessageEvent('message', { + origin: 'https://example.com', + data: { route: '/migrate/wordpress' }, + }), + ); + + await expect.poll(currentRoute).toBe('/migrate/beehiiv'); + }); + + it('closes to migration settings', async () => { + // The settings app owns its request graph; this spec asserts the handoff. + allowUnhandledRequests(); + await fakeFrameOrigin(MIGRATE_ORIGIN, migrateStandIn()); + await renderAdminApp('/migrate/substack'); + + await migrateScreen.closeButton().click(); + + await expect.poll(currentRoute).toBe('/settings/migration'); + await expect.element(migrateScreen.frame()).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/migrate/migrate.screen.ts b/apps/admin/src/migrate/migrate.screen.ts new file mode 100644 index 00000000000..f21be190d8b --- /dev/null +++ b/apps/admin/src/migrate/migrate.screen.ts @@ -0,0 +1,7 @@ +import { page } from 'vitest/browser'; +import { closeMigrateButton, migrateFrame } from '@tryghost/test-data/selectors/migrate'; + +export const migrateScreen = { + frame: () => page.getByTitle(migrateFrame, { exact: true }), + closeButton: () => page.getByRole('button', { name: closeMigrateButton, exact: true }), +}; diff --git a/apps/admin/src/migrate/migrate.tsx b/apps/admin/src/migrate/migrate.tsx new file mode 100644 index 00000000000..f858c97be5d --- /dev/null +++ b/apps/admin/src/migrate/migrate.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { useNavigate, useParams } from '@tryghost/admin-x-framework'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import type { IntegrationsResponseType } from '@tryghost/admin-x-framework/api/integrations'; +import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import type { UsersResponseType } from '@tryghost/admin-x-framework/api/users'; +import { apiUrl, getGhostPaths } from '@tryghost/admin-x-framework/helpers'; +import { useFeatureFlag, useFetchApi } from '@tryghost/admin-x-framework/hooks'; +import { useEmberOwnedRouteMatcher } from '@/routes'; +import { FullscreenCloseButton } from '@/shared/fullscreen-close-button'; + +const MIGRATE_ORIGIN = 'https://migrate.ghost.org'; + +interface MigrateMessage { + request?: unknown; + route?: unknown; +} + +function migrateFrameUrl(platform: string | undefined): string { + return platform ? `${MIGRATE_ORIGIN}?platform=${platform}` : MIGRATE_ORIGIN; +} + +/** + * The self-serve migration app, embedded fullscreen. The app asks for the + * credentials it imports with (`{request: 'apiUrl'}`) and can send Admin to + * another route (`{route}`). + */ +const Migrate = () => { + const { '*': platform } = useParams(); + const [src] = useState(() => migrateFrameUrl(platform || undefined)); + const frameRef = useRef(null); + const navigate = useNavigate(); + const isEmberOwned = useEmberOwnedRouteMatcher(); + const fetchApi = useFetchApi(); + const { data: configData } = useBrowseConfig(); + const { data: settingsData } = useBrowseSettings(); + const csvContentImporter = useFeatureFlag('csvContentImporter'); + + const settings = settingsData?.settings ?? null; + const stripe = Boolean( + getSettingValue(settings, 'stripe_connect_account_id') && + getSettingValue(settings, 'stripe_connect_publishable_key') && + getSettingValue(settings, 'stripe_connect_livemode'), + ); + const ghostVersion = configData?.config.version.match(/^(\d+\.)?(\d+)/)?.[0]; + + useEffect(() => { + const sendInitialData = async () => { + try { + const [{ integrations }, { users }] = await Promise.all([ + fetchApi(apiUrl('/integrations/', { include: 'api_keys' })), + fetchApi( + apiUrl('/users/', { filter: "roles.name:'Owner'", limit: '1', include: 'roles' }), + ), + ]); + const apiKey = integrations.find( + (integration) => integration.slug === 'self-serve-migration', + )?.api_keys?.[0]?.secret; + const ownerEmail = users[0]?.email; + + if (!apiKey || !ownerEmail) { + throw new Error('The self-serve migration integration or site owner is missing'); + } + + frameRef.current?.contentWindow?.postMessage( + { + request: 'initialData', + response: { + apiUrl: `${window.location.origin}${getGhostPaths().adminRoot}`.replace(/\/$/, ''), + apiKey, + stripe, + csvContentImporter, + ghostVersion, + ownerEmail, + }, + }, + MIGRATE_ORIGIN, + ); + } catch { + // Leaving the screen is only right while it is still open. + if (!frameRef.current) { + return; + } + navigate('/settings/migration'); + toast.error('Error initialising migration. Please try again later.'); + } + }; + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== MIGRATE_ORIGIN) { + return; + } + + if (event.data?.request === 'apiUrl') { + void sendInitialData(); + return; + } + + const route = event.data?.route; + if (typeof route === 'string') { + navigate(route, { crossApp: isEmberOwned(route) }); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [csvContentImporter, fetchApi, ghostVersion, isEmberOwned, navigate, stripe]); + + return ( +
+ {/* The credentials reply reads settings and config, so the app waits for both. */} + {settingsData && configData && ( + \ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-migrate-iframe.js b/apps/ember-admin/app/components/gh-migrate-iframe.js deleted file mode 100644 index 7d4b29fb7cc..00000000000 --- a/apps/ember-admin/app/components/gh-migrate-iframe.js +++ /dev/null @@ -1,88 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {htmlSafe} from '@ember/template'; -import {inject as service} from '@ember/service'; - -export default class GhMigrateIframe extends Component { - @service migrate; - @service router; - @service feature; - @service notifications; - - willDestroy() { - super.willDestroy(...arguments); - window.removeEventListener('message', this.handleIframeMessage); - - // Remove the class that adds a higher z-index that is added on setup - document.getElementById('ember-app').classList.remove('migration-app-open'); - } - - @action - setup() { - this.migrate.getMigrateIframe().src = this.migrate.getIframeURL(); - window.addEventListener('message', this.handleIframeMessage); - - // Add class that adds a higher z-index so the app site over the top of the nav bar. #ember-app is a - // few levels up from this component, document.getElementById is a light-touch way to add the class. - document.getElementById('ember-app').classList.add('migration-app-open'); - } - - @action - async handleIframeMessage(event) { - if (this.isDestroyed || this.isDestroying) { - return; - } - - // Only process messages coming from the migrate iframe - const url = new URL(this.migrate.getIframeURL()); - if (event.origin === url.origin) { - if (event.data?.request === 'apiUrl') { - await this._handleUrlRequest(); - return; - } - - if (event.data?.route) { - this._handleRouteUpdate(event.data); - return; - } - - if (event.data?.siteData) { - this._handleSiteDataUpdate(event.data); - return; - } - } - } - - // The iframe can send route updates to navigate to within Admin, as some routes - // have to be rendered within the iframe and others require to break out of it. - _handleRouteUpdate(data) { - const route = data.route; - this.migrate.isIframeTransition = route?.includes('/migrate'); - this.migrate.toggleMigrateWindow(this.migrate.isIframeTransition); - this.router.transitionTo(route); - } - - async _handleUrlRequest() { - try { - const response = await this.migrate.postMessagePayload(); - - this.migrate.getMigrateIframe().contentWindow.postMessage({ - request: 'initialData', - response - }, new URL(this.migrate.getIframeURL()).origin); - } catch (err) { - // Close the iframe so the user can see the notification - this.migrate.closeMigrateWindow(); - this.notifications.showAlert(htmlSafe(`Error initialising migration. Please try again later.`), {type: 'error', key: 'migrate.iframe-postMessage.error'}); - } - } - - _handleSiteDataUpdate(data) { - this.migrate.siteData = data?.siteData ?? {}; - - if (this.migrate.siteData?.migrationComplete) { - // If we want to show a notification, this is where to do it - // this.notifications.showAlert(htmlSafe(`Migration complete!`), {type: 'success', key: 'migrate.completed'}); // Green persistent banner at the top - } - } -} diff --git a/apps/ember-admin/app/components/gh-migrate-modal.hbs b/apps/ember-admin/app/components/gh-migrate-modal.hbs deleted file mode 100644 index 47d38594abc..00000000000 --- a/apps/ember-admin/app/components/gh-migrate-modal.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
-
- -
-
\ No newline at end of file diff --git a/apps/ember-admin/app/components/gh-migrate-modal.js b/apps/ember-admin/app/components/gh-migrate-modal.js deleted file mode 100644 index 0beac5ec606..00000000000 --- a/apps/ember-admin/app/components/gh-migrate-modal.js +++ /dev/null @@ -1,10 +0,0 @@ -import Component from '@glimmer/component'; -import {inject as service} from '@ember/service'; - -export default class GhMigrateModal extends Component { - @service migrate; - - get visibilityClass() { - return this.migrate.migrateWindowOpen ? 'gh-migrate' : 'gh-migrate closed'; - } -} diff --git a/apps/ember-admin/app/components/gh-site-iframe.hbs b/apps/ember-admin/app/components/gh-site-iframe.hbs deleted file mode 100644 index 182c272c6fe..00000000000 --- a/apps/ember-admin/app/components/gh-site-iframe.hbs +++ /dev/null @@ -1,12 +0,0 @@ - diff --git a/apps/ember-admin/app/components/gh-site-iframe.js b/apps/ember-admin/app/components/gh-site-iframe.js deleted file mode 100644 index 8310c79fbc0..00000000000 --- a/apps/ember-admin/app/components/gh-site-iframe.js +++ /dev/null @@ -1,102 +0,0 @@ -import Component from '@glimmer/component'; -import {action} from '@ember/object'; -import {inject} from 'ghost-admin/decorators/inject'; -import {task, timeout} from 'ember-concurrency'; -import {tracked} from '@glimmer/tracking'; - -export default class GhSiteIframeComponent extends Component { - @inject config; - - @tracked isInvisible = this.args.invisibleUntilLoaded; - - willDestroy() { - super.willDestroy?.(...arguments); - - if (this.messageListener) { - window.removeEventListener('message', this.messageListener); - } - this.args.onDestroyed?.(); - } - - get srcUrl() { - const srcUrl = new URL(this.args.src || `${this.config.blogUrl}/`); - - if (this.args.guid) { - srcUrl.searchParams.set('v', this.args.guid); - } - - srcUrl.searchParams.set('admin', '1'); - srcUrl.searchParams.set('admin_toolbar', '0'); - - return srcUrl.href; - } - - @action - resetSrcAttribute(iframe) { - // reset the src attribute and force reload each time the guid changes - // - allows for a click on the navigation item to reset back to the homepage - // or a portal preview modal to force a reload so it can fetch server-side data - if (this.args.guid !== this._lastGuid) { - if (iframe) { - if (this.args.invisibleUntilLoaded) { - this.isInvisible = true; - } - - try { - if (iframe.contentWindow.location.href !== this.srcUrl) { - iframe.contentWindow.location = this.srcUrl; - } else { - iframe.contentWindow.location.reload(); - } - } catch (e) { - if (e.name === 'SecurityError') { - iframe.src = this.srcUrl; - } - } - } - } - this._lastGuid = this.args.guid; - } - - @action - onLoad(event) { - this.iframe = event.target; - - if (this.args.invisibleUntilLoaded && typeof this.args.invisibleUntilLoaded === 'boolean') { - this.makeVisible.perform(); - } else { - this.args.onLoad?.(this.iframe); - } - } - - @action - attachMessageListener() { - if (typeof this.args.invisibleUntilLoaded === 'string') { - this.messageListener = (event) => { - if (this.isDestroying || this.isDestroyed) { - return; - } - - const srcURL = new URL(this.srcUrl); - const originURL = new URL(event.origin); - - if (originURL.origin === srcURL.origin) { - if (event.data === this.args.invisibleUntilLoaded || event.data.type === this.args.invisibleUntilLoaded) { - this.makeVisible.perform(); - } - } - }; - - window.addEventListener('message', this.messageListener, true); - } - } - - @task - *makeVisible() { - // give any scripts a bit of time to render before making visible - // allows portal to render it's overlay and prevent site background flashes - yield timeout(100); - this.isInvisible = false; - this.args.onLoad?.(this.iframe); - } -} diff --git a/apps/ember-admin/app/controllers/lexical-editor.js b/apps/ember-admin/app/controllers/lexical-editor.js index eab04c00bf6..5efd366162c 100644 --- a/apps/ember-admin/app/controllers/lexical-editor.js +++ b/apps/ember-admin/app/controllers/lexical-editor.js @@ -158,6 +158,7 @@ export default class LexicalEditorController extends Controller { @service search; @service session; @service settings; + @service stateBridge; @service ui; @service localRevisions; @service('unsaved-changes') unsavedChanges; @@ -176,6 +177,19 @@ export default class LexicalEditorController extends Controller { */ fromAnalytics = false; + get listQueryParams() { + if (this.feature.postsListReact !== true) { + return {}; + } + + const resource = this.post?.displayName === 'page' ? 'pages' : 'posts'; + // Explicit nulls clear any stale query params retained by Ember. + return { + type: null, visibility: null, author: null, tag: null, order: null, + ...this.stateBridge.postListQueryParams[resource] + }; + } + // koenig related properties wordCount = 0; postTkCount = 0; diff --git a/apps/ember-admin/app/controllers/migrate.js b/apps/ember-admin/app/controllers/migrate.js deleted file mode 100644 index 4aa0628652b..00000000000 --- a/apps/ember-admin/app/controllers/migrate.js +++ /dev/null @@ -1,17 +0,0 @@ -import Controller from '@ember/controller'; -import {action} from '@ember/object'; -import {inject as service} from '@ember/service'; - -export default class MigrateController extends Controller { - @service migrate; - @service router; - - get visibilityClass() { - return this.migrate.isIframeTransition ? 'migrate iframe-migrate-container' : ' migrate fullscreen-migrate-container'; - } - - @action - closeMigrate() { - this.router.transitionTo('/settings/migration'); - } -} diff --git a/apps/ember-admin/app/controllers/site.js b/apps/ember-admin/app/controllers/site.js deleted file mode 100644 index ea1b193c608..00000000000 --- a/apps/ember-admin/app/controllers/site.js +++ /dev/null @@ -1,9 +0,0 @@ -import Controller from '@ember/controller'; -import classic from 'ember-classic-decorator'; -import {alias} from '@ember/object/computed'; - -@classic -export default class SiteController extends Controller { - @alias('model') - guid; -} diff --git a/apps/ember-admin/app/router.js b/apps/ember-admin/app/router.js index 3a91492db3c..2bdc2f51081 100644 --- a/apps/ember-admin/app/router.js +++ b/apps/ember-admin/app/router.js @@ -17,8 +17,6 @@ Router.map(function () { this.route('signup', {path: '/signup/:token'}); this.route('reset', {path: '/reset/:token'}); - this.route('site'); - this.route('pro', function () { this.route('pro-sub', {path: '/*sub'}); }); @@ -33,10 +31,6 @@ Router.map(function () { this.route('edit', {path: ':type/:post_id'}); }); - this.route('migrate', function () { - this.route('migrate', {path: '/*platform'}); - }); - this.route('members-activity'); this.route('react-fallback', {path: '/*path'}); diff --git a/apps/ember-admin/app/routes/migrate.js b/apps/ember-admin/app/routes/migrate.js deleted file mode 100644 index d3158731ce8..00000000000 --- a/apps/ember-admin/app/routes/migrate.js +++ /dev/null @@ -1,16 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; -import {inject as service} from '@ember/service'; - -export default class MigrateRoute extends AuthenticatedRoute { - @service feature; - @service session; - - beforeModel() { - super.beforeModel(...arguments); - - // Only allow Owner & Administrator to access this route - if (!this.session.user.isAdmin) { - return this.transitionTo('index'); - } - } -} diff --git a/apps/ember-admin/app/routes/site.js b/apps/ember-admin/app/routes/site.js deleted file mode 100644 index e95d427b717..00000000000 --- a/apps/ember-admin/app/routes/site.js +++ /dev/null @@ -1,13 +0,0 @@ -import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; - -export default class SiteRoute extends AuthenticatedRoute { - model() { - return (new Date()).valueOf(); - } - - buildRouteInfoMetadata() { - return { - titleToken: 'Site' - }; - } -} diff --git a/apps/ember-admin/app/services/migrate.js b/apps/ember-admin/app/services/migrate.js deleted file mode 100644 index 7c21e5f830d..00000000000 --- a/apps/ember-admin/app/services/migrate.js +++ /dev/null @@ -1,135 +0,0 @@ -import Service, {inject as service} from '@ember/service'; -import config from 'ghost-admin/config/environment'; -import {tracked} from '@glimmer/tracking'; - -export default class MigrateService extends Service { - @service ajax; - @service billing; - @service feature; - @service router; - @service ghostPaths; - @service settings; - - migrateUrl = 'https://migrate.ghost.org'; - migrateRouteRoot = '#/migrate'; - - @tracked migrateWindowOpen = false; - @tracked siteData = null; - @tracked previousRoute = null; - @tracked isIframeTransition = false; - @tracked platform = null; - - get apiUrl() { - const origin = window.location.origin; - const subdir = this.ghostPaths.subdir; - const rootURL = this.router.rootURL; - let url = this.ghostPaths.url.join(origin, subdir, rootURL); - url = url.replace(/\/$/, ''); // Strips the trailing slash - return url; - } - - async apiKey() { - const ghostIntegrationsUrl = this.ghostPaths.url.api('integrations') + '?include=api_keys'; - return this.ajax.request(ghostIntegrationsUrl).then(async (response) => { - const ssmIntegration = response.integrations.find(r => r.slug === 'self-serve-migration'); - - const key = ssmIntegration.api_keys[0].secret; - - return key; - }).catch((error) => { - throw error; - }); - } - - async postMessagePayload() { - const theKey = await this.apiKey(); - const theOwner = await this.billing.getOwnerUser(); - - const payload = { - apiUrl: this.apiUrl, - apiKey: theKey, - stripe: this.isStripeConnected, - csvContentImporter: this.isCsvContentImporterEnabled, - ghostVersion: this.ghostVersion, - ownerEmail: theOwner.email - }; - - return payload; - } - - get isStripeConnected() { - return (this.settings.stripeConnectAccountId && this.settings.stripeConnectPublishableKey && this.settings.stripeConnectLivemode) ? true : false; - } - - get isCsvContentImporterEnabled() { - return this.feature.csvContentImporter ? true : false; - } - - get ghostVersion() { - return config.APP.version; - } - - constructor() { - super(...arguments); - } - - getIframeURL() { - let url = this.migrateUrl; - const params = this.router.currentRoute.params; - if (params.platform) { - url = url + '?platform=' + params.platform; - } - - return url; - } - - // Sends a route update to a child route in the migrate app, because we can't control - // navigating to it otherwise - sendRouteUpdate(route) { - this.getMigrateIframe().contentWindow.postMessage({ - query: 'routeUpdate', - response: route - }, this.migrateUrl); - } - - // Controls migrate window modal visibility and sync of the URL visible in browser - // and the URL opened on the iframe. It is responsible to non user triggered iframe opening, - // for example: by entering "/migrate" route in the URL or using history navigation (back and forward) - toggleMigrateWindow(value) { - if (this.migrateWindowOpen && value) { - // don't attempt to open again - return; - } - this.migrateWindowOpen = value; - } - - // Controls navigation to migrate window modal which is triggered from the application UI. - // For example: pressing "View migrate" link in navigation menu. It's main side effect is - // remembering the route from which the action has been triggered - "previousRoute" so it - // could be reused when closing the migrate window - openMigrateWindow(currentRoute, childRoute) { - if (this.migrateWindowOpen) { - // don't attempt to open again - return; - } - - this.previousRoute = currentRoute; - - // Ensures correct "getIframeURL" calculation when syncing iframe location - // in toggleMigrateWindow - window.location.hash = childRoute || '/migrate'; - - this.router.transitionTo(childRoute || '/migrate'); - this.toggleMigrateWindow(true); - } - - closeMigrateWindow() { - window.location.hash = '/settings/migration'; - this.router.transitionTo('/settings/migration'); - this.toggleMigrateWindow(false); - } - - getMigrateIframe() { - return document.getElementById('migrate-frame'); - } -} diff --git a/apps/ember-admin/app/services/state-bridge.js b/apps/ember-admin/app/services/state-bridge.js index 179726bb4be..201b39887e2 100644 --- a/apps/ember-admin/app/services/state-bridge.js +++ b/apps/ember-admin/app/services/state-bridge.js @@ -4,6 +4,7 @@ import {action} from '@ember/object'; import {getOwner} from '@ember/application'; import {inject} from 'ghost-admin/decorators/inject'; import {run} from '@ember/runloop'; +import {tracked} from '@glimmer/tracking'; const emberDataTypeMapping = { AutomatedEmailsResponseType: null, // automated emails only exist in React admin @@ -43,6 +44,13 @@ export default class StateBridgeService extends Service.extend(Evented) { @inject config; + @tracked postListQueryParams = {posts: {}, pages: {}}; + + @action + setPostListQueryParams(resource, params) { + this.postListQueryParams = {...this.postListQueryParams, [resource]: params}; + } + /** * Gives React the same synchronous Labs route-ownership decision Ember * uses. Both routers must share one authority or they can each defer to diff --git a/apps/ember-admin/app/styles/app.css b/apps/ember-admin/app/styles/app.css index 597b9218e85..eb5a7695de8 100644 --- a/apps/ember-admin/app/styles/app.css +++ b/apps/ember-admin/app/styles/app.css @@ -64,7 +64,6 @@ @import "layouts/post-preview.css"; @import "layouts/tiers.css"; @import "layouts/mentions.css"; -@import "layouts/migrate.css"; /* Suppress transitions/animations during a light/dark mode swap so the diff --git a/apps/ember-admin/app/styles/components/browser-preview.css b/apps/ember-admin/app/styles/components/browser-preview.css index 4c8e41496db..de13b7da489 100644 --- a/apps/ember-admin/app/styles/components/browser-preview.css +++ b/apps/ember-admin/app/styles/components/browser-preview.css @@ -22,29 +22,6 @@ border-radius: 8px 8px 0 0; } -.gh-browserpreview-iframecontainer .site-frame { - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; -} - -@media (max-width: 1600px) { - .gh-browserpreview-iframecontainer .site-frame iframe { - width: 130%; - height: 130%; - transform: scale(calc(100 / 130)); - transform-origin: 0 0; - } -} - -@media (min-width: 1601px) and (max-width: 1920px) { - .gh-browserpreview-iframecontainer .site-frame iframe { - width: 110%; - height: 110%; - transform: scale(calc(100 / 110)); - transform-origin: 0 0; - } -} - .gh-browserpreview-browser { background: var(--whitegrey-l1); border-top-left-radius: 3px; diff --git a/apps/ember-admin/app/styles/layouts/migrate.css b/apps/ember-admin/app/styles/layouts/migrate.css deleted file mode 100644 index 0418653c367..00000000000 --- a/apps/ember-admin/app/styles/layouts/migrate.css +++ /dev/null @@ -1,57 +0,0 @@ -#ember-app.migration-app-open { - position: relative; - z-index: 10; -} - -.gh-migrate { - position: absolute; - top: 0; - left: 0; - height: 100%; - width: 100%; - z-index: 9999; - background: var(--main-bg-color); -} - -.gh-migrate-container { - position: relative; - height: 100%; - width: 100%; -} - -.gh-migrate-fullscreen-container { - position: relative; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 10000; - height: 100vh; - background: var(--main-bg-color); - overflow: hidden; -} - -.gh-migrate .migrate-frame { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - border: none; - z-index: 10; - transform: translate3d(0, 0, 0); -} - -.gh-migrate-close { - /* Other styles are classes form the design system */ - z-index: 10000; /* One more than the iframe wrapper */ -} - -.gh-migrate-close a svg { - display: block; - width: 2rem; - height: 2rem; -} diff --git a/apps/ember-admin/app/styles/patterns/global.css b/apps/ember-admin/app/styles/patterns/global.css index c6af0ace8dc..e8a925812ff 100644 --- a/apps/ember-admin/app/styles/patterns/global.css +++ b/apps/ember-admin/app/styles/patterns/global.css @@ -814,15 +814,3 @@ input[type="image"] { .liquid-container.show-overflow.liquid-animating .liquid-child { overflow: hidden; } - -.site-frame { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - border: none; - transform: translate3d(0, 0, 0); -} diff --git a/apps/ember-admin/app/templates/lexical-editor.hbs b/apps/ember-admin/app/templates/lexical-editor.hbs index 58a8d333c6e..42259c82831 100644 --- a/apps/ember-admin/app/templates/lexical-editor.hbs +++ b/apps/ember-admin/app/templates/lexical-editor.hbs @@ -24,7 +24,7 @@ Analytics {{else}} - + {{svg-jar "arrow-left"}} {{capitalize (pluralize this.post.displayName)}} diff --git a/apps/ember-admin/app/templates/migrate.hbs b/apps/ember-admin/app/templates/migrate.hbs deleted file mode 100644 index f565f1ec900..00000000000 --- a/apps/ember-admin/app/templates/migrate.hbs +++ /dev/null @@ -1,9 +0,0 @@ - \ No newline at end of file diff --git a/apps/ember-admin/app/templates/site.hbs b/apps/ember-admin/app/templates/site.hbs deleted file mode 100644 index 2170561630d..00000000000 --- a/apps/ember-admin/app/templates/site.hbs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/ember-admin/tests/acceptance/editor-test.js b/apps/ember-admin/tests/acceptance/editor-test.js index a6df38f6511..e6ebca3279c 100644 --- a/apps/ember-admin/tests/acceptance/editor-test.js +++ b/apps/ember-admin/tests/acceptance/editor-test.js @@ -594,6 +594,38 @@ describe('Acceptance: Editor', function () { ).to.equal('/ghost/posts'); }); + for (const resource of ['posts', 'pages']) { + it(`returns the breadcrumb to the React ${resource} filters`, async function () { + enableLabsFlag(this.server, 'postsListReact'); + const postType = resource === 'pages' ? 'page' : 'post'; + const post = this.server.create(postType, {authors: [author]}); + const bridge = this.owner.lookup('service:state-bridge'); + bridge.setPostListQueryParams('posts', {tag: 'news'}); + bridge.setPostListQueryParams('pages', {tag: 'pages'}); + bridge.setPostListQueryParams(resource, {type: 'draft', tag: 'engineering', order: 'title asc'}); + const route = this.owner.lookup(`route:${resource}`); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visit(`/editor/${postType}/${post.id}`); + + const href = find('[data-test-breadcrumb]').getAttribute('href'); + const params = new URLSearchParams(href.split('?')[1]); + expect(params.get('tag')).to.equal('engineering'); + expect(params.get('type')).to.equal('draft'); + expect(params.get('order')).to.equal('title asc'); + + await click('[data-test-breadcrumb]'); + + expect(navigate.calledOnce).to.be.true; + const destination = new URL(navigate.firstCall.args[0], 'https://example.com'); + expect(destination.pathname).to.equal(`/${resource}`); + expect(destination.searchParams.get('tag')).to.equal('engineering'); + expect(destination.searchParams.get('type')).to.equal('draft'); + expect(destination.searchParams.get('order')).to.equal('title asc'); + navigate.restore(); + }); + } + it('renders a breadcrumb back to post analytics root if that\'s where we came from', async function () { const post = this.server.create('post', { authors: [author], diff --git a/apps/ember-admin/tests/integration/components/gh-site-iframe-test.js b/apps/ember-admin/tests/integration/components/gh-site-iframe-test.js deleted file mode 100644 index 4bb24b6ce33..00000000000 --- a/apps/ember-admin/tests/integration/components/gh-site-iframe-test.js +++ /dev/null @@ -1,23 +0,0 @@ -import hbs from 'htmlbars-inline-precompile'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {find, render} from '@ember/test-helpers'; -import {setupRenderingTest} from 'ember-mocha'; - -describe('Integration: Component: gh-site-iframe', function () { - setupRenderingTest(); - - beforeEach(function () { - this.owner.register('config:main', { - blogUrl: 'http://localhost:2368' - }, {instantiate: false}); - }); - - it('forwards the View site preview marker to the iframe element', async function () { - await render(hbs``); - - const iframe = find('iframe'); - expect(iframe).to.have.attribute('data-view-site-preview'); - expect(iframe).to.have.class('site-frame'); - }); -}); diff --git a/apps/ember-admin/tests/unit/services/migrate-test.js b/apps/ember-admin/tests/unit/services/migrate-test.js deleted file mode 100644 index 8e48470f6dc..00000000000 --- a/apps/ember-admin/tests/unit/services/migrate-test.js +++ /dev/null @@ -1,74 +0,0 @@ -import Service from '@ember/service'; -import sinon from 'sinon'; -import {describe, it} from 'mocha'; -import {expect} from 'chai'; -import {setupTest} from 'ember-mocha'; - -const isValidUrl = (str) => { - try { - new URL(str); return true; - } catch { - return false; - } -}; - -describe('Unit: Service: migrate', function () { - setupTest(); - - let migrateService; - - beforeEach(function () { - migrateService = this.owner.lookup('service:migrate'); - }); - - it('exists', function () { - expect(migrateService).to.be.ok; - }); - - it('can generate valid payload', async function () { - sinon.stub(migrateService, 'apiKey').resolves('abcd:1234'); - - this.owner.register('service:billing', Service.extend({ - getOwnerUser: () => { - return { - email: 'name@example.com' - }; - } - })); - - this.owner.register('service:feature', Service.extend({ - csvContentImporter: false - })); - - const payload = await migrateService.postMessagePayload(); - - expect(payload).to.be.an('object').that.has.all.keys('apiUrl', 'apiKey', 'stripe', 'csvContentImporter', 'ghostVersion', 'ownerEmail'); - expect(isValidUrl(payload.apiUrl)).to.be.true; - expect(payload.apiUrl.endsWith('/ghost')).to.be.true; - expect(payload.apiKey).to.equal('abcd:1234'); - expect(payload.stripe).to.be.false; - expect(payload.csvContentImporter).to.be.false; - expect(payload.ghostVersion).to.be.string; - expect(payload.ownerEmail).to.equal('name@example.com'); - }); - - it('reports csvContentImporter as enabled when the labs flag is on', async function () { - sinon.stub(migrateService, 'apiKey').resolves('abcd:1234'); - - this.owner.register('service:billing', Service.extend({ - getOwnerUser: () => { - return { - email: 'name@example.com' - }; - } - })); - - this.owner.register('service:feature', Service.extend({ - csvContentImporter: true - })); - - const payload = await migrateService.postMessagePayload(); - - expect(payload.csvContentImporter).to.be.true; - }); -}); diff --git a/apps/portal/src/utils/helpers.js b/apps/portal/src/utils/helpers.js index 30332001ae6..a8f096c8e29 100644 --- a/apps/portal/src/utils/helpers.js +++ b/apps/portal/src/utils/helpers.js @@ -218,13 +218,13 @@ export function getCheckoutSessionDataFromPlanAttribute(site, plan) { if (plan === 'monthly') { return { cadence: 'month', - tierId: defaultTier.id, + tierId: defaultTier?.id, }; } if (plan === 'yearly') { return { cadence: 'year', - tierId: defaultTier.id, + tierId: defaultTier?.id, }; } return { diff --git a/apps/portal/test/data-attributes.test.jsx b/apps/portal/test/data-attributes.test.jsx index d76c1e4539b..a10a6ee2a8a 100644 --- a/apps/portal/test/data-attributes.test.jsx +++ b/apps/portal/test/data-attributes.test.jsx @@ -425,6 +425,27 @@ describe('Member Data attributes:', () => { }, ); }); + + test('shows an error instead of crashing when no paid tier is available', async () => { + const { event, errorEl, siteUrl, member, element } = getMockData(); + const site = FixturesSite.singleTier.onlyFreePlan; + const clickHandler = () => {}; + element.addEventListener = vi.fn(); + + window.fetch.mockImplementation((url) => { + if (url.includes('api/session')) { + return Promise.resolve({ ok: true, text: async () => 'session-identity' }); + } + return Promise.resolve({ ok: false }); + }); + + await planClickHandler({ event, errorEl, siteUrl, clickHandler, site, member, el: element }); + + const [, checkoutOptions] = window.fetch.mock.calls[1]; + expect(JSON.parse(checkoutOptions.body)).not.toHaveProperty('tierId'); + expect(errorEl.innerText).toBe('Could not create Stripe checkout session'); + expect(element.addEventListener).toHaveBeenCalledWith('click', clickHandler); + }); }); describe('data-members-manage-billing', () => { diff --git a/compose.dev.analytics.yaml b/compose.dev.analytics.yaml index 198460fe322..7097358f68b 100644 --- a/compose.dev.analytics.yaml +++ b/compose.dev.analytics.yaml @@ -53,12 +53,12 @@ services: start_interval: 1s tb-cli: - build: + build: &tb-cli-build context: ./ dockerfile: docker/tb-cli/Dockerfile container_name: ghost-dev-tb-cli working_dir: /home/tinybird - environment: + environment: &tb-cli-environment - TB_HOST=http://tinybird-local:7181 - TB_LOCAL_HOST=tinybird-local volumes: @@ -68,6 +68,19 @@ services: tinybird-local: condition: service_healthy + tb-watch: + build: *tb-cli-build + container_name: ghost-dev-tb-watch + working_dir: /home/tinybird + # Keep initialization in tb-cli so Ghost can wait for it to finish. + entrypoint: ['tb', '--local', 'dev'] + environment: *tb-cli-environment + volumes: + - ./ghost/core/core/server/data/tinybird:/home/tinybird + depends_on: + tb-cli: + condition: service_completed_successfully + ghost-dev: volumes: # Mount shared-config volume to access Tinybird tokens created by tb-cli diff --git a/docs/practices/database-migrations.md b/docs/practices/database-migrations.md index a7a008d6750..fd2a537c471 100644 --- a/docs/practices/database-migrations.md +++ b/docs/practices/database-migrations.md @@ -84,6 +84,36 @@ pnpm knex-migrator rollback --v --force method. You can use this workflow to iterate while `down()` restores the same state that existed before `up()`. +### New tables still in development + +A new table's shape often changes several times before its feature is ready. +Rather than writing a migration for each change, list the table in +`IN_DEVELOPMENT_TABLES` in +[`core/server/data/schema/in-development.ts`](../../ghost/core/core/server/data/schema/in-development.ts) +and define its schema in `schema.js` without adding a versioned migration. It +is only created in development and testing databases. + +To apply a changed definition to your local database, rebuild the listed +tables. This drops them and discards their data: + +```bash +cd ghost/core +pnpm migrate:rebuild-in-development-tables +``` + +Code that reads or writes the table must stay dormant wherever the table is not +created, for example behind a [feature flag](feature-flags.md). +The table still needs a classification in the exporter table lists. + +When the definition is final, remove the table from `IN_DEVELOPMENT_TABLES`, +add the versioned migration that creates it, and update the schema integrity +hash. Create the table with +`addTable(name, tableSpec, {replaceDevelopmentCopy: true})`: development and +testing databases already have a copy built from an earlier definition, and the +option replaces it with the final one. This discards its data and drops the +tables that reference it; Ghost recreates the ones still in development when it +next boots. From then on the table follows the normal migration rules. + ### Testing The database-backed migration suites run against MySQL, Ghost's supported diff --git a/e2e/helpers/pages/admin/index.ts b/e2e/helpers/pages/admin/index.ts index 596ac2f32aa..c17d7a55152 100644 --- a/e2e/helpers/pages/admin/index.ts +++ b/e2e/helpers/pages/admin/index.ts @@ -13,5 +13,6 @@ export * from './posts'; export * from './tags'; export * from './sidebar'; export * from './site-page'; +export * from './migrate-page'; export * from './billing'; export * from './comments'; diff --git a/e2e/helpers/pages/admin/migrate-page.ts b/e2e/helpers/pages/admin/migrate-page.ts new file mode 100644 index 00000000000..10b1248bd8f --- /dev/null +++ b/e2e/helpers/pages/admin/migrate-page.ts @@ -0,0 +1,17 @@ +import { AdminPage } from './admin-page'; +import { FrameLocator, Locator, Page } from '@playwright/test'; +import { closeMigrateButton, migrateFrame } from '@tryghost/test-data/selectors/migrate'; + +export class MigratePage extends AdminPage { + readonly migrationAppFrame: Locator; + readonly migrationApp: FrameLocator; + readonly closeButton: Locator; + + constructor(page: Page) { + super(page); + this.pageUrl = '/ghost/#/migrate'; + this.migrationAppFrame = page.getByTitle(migrateFrame, { exact: true }); + this.migrationApp = this.migrationAppFrame.contentFrame(); + this.closeButton = page.getByRole('button', { name: closeMigrateButton, exact: true }); + } +} diff --git a/e2e/helpers/pages/admin/site-page.ts b/e2e/helpers/pages/admin/site-page.ts index c14f34c6b7d..290099cde33 100644 --- a/e2e/helpers/pages/admin/site-page.ts +++ b/e2e/helpers/pages/admin/site-page.ts @@ -1,5 +1,6 @@ import { AdminPage } from './admin-page'; import { Locator, Page } from '@playwright/test'; +import { sitePreviewFrame } from '@tryghost/test-data/selectors/view-site'; export class SitePage extends AdminPage { readonly sitePreview: Locator; @@ -7,7 +8,7 @@ export class SitePage extends AdminPage { constructor(page: Page) { super(page); this.pageUrl = '/ghost/#/site'; - this.sitePreview = page.getByTitle('Site preview'); + this.sitePreview = page.getByTitle(sitePreviewFrame); } async waitForPageToFullyLoad(): Promise { diff --git a/e2e/tests/admin/iframe-routes/migration-app-handoff.test.ts b/e2e/tests/admin/iframe-routes/migration-app-handoff.test.ts new file mode 100644 index 00000000000..f5762862366 --- /dev/null +++ b/e2e/tests/admin/iframe-routes/migration-app-handoff.test.ts @@ -0,0 +1,62 @@ +import { MigratePage } from '@/admin-pages'; +import { expect, test } from '@/helpers/playwright'; + +const MIGRATION_APP_ORIGIN = 'https://migrate.ghost.org'; + +// Stands in for the migration app: asks Admin for its credentials, shows what +// it received, and can send Admin back to the migration settings. +const migrationAppStandIn = ` +

+  
+  
+`;
+
+interface IntegrationsResponse {
+  integrations: Array<{ slug: string; api_keys: Array<{ secret: string }> }>;
+}
+
+test.describe('Ghost Admin - Migration app handoff', () => {
+  test('gives the migration app its credentials and follows it back to settings', async ({
+    page,
+    baseURL,
+    ghostAccountOwner,
+  }) => {
+    const response = await page.request.get('/ghost/api/admin/integrations/', {
+      params: { include: 'api_keys' },
+    });
+    const { integrations } = (await response.json()) as IntegrationsResponse;
+    const migrationKey = integrations.find(({ slug }) => slug === 'self-serve-migration')
+      ?.api_keys[0].secret;
+    await page.route(
+      (url) => url.origin === MIGRATION_APP_ORIGIN,
+      (route) => route.fulfill({ contentType: 'text/html', body: migrationAppStandIn }),
+    );
+
+    const migratePage = new MigratePage(page);
+    await migratePage.goto('/ghost/#/migrate/substack');
+
+    const initialData = migratePage.migrationApp.locator('#initial-data');
+    await expect(initialData).not.toBeEmpty();
+    await expect(migratePage.closeButton).toBeVisible();
+    expect(JSON.parse((await initialData.textContent()) ?? '')).toEqual({
+      apiUrl: `${new URL(baseURL ?? '').origin}/ghost`,
+      apiKey: migrationKey,
+      stripe: false,
+      csvContentImporter: false,
+      ghostVersion: expect.stringMatching(/^\d+\.\d+$/),
+      ownerEmail: ghostAccountOwner.email,
+    });
+
+    await migratePage.migrationApp.getByRole('button', { name: 'Back to Ghost' }).click();
+
+    await expect(page).toHaveURL(/\/ghost\/#\/settings\/migration\/?$/);
+    await expect(migratePage.migrationAppFrame).toBeHidden();
+  });
+});
diff --git a/e2e/tests/admin/iframe-routes/view-site.test.ts b/e2e/tests/admin/iframe-routes/view-site.test.ts
new file mode 100644
index 00000000000..fd4ec946f94
--- /dev/null
+++ b/e2e/tests/admin/iframe-routes/view-site.test.ts
@@ -0,0 +1,15 @@
+import { SidebarPage, SitePage } from '@/admin-pages';
+import { expect, test } from '@/helpers/playwright';
+
+test.describe('Ghost Admin - View site', () => {
+  test('shows the site homepage inside Admin', async ({ page }) => {
+    const sidebar = new SidebarPage(page);
+    await sidebar.goto('/ghost/#/analytics');
+
+    await sidebar.getNavLink('View site').click();
+
+    const sitePage = new SitePage(page);
+    await sitePage.waitForPageToFullyLoad();
+    await expect(sitePage.sitePreview.contentFrame().locator('body.home-template')).toBeVisible();
+  });
+});
diff --git a/ghost/core/bin/rebuild-in-development-tables.ts b/ghost/core/bin/rebuild-in-development-tables.ts
new file mode 100644
index 00000000000..e641598e34f
--- /dev/null
+++ b/ghost/core/bin/rebuild-in-development-tables.ts
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+
+// Drops and recreates the in-development tables listed in
+// core/server/data/schema/in-development.ts so a local database picks up
+// changes to their definitions. Their data is discarded.
+
+import '../core/server/overrides';
+import logging from '@tryghost/logging';
+import db from '../core/server/data/db';
+import { rebuildInDevelopmentTables } from '../core/server/data/schema/in-development';
+
+async function main() {
+  try {
+    await rebuildInDevelopmentTables(db.knex);
+  } catch (err) {
+    logging.error(err);
+    process.exitCode = 1;
+  } finally {
+    await db.knex.destroy();
+  }
+}
+
+main();
diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js
index cc639cb537d..46247565ade 100644
--- a/ghost/core/core/boot.js
+++ b/ghost/core/core/boot.js
@@ -101,6 +101,29 @@ async function initCore({ ghostServer, config }) {
   adapterManager.init();
   debug('End: adapters');
 
+  // Limit image processing to the configured image formats before anything
+  // can process an image
+  debug('Begin: image upload config');
+  const { restrictImageDecoders } = require('./server/lib/image/image-decoders');
+  const {
+    IMAGE_UPLOAD_TYPES,
+    getIgnoredImageContentTypes,
+  } = require('./server/lib/image/image-content');
+  const uploads = config.get('uploads');
+  restrictImageDecoders(IMAGE_UPLOAD_TYPES.flatMap((type) => uploads[type]?.extensions ?? []));
+
+  // Image uploads can only be stored as image types, so point out any
+  // configured types that will be ignored
+  for (const type of IMAGE_UPLOAD_TYPES) {
+    const ignored = getIgnoredImageContentTypes(uploads[type]?.contentTypes ?? []);
+    if (ignored.length > 0) {
+      require('@tryghost/logging').warn(
+        `Ignoring uploads.${type}.contentTypes that are not image types: ${ignored.join(', ')}`,
+      );
+    }
+  }
+  debug('End: image upload config');
+
   // URL Utils is a bit slow, put it here so the timing is visible separate from models
   debug('Begin: Load urlUtils');
   require('./shared/url-utils');
diff --git a/ghost/core/core/server/api/endpoints/themes.js b/ghost/core/core/server/api/endpoints/themes.js
index dc1ba2b3e75..7ca282a338b 100644
--- a/ghost/core/core/server/api/endpoints/themes.js
+++ b/ghost/core/core/server/api/endpoints/themes.js
@@ -1,5 +1,5 @@
 const themeService = require('../../services/themes');
-const limitService = require('../../services/limits');
+const { limitService } = require('../../services/limits');
 const models = require('../../models');
 
 // Used to emit theme.uploaded which is used in core/server/analytics-events
diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/files.js b/ghost/core/core/server/api/endpoints/utils/validators/input/files.js
index a65e41f60c2..c6817dce24d 100644
--- a/ghost/core/core/server/api/endpoints/utils/validators/input/files.js
+++ b/ghost/core/core/server/api/endpoints/utils/validators/input/files.js
@@ -1,4 +1,4 @@
-const limitService = require('../../../../../services/limits');
+const { limitService } = require('../../../../../services/limits');
 
 module.exports = {
   async upload(apiConfig, frame) {
diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/media.js b/ghost/core/core/server/api/endpoints/utils/validators/input/media.js
index e975cd5cf01..23485844b37 100644
--- a/ghost/core/core/server/api/endpoints/utils/validators/input/media.js
+++ b/ghost/core/core/server/api/endpoints/utils/validators/input/media.js
@@ -1,4 +1,4 @@
-const limitService = require('../../../../../services/limits');
+const { limitService } = require('../../../../../services/limits');
 
 module.exports = {
   async upload(apiConfig, frame) {
diff --git a/ghost/core/core/server/data/db/database-state-manager.js b/ghost/core/core/server/data/db/database-state-manager.js
index a98e1caa4f4..0905b9e76e0 100644
--- a/ghost/core/core/server/data/db/database-state-manager.js
+++ b/ghost/core/core/server/data/db/database-state-manager.js
@@ -30,6 +30,12 @@ const printState = ({ state }) => {
   }
 };
 
+const createMissingInDevelopmentTables = async () => {
+  const { inDevelopment } = require('../schema');
+  const db = require('./index');
+  await inDevelopment.createMissingInDevelopmentTables(db.knex);
+};
+
 class DatabaseStateManager {
   constructor({ knexMigratorFilePath }) {
     this.knexMigrator = new KnexMigrator({
@@ -83,6 +89,7 @@ class DatabaseStateManager {
       printState({ state });
 
       if (state === states.READY) {
+        await createMissingInDevelopmentTables();
         return;
       }
 
@@ -111,6 +118,8 @@ class DatabaseStateManager {
       state = await this.getState();
 
       printState({ state });
+
+      await createMissingInDevelopmentTables();
     } catch (error) {
       let errorToThrow = error;
       if (!errors.utils.isGhostError(error)) {
diff --git a/ghost/core/core/server/data/importer/handlers/image.js b/ghost/core/core/server/data/importer/handlers/image.js
index 736dd3c10ba..937f52074c0 100644
--- a/ghost/core/core/server/data/importer/handlers/image.js
+++ b/ghost/core/core/server/data/importer/handlers/image.js
@@ -1,8 +1,34 @@
 const _ = require('lodash');
 const path = require('path');
+const logging = require('@tryghost/logging');
 const config = require('../../../../shared/config');
 const urlUtils = require('../../../../shared/url-utils').default;
 const adapterManager = require('../../../services/adapter-manager').default;
+const { isAllowedImageContent, isSvgExtension } = require('../../../lib/image/image-content');
+const { sanitizeSvgFile } = require('../../../lib/image/svg-sanitizer');
+const { promisePool } = require('../../../lib/promise-pool');
+
+// Imports can hold thousands of images, so bound the number of open files
+const VALIDATION_CONCURRENCY = 10;
+
+/**
+ * Files are picked out of the import by extension alone, so check the
+ * contents match before they are stored as images. SVGs are sanitized in
+ * place, the same as SVG uploads.
+ *
+ * @param {{name: string, path: string}} file
+ * @param {string[]} extensions
+ * @returns {Promise}
+ */
+const isValidImage = async (file, extensions) => {
+  const ext = path.extname(file.name).toLowerCase();
+
+  if (isSvgExtension(ext)) {
+    return sanitizeSvgFile(file.path, ext === '.svgz');
+  }
+
+  return isAllowedImageContent(file.path, extensions);
+};
 
 const ImageHandler = {
   type: 'images',
@@ -10,12 +36,26 @@ const ImageHandler = {
   contentTypes: config.get('uploads').images.contentTypes,
   directories: ['images', 'content'],
 
-  loadFile: function (files, baseDir) {
+  loadFile: async function (files, baseDir) {
     const store = adapterManager.getAdapter('storage:images');
-    const baseDirRegex = baseDir ? new RegExp('^' + baseDir + '/') : new RegExp('');
+
+    const validFiles = new Set();
+    await promisePool(
+      files.map((file) => async () => {
+        if (await isValidImage(file, ImageHandler.extensions)) {
+          validFiles.add(file);
+        } else {
+          logging.warn(`Skipped importing ${file.name}: not a supported image`);
+        }
+      }),
+      VALIDATION_CONCURRENCY,
+    );
+    files = files.filter((file) => validFiles.has(file));
+
+    const baseDirRegex = baseDir ? new RegExp('^' + _.escapeRegExp(baseDir) + '/') : new RegExp('');
 
     const imageFolderRegexes = _.map(store.staticFileURLPrefix.split('/'), function (dir) {
-      return new RegExp('^' + dir + '/');
+      return new RegExp('^' + _.escapeRegExp(dir) + '/');
     });
 
     // normalize the directory structure
diff --git a/ghost/core/core/server/data/importer/handlers/importer-content-file-handler.js b/ghost/core/core/server/data/importer/handlers/importer-content-file-handler.js
index 5154c1ba235..e01fe9311f7 100644
--- a/ghost/core/core/server/data/importer/handlers/importer-content-file-handler.js
+++ b/ghost/core/core/server/data/importer/handlers/importer-content-file-handler.js
@@ -36,12 +36,12 @@ class ImporterContentFileHandler {
   }
 
   async loadFile(files, baseDir) {
-    const baseDirRegex = baseDir ? new RegExp('^' + baseDir + '/') : new RegExp('');
+    const baseDirRegex = baseDir ? new RegExp('^' + _.escapeRegExp(baseDir) + '/') : new RegExp('');
 
     const contentFilesFolderRegexes = _.map(
       this.storage.staticFileURLPrefix.split('/'),
       function (dir) {
-        return new RegExp('^' + dir + '/');
+        return new RegExp('^' + _.escapeRegExp(dir) + '/');
       },
     );
 
diff --git a/ghost/core/core/server/data/importer/handlers/markdown.js b/ghost/core/core/server/data/importer/handlers/markdown.js
index 626fa14bb84..287616aed34 100644
--- a/ghost/core/core/server/data/importer/handlers/markdown.js
+++ b/ghost/core/core/server/data/importer/handlers/markdown.js
@@ -80,7 +80,9 @@ const MarkdownHandler = {
   directories: [],
 
   loadFile: function (files, startDir) {
-    const startDirRegex = startDir ? new RegExp('^' + startDir + '/') : new RegExp('');
+    const startDirRegex = startDir
+      ? new RegExp('^' + _.escapeRegExp(startDir) + '/')
+      : new RegExp('');
     const posts = [];
     const ops = [];
 
diff --git a/ghost/core/core/server/data/importer/handlers/revue.js b/ghost/core/core/server/data/importer/handlers/revue.js
index 7fb2473545b..4110dbd95c5 100644
--- a/ghost/core/core/server/data/importer/handlers/revue.js
+++ b/ghost/core/core/server/data/importer/handlers/revue.js
@@ -17,7 +17,9 @@ const RevueHandler = {
 
   loadFile: function (files, startDir) {
     debug('loadFile', files);
-    const startDirRegex = startDir ? new RegExp('^' + startDir + '/') : new RegExp('');
+    const startDirRegex = startDir
+      ? new RegExp('^' + _.escapeRegExp(startDir) + '/')
+      : new RegExp('');
     const idRegex = /_.*?\./;
     const ops = [];
     const revue = {};
diff --git a/ghost/core/core/server/data/importer/importers/data/users-importer.js b/ghost/core/core/server/data/importer/importers/data/users-importer.js
index 1745c6d6693..91dfa6e3563 100644
--- a/ghost/core/core/server/data/importer/importers/data/users-importer.js
+++ b/ghost/core/core/server/data/importer/importers/data/users-importer.js
@@ -2,7 +2,7 @@ const debug = require('@tryghost/debug')('importer:users');
 const _ = require('lodash');
 const BaseImporter = require('./base');
 const models = require('../../../../models');
-const limitService = require('../../../../services/limits');
+const { limitService } = require('../../../../services/limits');
 
 class UsersImporter extends BaseImporter {
   constructor(allDataFromFile) {
diff --git a/ghost/core/core/server/data/migrations/init/1-create-tables.js b/ghost/core/core/server/data/migrations/init/1-create-tables.js
index 2ae6f5e40e1..18b8a7c9e14 100644
--- a/ghost/core/core/server/data/migrations/init/1-create-tables.js
+++ b/ghost/core/core/server/data/migrations/init/1-create-tables.js
@@ -1,14 +1,16 @@
 const commands = require('../../schema').commands;
-const schema = require('../../schema').tables;
 const views = require('../../schema').views;
+const inDevelopment = require('../../schema').inDevelopment;
 const logging = require('@tryghost/logging');
-const schemaTables = Object.keys(schema);
 
 module.exports.up = async (options) => {
   const connection = options.connection;
 
   const existingTables = await commands.getTables(connection);
-  const missingTables = schemaTables.filter((t) => !existingTables.includes(t));
+  // In-development tables are only created where config enables them, see
+  // schema/in-development.ts
+  const tablesToCreate = inDevelopment.getTablesToCreate();
+  const missingTables = tablesToCreate.filter((t) => !existingTables.includes(t));
 
   for (const table of missingTables) {
     logging.info('Creating table: ' + table);
diff --git a/ghost/core/core/server/data/migrations/utils/tables.js b/ghost/core/core/server/data/migrations/utils/tables.js
index 348c2749a11..c855eb73914 100644
--- a/ghost/core/core/server/data/migrations/utils/tables.js
+++ b/ghost/core/core/server/data/migrations/utils/tables.js
@@ -1,19 +1,87 @@
 const logging = require('@tryghost/logging');
+const DatabaseInfo = require('@tryghost/database-info');
+const config = require('../../../../shared/config');
 const { commands } = require('../../schema');
 const { createIrreversibleMigration, createNonTransactionalMigration } = require('./migrations');
 
+function isDevelopmentOrTesting() {
+  const env = config.get('env');
+  return env === 'development' || env.startsWith('testing');
+}
+
+/**
+ * @param {import('knex').Knex} connection
+ * @param {string} table
+ * @returns {Promise} the other tables with a foreign key to `table`
+ */
+async function getReferencingTables(connection, table) {
+  if (DatabaseInfo.isMySQL(connection)) {
+    const [rows] = await connection.raw(
+      `SELECT DISTINCT TABLE_NAME AS name
+        FROM information_schema.KEY_COLUMN_USAGE
+        WHERE REFERENCED_TABLE_SCHEMA = DATABASE()
+        AND REFERENCED_TABLE_NAME = ?
+        AND TABLE_NAME <> ?`,
+      [table, table],
+    );
+    return rows.map((/** @type {{name: string}} */ row) => row.name);
+  }
+
+  const referencing = [];
+  for (const other of await commands.getTables(connection)) {
+    if (other === table) {
+      continue;
+    }
+    const foreignKeys = await connection.raw(`PRAGMA foreign_key_list('${other}');`);
+    if (
+      foreignKeys.some((/** @type {{table: string}} */ foreignKey) => foreignKey.table === table)
+    ) {
+      referencing.push(other);
+    }
+  }
+  return referencing;
+}
+
+/**
+ * Drops a development copy of a table along with the tables that reference it,
+ * so no rows are left pointing at a table that no longer exists. Boot recreates
+ * the dropped tables that are still in development.
+ *
+ * @param {import('knex').Knex} connection
+ * @param {string} table
+ * @param {Set} [dropped]
+ */
+async function dropDevelopmentCopy(connection, table, dropped = new Set()) {
+  dropped.add(table);
+  for (const referencing of await getReferencingTables(connection, table)) {
+    if (!dropped.has(referencing)) {
+      await dropDevelopmentCopy(connection, referencing, dropped);
+    }
+  }
+
+  logging.info(`Dropping development copy of table: ${table}`);
+  await commands.deleteTable(table, connection);
+}
+
 /**
  * Creates a migrations which will add a new table from schema.js to the database
  * @param {string} name - table name
  * @param {Object} tableSpec - copy of table schema definition as defined in schema.js at the moment of writing the migration, this parameter MUST be present
+ * @param {Object} [options]
+ * @param {boolean} [options.replaceDevelopmentCopy] - set when finalising a table that was in development (see
+ *   schema/in-development.ts). Development and testing databases already have a copy built from an earlier
+ *   definition, so there an existing table is replaced, discarding its data and dropping the tables that reference
+ *   it. Other environments skip it as usual.
  *
  * @returns {Object} migration object returning config/up/down properties
  */
-function addTable(name, tableSpec) {
+function addTable(name, tableSpec, { replaceDevelopmentCopy = false } = {}) {
   return createNonTransactionalMigration(
     async function up(connection) {
       const tableExists = await connection.schema.hasTable(name);
-      if (tableExists) {
+      if (tableExists && replaceDevelopmentCopy && isDevelopmentOrTesting()) {
+        await dropDevelopmentCopy(connection, name);
+      } else if (tableExists) {
         logging.warn(`Skipping adding table: ${name} - table already exists`);
         return;
       }
@@ -28,6 +96,10 @@ function addTable(name, tableSpec) {
         return;
       }
 
+      if (replaceDevelopmentCopy && isDevelopmentOrTesting()) {
+        return dropDevelopmentCopy(connection, name);
+      }
+
       logging.info(`Dropping table: ${name}`);
       return commands.deleteTable(name, connection);
     },
diff --git a/ghost/core/core/server/data/schema/README.md b/ghost/core/core/server/data/schema/README.md
index 03a8b11ca60..abd3f28ad21 100644
--- a/ghost/core/core/server/data/schema/README.md
+++ b/ghost/core/core/server/data/schema/README.md
@@ -85,3 +85,8 @@ Changing `schema.js` alone does not update an existing database. Every schema
 change needs a migration that moves an installed database to the new shape.
 Follow the [database migrations guide](../../../../../../docs/practices/database-migrations.md)
 for generation, iteration, testing, and review requirements.
+
+Tables listed in `in-development.ts` are the exception: they are created only
+in development and testing databases and need no migration until their
+definition is final. See the guide's section on new tables still in
+development.
diff --git a/ghost/core/core/server/data/schema/in-development.ts b/ghost/core/core/server/data/schema/in-development.ts
new file mode 100644
index 00000000000..ff0aaceb597
--- /dev/null
+++ b/ghost/core/core/server/data/schema/in-development.ts
@@ -0,0 +1,107 @@
+import logging from '@tryghost/logging';
+import type { Knex } from 'knex';
+import config from '../../../shared/config';
+// @ts-expect-error This module lacks type definitions.
+import commands from './commands';
+// @ts-expect-error This module lacks type definitions.
+import schema from './schema';
+
+/**
+ * Tables listed here are defined in `schema.js` but are still being iterated on.
+ *
+ * - `knex-migrator init` only creates them in the development and testing
+ *   environments, and only while `createInDevelopmentTables` is enabled in
+ *   config, so production databases never contain them.
+ * - Boot creates any that are missing from an existing development database.
+ * - They need no versioned migration and are left out of the schema integrity
+ *   hash, so their definition can change freely.
+ *
+ * Once a table's definition is final, remove it from this list and add the
+ * versioned migration that creates it.
+ *
+ * Code that reads or writes these tables must stay dormant wherever the tables
+ * are not created.
+ */
+export const IN_DEVELOPMENT_TABLES: string[] = [];
+
+export function isInDevelopmentTable(tableName: string): boolean {
+  return IN_DEVELOPMENT_TABLES.includes(tableName);
+}
+
+/**
+ * Only development and testing databases may contain in-development tables,
+ * whatever the config says, so a misconfigured production site can't create
+ * tables that have no migrations
+ */
+export function shouldCreateInDevelopmentTables(): boolean {
+  const env: string = config.get('env');
+  const isDevelopmentOrTesting = env === 'development' || env.startsWith('testing');
+  return isDevelopmentOrTesting && config.get('createInDevelopmentTables') === true;
+}
+
+/**
+ * The in-development tables, in the order they appear in `schema.js`
+ */
+export function getInDevelopmentTables(): string[] {
+  return Object.keys(schema).filter(isInDevelopmentTable);
+}
+
+/**
+ * The tables `knex-migrator init` should create in the current environment
+ */
+export function getTablesToCreate(): string[] {
+  const includeInDevelopment = shouldCreateInDevelopmentTables();
+  return Object.keys(schema).filter(
+    (tableName) => includeInDevelopment || !isInDevelopmentTable(tableName),
+  );
+}
+
+/**
+ * Creates in-development tables missing from an already initialised database
+ */
+export async function createMissingInDevelopmentTables(knex: Knex): Promise {
+  if (!shouldCreateInDevelopmentTables()) {
+    return;
+  }
+
+  const tables = getInDevelopmentTables();
+  if (!tables.length) {
+    return;
+  }
+
+  const existingTables: string[] = await commands.getTables(knex);
+
+  for (const tableName of tables) {
+    if (!existingTables.includes(tableName)) {
+      logging.info(`Creating in-development table: ${tableName}`);
+      await commands.createTable(tableName, knex);
+    }
+  }
+}
+
+/**
+ * Drops and recreates every in-development table, discarding its data, so the
+ * database picks up changes to their definitions in `schema.js`
+ */
+export async function rebuildInDevelopmentTables(knex: Knex): Promise {
+  if (!shouldCreateInDevelopmentTables()) {
+    logging.warn(
+      'In-development tables are disabled in this environment (createInDevelopmentTables)',
+    );
+    return;
+  }
+
+  const tables = getInDevelopmentTables();
+
+  // schema.js lists each table after the tables it references, so drop in
+  // reverse to remove referencing tables before the tables they point to
+  for (const tableName of tables.toReversed()) {
+    logging.info(`Dropping in-development table: ${tableName}`);
+    await commands.deleteTable(tableName, knex);
+  }
+
+  for (const tableName of tables) {
+    logging.info(`Creating in-development table: ${tableName}`);
+    await commands.createTable(tableName, knex);
+  }
+}
diff --git a/ghost/core/core/server/data/schema/index.js b/ghost/core/core/server/data/schema/index.js
index aebd061f0e3..d644534c270 100644
--- a/ghost/core/core/server/data/schema/index.js
+++ b/ghost/core/core/server/data/schema/index.js
@@ -1,5 +1,6 @@
 module.exports.tables = require('./schema');
 module.exports.views = require('./views');
 module.exports.commands = require('./commands');
+module.exports.inDevelopment = require('./in-development');
 module.exports.defaultSettings = require('./default-settings');
 module.exports.validate = require('./validator').validateSchema;
diff --git a/ghost/core/core/server/data/tinybird/README.md b/ghost/core/core/server/data/tinybird/README.md
index fbcb0cd0307..7884f11d9cd 100644
--- a/ghost/core/core/server/data/tinybird/README.md
+++ b/ghost/core/core/server/data/tinybird/README.md
@@ -2,104 +2,75 @@
 
 This is the web analytics implementation using [Tinybird Forward](https://www.tinybird.co/docs/forward).
 
-### Using Tinybird with Docker (Recommended)
+### Local development with Docker
 
-The easiest way to run Tinybird with Ghost locally is to use Docker and Docker Compose. This is the recommended approach because it requires zero additional configuration and avoids the need to install the Tinybird CLI on your machine. You'll need to have Docker and Docker Compose installed on your machine.
+The supported local development workflow uses `pnpm dev:analytics` with Docker
+and Docker Compose. Install both before starting; the Tinybird CLI runs in a
+container and Ghost is configured automatically.
 
 All of these commands should be run from the root of the Ghost repository:
 
-1. `docker compose --profile analytics up -d` - This will start the Tinybird local container, deploy the datafiles in this directory to it, and configure + start the Analytics service.
-1. `docker compose --profile split up` - This will start the Ghost service and configure it to use the bundled Tinybird & Analytics services automatically.
+```bash
+pnpm dev:analytics
+```
+
+This starts Ghost, Tinybird Local, and the Analytics service. The `tb-cli`
+service builds the datafiles in this directory and writes the connection
+configuration before Ghost and Analytics start. After that initial build, the
+`tb-watch` service runs `tb --local dev` to watch the mounted files and
+automatically rebuild the local Tinybird workspace when they change.
+
+Watch output and build errors appear in the development logs. Fix an invalid
+datafile and save it to trigger another build. Press `Ctrl+C` to stop the
+development environment, including the watcher.
+
+Watching is also enabled for the other development commands that include
+analytics, such as `pnpm dev:analytics:local` and `pnpm dev:full`.
 
 Ghost will be accessible at `http://localhost:2368`, and analytics should work out of the box.
 
 #### Using the Tinybird CLI from Docker
 
-Following the above steps will deploy the Tinybird datafiles to the local Tinybird instance. If you want to use the Tinybird CLI without installing it on your machine, you can use the `tb` docker compose service to run a one-off command or initiate a shell session.
+The development environment already watches Tinybird files. To run additional
+CLI commands without installing Tinybird on your machine, use `pnpm tb` while
+`pnpm dev:analytics` is running. This runs the CLI in the `tb-cli` Docker Compose
+service and forwards any arguments you provide.
 
 To run a one-off command, use the following command:
 
 ```bash
-docker compose run --rm -it tb-cli tb 
+pnpm tb 
 ```
 
 For example, to list all tokens, you can run:
 
 ```bash
-docker compose run --rm -it tb-cli tb token ls
+pnpm tb token ls
 ```
 
-To start a persistent shell session with `tb dev`, which will continuously watch for changes in the data files and automatically deploy them, you can run:
+These commands use the running Tinybird Local instance and bypass the setup
+entrypoint. To follow just the watcher logs, run:
 
 ```bash
-docker compose run --rm -it tb-cli tb dev
+docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml logs -f tb-watch
 ```
 
-### Using Tinybird locally
-
-#### Requirements
-
-In order to use Tinybird locally, make sure to install the Tinybird CLI.
-**Only for the first time, run:** `pnpm tb:install` from the root folder.
-
-To run Tinybird locally, run `pnpm tb` from root.
-
-#### Connecting Tinybird to Ghost config file
-
-In order to use Tinybird local service with Ghost, the config local needs to be updated with Tinybird details.
-Config can contain information about remote and local environments.
-
-Make sure to get the proper tokens, which you can obtain by running `tb token ls`, or if you are within Tinybird
-local shell environment, you can just run `token ls`.
-
-Make sure you use the proper tokens. If you need read, write permissions, use a token that allows both.
-
-Simply disable local to use the cloud version by switching `local: false`.
-This is switchable both via tracker script (ideally not used with the cloud data except with a unique id) and stats page.
-
-You can enable or disable local in config script below. Update your `/ghost/core/config.local.json` or `/ghost/core/config.local.jsonc`
-with the following information.
-
-#### Config
-
-Sample config:
-
-```jsonc
-{
-   "someOtherConfigurationForEmail": {
-        "transport": "SMTP",
-        "options": {
-            "port": 12345
-        }
-    },
-    "tinybird": {
-        "workspaceId": "workspace-id-from-tinybird",
-        "adminToken": "admin-token-from-tinybird",
-        "tracker": {
-            // -- needs to be present, and required Traffic Analytics service running with correct setup
-            "endpoint": "http://localhost:3000/api/v1/page_hit"
-        },
-        "stats": {
-            //  -- optional override for site uuid
-            // "id": "106a623d-9792-4b63-acde-4a0c28ead3dc",
-            "endpoint": "https://api.tinybird.co",
-            // -- optional endpoint version suffix (e.g., "v2" calls api_kpis_v2 instead of api_kpis)
-            // "version": "v2",
-            // -- tinybird local configuration (optional)
-            "local": {
-                "enabled": true,
-                "token": "local-stats-or-admin-token",
-            }
-        }
-    }
-}
+### Testing
+
+With `pnpm dev:analytics` running, run the Tinybird tests against Tinybird Local
+from the repository root:
+
+```bash
+pnpm tb:test
 ```
 
-#### Testing
+To run a specific test file, pass its path relative to the Tinybird project:
 
-Tests are executed using `test run` when running `tb dev`.
+```bash
+pnpm tb:test tests/api_post_visitor_counts.yaml
+```
 
-#### Testing data
+### Testing data
 
 In fixtures folder, you can find local test data. When tinybird local is running, you can append data, or remove data
 from fixture files here. As you modify the files, the datasources will be auto updated.
diff --git a/ghost/core/core/server/data/tinybird/endpoints/api_kpis.pipe b/ghost/core/core/server/data/tinybird/endpoints/api_kpis.pipe
index c5f6b4b47a6..fa0ec1eef75 100644
--- a/ghost/core/core/server/data/tinybird/endpoints/api_kpis.pipe
+++ b/ghost/core/core/server/data/tinybird/endpoints/api_kpis.pipe
@@ -3,106 +3,122 @@ TOKEN "axis" READ
 
 NODE timeseries
 SQL >
-
     %
-        {% set _single_day = defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
-        with
-            {% if defined(date_from) %}
-                toStartOfDay(
-                    toDate(
-                        {{
-                            Date(
-                                date_from,
-                                description="Starting day for filtering a date range",
-                                required=False,
-                            )
-                        }}
-                    )
-                ) as start,
-            {% else %} toStartOfDay(timestampAdd(today(), interval -7 day)) as start,
-            {% end %}
-            {% if defined(date_to) %}
-                toStartOfDay(
-                    toDate(
-                        {{
-                            Date(
-                                date_to,
-                                description="Finishing day for filtering a date range",
-                                required=False,
-                            )
-                        }}
-                    )
-                ) as end
-            {% else %} toStartOfDay(today()) as end
-            {% end %}
-            {% if _single_day %}
-                ,
-                {% if defined(current_time) %}
-                    toDateTime({{ String(current_time, description="Current time override for tests", required=False) }}, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}})
-                {% else %}
-                    toTimezone(now(), {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}})
-                {% end %} as current_time,
-                if(
-                    toDate(end) = toDate(current_time),
-                    least(
-                        timestampAdd(end, interval 1 day),
-                        timestampAdd(start, toIntervalHour(toHour(current_time) + 1))
-                    ),
-                    timestampAdd(end, interval 1 day)
-                ) as end_exclusive
-            {% end %}
-        {% if _single_day %}
-            select
-                arrayJoin(
-                    arrayMap(
-                        x -> toDateTime(toString(toDateTime(x)), {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}}),
-                        range(
-                            toUInt32(toDateTime(start)), toUInt32(end_exclusive), 3600
+    {% set _single_day = (
+        defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0
+    ) %}
+    with
+        {% if defined(date_from) %}
+            toStartOfDay(
+                toDate(
+                    {{
+                        Date(
+                            date_from,
+                            description="Starting day for filtering a date range",
+                            required=False,
                         )
+                    }}
+                )
+            ) as start,
+        {% else %} toStartOfDay(timestampAdd(today(), interval -7 day)) as start,
+        {% end %}
+        {% if defined(date_to) %}
+            toStartOfDay(
+                toDate(
+                    {{
+                        Date(
+                            date_to,
+                            description="Finishing day for filtering a date range",
+                            required=False,
+                        )
+                    }}
+                )
+            ) as
+    end {% else %} toStartOfDay(today()) as
+    end {% end %}
+    {% if _single_day %}
+        ,
+        {% if defined(current_time) %}
+            toDateTime(
+                {{
+                    String(
+                        current_time, description="Current time override for tests", required=False
                     )
-                ) as date
+                }}, {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+            )
         {% else %}
-            select
-                arrayJoin(
-                    arrayMap(
-                        x -> toDate(x),
-                        range(toUInt32(start), toUInt32(timestampAdd(end, interval 1 day)), 24 * 3600)
-                    )
-                ) as date
-        {% end %}
-
+            toTimezone(
+                now(), {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+            )
+        {% end %} as current_time,
+        if(toDate(end) = toDate(current_time),
+        least(timestampAdd(end, interval 1 day),
+        timestampAdd(start, toIntervalHour(toHour(current_time) + 1))
+    ),
+    timestampAdd(end,
+    interval 1 day
+    )
+    ) as end_exclusive
+    {% end %}
+    {% if _single_day %}
+        select
+            arrayJoin(
+                arrayMap(
+                    x -> toDateTime(
+                        toString(toDateTime(x)),
+                        {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                    ),
+                    range(toUInt32(toDateTime(start)), toUInt32(end_exclusive), 3600)
+                )
+            ) as date
+    {% else %}
+        select
+            arrayJoin(
+                arrayMap(
+                    x -> toDate(x),
+                    range(toUInt32(start), toUInt32(timestampAdd(end, interval 1 day)),
+                    24 * 3600
+                )
+            )
+    ) as date
+    {% end %}
 
 NODE session_metrics
 DESCRIPTION >
     Calculate session-level metrics (visits, pageviews, bounce rate, avg session duration)
 
 SQL >
-
     %
-        select
-            site_uuid,
-            {% if defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
-                toStartOfHour(toTimezone(first_pageview, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}})) as date,
-            {% else %}
-                toDate(toTimezone(first_pageview, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}})) as date,
-            {% end %}
-            session_id,
-            pageviews,
-            is_bounce,
-            duration as session_sec
-        from mv_session_data sd
-            inner join filtered_sessions fs
-                on fs.session_id = sd.session_id
-        where
-            site_uuid = {{ String(site_uuid, 'mock_site_uuid', description="Tenant ID", required=True) }}
-
+    select
+        site_uuid,
+        {% if defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
+            toStartOfHour(
+                toTimezone(
+                    first_pageview,
+                    {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                )
+            ) as date,
+        {% else %}
+            toDate(
+                toTimezone(
+                    first_pageview,
+                    {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                )
+            ) as date,
+        {% end %}
+        session_id,
+        pageviews,
+        is_bounce,
+        duration as session_sec
+    from mv_session_data sd
+    inner join filtered_sessions fs on fs.session_id = sd.session_id
+    where site_uuid = {{ String(site_uuid, 'mock_site_uuid', description="Tenant ID", required=True) }}
 
 NODE data
 DESCRIPTION >
     Calculate KPIs per time period
 
 SQL >
-
     select
         a.date,
         uniq(distinct s.session_id) as visits,
@@ -114,59 +130,98 @@ SQL >
     group by a.date
     order by a.date
 
-
 NODE pathname_pageviews
 DESCRIPTION >
     Calculate pageviews for specific pathname with time granularity handling
 
 SQL >
-
     %
-            select
-                {% if defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
-                    toStartOfHour(toTimezone(timestamp, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}})) as date,
-                {% else %}
-                    toDate(toTimezone(timestamp, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}})) as date,
-                {% end %}
-                count() pageviews
-            from timeseries a
-            inner join _mv_hits h on
-                {% if defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
-                    a.date = toStartOfHour(toTimezone(timestamp, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}}))
-                {% else %}
-                    a.date = toDate(toTimezone(timestamp, {{String(timezone, 'Etc/UTC', description="Site timezone", required=True)}}))
-                {% end %}
-            inner join filtered_sessions fs
-                on fs.session_id = h.session_id
-            where
-                site_uuid = {{ String(site_uuid, 'mock_site_uuid', description="Tenant ID", required=True) }}
-                {% if defined(member_status) %}
-                    and member_status IN (
-                        select arrayJoin(
-                            {{ Array(member_status, "'undefined', 'free', 'paid'", description="Member status to filter on", required=False) }}
-                            || if('paid' IN {{ Array(member_status) }}, ['comped', 'gift'], [])
-                        )
+    select
+        {% if defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
+            toStartOfHour(
+                toTimezone(
+                    timestamp,
+                    {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                )
+            ) as date,
+        {% else %}
+            toDate(
+                toTimezone(
+                    timestamp,
+                    {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                )
+            ) as date,
+        {% end %}
+        count() pageviews
+    from timeseries a
+    inner join
+        _mv_hits h
+        on {% if defined(date_from) and defined(date_to) and day_diff(date_from, date_to) == 0 %}
+            a.date = toStartOfHour(
+                toTimezone(
+                    timestamp,
+                    {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                )
+            )
+        {% else %}
+            a.date = toDate(
+                toTimezone(
+                    timestamp,
+                    {{ String(timezone, 'Etc/UTC', description="Site timezone", required=True) }}
+                )
+            )
+        {% end %}
+    inner join filtered_sessions fs on fs.session_id = h.session_id
+    where
+        site_uuid = {{ String(site_uuid, 'mock_site_uuid', description="Tenant ID", required=True) }}
+        {% if defined(member_status) %}
+            and member_status IN (
+                select
+                    arrayJoin(
+                        {{
+                            Array(
+                                member_status,
+                                "'undefined', 'free', 'paid'",
+                                description="Member status to filter on",
+                                required=False,
+                            )
+                        }} || if('paid' IN {{ Array(member_status) }}, ['comped', 'gift'], [])
                     )
-                {% end %}
-                {% if defined(location) %} and location = {{ String(location, description="Location to filter on", required=False) }} {% end %}
-                {% if defined(pathname) %} and pathname = {{ String(pathname, description="Pathname to filter on", required=False) }} {% end %}
-                {% if defined(post_uuid) %} and post_uuid = {{String(post_uuid, description="Post UUID to filter on", required=False) }} {% end %}
-                {% if defined(gift_link) %}{% if gift_link == 'false' or gift_link == '0' %} and gift_link = '' {% else %} and gift_link != '' {% end %}{% end %}
-            group by date
-            order by date
-
+            )
+        {% end %}
+        {% if defined(location) %}
+            and location = {{ String(location, description="Location to filter on", required=False) }}
+        {% end %}
+        {% if defined(pathname) %}
+            and pathname = {{ String(pathname, description="Pathname to filter on", required=False) }}
+        {% end %}
+        {% if defined(post_uuid) %}
+            and post_uuid
+            = {{ String(post_uuid, description="Post UUID to filter on", required=False) }}
+        {% end %}
+        {% if defined(gift_link) %}
+            {% if gift_link == 'false' or gift_link == '0' %} and gift_link = ''
+            {% else %} and gift_link != ''
+            {% end %}
+        {% end %}
+    group by date
+    order by date
 
 NODE finished_data
 SQL >
-
     %
-            select
-                a.date as date,
-                coalesce(b.visits, 0) as visits,
-                {% if defined(pathname) or defined(post_uuid) or defined(gift_link) %}coalesce(c.pageviews, 0){% else %}coalesce(b.pageviews, 0){% end %} as pageviews,
-                coalesce(b.bounce_rate, 0) as bounce_rate,
-                coalesce(b.avg_session_sec, 0) as avg_session_sec
-            from timeseries a
-            left join data b on a.date = b.date
-            {% if defined(pathname) or defined(post_uuid) or defined(gift_link) %}left join pathname_pageviews c on a.date = c.date{% end %}
+    select
+        a.date as date,
+        coalesce(b.visits, 0) as visits,
+        {% if defined(pathname) or defined(post_uuid) or defined(gift_link) %} coalesce(c.pageviews, 0)
+        {% else %} coalesce(b.pageviews, 0)
+        {% end %} as pageviews,
+        coalesce(b.bounce_rate, 0) as bounce_rate,
+        coalesce(b.avg_session_sec, 0) as avg_session_sec
+    from timeseries a
+    left join data b on a.date = b.date
+    {% if defined(pathname) or defined(post_uuid) or defined(gift_link) %}
+        left join pathname_pageviews c on a.date = c.date
+    {% end %}
+
 TYPE ENDPOINT
diff --git a/ghost/core/core/server/lib/image/image-content.ts b/ghost/core/core/server/lib/image/image-content.ts
new file mode 100644
index 00000000000..86cb359422f
--- /dev/null
+++ b/ghost/core/core/server/lib/image/image-content.ts
@@ -0,0 +1,102 @@
+// Image processing picks its decoder from a file's contents rather than its
+// name, so anything that stores an image or hands it to image processing must
+// check what the contents actually are. These helpers hold the contents to the
+// configured `uploads.images.extensions`-style allowlists.
+//
+// `file-type` can't detect SVG (it's text), so SVGs never pass the content
+// check. Callers route SVG files to the SVG sanitizer instead.
+
+import type { FileTypeResult } from 'file-type';
+import { getImageFormat } from './image-formats';
+
+export function isSvgExtension(ext: string): boolean {
+  return getImageFormat(ext) === 'svg';
+}
+
+async function detectFileType(input: string | Uint8Array): Promise {
+  // file-type is ESM-only. tsc emits this as `require('file-type')` under
+  // module: commonjs, which resolves via Node's require(esm).
+  const { fileTypeFromBuffer, fileTypeFromFile } = await import('file-type');
+  return typeof input === 'string' ? fileTypeFromFile(input) : fileTypeFromBuffer(input);
+}
+
+/**
+ * Detects a file's type from its contents.
+ *
+ * @returns the detected extension including the leading dot (e.g. `.jpg`), or
+ * `undefined` when the contents aren't a recognised binary format
+ */
+export async function detectFileExtension(input: string | Uint8Array): Promise {
+  const fileType = await detectFileType(input);
+
+  return fileType ? `.${fileType.ext}` : undefined;
+}
+
+/**
+ * Checks an extension against an allowlist by format, so e.g. `.tif` matches
+ * a configured `.tiff` and `.jpg` matches `.jpeg`.
+ */
+export function isAllowedImageExtension(
+  ext: string | undefined,
+  extensions: readonly string[],
+): boolean {
+  if (!ext) {
+    return false;
+  }
+
+  const format = getImageFormat(ext);
+  if (!format) {
+    return extensions.some((allowed) => allowed.toLowerCase() === ext.toLowerCase());
+  }
+
+  return extensions.some((allowed) => getImageFormat(allowed) === format);
+}
+
+/**
+ * Checks that a file's contents are one of the allowed image formats.
+ *
+ * @param input a file path or the file's contents
+ * @param extensions allowed extensions, including the leading dot
+ */
+export async function isAllowedImageContent(
+  input: string | Uint8Array,
+  extensions: readonly string[],
+): Promise {
+  const fileType = await detectFileType(input);
+
+  // A site can allow any extension, so also require the contents to be an
+  // image, e.g. a PDF doesn't pass because `.pdf` was added to the list
+  if (!fileType || !fileType.mime.startsWith('image/')) {
+    return false;
+  }
+
+  const ext = `.${fileType.ext}`;
+
+  // Keep SVG out even if file-type ever learns to detect it, so it can't skip
+  // the sanitizer.
+  return !isSvgExtension(ext) && isAllowedImageExtension(ext, extensions);
+}
+
+/**
+ * Upload config keys whose files are stored as images.
+ */
+export const IMAGE_UPLOAD_TYPES = ['images', 'thumbnails', 'icons'] as const;
+
+/**
+ * Checks a content type an image upload may be stored with. Storage adapters
+ * that keep the uploaded content type (e.g. S3) serve the file with it, so a
+ * type such as text/html would have browsers treat an image as a page. SVGs
+ * are sanitized, and application/octet-stream is served as a download.
+ */
+export function isImageContentType(contentType: string): boolean {
+  const normalized = contentType.trim().toLowerCase();
+
+  return normalized.startsWith('image/') || normalized === 'application/octet-stream';
+}
+
+/**
+ * @returns the configured content types that image uploads ignore
+ */
+export function getIgnoredImageContentTypes(contentTypes: readonly string[]): string[] {
+  return contentTypes.filter((contentType) => !isImageContentType(contentType));
+}
diff --git a/ghost/core/core/server/lib/image/image-decoders.ts b/ghost/core/core/server/lib/image/image-decoders.ts
new file mode 100644
index 00000000000..1100ee9fa4d
--- /dev/null
+++ b/ghost/core/core/server/lib/image/image-decoders.ts
@@ -0,0 +1,56 @@
+import type SharpType from 'sharp';
+import { getImageLoader } from './image-formats';
+
+// sharp picks its decoder from a file's contents, not its name, so limit the
+// libvips loaders it can use to the image formats Ghost is configured to
+// accept.
+
+// Gift link previews are rendered from an internal PNG and SVG, whatever the
+// upload config says
+const ALWAYS_ALLOWED_LOADERS = ['VipsForeignLoadPng', 'VipsForeignLoadSvg'];
+
+export function getAllowedImageLoaders(extensions: readonly string[]): string[] {
+  const loaders = new Set(ALWAYS_ALLOWED_LOADERS);
+
+  for (const ext of extensions) {
+    const loader = getImageLoader(ext);
+    if (loader) {
+      loaders.add(loader);
+    }
+  }
+
+  return [...loaders].sort();
+}
+
+function loadSharp(): typeof SharpType | undefined {
+  try {
+    return require('sharp');
+  } catch {
+    // sharp is optional - without it Ghost stores images without processing them
+    return undefined;
+  }
+}
+
+/**
+ * Blocks every libvips loader except the ones needed for the given extensions.
+ * The block is process-wide, but lives in libvips, so it only covers copies of
+ * sharp that share Ghost's libvips.
+ *
+ * @returns the allowed loaders, or undefined when sharp isn't installed
+ */
+export function restrictImageDecoders(
+  extensions: readonly string[],
+  { requireSharp = loadSharp }: { requireSharp?: () => typeof SharpType | undefined } = {},
+): string[] | undefined {
+  const sharp = requireSharp();
+  if (!sharp) {
+    return undefined;
+  }
+
+  const loaders = getAllowedImageLoaders(extensions);
+
+  sharp.block({ operation: ['VipsForeignLoad'] });
+  sharp.unblock({ operation: loaders });
+
+  return loaders;
+}
diff --git a/ghost/core/core/server/lib/image/image-formats.ts b/ghost/core/core/server/lib/image/image-formats.ts
new file mode 100644
index 00000000000..4610cc351c3
--- /dev/null
+++ b/ghost/core/core/server/lib/image/image-formats.ts
@@ -0,0 +1,46 @@
+// Image formats keyed by name, with every extension that can refer to them
+// and the libvips loader that decodes them. Configured extensions and the
+// extensions `file-type` reports (e.g. `.tif`, `.jpg`, `.apng`, `.heic`) often
+// differ, so both are compared by format rather than by spelling.
+interface ImageFormat {
+  extensions: readonly string[];
+  // Blocking a loader also blocks its file/buffer/source subclasses
+  loader?: string;
+}
+
+const IMAGE_FORMATS: Record = {
+  jpeg: { extensions: ['.jpg', '.jpeg', '.jpe', '.jfif'], loader: 'VipsForeignLoadJpeg' },
+  // file-type reports animated PNGs as .apng; they decode as PNG
+  png: { extensions: ['.png', '.apng'], loader: 'VipsForeignLoadPng' },
+  gif: { extensions: ['.gif'], loader: 'VipsForeignLoadNsgif' },
+  webp: { extensions: ['.webp'], loader: 'VipsForeignLoadWebp' },
+  svg: { extensions: ['.svg', '.svgz'], loader: 'VipsForeignLoadSvg' },
+  avif: { extensions: ['.avif'], loader: 'VipsForeignLoadHeif' },
+  // file-type reports both .heic and .heif files as .heic
+  heic: { extensions: ['.heic', '.heif'], loader: 'VipsForeignLoadHeif' },
+  tiff: { extensions: ['.tif', '.tiff'], loader: 'VipsForeignLoadTiff' },
+  // No libvips loader, so .ico files can be stored but never processed
+  ico: { extensions: ['.ico'] },
+};
+
+const FORMAT_BY_EXTENSION = new Map(
+  Object.entries(IMAGE_FORMATS).flatMap(([name, format]) =>
+    format.extensions.map((ext) => [ext, name] as const),
+  ),
+);
+
+/**
+ * @returns the format name for an extension (including the leading dot), or
+ * undefined when it isn't a known image format
+ */
+export function getImageFormat(ext: string): string | undefined {
+  return FORMAT_BY_EXTENSION.get(ext.toLowerCase());
+}
+
+/**
+ * @returns the libvips loader for an extension, or undefined when there is none
+ */
+export function getImageLoader(ext: string): string | undefined {
+  const format = getImageFormat(ext);
+  return format ? IMAGE_FORMATS[format].loader : undefined;
+}
diff --git a/ghost/core/core/server/lib/image/svg-sanitizer.ts b/ghost/core/core/server/lib/image/svg-sanitizer.ts
new file mode 100644
index 00000000000..2e57047db8a
--- /dev/null
+++ b/ghost/core/core/server/lib/image/svg-sanitizer.ts
@@ -0,0 +1,81 @@
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { promisify } from 'node:util';
+import zlib from 'node:zlib';
+import logging from '@tryghost/logging';
+import type CreateDOMPurify from 'dompurify';
+import type JSDOMModule from 'jsdom';
+
+const gunzip = promisify(zlib.gunzip);
+const gzip = promisify(zlib.gzip);
+
+/**
+ * Returns sanitized SVG content, or null if the content is invalid.
+ */
+export function sanitizeSvgContent(content: string): string | null {
+  // Loaded lazily: jsdom is slow to require and only needed for SVGs.
+  const { JSDOM } = require('jsdom') as typeof JSDOMModule;
+  const createDOMPurify = require('dompurify') as typeof CreateDOMPurify;
+  const window = new JSDOM('').window;
+  const DOMPurify = createDOMPurify(window);
+
+  const sanitized = DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } });
+
+  // Check whether the sanitized content still contains a non-empty  tag
+  const validSvgTag = sanitized?.match(/]*>\s*[\S]+[\S\s]*<\/svg>/);
+  if (!sanitized || sanitized.trim() === '' || !validSvgTag) {
+    return null;
+  }
+
+  return sanitized;
+}
+
+/**
+ * Sanitizes the contents of an .svg or .svgz file.
+ *
+ * @returns the sanitized file contents, or null if the SVG could not be sanitized
+ */
+export async function sanitizeSvgBuffer(buffer: Buffer, isZipped = false): Promise {
+  try {
+    const original = isZipped ? (await gunzip(buffer)).toString() : buffer.toString('utf8');
+    const sanitized = sanitizeSvgContent(original);
+
+    if (!sanitized) {
+      return null;
+    }
+
+    return isZipped ? await gzip(sanitized) : Buffer.from(sanitized);
+  } catch (error) {
+    logging.error('Error sanitizing SVG:', error);
+    return null;
+  }
+}
+
+/**
+ * Sanitizes an .svg or .svgz file in place. Only files in the temp directory,
+ * where uploads and extracted imports are written, can be rewritten.
+ *
+ * @returns whether the SVG could be sanitized
+ */
+export async function sanitizeSvgFile(filepath: string, isZipped = false): Promise {
+  const resolvedPath = path.resolve(filepath);
+  if (!resolvedPath.startsWith(path.resolve(os.tmpdir()) + path.sep)) {
+    logging.error(`Refused to sanitize SVG outside the temp directory: ${filepath}`);
+    return false;
+  }
+
+  try {
+    const sanitized = await sanitizeSvgBuffer(await fs.readFile(resolvedPath), isZipped);
+
+    if (!sanitized) {
+      return false;
+    }
+
+    await fs.writeFile(resolvedPath, sanitized);
+    return true;
+  } catch (error) {
+    logging.error('Error sanitizing SVG:', error);
+    return false;
+  }
+}
diff --git a/ghost/core/core/server/services/lib/magic-link/magic-link.js b/ghost/core/core/server/lib/magic-link/magic-link.js
similarity index 100%
rename from ghost/core/core/server/services/lib/magic-link/magic-link.js
rename to ghost/core/core/server/lib/magic-link/magic-link.js
diff --git a/ghost/core/core/server/models/integration.js b/ghost/core/core/server/models/integration.js
index 752b95bc741..5db07804574 100644
--- a/ghost/core/core/server/models/integration.js
+++ b/ghost/core/core/server/models/integration.js
@@ -1,5 +1,5 @@
 const _ = require('lodash');
-const limitService = require('../services/limits');
+const { limitService } = require('../services/limits');
 const ghostBookshelf = require('./base');
 const errors = require('@tryghost/errors');
 const { NoPermissionError } = errors;
diff --git a/ghost/core/core/server/models/invite.js b/ghost/core/core/server/models/invite.js
index 7b56f5f1ede..e2d6c1de408 100644
--- a/ghost/core/core/server/models/invite.js
+++ b/ghost/core/core/server/models/invite.js
@@ -4,7 +4,7 @@ const security = require('@tryghost/security');
 const moment = require('moment');
 
 const settingsCache = require('../../shared/settings-cache');
-const limitService = require('../services/limits');
+const { limitService } = require('../services/limits');
 const ghostBookshelf = require('./base');
 const { setIsRoles } = require('./role-utils');
 
diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js
index bbd7688d545..63dee41aad3 100644
--- a/ghost/core/core/server/models/post.js
+++ b/ghost/core/core/server/models/post.js
@@ -9,7 +9,7 @@ const htmlToPlaintext = require('@tryghost/html-to-plaintext');
 const ghostBookshelf = require('./base');
 const config = require('../../shared/config');
 const settingsCache = require('../../shared/settings-cache');
-const limitService = require('../services/limits');
+const { limitService } = require('../services/limits');
 const mobiledocLib = require('../lib/mobiledoc');
 const lexicalLib = require('../lib/lexical');
 const relations = require('./relations');
diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js
index 0fce6a2c214..0617265b5c0 100644
--- a/ghost/core/core/server/models/user.js
+++ b/ghost/core/core/server/models/user.js
@@ -2,7 +2,7 @@ const validator = require('@tryghost/validator');
 const ObjectId = require('bson-objectid').default;
 const ghostBookshelf = require('./base');
 const baseUtils = require('./base/utils');
-const limitService = require('../services/limits');
+const { limitService } = require('../services/limits');
 const tpl = require('@tryghost/tpl');
 const errors = require('@tryghost/errors');
 const security = require('@tryghost/security');
diff --git a/ghost/core/core/server/services/auth/api-key/admin.js b/ghost/core/core/server/services/auth/api-key/admin.js
index 014a57bd254..f45132bac9a 100644
--- a/ghost/core/core/server/services/auth/api-key/admin.js
+++ b/ghost/core/core/server/services/auth/api-key/admin.js
@@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken');
 const url = require('url');
 const models = require('../../../models');
 const errors = require('@tryghost/errors');
-const limitService = require('../../../services/limits');
+const { limitService } = require('../../../services/limits');
 const { legacyApiPathMatch } = require('../../../web/api/middleware/api-version-compatibility');
 const tpl = require('@tryghost/tpl');
 const _ = require('lodash');
diff --git a/ghost/core/core/server/services/auth/api-key/content.js b/ghost/core/core/server/services/auth/api-key/content.js
index feba1f9c02f..1d89e782ca0 100644
--- a/ghost/core/core/server/services/auth/api-key/content.js
+++ b/ghost/core/core/server/services/auth/api-key/content.js
@@ -1,6 +1,6 @@
 const models = require('../../../models');
 const errors = require('@tryghost/errors');
-const limitService = require('../../../services/limits');
+const { limitService } = require('../../../services/limits');
 const tpl = require('@tryghost/tpl');
 
 const messages = {
diff --git a/ghost/core/core/server/services/email-service/email-service-wrapper.js b/ghost/core/core/server/services/email-service/email-service-wrapper.js
index 53ea3a65062..58a2392b5fb 100644
--- a/ghost/core/core/server/services/email-service/email-service-wrapper.js
+++ b/ghost/core/core/server/services/email-service/email-service-wrapper.js
@@ -42,7 +42,7 @@ class EmailServiceWrapper {
     const db = require('../../data/db');
     const sentry = require('../../../shared/sentry');
     const membersRepository = membersService.api.members;
-    const limitService = require('../limits');
+    const { limitService } = require('../limits');
     const labs = require('../../../shared/labs');
     const emailAddressService = require('../email-address');
     const i18nLib = require('@tryghost/i18n').default;
diff --git a/ghost/core/core/server/services/limits.js b/ghost/core/core/server/services/limits.ts
similarity index 69%
rename from ghost/core/core/server/services/limits.js
rename to ghost/core/core/server/services/limits.ts
index ddd8c25c7ce..87fbfae55ba 100644
--- a/ghost/core/core/server/services/limits.js
+++ b/ghost/core/core/server/services/limits.ts
@@ -1,11 +1,14 @@
-const errors = require('@tryghost/errors');
-const config = require('../../shared/config');
-const db = require('../data/db');
-const logging = require('@tryghost/logging');
-const { LimitService } = require('@tryghost/limit-service');
-const limitService = new LimitService();
+import errors from '@tryghost/errors';
+import logging from '@tryghost/logging';
+import { LimitService } from '@tryghost/limit-service';
+import type { Subscription } from '@tryghost/limit-service';
+import type { RequestHandler } from 'express';
+import config from '../../shared/config';
+import db from '../data/db';
 
-const init = () => {
+export const limitService = new LimitService();
+
+export const init = () => {
   let helpLink;
 
   if (
@@ -18,7 +21,7 @@ const init = () => {
     helpLink = 'https://ghost.org/help/';
   }
 
-  let subscription;
+  let subscription: Subscription | undefined;
 
   if (config.get('hostSettings:subscription')) {
     subscription = {
@@ -52,8 +55,8 @@ const init = () => {
  * change it. Answers 403 with the host's own wording, which is a different thing to tell a
  * caller than the 404 a labs flag gives: the feature exists, this plan does not include it.
  */
-const requireFeature = (limitName) =>
-  async function requireFeatureMw(req, res, next) {
+export const requireFeature = (limitName: string): RequestHandler =>
+  async function requireFeatureMw(_req, _res, next) {
     try {
       await limitService.errorIfWouldGoOverLimit(limitName);
       next();
@@ -61,8 +64,3 @@ const requireFeature = (limitName) =>
       next(err);
     }
   };
-
-module.exports = limitService;
-
-module.exports.init = init;
-module.exports.requireFeature = requireFeature;
diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner.js b/ghost/core/core/server/services/media-inliner/external-media-inliner.js
index 2d14dcfd891..d290398bb5f 100644
--- a/ghost/core/core/server/services/media-inliner/external-media-inliner.js
+++ b/ghost/core/core/server/services/media-inliner/external-media-inliner.js
@@ -5,6 +5,36 @@ const errors = require('@tryghost/errors');
 const logging = require('@tryghost/logging');
 const string = require('@tryghost/string');
 const path = require('path');
+const { isSvgExtension } = require('../../lib/image/image-content');
+const { sanitizeSvgBuffer } = require('../../lib/image/svg-sanitizer');
+const vm = require('node:vm');
+
+// Domains are regular expression patterns (migration tooling sends wildcards
+// such as `https?://i[0-9]{1}.wp.com`), so they cannot be escaped. Matching
+// runs under a V8 execution timeout instead to bound catastrophic backtracking.
+const FIND_MATCHES_TIMEOUT_MS = 1000;
+const PATTERN_TIMEOUT_CODE = 'MEDIA_INLINER_PATTERN_TIMEOUT';
+
+let findMatchesScript;
+let findMatchesContext;
+
+function matchAllWithTimeout(content, regex, timeout) {
+  if (!findMatchesScript) {
+    findMatchesScript = new vm.Script('Array.from(content.matchAll(regex), (match) => match[1])');
+    findMatchesContext = vm.createContext({});
+  }
+
+  findMatchesContext.content = content;
+  findMatchesContext.regex = regex;
+
+  try {
+    // Copy into this realm so callers get a normal Array
+    return Array.from(findMatchesScript.runInContext(findMatchesContext, { timeout }));
+  } finally {
+    findMatchesContext.content = undefined;
+    findMatchesContext.regex = undefined;
+  }
+}
 
 let fileTypeFromBuffer;
 
@@ -36,7 +66,7 @@ class ExternalMediaInliner {
    * @param {Object} deps.PostMetaModel - PostMeta model
    * @param {Object} deps.TagModel - Tag model
    * @param {Object} deps.UserModel - User model
-   * @param {(extension) => import('ghost-storage-base').StorageBase} deps.getMediaStorage - getMediaStorage
+   * @param {(extension: string, fileBuffer: Buffer) => Promise} deps.getMediaStorage - picks the storage for a file, or null if no storage accepts it
    */
   constructor(deps) {
     this.#PostModel = deps.PostModel;
@@ -155,7 +185,29 @@ class ExternalMediaInliner {
    * @returns {Promise} - path to stored media
    */
   async storeMediaLocally(media) {
-    const storage = this.getMediaStorage(media.extension);
+    // The extension can come from the response's Content-Type or the URL
+    // when the contents aren't a recognised binary format, so SVGs get the
+    // same sanitizing as SVG uploads
+    if (isSvgExtension(media.extension)) {
+      const sanitized = await sanitizeSvgBuffer(media.fileBuffer, media.extension === '.svgz');
+
+      if (!sanitized) {
+        logging.warn(`Could not sanitize SVG file: ${media.filename}`);
+        return null;
+      }
+
+      media = { ...media, fileBuffer: sanitized };
+    }
+
+    // A failed content check skips this file rather than the rest of the post
+    let storage;
+    try {
+      storage = await this.getMediaStorage(media.extension, media.fileBuffer);
+    } catch (error) {
+      logging.warn(`Could not determine storage adapter for file: ${media.filename}`);
+      logging.error(error);
+      return null;
+    }
 
     if (!storage) {
       logging.warn(`No storage adapter found for file extension: ${media.extension}`);
@@ -267,15 +319,33 @@ class ExternalMediaInliner {
     return null;
   }
 
-  static findMatches(content, domain) {
+  /**
+   * @param {string} content
+   * @param {string} domain - regular expression pattern matching the start of a media URL
+   * @param {Object} [options]
+   * @param {number} [options.timeout] - milliseconds before matching is abandoned
+   * @returns {string[]}
+   */
+  static findMatches(content, domain, { timeout = FIND_MATCHES_TIMEOUT_MS } = {}) {
     // NOTE: the src could end with a quote, bracket, apostrophe, double-backslash, or encoded quote.
     //     Backlashes are added to content as an escape character
     const srcTerminationSymbols = `("|\\)|'|(?=(?:,https?))| |<|\\\\|"|$)`;
     const regex = new RegExp(`(${domain}.*?)(${srcTerminationSymbols})`, 'igm');
-    const matches = content.matchAll(regex);
 
     // Simplify the matches so we only get the result needed
-    let matchesArray = Array.from(matches, (m) => m[1]);
+    let matchesArray;
+    try {
+      matchesArray = matchAllWithTimeout(content, regex, timeout);
+    } catch (error) {
+      if (error.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
+        throw new errors.DataImportError({
+          message: `Matching media URLs for domain pattern "${domain}" timed out.`,
+          code: PATTERN_TIMEOUT_CODE,
+          errorDetails: { domain },
+        });
+      }
+      throw error;
+    }
 
     // Trim trailing commas from each match
     matchesArray = matchesArray.map((item) => {
@@ -291,11 +361,27 @@ class ExternalMediaInliner {
    *
    * @param {string} content - stringified JSON of post Lexical or Mobiledoc content
    * @param {String[]} domains - domains to inline media from
+   * @param {Set} [timedOutDomains] - patterns to skip; timed out patterns are added
    * @returns {Promise} - updated stringified JSON of post content
    */
-  async inlineContent(content, domains) {
+  async inlineContent(content, domains, timedOutDomains = new Set()) {
     for (const domain of domains) {
-      const matches = this.constructor.findMatches(content, domain);
+      if (timedOutDomains.has(domain)) {
+        continue;
+      }
+
+      let matches;
+      try {
+        matches = this.constructor.findMatches(content, domain);
+      } catch (err) {
+        if (err.code !== PATTERN_TIMEOUT_CODE) {
+          throw err;
+        }
+        // Skip only this pattern so replacements from other domains are kept
+        timedOutDomains.add(domain);
+        logging.error(err);
+        continue;
+      }
 
       for (const src of matches) {
         const result = await this.importUrl(src);
@@ -387,6 +473,10 @@ class ExternalMediaInliner {
 
     logging.info(`Starting inlining external media for posts: ${posts?.length}`);
 
+    // A pattern that timed out once is likely to time out again, so stop
+    // applying it rather than spending the timeout on every remaining post.
+    const timedOutDomains = new Set();
+
     for (const post of posts) {
       try {
         const mobiledocContent = post.get('mobiledoc');
@@ -395,7 +485,11 @@ class ExternalMediaInliner {
         const updatedFields = await this.inlineFields(post, postsInilingFields, domains);
 
         if (mobiledocContent) {
-          const inlinedContent = await this.inlineContent(mobiledocContent, domains);
+          const inlinedContent = await this.inlineContent(
+            mobiledocContent,
+            domains,
+            timedOutDomains,
+          );
 
           // If content has changed, update the post
           if (inlinedContent !== mobiledocContent) {
@@ -404,7 +498,7 @@ class ExternalMediaInliner {
         }
 
         if (lexicalContent) {
-          const inlinedContent = await this.inlineContent(lexicalContent, domains);
+          const inlinedContent = await this.inlineContent(lexicalContent, domains, timedOutDomains);
 
           // If content has changed, update the post
           if (inlinedContent !== lexicalContent) {
diff --git a/ghost/core/core/server/services/media-inliner/service.js b/ghost/core/core/server/services/media-inliner/service.js
index df2e05de32d..125645974e3 100644
--- a/ghost/core/core/server/services/media-inliner/service.js
+++ b/ghost/core/core/server/services/media-inliner/service.js
@@ -1,4 +1,9 @@
 const errors = require('@tryghost/errors');
+const {
+  isAllowedImageContent,
+  isAllowedImageExtension,
+  isSvgExtension,
+} = require('../../lib/image/image-content');
 
 let instance;
 
@@ -19,9 +24,22 @@ module.exports = {
       TagModel: models.Tag,
       UserModel: models.User,
       PostMetaModel: models.PostsMeta,
-      getMediaStorage: (extension) => {
-        if (config.get('uploads').images.extensions.includes(extension)) {
-          return imageStorage;
+      getMediaStorage: async (extension, fileBuffer) => {
+        const imageExtensions = config.get('uploads').images.extensions;
+
+        if (isAllowedImageExtension(extension, imageExtensions)) {
+          // The extension falls back to the response's Content-Type or the
+          // URL when the contents aren't recognised, so only store images
+          // whose contents are an allowed format. SVGs are sanitized by the
+          // inliner before they get here.
+          if (
+            isSvgExtension(extension) ||
+            (await isAllowedImageContent(fileBuffer, imageExtensions))
+          ) {
+            return imageStorage;
+          }
+
+          return null;
         } else if (config.get('uploads').media.extensions.includes(extension)) {
           return mediaStorage;
         } else if (config.get('uploads').files.extensions.includes(extension)) {
diff --git a/ghost/core/core/server/services/member-welcome-emails/service.js b/ghost/core/core/server/services/member-welcome-emails/service.js
index 95bb4241cc7..e943a3a1cdd 100644
--- a/ghost/core/core/server/services/member-welcome-emails/service.js
+++ b/ghost/core/core/server/services/member-welcome-emails/service.js
@@ -3,7 +3,7 @@ const errors = require('@tryghost/errors');
 const urlUtils = require('../../../shared/url-utils').default;
 const settingsCache = require('../../../shared/settings-cache');
 const verifyEmailTemplate = require('../newsletters/emails/verify-email');
-const MagicLink = require('../lib/magic-link/magic-link');
+const MagicLink = require('../../lib/magic-link/magic-link');
 const sentry = require('../../../shared/sentry');
 const emailAddressService = require('../email-address');
 const settingsHelpers = require('../settings-helpers');
diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js
index a94b44258c3..903a0fdf4b4 100644
--- a/ghost/core/core/server/services/members/members-api/members-api.js
+++ b/ghost/core/core/server/services/members/members-api/members-api.js
@@ -18,7 +18,7 @@ const MemberController = require('./controllers/member-controller');
 const WellKnownController = require('./controllers/well-known-controller');
 
 const { EmailSuppressedEvent } = require('../../email-suppression-list/email-suppression-list');
-const MagicLink = require('../../lib/magic-link/magic-link');
+const MagicLink = require('../../../lib/magic-link/magic-link');
 const DomainEvents = require('@tryghost/domain-events');
 const automationsApi = require('../../automations/automations-api');
 
diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js
index 3538654b608..99331d6cb52 100644
--- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js
+++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js
@@ -1247,6 +1247,10 @@ module.exports = class MemberRepository {
       { ...options, forUpdate: true },
     );
 
+    if (!memberModel) {
+      throw new errors.NotFoundError({ message: tpl(messages.memberNotFound, { id: data.id }) });
+    }
+
     const memberStripeCustomerModel = await memberModel
       .related('stripeCustomers')
       .query({
@@ -2038,6 +2042,10 @@ module.exports = class MemberRepository {
       email: data.email,
     });
 
+    if (!member) {
+      throw new errors.NotFoundError({ message: tpl(messages.memberNotFound, { id: data.email }) });
+    }
+
     const subscription = await member
       .related('stripeSubscriptions')
       .query({
@@ -2079,6 +2087,12 @@ module.exports = class MemberRepository {
 
     const member = await this._Member.findOne(findQuery);
 
+    if (!member) {
+      throw new errors.NotFoundError({
+        message: tpl(messages.memberNotFound, { id: data.id || data.email }),
+      });
+    }
+
     const subscription = await member
       .related('stripeSubscriptions')
       .query({
@@ -2138,6 +2152,12 @@ module.exports = class MemberRepository {
 
     const member = await this._Member.findOne(findQuery);
 
+    if (!member) {
+      throw new errors.NotFoundError({
+        message: tpl(messages.memberNotFound, { id: data.id || data.email }),
+      });
+    }
+
     const subscriptionModel = await member
       .related('stripeSubscriptions')
       .query({
diff --git a/ghost/core/core/server/services/newsletters/index.js b/ghost/core/core/server/services/newsletters/index.js
index c69f1aa1f82..beb877e4282 100644
--- a/ghost/core/core/server/services/newsletters/index.js
+++ b/ghost/core/core/server/services/newsletters/index.js
@@ -3,7 +3,7 @@ const SingleUseTokenProvider = require('../members/single-use-token-provider');
 const mail = require('../mail');
 const models = require('../../models');
 const urlUtils = require('../../../shared/url-utils').default;
-const limitService = require('../limits');
+const { limitService } = require('../limits');
 const labs = require('../../../shared/labs');
 const emailAddressService = require('../email-address');
 
diff --git a/ghost/core/core/server/services/newsletters/newsletters-service.js b/ghost/core/core/server/services/newsletters/newsletters-service.js
index 7e9a78e2973..60b9401a1bd 100644
--- a/ghost/core/core/server/services/newsletters/newsletters-service.js
+++ b/ghost/core/core/server/services/newsletters/newsletters-service.js
@@ -6,7 +6,7 @@ const tpl = require('@tryghost/tpl');
 const errors = require('@tryghost/errors');
 
 const sentry = require('../../../shared/sentry');
-const MagicLink = require('../lib/magic-link/magic-link');
+const MagicLink = require('../../lib/magic-link/magic-link');
 
 const messages = {
   nameAlreadyExists: 'A newsletter with the same name already exists',
diff --git a/ghost/core/core/server/services/oembed/oembed-service.js b/ghost/core/core/server/services/oembed/oembed-service.js
index 825d9c5fe69..1678fda31b8 100644
--- a/ghost/core/core/server/services/oembed/oembed-service.js
+++ b/ghost/core/core/server/services/oembed/oembed-service.js
@@ -10,6 +10,11 @@ const iconv = require('iconv-lite');
 const path = require('path');
 const crypto = require('crypto');
 const imageTransform = require('@tryghost/image-transform');
+const {
+  detectFileExtension,
+  isAllowedImageExtension,
+  isSvgExtension,
+} = require('../../lib/image/image-content');
 
 // Some sites block non-standard user agents so we need to mimic a typical browser
 // Note: the Ghost/5.0 string _may_ be in use by 3rd parties so use caution when updating across majors
@@ -50,7 +55,6 @@ const SVG_RASTER_TIMEOUT_SECONDS = 10;
 const MAX_SVG_BYTES = 32 * 1024;
 const GZIP_MAGIC = [0x1f, 0x8b];
 
-const SVG_EXTENSIONS = new Set(['.svg', '.svgz']);
 const SVG_SNIFF_BYTES = 1024;
 
 const toBuffer = (bytes) => {
@@ -59,24 +63,25 @@ const toBuffer = (bytes) => {
     : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
 };
 
-const shouldRasterize = (buffer, ext) => {
-  if (SVG_EXTENSIONS.has(ext.toLowerCase())) {
-    return true;
-  }
+const isGzip = (buffer) => {
+  return GZIP_MAGIC.every((byte, index) => buffer[index] === byte);
+};
 
-  const head = buffer.subarray(0, SVG_SNIFF_BYTES).toString('utf8').trimStart();
+const looksLikeSvg = (buffer, sniffBytes) => {
+  const head = buffer.subarray(0, sniffBytes).toString('utf8').trimStart();
 
   return head.startsWith('<') && /]/i.test(head);
 };
 
-let fileTypeFromBuffer;
-
-const detectFileType = async (buffer) => {
-  if (!fileTypeFromBuffer) {
-    ({ fileTypeFromBuffer } = await import('file-type'));
+// sharp picks its decoder from the contents rather than the file name, so an
+// SVG extension alone isn't enough to rasterize. Gzipped files still count so
+// they are rejected as unconvertible below instead of as an unknown type.
+const shouldRasterize = (buffer, ext) => {
+  if (isSvgExtension(ext)) {
+    return isGzip(buffer) || looksLikeSvg(buffer, MAX_SVG_BYTES);
   }
 
-  return fileTypeFromBuffer(buffer);
+  return looksLikeSvg(buffer, SVG_SNIFF_BYTES);
 };
 
 /**
@@ -227,10 +232,7 @@ class OEmbedService {
     const name = this.imageStore.getSanitizedFileName(baseName);
 
     if (shouldRasterize(imageBuffer, ext)) {
-      if (
-        imageBuffer.length > MAX_SVG_BYTES ||
-        GZIP_MAGIC.every((byte, index) => imageBuffer[index] === byte)
-      ) {
+      if (imageBuffer.length > MAX_SVG_BYTES || isGzip(imageBuffer)) {
         throw new errors.ValidationError({
           message: tpl(messages.unconvertibleSvg),
           context: imageUrl,
@@ -253,10 +255,9 @@ class OEmbedService {
       // Content-Type the stored file is later served with, so name the
       // file after its contents and hold it to the same allowlist as image
       // uploads. `file-type` never reports SVG, which is handled above.
-      const fileType = await detectFileType(imageBuffer);
-      ext = fileType ? `.${fileType.ext}` : '';
+      ext = (await detectFileExtension(imageBuffer)) ?? '';
 
-      if (!this.config.get('uploads').images.extensions.includes(ext)) {
+      if (!isAllowedImageExtension(ext, this.config.get('uploads').images.extensions)) {
         throw new errors.ValidationError({
           message: tpl(messages.unsupportedImage),
           context: imageUrl,
diff --git a/ghost/core/core/server/services/settings-helpers/index.js b/ghost/core/core/server/services/settings-helpers/index.js
index e9848ea9d51..8a46a6854ce 100644
--- a/ghost/core/core/server/services/settings-helpers/index.js
+++ b/ghost/core/core/server/services/settings-helpers/index.js
@@ -3,6 +3,6 @@ const urlUtils = require('../../../shared/url-utils').default;
 const config = require('../../../shared/config');
 const SettingsHelpers = require('./settings-helpers');
 const labs = require('../../../shared/labs');
-const limitService = require('../limits');
+const { limitService } = require('../limits');
 
 module.exports = new SettingsHelpers({ settingsCache, urlUtils, config, labs, limitService });
diff --git a/ghost/core/core/server/services/settings/settings-bread-service.js b/ghost/core/core/server/services/settings/settings-bread-service.js
index fff5685a8b3..0aae9525dff 100644
--- a/ghost/core/core/server/services/settings/settings-bread-service.js
+++ b/ghost/core/core/server/services/settings/settings-bread-service.js
@@ -10,7 +10,7 @@ const {
 const { obfuscatedSetting, isSecretSetting, hideValueIfSecret } = require('./settings-utils');
 const logging = require('@tryghost/logging');
 const verifyEmailTemplate = require('./emails/verify-email');
-const MagicLink = require('../lib/magic-link/magic-link');
+const MagicLink = require('../../lib/magic-link/magic-link');
 const sentry = require('../../../shared/sentry');
 
 const EMAIL_KEYS = ['members_support_address'];
diff --git a/ghost/core/core/server/services/settings/settings-service.js b/ghost/core/core/server/services/settings/settings-service.js
index b6c9929c67d..6180d247dc1 100644
--- a/ghost/core/core/server/services/settings/settings-service.js
+++ b/ghost/core/core/server/services/settings/settings-service.js
@@ -5,7 +5,7 @@
 const events = require('../../lib/common/events');
 const models = require('../../models');
 const labs = require('../../../shared/labs');
-const limits = require('../limits');
+const { limitService: limits } = require('../limits');
 const config = require('../../../shared/config');
 const adapterManager = require('../adapter-manager').default;
 const SettingsCache = require('../../../shared/settings-cache');
diff --git a/ghost/core/core/server/services/themes/installer.js b/ghost/core/core/server/services/themes/installer.js
index 961be3531a8..24162bf60a5 100644
--- a/ghost/core/core/server/services/themes/installer.js
+++ b/ghost/core/core/server/services/themes/installer.js
@@ -4,7 +4,7 @@ const path = require('path');
 const security = require('@tryghost/security');
 const request = require('@tryghost/request');
 const errors = require('@tryghost/errors');
-const limitService = require('../../services/limits');
+const { limitService } = require('../../services/limits');
 const { setFromZip } = require('./storage');
 
 const messages = {
diff --git a/ghost/core/core/server/services/webhooks/index.js b/ghost/core/core/server/services/webhooks/index.js
index 8389e8c1022..cb43776fbc8 100644
--- a/ghost/core/core/server/services/webhooks/index.js
+++ b/ghost/core/core/server/services/webhooks/index.js
@@ -5,7 +5,7 @@
 module.exports = {
   listen() {
     const models = require('../../models');
-    const limitService = require('../../services/limits');
+    const { limitService } = require('../../services/limits');
     const events = require('../../lib/common/events');
     const urlService = require('../url');
     const createSerialize = require('./serialize');
diff --git a/ghost/core/core/server/services/webhooks/webhook-trigger.js b/ghost/core/core/server/services/webhooks/webhook-trigger.js
index 134b7119824..d350c5316dc 100644
--- a/ghost/core/core/server/services/webhooks/webhook-trigger.js
+++ b/ghost/core/core/server/services/webhooks/webhook-trigger.js
@@ -10,7 +10,7 @@ class WebhookTrigger {
    * @param {Object} options
    * @param {Object} options.models - Ghost models
    * @param {Function} options.payload - Function to generate payload
-   * @param {import('../../services/limits')} options.limitService - Function to generate payload
+   * @param {typeof import('../../services/limits').limitService} options.limitService - Limit service
    * @param {Object} [options.request] - HTTP request handling library
    */
   constructor({ models, payload, request, limitService }) {
diff --git a/ghost/core/core/server/web/api/middleware/upload.js b/ghost/core/core/server/web/api/middleware/upload.js
index 59620e846fa..ce6f68d4aa6 100644
--- a/ghost/core/core/server/web/api/middleware/upload.js
+++ b/ghost/core/core/server/web/api/middleware/upload.js
@@ -2,19 +2,20 @@ const path = require('path');
 const os = require('os');
 const multer = require('multer');
 const fs = require('fs-extra');
-const zlib = require('zlib');
-const util = require('util');
 const errors = require('@tryghost/errors');
 const config = require('../../../../shared/config');
 const tpl = require('@tryghost/tpl');
 const logging = require('@tryghost/logging');
+const {
+  IMAGE_UPLOAD_TYPES,
+  isAllowedImageContent,
+  isImageContentType,
+} = require('../../../lib/image/image-content');
+const { sanitizeSvgContent, sanitizeSvgFile } = require('../../../lib/image/svg-sanitizer');
 const {
   reportThemeUploadSizeLimitError,
 } = require('../../../services/themes/upload-size-limit-reporter');
 
-const gunzip = util.promisify(zlib.gunzip);
-const gzip = util.promisify(zlib.gzip);
-
 const messages = {
   db: {
     missingFile: 'Please select a database file to import.',
@@ -239,92 +240,6 @@ const checkFileIsValid = (fileData, types, extensions) => {
   return false;
 };
 
-/**
- *
- * @param {string} filepath
- * @returns {Promise}
- *
- * Reads the SVG file, sanitizes it, and writes the sanitized content back to the file.
- * Returns the sanitized content or null if the SVG could not be sanitized.
- */
-
-const sanitizeSvg = async (filepath, isZipped = false) => {
-  try {
-    const original = await readSvg(filepath, isZipped);
-    const sanitized = sanitizeSvgContent(original);
-
-    if (!sanitized) {
-      return null;
-    }
-
-    await writeSvg(filepath, sanitized, isZipped);
-    return sanitized;
-  } catch (error) {
-    logging.error('Error sanitizing SVG:', error);
-    return null;
-  }
-};
-
-/**
- *
- * @param {string} content
- * @returns {String | null}
- *
- * Returns sanitized SVG content, or null if the content is invalid.
- *
- */
-const sanitizeSvgContent = (content) => {
-  const { JSDOM } = require('jsdom');
-  const createDOMPurify = require('dompurify');
-  const window = new JSDOM('').window;
-  const DOMPurify = createDOMPurify(window);
-
-  const sanitized = DOMPurify.sanitize(content, { USE_PROFILES: { svg: true, svgFilters: true } });
-
-  // Check whether the sanitized content still contains a non-empty  tag
-  const validSvgTag = sanitized?.match(/]*>\s*[\S]+[\S\s]*<\/svg>/);
-  if (!sanitized || sanitized.trim() === '' || !validSvgTag) {
-    return null;
-  }
-
-  return sanitized;
-};
-
-/**
- *
- * @param {string} filepath
- * @param {boolean} isZipped
- * @returns {Promise}
- *
- * Reads .svg or .svgz files and returns the content as a string.
- *
- */
-const readSvg = async (filepath, isZipped = false) => {
-  if (isZipped) {
-    const compressed = await fs.readFile(filepath);
-    return (await gunzip(compressed)).toString();
-  }
-
-  return await fs.readFile(filepath, 'utf8');
-};
-
-/**
- *
- * @param {string} filepath
- * @param {string} content
- * @param {boolean} isZipped
- *
- * Writes SVG content to a .svg or .svgz file.
- */
-const writeSvg = async (filepath, content, isZipped = false) => {
-  if (isZipped) {
-    const compressed = await gzip(content);
-    return await fs.writeFile(filepath, compressed);
-  }
-
-  return await fs.writeFile(filepath, content);
-};
-
 /**
  *
  * @param {{ext: string, type: string}} file
@@ -355,9 +270,14 @@ const validation = function ({ type }) {
   return async function uploadValidation(req, res, next) {
     const extensions =
       (config.get('uploads')[type] && config.get('uploads')[type].extensions) || [];
-    const contentTypes =
+    let contentTypes =
       (config.get('uploads')[type] && config.get('uploads')[type].contentTypes) || [];
 
+    // Boot warns about any configured types this ignores
+    if (IMAGE_UPLOAD_TYPES.includes(type)) {
+      contentTypes = contentTypes.filter(isImageContentType);
+    }
+
     req.file = req.file || {};
     req.file.name = req.file.originalname;
     req.file.type = req.file.mimetype;
@@ -384,7 +304,7 @@ const validation = function ({ type }) {
 
     // Sanitize SVG files
     if (isSvgFile(req.file)) {
-      const sanitized = await sanitizeSvg(req.file.path, req.file.ext === '.svgz');
+      const sanitized = await sanitizeSvgFile(req.file.path, req.file.ext === '.svgz');
 
       if (!sanitized) {
         return next(
@@ -393,6 +313,17 @@ const validation = function ({ type }) {
           }),
         );
       }
+    } else if (
+      // The extension and content type both come from the client, so also
+      // check that the contents are an allowed image format
+      type === 'images' &&
+      !(await isAllowedImageContent(req.file.path, extensions))
+    ) {
+      return next(
+        new errors.UnsupportedMediaTypeError({
+          message: tpl(messages[type].invalidFile, { extensions: extensions }),
+        }),
+      );
     }
 
     next();
@@ -414,8 +345,10 @@ const mediaValidation = function ({ type }) {
 
     const thumbnailExtensions =
       (config.get('uploads').thumbnails && config.get('uploads').thumbnails.extensions) || [];
-    const thumbnailContentTypes =
-      (config.get('uploads').thumbnails && config.get('uploads').thumbnails.contentTypes) || [];
+    const thumbnailContentTypes = (
+      (config.get('uploads').thumbnails && config.get('uploads').thumbnails.contentTypes) ||
+      []
+    ).filter(isImageContentType);
 
     const { file: [file] = [] } = req.files;
     if (!file || !checkFileExists(file)) {
@@ -465,7 +398,7 @@ const mediaValidation = function ({ type }) {
 
       // Sanitize SVG thumbnails
       if (isSvgFile(req.thumbnail)) {
-        const sanitized = await sanitizeSvg(req.thumbnail.path, req.thumbnail.ext === '.svgz');
+        const sanitized = await sanitizeSvgFile(req.thumbnail.path, req.thumbnail.ext === '.svgz');
 
         if (!sanitized) {
           return next(
@@ -474,6 +407,12 @@ const mediaValidation = function ({ type }) {
             }),
           );
         }
+      } else if (!(await isAllowedImageContent(req.thumbnail.path, thumbnailExtensions))) {
+        return next(
+          new errors.UnsupportedMediaTypeError({
+            message: tpl(messages.thumbnail.invalidFile, { extensions: thumbnailExtensions }),
+          }),
+        );
       }
     }
 
diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json
index 01440be1f01..d5c34d3d09e 100644
--- a/ghost/core/core/shared/config/defaults.json
+++ b/ghost/core/core/shared/config/defaults.json
@@ -373,5 +373,6 @@
   },
   "disableJSBackups": false,
   "disableMigrationBackups": false,
+  "createInDevelopmentTables": false,
   "usingLoopbackReverseProxy": false
 }
diff --git a/ghost/core/core/shared/config/env/config.development.json b/ghost/core/core/shared/config/env/config.development.json
index d1ce3c79799..9c2895cc865 100644
--- a/ghost/core/core/shared/config/env/config.development.json
+++ b/ghost/core/core/shared/config/env/config.development.json
@@ -1,5 +1,6 @@
 {
   "url": "http://localhost:2368",
+  "createInDevelopmentTables": true,
   "mail": {
     "from": "test@example.com",
     "transport": "SMTP",
diff --git a/ghost/core/core/shared/config/env/config.testing-mysql.json b/ghost/core/core/shared/config/env/config.testing-mysql.json
index c9e783c92d4..651542404fe 100644
--- a/ghost/core/core/shared/config/env/config.testing-mysql.json
+++ b/ghost/core/core/shared/config/env/config.testing-mysql.json
@@ -1,5 +1,6 @@
 {
   "url": "http://127.0.0.1:2369",
+  "createInDevelopmentTables": true,
   "server": {
     "port": 2369
   },
diff --git a/ghost/core/core/shared/config/env/config.testing.json b/ghost/core/core/shared/config/env/config.testing.json
index 4b4208930ba..4830224a0a4 100644
--- a/ghost/core/core/shared/config/env/config.testing.json
+++ b/ghost/core/core/shared/config/env/config.testing.json
@@ -1,5 +1,6 @@
 {
   "url": "http://127.0.0.1:2369",
+  "createInDevelopmentTables": true,
   "database": {
     "client": "better-sqlite3",
     "connection": {
diff --git a/ghost/core/package.json b/ghost/core/package.json
index fa3b385f2c8..8817446591d 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -51,6 +51,7 @@
     "generate-golden-email": "node bin/generate-golden-email.js",
     "query-parameter-policy:export": "node --import=tsx scripts/export-query-parameter-policy.ts",
     "migrate:create": "node bin/create-migration.js",
+    "migrate:rebuild-in-development-tables": "node --import=tsx bin/rebuild-in-development-tables.ts",
     "build:assets:css": "postcss core/frontend/public/ghost.css --no-map --use cssnano -o core/frontend/public/ghost.min.css",
     "build:tsc": "tsc",
     "pretest": "pnpm build:assets",
diff --git a/ghost/core/test/e2e-api/admin/host-limits.test.ts b/ghost/core/test/e2e-api/admin/host-limits.test.ts
index dbecc242a90..e4d65c1fd9b 100644
--- a/ghost/core/test/e2e-api/admin/host-limits.test.ts
+++ b/ghost/core/test/e2e-api/admin/host-limits.test.ts
@@ -22,7 +22,9 @@ const mailService = require('../../../core/server/services/mail') as {
 const membersService = require('../../../core/server/services/members') as {
   stripeConnect: StripeConnect;
 };
-const limits = require('../../../core/server/services/limits') as LimitService;
+const {
+  limitService: limits,
+}: typeof import('../../../core/server/services/limits') = require('../../../core/server/services/limits');
 
 /** What an Admin API request answers with, narrowed to the parts these tests read. */
 interface ApiResponse {
@@ -61,12 +63,6 @@ interface HostLimits {
   restoreHostLimits(): Promise;
 }
 
-interface LimitService {
-  isLimited(name: string): boolean;
-  isDisabled(name: string): boolean | undefined;
-  problems: Array<{ limit: string; reason: string }>;
-}
-
 interface StripeConnect {
   getStripeConnectTokenData(): Promise;
 }
diff --git a/ghost/core/test/e2e-api/admin/posts-importer.test.js b/ghost/core/test/e2e-api/admin/posts-importer.test.js
index 92c576b6c6b..809f9502e40 100644
--- a/ghost/core/test/e2e-api/admin/posts-importer.test.js
+++ b/ghost/core/test/e2e-api/admin/posts-importer.test.js
@@ -29,6 +29,10 @@ const { compress } = require('@tryghost/zip');
 const sinon = require('sinon');
 
 const csvPath = path.join(__dirname, '../../utils/fixtures/csv/valid-posts-import.csv');
+// Imported images must have image contents, not just an image extension
+const imageBytes = require('node:fs').readFileSync(
+  path.join(__dirname, '../../utils/fixtures/images/ghosticon.jpg'),
+);
 
 // Test CSVs are written inline to a temp dir rather than committed as fixtures.
 let tmpDir;
@@ -657,7 +661,7 @@ describe('Posts Importer API', function () {
       'ZIP short asset path,"

",,,,\n'; const zipPath = await zipFile('posts-with-assets.zip', { 'export/posts.csv': csv, - 'export/content/images/csv-zip-photo.jpg': 'image bytes', + 'export/content/images/csv-zip-photo.jpg': imageBytes, 'export/content/media/csv-zip-movie.mp4': 'media bytes', 'export/content/files/csv-zip-guide.pdf': 'file bytes', }); @@ -718,7 +722,7 @@ describe('Posts Importer API', function () { 'Canonical ZIP assets,"

MediaFile",__GHOST_URL__/content/images/canonical-image.jpg,__GHOST_URL__/content/images/canonical-image.jpg,__GHOST_URL__/content/images/canonical-image.jpg\n'; const zipPath = await zipFile('posts-with-canonical-assets.zip', { 'posts.csv': csv, - 'images/canonical-image.jpg': 'image bytes', + 'images/canonical-image.jpg': imageBytes, 'media/canonical-media.mp4': 'media bytes', 'files/canonical-file.pdf': 'file bytes', }); @@ -810,7 +814,7 @@ describe('Posts Importer API', function () { .rejects(new Error('file storage failed')); const zipPath = await zipFile('posts-with-cross-group-failure.zip', { 'posts.csv': 'title,html\nZIP cross-group failure,

Must not import

\n', - 'content/images/rollback-cross-image.jpg': 'image bytes', + 'content/images/rollback-cross-image.jpg': imageBytes, 'content/files/rollback-cross-file.pdf': 'file bytes', }); diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index 73c1165b133..e637b93cd63 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -14,7 +14,7 @@ const { const { stringMatching, anyEtag, anyUuid, anyContentLength, anyContentVersion } = matchers; const models = require('../../../core/server/models'); const membersService = require('../../../core/server/services/members'); -const limits = require('../../../core/server/services/limits'); +const { limitService: limits } = require('../../../core/server/services/limits'); const { anyErrorId } = matchers; // Updated to reflect current total based on test output @@ -918,7 +918,7 @@ describe('Settings API', function () { describe('publicSiteAccess limit', function () { function stubPublicSiteAccessDisabled(disabled) { // Stub the singleton directly rather than driving the limit through configUtils + - // limits.init(). The hostSettings.limits config is registered once at boot from the + // service initialization. The hostSettings.limits config is registered once at boot from the // `@tryghost/limit-service` allowlist; bumps of that package are owned by Renovate // so this PR cannot rely on `publicSiteAccess` being a recognised name yet. sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(disabled); diff --git a/ghost/core/test/integration/importer/content-import-source.test.ts b/ghost/core/test/integration/importer/content-import-source.test.ts index 1dc53e36a51..255e75cbe01 100644 --- a/ghost/core/test/integration/importer/content-import-source.test.ts +++ b/ghost/core/test/integration/importer/content-import-source.test.ts @@ -10,6 +10,11 @@ import { const { compress } = require('@tryghost/zip'); +// Imported images must have image contents, not just an image extension +const imageBytes = fs.readFileSync( + path.join(__dirname, '../../utils/fixtures/images/ghosticon.jpg'), +); + describe('content import source', function () { let directory: string; @@ -21,7 +26,10 @@ describe('content import source', function () { await fs.remove(directory); }); - async function archive(files: Record, name = 'posts.zip'): Promise { + async function archive( + files: Record, + name = 'posts.zip', + ): Promise { const contents = path.join(directory, 'contents'); await fs.ensureDir(contents); for (const [fileName, value] of Object.entries(files)) { @@ -101,7 +109,7 @@ describe('content import source', function () { it('extracts a wrapped CSV and prepares wrapped image, media, and file assets', async function () { const zipPath = await archive({ 'export/posts.csv': 'title\nWrapped', - 'export/content/images/photo.jpg': 'image', + 'export/content/images/photo.jpg': imageBytes, 'export/content/media/movie.mp4': 'media', 'export/content/files/attachment.csv': 'download,only', 'export/content/files/attachment.json': '{"download":true}', @@ -132,7 +140,7 @@ describe('content import source', function () { it('prepares assets from the existing top-level directory form', async function () { const zipPath = await archive({ 'posts.csv': 'title\nTop level', - 'images/photo.jpg': 'image', + 'images/photo.jpg': imageBytes, 'media/movie.mp4': 'media', 'files/guide.pdf': 'file', }); diff --git a/ghost/core/test/integration/migrations/in-development-tables.test.ts b/ghost/core/test/integration/migrations/in-development-tables.test.ts new file mode 100644 index 00000000000..0e7538c6624 --- /dev/null +++ b/ghost/core/test/integration/migrations/in-development-tables.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import sinon from 'sinon'; + +// require, not import: these must be the same CommonJS instances that +// in-development.ts loads, so the tables added to the schema, the config set +// here and the spies on commands are the ones it sees +const testUtils = require('../../utils'); +const configUtils = require('../../utils/config-utils'); +const db = require('../../../core/server/data/db'); +const commands = require('../../../core/server/data/schema/commands'); +const schema = require('../../../core/server/data/schema/schema'); +const { addTable } = require('../../../core/server/data/migrations/utils'); +const inDevelopment: typeof import('../../../core/server/data/schema/in-development') = require('../../../core/server/data/schema/in-development'); + +const PARENT = 'in_dev_test_parents'; +const CHILD = 'in_dev_test_children'; + +const TEST_TABLES = { + [PARENT]: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + }, + [CHILD]: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + parent_id: { type: 'string', maxlength: 24, nullable: false, references: `${PARENT}.id` }, + }, +}; + +async function hasTable(tableName: string): Promise { + return db.knex.schema.hasTable(tableName); +} + +async function dropTestTables() { + await db.knex.schema.dropTableIfExists(CHILD); + await db.knex.schema.dropTableIfExists(PARENT); +} + +describe('In-development tables', function () { + let originalTables: string[]; + + beforeAll(async function () { + await testUtils.startGhost(); + }); + + beforeEach(async function () { + Object.assign(schema, TEST_TABLES); + originalTables = [...inDevelopment.IN_DEVELOPMENT_TABLES]; + inDevelopment.IN_DEVELOPMENT_TABLES.splice(0, Infinity, PARENT, CHILD); + await dropTestTables(); + }); + + afterEach(async function () { + sinon.restore(); + await dropTestTables(); + inDevelopment.IN_DEVELOPMENT_TABLES.splice(0, Infinity, ...originalTables); + delete schema[PARENT]; + delete schema[CHILD]; + await configUtils.restore(); + }); + + describe('createMissingInDevelopmentTables', function () { + it('creates missing tables and leaves existing ones alone', async function () { + await commands.createTable(PARENT, db.knex); + await db.knex(PARENT).insert({ id: 'parent' }); + + await inDevelopment.createMissingInDevelopmentTables(db.knex); + + assert.equal(await hasTable(CHILD), true); + assert.deepEqual(await db.knex(PARENT).pluck('id'), ['parent']); + }); + + it('creates nothing when disabled', async function () { + configUtils.set('createInDevelopmentTables', false); + + await inDevelopment.createMissingInDevelopmentTables(db.knex); + + assert.equal(await hasTable(PARENT), false); + assert.equal(await hasTable(CHILD), false); + }); + }); + + describe('rebuildInDevelopmentTables', function () { + it('drops referencing tables first, then recreates them empty', async function () { + await commands.createTable(PARENT, db.knex); + await commands.createTable(CHILD, db.knex); + await db.knex(PARENT).insert({ id: 'parent' }); + await db.knex(CHILD).insert({ id: 'child', parent_id: 'parent' }); + + const deleteTable = sinon.spy(commands, 'deleteTable'); + const createTable = sinon.spy(commands, 'createTable'); + + await inDevelopment.rebuildInDevelopmentTables(db.knex); + + assert.deepEqual( + deleteTable.getCalls().map((call) => call.args[0]), + [CHILD, PARENT], + ); + assert.deepEqual( + createTable.getCalls().map((call) => call.args[0]), + [PARENT, CHILD], + ); + assert.equal((await db.knex(PARENT).pluck('id')).length, 0); + assert.equal((await db.knex(CHILD).pluck('id')).length, 0); + }); + + it('leaves the tables alone when disabled', async function () { + await commands.createTable(PARENT, db.knex); + await db.knex(PARENT).insert({ id: 'parent' }); + configUtils.set('createInDevelopmentTables', false); + + await inDevelopment.rebuildInDevelopmentTables(db.knex); + + assert.deepEqual(await db.knex(PARENT).pluck('id'), ['parent']); + }); + }); + + describe('addTable with replaceDevelopmentCopy', function () { + const finalParentSpec = { + ...TEST_TABLES[PARENT], + name: { type: 'string', maxlength: 191, nullable: true }, + }; + + async function columns(tableName: string): Promise { + return Object.keys(await db.knex(tableName).columnInfo()).sort(); + } + + it('creates the table when there is no copy', async function () { + await addTable(PARENT, finalParentSpec, { replaceDevelopmentCopy: true }).up({ + connection: db.knex, + }); + + assert.deepEqual(await columns(PARENT), ['id', 'name']); + }); + + it('replaces an existing copy, dropping the tables that reference it', async function () { + await commands.createTable(PARENT, db.knex); + await commands.createTable(CHILD, db.knex); + await db.knex(PARENT).insert({ id: 'parent' }); + await db.knex(CHILD).insert({ id: 'child', parent_id: 'parent' }); + + await addTable(PARENT, finalParentSpec, { replaceDevelopmentCopy: true }).up({ + connection: db.knex, + }); + + assert.deepEqual(await columns(PARENT), ['id', 'name']); + assert.equal((await db.knex(PARENT).pluck('id')).length, 0); + assert.equal(await hasTable(CHILD), false); + + await inDevelopment.createMissingInDevelopmentTables(db.knex); + assert.equal(await hasTable(CHILD), true); + }); + + it('rolls back while other tables reference it', async function () { + const migration = addTable(PARENT, finalParentSpec, { replaceDevelopmentCopy: true }); + await migration.up({ connection: db.knex }); + await commands.createTable(CHILD, db.knex); + + await migration.down({ connection: db.knex }); + + assert.equal(await hasTable(PARENT), false); + assert.equal(await hasTable(CHILD), false); + }); + + it('leaves an existing table alone outside development and testing', async function () { + await commands.createTable(PARENT, db.knex); + configUtils.set('env', 'production'); + + await addTable(PARENT, finalParentSpec, { replaceDevelopmentCopy: true }).up({ + connection: db.knex, + }); + + assert.deepEqual(await columns(PARENT), ['id']); + }); + }); +}); diff --git a/ghost/core/test/unit/api/canary/session.test.js b/ghost/core/test/unit/api/canary/session.test.ts similarity index 84% rename from ghost/core/test/unit/api/canary/session.test.js rename to ghost/core/test/unit/api/canary/session.test.ts index 63f8300c3a8..c089e9ba87a 100644 --- a/ghost/core/test/unit/api/canary/session.test.js +++ b/ghost/core/test/unit/api/canary/session.test.ts @@ -1,11 +1,27 @@ -const assert = require('node:assert/strict'); -const sinon = require('sinon'); -const { UnauthorizedError } = require('@tryghost/errors'); - -const models = require('../../../../core/server/models'); - -const sessionController = require('../../../../core/server/api/endpoints/session'); -const sessionServiceMiddleware = require('../../../../core/server/services/auth/session'); +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import { UnauthorizedError } from '@tryghost/errors'; + +// @ts-expect-error This module lacks type definitions. +import models = require('../../../../core/server/models'); +// @ts-expect-error This module lacks type definitions. +import sessionController from '../../../../core/server/api/endpoints/session'; +// @ts-expect-error This module lacks type definitions. +import sessionServiceMiddleware = require('../../../../core/server/services/auth/session'); + +type TestRequest = { + brute: { + reset: sinon.SinonStub; + }; + user?: unknown; + skipVerification?: boolean; +}; + +type SessionMiddleware = ( + req: unknown, + res: unknown, + next: (error?: unknown) => unknown, +) => unknown; describe('Session controller', function () { afterEach(function () { @@ -25,7 +41,7 @@ describe('Session controller', function () { () => { assert.fail('session.add did not throw'); }, - (err) => { + (err: unknown) => { assert.equal(err instanceof UnauthorizedError, true); }, ); @@ -46,14 +62,14 @@ describe('Session controller', function () { () => { assert.fail('session.add did not throw'); }, - (err) => { + (err: unknown) => { assert.equal(err instanceof UnauthorizedError, true); }, ); }); it('it returns a function that calls req.brute.reset, sets req.user and calls createSession if the check works', function () { - const fakeReq = { + const fakeReq: TestRequest = { brute: { reset: sinon.stub().callsArg(0), }, @@ -73,7 +89,7 @@ describe('Session controller', function () { password: 'qu33nRul35', }, }) - .then((fn) => { + .then((fn: SessionMiddleware) => { fn(fakeReq, fakeRes, fakeNext); }) .then(function () { @@ -89,7 +105,7 @@ describe('Session controller', function () { it('it returns a function that calls req.brute.reset and calls next if reset errors', function () { const resetError = new Error(); - const fakeReq = { + const fakeReq: TestRequest = { brute: { reset: sinon.stub().callsArgWith(0, resetError), }, @@ -109,7 +125,7 @@ describe('Session controller', function () { password: 'qu33nRul35', }, }) - .then((fn) => { + .then((fn: SessionMiddleware) => { fn(fakeReq, fakeRes, fakeNext); }) .then(function () { @@ -120,7 +136,7 @@ describe('Session controller', function () { }); it('it creates a verified session when the user has not logged in before', function () { - const fakeReq = { + const fakeReq: TestRequest = { brute: { reset: sinon.stub().callsArg(0), }, @@ -140,7 +156,7 @@ describe('Session controller', function () { password: 'qu33nRul35', }, }) - .then((fn) => { + .then((fn: SessionMiddleware) => { fn(fakeReq, fakeRes, fakeNext); }) .then(function () { @@ -157,7 +173,7 @@ describe('Session controller', function () { }); it('it creates a non-verified session when the user has logged in before', function () { - const fakeReq = { + const fakeReq: TestRequest = { brute: { reset: sinon.stub().callsArg(0), }, @@ -177,7 +193,7 @@ describe('Session controller', function () { password: 'qu33nRul35', }, }) - .then((fn) => { + .then((fn: SessionMiddleware) => { fn(fakeReq, fakeRes, fakeNext); }) .then(function () { @@ -203,7 +219,7 @@ describe('Session controller', function () { return sessionController .delete() - .then((fn) => { + .then((fn: SessionMiddleware) => { fn(fakeReq, fakeRes, fakeNext); }) .then(function () { diff --git a/ghost/core/test/unit/server/data/importer/handlers/image.test.js b/ghost/core/test/unit/server/data/importer/handlers/image.test.js index 5de797636e9..c094b463a96 100644 --- a/ghost/core/test/unit/server/data/importer/handlers/image.test.js +++ b/ghost/core/test/unit/server/data/importer/handlers/image.test.js @@ -1,17 +1,37 @@ const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const sinon = require('sinon'); const _ = require('lodash'); +const logging = require('@tryghost/logging'); const ImageHandler = require('../../../../../../core/server/data/importer/handlers/image'); const adapterManager = require('../../../../../../core/server/services/adapter-manager').default; const configUtils = require('../../../../../utils/config-utils'); +const imageFixturePath = path.join(__dirname, '../../../../../utils/fixtures/images'); + describe('ImageHandler', function () { const store = adapterManager.getAdapter('storage:images'); + let tmpDir; + + // Copies an image fixture into the extracted import at the given path + const fixture = (name, fixtureName = 'ghosticon.jpg') => { + const filePath = path.join(tmpDir, name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.copyFileSync(path.join(imageFixturePath, fixtureName), filePath); + return filePath; + }; + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-image-import-')); + }); afterEach(async function () { sinon.restore(); await configUtils.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('has the correct interface', function () { @@ -43,7 +63,7 @@ describe('ImageHandler', function () { const file = [ { - path: '/my/test/' + filename, + path: fixture(filename), name: filename, }, ]; @@ -62,7 +82,7 @@ describe('ImageHandler', function () { const file = [ { - path: '/my/test/' + filename, + path: fixture(filename), name: filename, }, ]; @@ -81,7 +101,7 @@ describe('ImageHandler', function () { const file = [ { - path: '/my/test/content/images/' + filename, + path: fixture('content/images/' + filename), name: filename, }, ]; @@ -102,7 +122,7 @@ describe('ImageHandler', function () { const file = [ { - path: '/my/test/' + filename, + path: fixture(filename), name: filename, }, ]; @@ -119,19 +139,19 @@ describe('ImageHandler', function () { it('can load multiple files', async function () { const files = [ { - path: '/my/test/testing.png', + path: fixture('testing.png', 'ghost-logo.png'), name: 'testing.png', }, { - path: '/my/test/photo/kitten.jpg', + path: fixture('photo/kitten.jpg'), name: 'photo/kitten.jpg', }, { - path: '/my/test/content/images/animated/bunny.gif', + path: fixture('content/images/animated/bunny.gif', 'loadingcat.gif'), name: 'content/images/animated/bunny.gif', }, { - path: '/my/test/images/puppy.jpg', + path: fixture('images/puppy.jpg'), name: 'images/puppy.jpg', }, ]; @@ -156,4 +176,45 @@ describe('ImageHandler', function () { assert.match(storeSpy.lastCall.args[0].targetDir, /(\/|\\)content(\/|\\)images$/); assert.equal(storeSpy.lastCall.args[0].newPath, '/content/images/puppy.jpg'); }); + + it('skips files whose contents are not an allowed image format', async function () { + const warn = sinon.stub(logging, 'warn'); + const files = [ + { path: fixture('photo.jpg'), name: 'photo.jpg' }, + { path: fixture('avif.jpg', 'ghosticon.avif'), name: 'avif.jpg' }, + { path: fixture('text.png', 'svg-malformed.svg'), name: 'text.png' }, + ]; + + const storeSpy = sinon.spy(store, 'getUniqueFileName'); + + const loaded = await ImageHandler.loadFile(_.clone(files)); + assert.deepEqual( + loaded.map((file) => file.originalPath), + ['photo.jpg'], + ); + sinon.assert.calledOnce(storeSpy); + sinon.assert.calledTwice(warn); + }); + + it('sanitizes SVG files', async function () { + const svgPath = fixture('unsafe.svg', 'svg-with-unsafe-script.svg'); + + const loaded = await ImageHandler.loadFile([{ path: svgPath, name: 'unsafe.svg' }]); + + assert.equal(loaded.length, 1); + const svg = fs.readFileSync(svgPath, 'utf8'); + assert.match(svg, / + Promise.resolve(path.join(targetDir, path.basename(file.name))), + }, + urlUtils: { + getSubdir: () => '', + urlJoin: (...args) => args.join('/'), + }, + }); + + const backtrackingName = `${'a'.repeat(40)}c/content/media/2.mp4`; + const files = [{ name: '(a+)+b/content/media/1.mp4' }, { name: backtrackingName }]; + + await contentFileImporter.loadFile(files, '(a+)+b'); + + assert.equal(files[0].originalPath, 'content/media/1.mp4'); + assert.equal(files[0].name, '1.mp4'); + assert.equal(files[1].originalPath, backtrackingName); + }); + it('preserves date subdirectories in targetDir', async function () { const contentFileImporter = new ImporterContentFileHandler({ storage: { diff --git a/ghost/core/test/unit/server/data/importer/importers/content-file-importer.test.js b/ghost/core/test/unit/server/data/importer/importers/content-file-importer.test.js index 39d64e6b5a3..ffc1d83a554 100644 --- a/ghost/core/test/unit/server/data/importer/importers/content-file-importer.test.js +++ b/ghost/core/test/unit/server/data/importer/importers/content-file-importer.test.js @@ -122,6 +122,33 @@ describe('ContentFileImporter', function () { assert.equal(output.data.data.posts[0].html, ''); }); + it('treats regex metacharacters in archive paths literally', function () { + const imageImporter = new ContentFileImporter({ type: 'images', store: {} }); + const backtrackingInput = `/images/${'a'.repeat(40)}c`; + const inputData = { + images: [ + { + originalPath: 'images/(a+)+b.jpg', + newPath: '/content/images/a-b.jpg', + }, + ], + data: { + data: { + posts: [{ html: `

${backtrackingInput}

` }], + tags: [], + users: [], + }, + }, + }; + + const output = imageImporter.preProcess(inputData); + + assert.equal( + output.data.data.posts[0].html, + `

${backtrackingInput}

`, + ); + }); + it('does import the images correctly', async function () { const inputData = require('../../../../../utils/fixtures/import/import-data-1.json'); const storageApi = { diff --git a/ghost/core/test/unit/server/data/schema/in-development.test.ts b/ghost/core/test/unit/server/data/schema/in-development.test.ts new file mode 100644 index 00000000000..1ac35ea236f --- /dev/null +++ b/ghost/core/test/unit/server/data/schema/in-development.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +// @ts-expect-error This module lacks type definitions. +import schema from '../../../../../core/server/data/schema/schema'; + +// require, not import: config-utils and in-development must resolve to the same +// CommonJS config instance, so values set here are the ones the module reads +const configUtils = require('../../../../utils/config-utils'); +const inDevelopment: typeof import('../../../../../core/server/data/schema/in-development') = require('../../../../../core/server/data/schema/in-development'); + +type ColumnSpec = { references?: string }; + +describe('In-development schema tables', function () { + afterEach(async function () { + await configUtils.restore(); + }); + + it('only lists tables defined in schema.js', function () { + for (const tableName of inDevelopment.IN_DEVELOPMENT_TABLES) { + assert( + Object.hasOwn(schema, tableName), + `In-development table ${tableName} is not defined in schema.js`, + ); + } + }); + + it('are never referenced by finalised tables', function () { + for (const [tableName, table] of Object.entries>(schema)) { + if (inDevelopment.isInDevelopmentTable(tableName)) { + continue; + } + + for (const [columnName, column] of Object.entries(table)) { + if (!column.references) { + continue; + } + + const referencedTable = column.references.split('.')[0]; + assert( + !inDevelopment.isInDevelopmentTable(referencedTable), + `${tableName}.${columnName} references in-development table ${referencedTable}`, + ); + } + } + }); + + describe('shouldCreateInDevelopmentTables', function () { + it('is enabled in development and testing when configured', function () { + configUtils.set('createInDevelopmentTables', true); + + for (const env of ['development', 'testing', 'testing-mysql']) { + configUtils.set('env', env); + assert.equal(inDevelopment.shouldCreateInDevelopmentTables(), true, env); + } + }); + + it('is disabled when not configured', function () { + configUtils.set('env', 'development'); + configUtils.set('createInDevelopmentTables', false); + + assert.equal(inDevelopment.shouldCreateInDevelopmentTables(), false); + }); + + it('ignores the config outside development and testing', function () { + configUtils.set('env', 'production'); + configUtils.set('createInDevelopmentTables', true); + + assert.equal(inDevelopment.shouldCreateInDevelopmentTables(), false); + }); + }); + + describe('getTablesToCreate', function () { + let originalTables: string[]; + + beforeEach(function () { + originalTables = [...inDevelopment.IN_DEVELOPMENT_TABLES]; + inDevelopment.IN_DEVELOPMENT_TABLES.splice(0, Infinity, 'posts_meta'); + configUtils.set('env', 'development'); + }); + + afterEach(function () { + inDevelopment.IN_DEVELOPMENT_TABLES.splice(0, Infinity, ...originalTables); + }); + + it('includes in-development tables when enabled', function () { + configUtils.set('createInDevelopmentTables', true); + + assert.deepEqual(inDevelopment.getTablesToCreate(), Object.keys(schema)); + }); + + it('excludes in-development tables when disabled', function () { + configUtils.set('createInDevelopmentTables', false); + + const tables = inDevelopment.getTablesToCreate(); + assert(!tables.includes('posts_meta')); + assert.equal(tables.length, Object.keys(schema).length - 1); + }); + }); +}); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 3809127c273..93936f05e33 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -5,6 +5,7 @@ const fs = require('fs-extra'); const path = require('path'); const { config } = require('../../../../utils/config-utils'); const schema = require('../../../../../core/server/data/schema/schema'); +const inDevelopment = require('../../../../../core/server/data/schema/in-development'); const fixtures = require('../../../../../core/server/data/schema/fixtures/fixtures.json'); const defaultSettings = require('../../../../../core/server/data/schema/default-settings/default-settings.json'); @@ -55,7 +56,7 @@ describe('DB version integrity', function () { 'yamlSource', ); - const tablesNoValidation = _.cloneDeep(schema); + const tablesNoValidation = _.cloneDeep(_.omit(schema, inDevelopment.IN_DEVELOPMENT_TABLES)); _.each(tablesNoValidation, function (table) { return _.each(table, function (column, name) { diff --git a/ghost/core/test/unit/server/lib/image/image-content.test.ts b/ghost/core/test/unit/server/lib/image/image-content.test.ts new file mode 100644 index 00000000000..9d478d71ac3 --- /dev/null +++ b/ghost/core/test/unit/server/lib/image/image-content.test.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import sharp from 'sharp'; +import { + detectFileExtension, + getIgnoredImageContentTypes, + isImageContentType, + isAllowedImageContent, + isAllowedImageExtension, + isSvgExtension, +} from '../../../../../core/server/lib/image/image-content'; + +const fixturePath = (fixture: string) => + path.join(__dirname, '../../../../utils/fixtures/images', fixture); + +// Turns a PNG into an APNG by adding an animation control chunk before the +// image data, which is what file-type looks for +const toApng = (png: Buffer) => { + const data = Buffer.alloc(8); + data.writeUInt32BE(1, 0); + const type = Buffer.from('acTL'); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zlib.crc32(Buffer.concat([type, data]))); + const idat = png.indexOf('IDAT') - 4; + + return Buffer.concat([png.subarray(0, idat), length, type, data, crc, png.subarray(idat)]); +}; + +const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.gif', '.png', '.svg', '.svgz', '.ico', '.webp']; + +describe('lib/image: image-content', function () { + describe('detectFileExtension', function () { + it('detects the type from a path or a buffer', async function () { + assert.equal(await detectFileExtension(fixturePath('ghosticon.jpg')), '.jpg'); + assert.equal( + await detectFileExtension(fs.readFileSync(fixturePath('ghost-logo.png'))), + '.png', + ); + }); + + it('detects formats regardless of the allowlist', async function () { + const buffer = fs.readFileSync(fixturePath('ghosticon.avif')); + + assert.equal(await detectFileExtension(buffer), '.avif'); + }); + + it('returns undefined for contents it cannot detect', async function () { + assert.equal(await detectFileExtension(Buffer.from('not an image')), undefined); + }); + }); + + describe('isAllowedImageExtension', function () { + it('treats .jpg and .jpeg as the same format', function () { + assert.equal(isAllowedImageExtension('.jpg', ['.jpeg']), true); + assert.equal(isAllowedImageExtension('.jpeg', ['.jpg']), true); + assert.equal(isAllowedImageExtension('.JPG', ['.jpg']), true); + }); + + it('matches every spelling of a format', function () { + assert.equal(isAllowedImageExtension('.tif', ['.tiff']), true); + assert.equal(isAllowedImageExtension('.jpg', ['.jpe']), true); + assert.equal(isAllowedImageExtension('.jpg', ['.jfif']), true); + assert.equal(isAllowedImageExtension('.apng', ['.png']), true); + assert.equal(isAllowedImageExtension('.heic', ['.heif']), true); + }); + + it('keeps formats that share a decoder separate', function () { + assert.equal(isAllowedImageExtension('.avif', ['.heic', '.heif']), false); + assert.equal(isAllowedImageExtension('.heic', ['.avif']), false); + }); + + it('matches extensions that are not image formats exactly', function () { + assert.equal(isAllowedImageExtension('.BMP', ['.bmp']), true); + assert.equal(isAllowedImageExtension('.bmp', ['.png']), false); + }); + + it('rejects extensions that are not allowed', function () { + assert.equal(isAllowedImageExtension('.avif', IMAGE_EXTENSIONS), false); + assert.equal(isAllowedImageExtension('.png', ['.jpg']), false); + assert.equal(isAllowedImageExtension(undefined, IMAGE_EXTENSIONS), false); + }); + }); + + describe('isAllowedImageContent', function () { + it('accepts each allowed raster image format', async function () { + for (const fixture of [ + 'ghosticon.jpg', + 'ghost-logo.png', + 'loadingcat.gif', + 'ghosticon.webp', + 'favicon.ico', + 'favicon_multi_sizes.ico', + ]) { + assert.equal(await isAllowedImageContent(fixturePath(fixture), IMAGE_EXTENSIONS), true); + } + }); + + it('accepts contents whose detected extension is another spelling of an allowed one', async function () { + const jpeg = fs.readFileSync(fixturePath('ghosticon.jpg')); + const tiff = await sharp(jpeg).tiff().toBuffer(); + const apng = toApng(fs.readFileSync(fixturePath('ghost-logo.png'))); + + assert.equal(await detectFileExtension(tiff), '.tif'); + assert.equal(await isAllowedImageContent(tiff, ['.tiff']), true); + assert.equal(await isAllowedImageContent(jpeg, ['.jpe', '.jfif']), true); + assert.equal(await detectFileExtension(apng), '.apng'); + assert.equal(await isAllowedImageContent(apng, IMAGE_EXTENSIONS), true); + }); + + it('rejects formats that are not in the allowed extensions', async function () { + assert.equal( + await isAllowedImageContent(fixturePath('ghosticon.avif'), IMAGE_EXTENSIONS), + false, + ); + assert.equal(await isAllowedImageContent(fixturePath('ghost-logo.png'), ['.jpg']), false); + }); + + it('rejects SVGs, which are left to the SVG sanitizer', async function () { + assert.equal( + await isAllowedImageContent(fixturePath('ghost-logo.svg'), IMAGE_EXTENSIONS), + false, + ); + assert.equal( + await isAllowedImageContent(fixturePath('ghost-logo.svgz'), IMAGE_EXTENSIONS), + false, + ); + }); + }); + + describe('isAllowedImageContent with a custom allowlist', function () { + it('accepts explicitly allowed image formats outside the format table', async function () { + // A valid 1x1, 24-bit BMP with one padded pixel row. + const bmp = Buffer.from( + '424d3a00000000000000360000002800000001000000010000000100180000000000040000000000000000000000000000000000000000000000', + 'hex', + ); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-image-content-')); + const filePath = path.join(tmpDir, 'pixel.bmp'); + + try { + fs.writeFileSync(filePath, bmp); + + for (const input of [bmp, filePath]) { + assert.equal(await isAllowedImageContent(input, ['.bmp']), true); + assert.equal(await isAllowedImageContent(input, IMAGE_EXTENSIONS), false); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('rejects non-image contents even when their extension is explicitly allowed', async function () { + const pdf = fixturePath('../files/test.pdf'); + + assert.equal(await detectFileExtension(pdf), '.pdf'); + assert.equal(await isAllowedImageContent(pdf, ['.pdf']), false); + }); + }); + + describe('isImageContentType', function () { + it('accepts image types and downloads', function () { + assert.equal(isImageContentType('image/png'), true); + assert.equal(isImageContentType('image/svg+xml'), true); + assert.equal(isImageContentType(' IMAGE/JPEG '), true); + assert.equal(isImageContentType('application/octet-stream'), true); + }); + + it('rejects types a browser could treat as a document', function () { + assert.equal(isImageContentType('text/html'), false); + assert.equal(isImageContentType('application/xhtml+xml'), false); + assert.equal(isImageContentType('text/xml'), false); + assert.equal(isImageContentType('application/pdf'), false); + }); + }); + + describe('getIgnoredImageContentTypes', function () { + it('returns the configured types that are not image types', function () { + assert.deepEqual(getIgnoredImageContentTypes(['image/png', 'text/html', 'text/xml']), [ + 'text/html', + 'text/xml', + ]); + assert.deepEqual(getIgnoredImageContentTypes(['image/png', 'image/svg+xml']), []); + }); + }); + + describe('isSvgExtension', function () { + it('matches .svg and .svgz in any case', function () { + assert.equal(isSvgExtension('.svg'), true); + assert.equal(isSvgExtension('.SVGZ'), true); + assert.equal(isSvgExtension('.png'), false); + }); + }); +}); diff --git a/ghost/core/test/unit/server/lib/image/image-decoders.test.ts b/ghost/core/test/unit/server/lib/image/image-decoders.test.ts new file mode 100644 index 00000000000..f3f9917317a --- /dev/null +++ b/ghost/core/test/unit/server/lib/image/image-decoders.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import sharp from 'sharp'; +// @ts-expect-error This module lacks type definitions. +import imageTransform from '@tryghost/image-transform'; +import { + getAllowedImageLoaders, + restrictImageDecoders, +} from '../../../../../core/server/lib/image/image-decoders'; +import config from '../../../../../core/shared/config'; + +const readFixture = (fixture: string) => + fs.readFileSync(path.join(__dirname, '../../../../utils/fixtures/images', fixture)); + +describe('lib/image: image-decoders', function () { + describe('getAllowedImageLoaders', function () { + it('maps the default image extensions to their loaders', function () { + assert.deepEqual(getAllowedImageLoaders(config.get('uploads').images.extensions), [ + 'VipsForeignLoadJpeg', + 'VipsForeignLoadNsgif', + 'VipsForeignLoadPng', + 'VipsForeignLoadSvg', + 'VipsForeignLoadWebp', + ]); + }); + + it('maps extra formats a site can enable', function () { + assert.deepEqual(getAllowedImageLoaders(['.AVIF', '.heic', '.tiff']), [ + 'VipsForeignLoadHeif', + 'VipsForeignLoadPng', + 'VipsForeignLoadSvg', + 'VipsForeignLoadTiff', + ]); + }); + + it('maps every spelling of a format', function () { + assert.deepEqual(getAllowedImageLoaders(['.jpe', '.jfif', '.tif', '.heif']), [ + 'VipsForeignLoadHeif', + 'VipsForeignLoadJpeg', + 'VipsForeignLoadPng', + 'VipsForeignLoadSvg', + 'VipsForeignLoadTiff', + ]); + }); + + it('always allows PNG and SVG for internal images', function () { + assert.deepEqual(getAllowedImageLoaders(['.jpg', '.ico', '.unknown']), [ + 'VipsForeignLoadJpeg', + 'VipsForeignLoadPng', + 'VipsForeignLoadSvg', + ]); + }); + }); + + describe('restrictImageDecoders', function () { + afterEach(function () { + // The block is process-wide, so undo it for the rest of the unit tests + sharp.unblock({ operation: ['VipsForeignLoad'] }); + }); + + it('does nothing when sharp is not installed', function () { + assert.equal(restrictImageDecoders(['.jpg'], { requireSharp: () => undefined }), undefined); + }); + + // Goes through @tryghost/image-transform rather than sharp directly, so + // this fails if image-transform ever resolves a copy of sharp the block + // doesn't cover + it('stops image processing from decoding formats that are not allowed', async function () { + const avif = readFixture('ghosticon.avif'); + const resize = () => imageTransform.resizeFromBuffer(avif, { width: 10, format: 'png' }); + + await resize(); + + restrictImageDecoders(config.get('uploads').images.extensions); + + await assert.rejects(resize, { code: 'IMAGE_PROCESSING' }); + }); + + it('keeps decoding the allowed formats', async function () { + restrictImageDecoders(config.get('uploads').images.extensions); + + for (const fixture of [ + 'ghosticon.jpg', + 'ghost-logo.png', + 'loadingcat.gif', + 'ghosticon.webp', + 'ghost-logo.svg', + ]) { + const resized = await imageTransform.resizeFromBuffer(readFixture(fixture), { + width: 10, + format: 'png', + }); + + assert.ok(resized.length > 0, fixture); + } + }); + }); +}); diff --git a/ghost/core/test/unit/server/lib/image/svg-sanitizer.test.ts b/ghost/core/test/unit/server/lib/image/svg-sanitizer.test.ts new file mode 100644 index 00000000000..6b48f40e942 --- /dev/null +++ b/ghost/core/test/unit/server/lib/image/svg-sanitizer.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import zlib from 'node:zlib'; +import sinon from 'sinon'; +import logging from '@tryghost/logging'; +import { sanitizeSvgFile } from '../../../../../core/server/lib/image/svg-sanitizer'; + +const fixturePath = (fixture: string) => + path.join(__dirname, '../../../../utils/fixtures/images', fixture); + +describe('lib/image: svg-sanitizer', function () { + describe('sanitizeSvgFile', function () { + let tmpDir: string; + + const copyFixture = (fixture: string) => { + const filePath = path.join(tmpDir, fixture); + fs.copyFileSync(fixturePath(fixture), filePath); + return filePath; + }; + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-svg-sanitizer-')); + }); + + afterEach(function () { + sinon.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('sanitizes an SVG file in place', async function () { + const filePath = copyFixture('svg-with-unsafe-script.svg'); + + assert.equal(await sanitizeSvgFile(filePath), true); + + const svg = fs.readFileSync(filePath, 'utf8'); + assert.match(svg, / + fs.readFileSync(path.join(__dirname, '../../../../utils/fixtures/images', fixture)); + +describe('media-inliner service', function () { + const imageStorage = { type: 'images' }; + const mediaStorage = { type: 'media' }; + const fileStorage = { type: 'files' }; + + let getMediaStorage: (extension: string, fileBuffer: Buffer) => Promise; + + beforeEach(async function () { + const storages: Record = { + 'storage:images': imageStorage, + 'storage:media': mediaStorage, + 'storage:files': fileStorage, + }; + sinon.stub(adapterManager, 'getAdapter').callsFake((name) => storages[name as string]); + + await mediaInlinerService.init(); + getMediaStorage = mediaInlinerService.getInstance().getMediaStorage; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('stores images whose contents are an allowed image format', async function () { + assert.equal(await getMediaStorage('.jpg', readFixture('ghosticon.jpg')), imageStorage); + assert.equal(await getMediaStorage('.png', readFixture('ghost-logo.png')), imageStorage); + }); + + it('does not store images whose contents are not an allowed image format', async function () { + assert.equal(await getMediaStorage('.jpg', readFixture('ghosticon.avif')), null); + assert.equal(await getMediaStorage('.gif', Buffer.from('not an image')), null); + }); + + it('stores SVGs as images', async function () { + assert.equal(await getMediaStorage('.svg', readFixture('ghost-logo.svg')), imageStorage); + }); + + it('picks media and file storage by extension', async function () { + assert.equal(await getMediaStorage('.mp4', Buffer.from('')), mediaStorage); + assert.equal(await getMediaStorage('.pdf', Buffer.from('')), fileStorage); + assert.equal(await getMediaStorage('.exe', Buffer.from('')), null); + }); +}); diff --git a/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js b/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js index f024481e9cc..545b394f228 100644 --- a/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js +++ b/ghost/core/test/unit/server/services/media-inliner/test/external-media-inliner.test.js @@ -1161,6 +1161,80 @@ describe('ExternalMediaInliner', function () { }); }); + describe('storeMediaLocally', function () { + const imageFixture = (fixture) => + fs.readFileSync(path.join(__dirname, '../../../../../utils/fixtures/images', fixture)); + + const createStorage = () => ({ + storagePath: '/content/images', + getTargetDir: () => '/content/images', + getUniqueFileName: () => '/content/images/image.svg', + saveRaw: sinon.stub().resolves('/content/images/image.svg'), + }); + + it('passes the file contents when picking the storage', async function () { + const storage = createStorage(); + const getMediaStorage = sinon.stub().resolves(storage); + const inliner = new ExternalMediaInliner({ getMediaStorage }); + + await inliner.storeMediaLocally({ + fileBuffer: GIF1x1, + filename: 'image.gif', + extension: '.gif', + }); + + sinon.assert.calledOnceWithExactly(getMediaStorage, '.gif', GIF1x1); + sinon.assert.calledOnceWithExactly(storage.saveRaw, GIF1x1, 'image.svg'); + }); + + it('sanitizes SVGs before storing them', async function () { + const storage = createStorage(); + const getMediaStorage = sinon.stub().resolves(storage); + const inliner = new ExternalMediaInliner({ getMediaStorage }); + + await inliner.storeMediaLocally({ + fileBuffer: imageFixture('svg-with-unsafe-script.svg'), + filename: 'image.svg', + extension: '.svg', + }); + + const storedSvg = storage.saveRaw.firstCall.args[0].toString(); + assert.match(storedSvg, /'; + const matches = ExternalMediaInliner.findMatches( + html, + 'https?://([a-zA-Z0-9-.]+)?example.com', + ); + + assert.deepEqual(matches, ['https://cdn.example.com/one.png', 'http://example.com/two.png']); + }); + + it('Times out domain patterns with catastrophic backtracking', function () { + const html = `${'a'.repeat(40)}c`; + + assert.throws(() => ExternalMediaInliner.findMatches(html, '(a+)+b', { timeout: 50 }), { + code: 'MEDIA_INLINER_PATTERN_TIMEOUT', + errorDetails: { domain: '(a+)+b' }, + }); + }); + }); + + describe('Domain pattern timeouts', function () { + it('keeps other domain replacements and skips a timed out pattern for later posts', async function () { + const findMatches = ExternalMediaInliner.findMatches; + const findMatchesStub = sinon + .stub(ExternalMediaInliner, 'findMatches') + .callsFake((content, domain) => findMatches(content, domain, { timeout: 50 })); + const lexical = `{"src":"https://example.com/media.jpg"} ${'a'.repeat(40)}c`; + postModelStub.findAll.resolves( + ['first-post-id', 'second-post-id'].map((id) => ({ + id, + get: (field) => (field === 'lexical' ? lexical : null), + })), + ); + + const inliner = new ExternalMediaInliner({ + PostModel: postModelStub, + PostMetaModel: postMetaModelStub, + TagModel: tagModelStub, + UserModel: userModelStub, + }); + sinon.stub(inliner, 'importUrl').resolves({ + status: 'stored', + storedUrl: '__GHOST_URL__/content/images/media.jpg', + }); + + await inliner.inline(['https://example.com', '(a+)+b']); + + assert.deepEqual( + findMatchesStub.args.map(([, domain]) => domain), + ['https://example.com', '(a+)+b', 'https://example.com'], + ); + sinon.assert.calledOnce(logging.error); + assert.equal(logging.error.args[0][0].code, 'MEDIA_INLINER_PATTERN_TIMEOUT'); + + const expectedLexical = lexical.replace( + 'https://example.com/media.jpg', + '__GHOST_URL__/content/images/media.jpg', + ); + sinon.assert.calledTwice(postModelStub.edit); + assert.deepEqual(postModelStub.edit.args[0][0], { lexical: expectedLexical }); + assert.equal(postModelStub.edit.args[0][1].id, 'first-post-id'); + assert.deepEqual(postModelStub.edit.args[1][0], { lexical: expectedLexical }); + assert.equal(postModelStub.edit.args[1][1].id, 'second-post-id'); + }); }); describe('CDN storage adapter', function () { diff --git a/ghost/core/test/unit/server/services/member-attribution/attribution.test.js b/ghost/core/test/unit/server/services/member-attribution/attribution.test.ts similarity index 82% rename from ghost/core/test/unit/server/services/member-attribution/attribution.test.js rename to ghost/core/test/unit/server/services/member-attribution/attribution.test.ts index fd8b3818490..2356e6521dd 100644 --- a/ghost/core/test/unit/server/services/member-attribution/attribution.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/attribution.test.ts @@ -1,17 +1,51 @@ -const { assertObjectMatches } = require('../../../../utils/assertions'); +import { assertObjectMatches } from '../../../../utils/assertions'; +// @ts-expect-error JavaScript module has no type declarations +import UrlHistory from '../../../../../core/server/services/member-attribution/url-history'; +// @ts-expect-error JavaScript module has no type declarations +import AttributionBuilder from '../../../../../core/server/services/member-attribution/attribution-builder'; -const UrlHistory = require('../../../../../core/server/services/member-attribution/url-history'); -const AttributionBuilder = require('../../../../../core/server/services/member-attribution/attribution-builder'); +type HistoryItem = { + id?: string; + path?: string; + time: number; + type?: string; +}; + +type UrlHistoryLike = Iterable & { length: number }; +type AttributionData = { + id?: string | number | null; + type?: string | null; + url?: string | null; +}; +type AttributionLike = { + fetchResource(): Promise>; +}; +type AttributionBuilderLike = { + build(data: AttributionData): AttributionLike; + getAttribution(history: UrlHistoryLike): Promise>; +}; +type ResourceModel = { + id: string; + get(property: string): string | undefined; +}; +type UrlTranslatorMock = { + getResourceDetails(item: HistoryItem): AttributionData | null; + getResourceById(id: string, type: string): ResourceModel | null; + getUrlTitle(url: string): string; + getResourceUrl(): string; + relativeToAbsolute(path: string): string; + stripSubdirectoryFromPath(path: string): string; +}; describe('AttributionBuilder', function () { - let attributionBuilder; - let urlTranslator; - let now; + let attributionBuilder: AttributionBuilderLike; + let urlTranslator: UrlTranslatorMock; + let now: number; beforeAll(function () { now = Date.now(); urlTranslator = { - getResourceDetails(item) { + getResourceDetails(item: HistoryItem) { if (!item.path) { if (item.id === 'invalid') { return null; @@ -46,13 +80,13 @@ describe('AttributionBuilder', function () { url: path, }; }, - getResourceById(id, type) { + getResourceById(id: string, type: string) { if (id === 'invalid') { return null; } return { id, - get(prop) { + get(prop: string) { if (prop === 'title' && type === 'author') { // Simulate an author doesn't have a title return undefined; @@ -65,16 +99,16 @@ describe('AttributionBuilder', function () { }, }; }, - getUrlTitle(url) { + getUrlTitle(url: string) { return url; }, getResourceUrl() { return 'https://absolute/dir/path'; }, - relativeToAbsolute(path) { + relativeToAbsolute(path: string) { return 'https://absolute/dir' + path; }, - stripSubdirectoryFromPath(path) { + stripSubdirectoryFromPath(path: string) { if (path.startsWith('/dir/')) { return path.substring('/dir/'.length - 1); } @@ -84,7 +118,7 @@ describe('AttributionBuilder', function () { attributionBuilder = new AttributionBuilder({ urlTranslator, referrerTranslator: { - getReferrerDetails(history) { + getReferrerDetails(history: UrlHistoryLike) { if (history) { return { referrerSource: 'Ghost Explore', diff --git a/ghost/core/test/unit/server/services/member-attribution/history.test.js b/ghost/core/test/unit/server/services/member-attribution/history.test.ts similarity index 92% rename from ghost/core/test/unit/server/services/member-attribution/history.test.js rename to ghost/core/test/unit/server/services/member-attribution/history.test.ts index b1d31682f3d..9587b1a8e89 100644 --- a/ghost/core/test/unit/server/services/member-attribution/history.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/history.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const UrlHistory = require('../../../../../core/server/services/member-attribution/url-history'); +// @ts-expect-error JavaScript module has no type declarations +import UrlHistory from '../../../../../core/server/services/member-attribution/url-history'; describe('UrlHistory', function () { it('sets history to empty array if invalid', function () { diff --git a/ghost/core/test/unit/server/services/member-attribution/outbound-link-tagger.test.js b/ghost/core/test/unit/server/services/member-attribution/outbound-link-tagger.test.ts similarity index 97% rename from ghost/core/test/unit/server/services/member-attribution/outbound-link-tagger.test.js rename to ghost/core/test/unit/server/services/member-attribution/outbound-link-tagger.test.ts index 8e6fec70f48..e3574107484 100644 --- a/ghost/core/test/unit/server/services/member-attribution/outbound-link-tagger.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/outbound-link-tagger.test.ts @@ -1,6 +1,7 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const OutboundLinkTagger = require('../../../../../core/server/services/member-attribution/outbound-link-tagger'); +// @ts-expect-error JavaScript module has no type declarations +import OutboundLinkTagger from '../../../../../core/server/services/member-attribution/outbound-link-tagger'; describe('OutboundLinkTagger', function () { describe('Constructor', function () { @@ -40,7 +41,7 @@ describe('OutboundLinkTagger', function () { const url = new URL('https://example.com/'); const newsletterName = 'used newsletter name'; const newsletter = { - get: (t) => { + get: (t: string) => { if (t === 'name') { return newsletterName; } @@ -63,7 +64,7 @@ describe('OutboundLinkTagger', function () { const url = new URL('https://example.com/'); const newsletterName = 'Weekly newsletter'; const newsletter = { - get: (t) => { + get: (t: string) => { if (t === 'name') { return newsletterName; } diff --git a/ghost/core/test/unit/server/services/member-attribution/referrer-translator.test.js b/ghost/core/test/unit/server/services/member-attribution/referrer-translator.test.ts similarity index 94% rename from ghost/core/test/unit/server/services/member-attribution/referrer-translator.test.js rename to ghost/core/test/unit/server/services/member-attribution/referrer-translator.test.ts index f67dd066965..18f0fecccf5 100644 --- a/ghost/core/test/unit/server/services/member-attribution/referrer-translator.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/referrer-translator.test.ts @@ -1,6 +1,33 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const ReferrerTranslator = require('../../../../../core/server/services/member-attribution/referrer-translator'); +// @ts-expect-error JavaScript module has no type declarations +import ReferrerTranslator from '../../../../../core/server/services/member-attribution/referrer-translator'; + +type ReferrerHistoryItem = { + referrerSource?: string | null; + referrerMedium?: string | null; + referrerUrl?: string | null; + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmTerm?: string; + utmContent?: string; +}; + +type ReferrerData = { + referrerSource?: string | null; + referrerMedium?: string | null; + referrerUrl?: string | null; + utmSource?: string | null; + utmMedium?: string | null; + utmCampaign?: string | null; + utmTerm?: string | null; + utmContent?: string | null; +}; + +type ReferrerTranslatorLike = { + getReferrerDetails(history: ReferrerHistoryItem[]): ReferrerData | null; +}; describe('ReferrerTranslator', function () { describe('Constructor', function () { @@ -10,7 +37,7 @@ describe('ReferrerTranslator', function () { }); describe('getReferrerDetails', function () { - let translator; + let translator: ReferrerTranslatorLike; beforeAll(function () { translator = new ReferrerTranslator({ siteUrl: 'https://example.com', diff --git a/ghost/core/test/unit/server/services/member-attribution/service.test.js b/ghost/core/test/unit/server/services/member-attribution/service.test.ts similarity index 90% rename from ghost/core/test/unit/server/services/member-attribution/service.test.js rename to ghost/core/test/unit/server/services/member-attribution/service.test.ts index 3bf8b1d800f..c91e47d8870 100644 --- a/ghost/core/test/unit/server/services/member-attribution/service.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/service.test.ts @@ -1,10 +1,28 @@ -const assert = require('node:assert/strict'); - -const MemberAttributionService = require('../../../../../core/server/services/member-attribution/member-attribution-service'); +import assert from 'node:assert/strict'; + +// @ts-expect-error JavaScript module has no type declarations +import MemberAttributionService from '../../../../../core/server/services/member-attribution/member-attribution-service'; + +type AttributionData = { + id?: string | null; + url?: string | null; + type?: string | null; + referrerSource?: string | null; + referrerMedium?: string | null; + referrerUrl?: string | null; +}; + +type EventAttributeKey = + | 'attribution_id' + | 'attribution_url' + | 'attribution_type' + | 'referrer_source' + | 'referrer_medium' + | 'referrer_url'; // Mocks a Bookshelf model that is queried via `.where(...).query(...).fetch(...)`, // resolving the fetch to the given event (or null). -function createEventModelMock(event) { +function createEventModelMock(event: T | null) { const chainable = { where: () => chainable, query: () => chainable, @@ -98,7 +116,7 @@ describe('MemberAttributionService', function () { it('returns null if attribution_type is null', function () { const service = new MemberAttributionService({ attributionBuilder: { - build(attribution) { + build(attribution: AttributionData) { return { ...attribution, getResource() { @@ -132,7 +150,7 @@ describe('MemberAttributionService', function () { it('returns url attribution types', function () { const service = new MemberAttributionService({ attributionBuilder: { - build(attribution) { + build(attribution: AttributionData) { return { ...attribution, getResource() { @@ -148,7 +166,7 @@ describe('MemberAttributionService', function () { }); const model = { id: 'event_id', - get(name) { + get(name: string) { if (name === 'attribution_type') { return 'url'; } @@ -172,7 +190,7 @@ describe('MemberAttributionService', function () { it('returns first loaded relation', function () { const service = new MemberAttributionService({ attributionBuilder: { - build(attribution) { + build(attribution: AttributionData) { return { ...attribution, getResource() { @@ -188,7 +206,7 @@ describe('MemberAttributionService', function () { }); const model = { id: 'event_id', - get(name) { + get(name: string) { if (name === 'attribution_type') { return 'user'; } @@ -200,7 +218,7 @@ describe('MemberAttributionService', function () { } return 'test_user_id'; }, - related(name) { + related(name: string) { if (name === 'userAttribution') { return { id: 'test_user_id', @@ -225,7 +243,7 @@ describe('MemberAttributionService', function () { it('returns attribution from builder with history', async function () { const service = new MemberAttributionService({ attributionBuilder: { - getAttribution: async function (history) { + getAttribution: async function (history: { length: number }) { assert('length' in history); return { success: true }; }, @@ -239,7 +257,7 @@ describe('MemberAttributionService', function () { it('returns empty history attribution when tracking disabled', async function () { const service = new MemberAttributionService({ attributionBuilder: { - getAttribution: async function (history) { + getAttribution: async function (history: { length: number }) { assert.equal(history.length, 0); return { success: true }; }, @@ -293,7 +311,7 @@ describe('MemberAttributionService', function () { const service = new MemberAttributionService({ models: { MemberCreatedEvent: createEventModelMock({ - get: function (key) { + get: function (key: EventAttributeKey) { const values = { attribution_id: 'attr_123', attribution_url: '/test', @@ -307,7 +325,7 @@ describe('MemberAttributionService', function () { }), }, attributionBuilder: { - build: function (attribution) { + build: function (attribution: AttributionData) { return { ...attribution, fetchResource: async function () { @@ -347,7 +365,7 @@ describe('MemberAttributionService', function () { const service = new MemberAttributionService({ models: { SubscriptionCreatedEvent: createEventModelMock({ - get: function (key) { + get: function (key: EventAttributeKey) { const values = { attribution_id: 'attr_123', attribution_url: '/test', @@ -361,7 +379,7 @@ describe('MemberAttributionService', function () { }), }, attributionBuilder: { - build: function (attribution) { + build: function (attribution: AttributionData) { return { ...attribution, fetchResource: async function () { @@ -395,7 +413,7 @@ describe('MemberAttributionService', function () { it('fetches resource using attribution builder', async function () { const service = new MemberAttributionService({ attributionBuilder: { - build: function (attribution) { + build: function (attribution: AttributionData) { return { ...attribution, fetchResource: async function () { diff --git a/ghost/core/test/unit/server/services/member-attribution/url-translator.test.js b/ghost/core/test/unit/server/services/member-attribution/url-translator.test.ts similarity index 81% rename from ghost/core/test/unit/server/services/member-attribution/url-translator.test.js rename to ghost/core/test/unit/server/services/member-attribution/url-translator.test.ts index 4fef0060664..b6ae4955322 100644 --- a/ghost/core/test/unit/server/services/member-attribution/url-translator.test.js +++ b/ghost/core/test/unit/server/services/member-attribution/url-translator.test.ts @@ -1,10 +1,44 @@ -const assert = require('node:assert/strict'); +import assert from 'node:assert/strict'; -const UrlTranslator = require('../../../../../core/server/services/member-attribution/url-translator'); +// @ts-expect-error JavaScript module has no type declarations +import UrlTranslator from '../../../../../core/server/services/member-attribution/url-translator'; + +type ResourceModel = { + id: string; + get(property: string): string | undefined; +}; +type ResourceDetails = { + type: string; + id: string | null; + url: string; +}; +type ModelForUrl = { + get(property: string): string | undefined; + toJSON(): Record; +}; +type UrlTranslatorLike = { + getResourceDetails(item: { + id?: string; + path?: string; + type?: string; + time: number; + }): Promise; + getUrlTitle(url: string): string; + getTypeAndIdFromPath(path: string): Promise<{ type: string; id: string } | undefined>; + getResourceById(id: string, type: string): Promise; + getResourceUrl( + id: string, + type: string, + model: ModelForUrl, + options: { absolute: boolean }, + ): string; + relativeToAbsolute(path: string): string; + stripSubdirectoryFromPath(path: string): string; +}; const models = { Post: { - findOne({ id }) { + findOne({ id }: { id: string }) { if (id === 'invalid') { return null; } @@ -12,7 +46,7 @@ const models = { }, }, User: { - findOne({ id }) { + findOne({ id }: { id: string }) { if (id === 'invalid') { return null; } @@ -20,7 +54,7 @@ const models = { }, }, Tag: { - findOne({ id }) { + findOne({ id }: { id: string }) { if (id === 'invalid') { return null; } @@ -37,24 +71,24 @@ describe('UrlTranslator', function () { }); describe('getResourceDetails', function () { - let translator; + let translator: UrlTranslatorLike; beforeAll(function () { translator = new UrlTranslator({ urlUtils: { - relativeToAbsolute: (t) => { + relativeToAbsolute: (t: string) => { return 'https://absolute' + t; }, - absoluteToRelative: (t) => { + absoluteToRelative: (t: string) => { return t .replace('https://absolute/with-subdirectory', '') .replace('https://absolute', ''); }, }, urlService: { - getUrlForResource: (resource) => { + getUrlForResource: (resource: { id: string }) => { return '/path/' + resource.id; }, - resolveUrl: async (path) => { + resolveUrl: async (path: string) => { switch (path) { case '/path/post': return { type: 'posts', id: 'post' }; @@ -141,7 +175,7 @@ describe('UrlTranslator', function () { }); describe('getUrlTitle', function () { - let translator; + let translator: UrlTranslatorLike; beforeAll(function () { translator = new UrlTranslator({}); }); @@ -156,11 +190,11 @@ describe('UrlTranslator', function () { }); describe('getTypeAndIdFromPath', function () { - let translator; + let translator: UrlTranslatorLike; beforeAll(function () { translator = new UrlTranslator({ urlService: { - resolveUrl: async (path) => { + resolveUrl: async (path: string) => { switch (path) { case '/post': return { type: 'posts', id: 'post' }; @@ -211,7 +245,7 @@ describe('UrlTranslator', function () { }); describe('getResourceById', function () { - let translator; + let translator: UrlTranslatorLike; beforeAll(function () { translator = new UrlTranslator({ urlService: { @@ -223,21 +257,25 @@ describe('UrlTranslator', function () { it('returns for post', async function () { const result = await translator.getResourceById('id', 'post'); + assert.ok(result); assert.equal(result.id, 'post_id'); }); it('returns for page', async function () { const result = await translator.getResourceById('id', 'page'); + assert.ok(result); assert.equal(result.id, 'post_id'); }); it('returns for tag', async function () { const result = await translator.getResourceById('id', 'tag'); + assert.ok(result); assert.equal(result.id, 'tag_id'); }); it('returns for user', async function () { const result = await translator.getResourceById('id', 'author'); + assert.ok(result); assert.equal(result.id, 'user_id'); }); @@ -267,13 +305,13 @@ describe('UrlTranslator', function () { // (slug, published_at, primary_tag, ...), so it needs the full resource // shape, not just `{id, type}`. it("passes the model's plain data (slug, etc.) to the URL service", function () { - let captured; + let captured: Record | undefined; const translator = new UrlTranslator({ urlUtils: { - relativeToAbsolute: (t) => 'https://abs' + t, + relativeToAbsolute: (t: string) => 'https://abs' + t, }, urlService: { - getUrlForResource: (resource) => { + getUrlForResource: (resource: Record) => { captured = resource; return '/' + resource.slug + '/'; }, @@ -289,6 +327,7 @@ describe('UrlTranslator', function () { const url = translator.getResourceUrl('abc', 'tag', tag, { absolute: false }); assert.equal(url, '/changelog/'); + assert.ok(captured); assert.equal(captured.id, 'abc'); assert.equal(captured.type, 'tags'); assert.equal(captured.slug, 'changelog'); @@ -297,7 +336,7 @@ describe('UrlTranslator', function () { it('keeps the email-only short-circuit for sent posts', function () { const translator = new UrlTranslator({ urlUtils: { - relativeToAbsolute: (t) => 'https://abs' + t, + relativeToAbsolute: (t: string) => 'https://abs' + t, }, urlService: { getUrlForResource: () => { @@ -308,7 +347,7 @@ describe('UrlTranslator', function () { }); const post = { - get(k) { + get(k: string) { return k === 'status' ? 'sent' : 'uuid-123'; }, toJSON: () => ({ id: 'pid', uuid: 'uuid-123', status: 'sent' }), @@ -322,11 +361,11 @@ describe('UrlTranslator', function () { }); describe('relativeToAbsolute', function () { - let translator; + let translator: UrlTranslatorLike; beforeAll(function () { translator = new UrlTranslator({ urlUtils: { - relativeToAbsolute: (t) => { + relativeToAbsolute: (t: string) => { return 'absolute/' + t; }, }, @@ -339,14 +378,14 @@ describe('UrlTranslator', function () { }); describe('stripSubdirectoryFromPath', function () { - let translator; + let translator: UrlTranslatorLike; beforeAll(function () { translator = new UrlTranslator({ urlUtils: { - relativeToAbsolute: (t) => { + relativeToAbsolute: (t: string) => { return 'absolute' + t; }, - absoluteToRelative: (t) => { + absoluteToRelative: (t: string) => { const prefix = 'absolute/dir/'; if (t.startsWith(prefix)) { return t.substring(prefix.length - 1); diff --git a/ghost/core/test/unit/server/services/members/members-api/members-api.test.js b/ghost/core/test/unit/server/services/members/members-api/members-api.test.js index aa606997040..d177fa20101 100644 --- a/ghost/core/test/unit/server/services/members/members-api/members-api.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/members-api.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const MembersAPI = require('../../../../../../core/server/services/members/members-api/members-api'); -const MagicLink = require('../../../../../../core/server/services/lib/magic-link/magic-link'); +const MagicLink = require('../../../../../../core/server/lib/magic-link/magic-link'); const GeolocationService = require('../../../../../../core/server/services/members/members-api/services/geolocation-service'); const MemberRepository = require('../../../../../../core/server/services/members/members-api/repositories/member-repository'); const MemberBREADService = require('../../../../../../core/server/services/members/members-api/services/member-bread-service'); diff --git a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js index 1f8fbb8e4d0..4855eba8e23 100644 --- a/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/repositories/member-repository.test.js @@ -531,6 +531,45 @@ describe('MemberRepository', function () { }); }); + describe('subscription methods with a missing member', function () { + let repo; + + beforeEach(function () { + Member.findOne = sinon.stub().resolves(null); + repo = buildRepo({ stripeAPIService: { configured: true } }); + }); + + const subscription = { id: 'sub_123', subscription_id: 'sub_123', customer: 'cus_123' }; + + it('linkSubscription throws NotFoundError', async function () { + await assert.rejects( + repo.linkSubscription({ id: 'missing', subscription }, { transacting: {} }), + errors.NotFoundError, + ); + }); + + it('getSubscription throws NotFoundError', async function () { + await assert.rejects( + repo.getSubscription({ email: 'missing@example.com', subscription }), + errors.NotFoundError, + ); + }); + + it('cancelSubscription throws NotFoundError', async function () { + await assert.rejects( + repo.cancelSubscription({ id: 'missing', subscription }), + errors.NotFoundError, + ); + }); + + it('updateSubscription throws NotFoundError', async function () { + await assert.rejects( + repo.updateSubscription({ email: 'missing@example.com', subscription }), + errors.NotFoundError, + ); + }); + }); + describe('linkSubscription', function () { let subscriptionData; let subscriptionCreatedNotifySpy; diff --git a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js index 6a24ec26f09..986ae4b31cf 100644 --- a/ghost/core/test/unit/server/services/oembed/oembed-service.test.js +++ b/ghost/core/test/unit/server/services/oembed/oembed-service.test.js @@ -1097,8 +1097,8 @@ describe('oembed-service', function () { }); it('converts anything served under an .svg name, whatever its case', async function () { - // Padding defeats the content sniff, so the extension is what - // guarantees nothing is stored as an SVG document. + // Padding defeats the short content sniff used for other names, so + // the extension is what guarantees nothing is stored as an SVG document. const saveRaw = sinon.stub().resolves('/stored'); const padded = `${SVG}`; @@ -1110,6 +1110,27 @@ describe('oembed-service', function () { assert.match(saveRaw.firstCall.args[1], /\.png$/); }); + it('does not convert a non-SVG image served under an .svg name', async function () { + // The converter picks its decoder from the contents, so the name + // alone must not route other formats into it. + const saveRaw = sinon.stub().resolves('/stored'); + const avif = await sharp({ + create: { width: 8, height: 8, channels: 3, background: 'red' }, + }) + .avif() + .toBuffer(); + + await assert.rejects( + () => + buildService(saveRaw, avif).processImageFromUrl( + 'https://example.com/favicon.svg', + 'icon', + ), + { message: /not a supported file type/ }, + ); + sinon.assert.notCalled(saveRaw); + }); + it('rejects a gzipped SVG rather than inflating it', async function () { // libvips inflates these internally, so the byte cap cannot see // their real size: 10KB on the wire took 47s to render. diff --git a/ghost/core/test/unit/server/services/settings/settings-service.test.js b/ghost/core/test/unit/server/services/settings/settings-service.test.js index 62a440294e2..a27e401b72d 100644 --- a/ghost/core/test/unit/server/services/settings/settings-service.test.js +++ b/ghost/core/test/unit/server/services/settings/settings-service.test.js @@ -5,7 +5,7 @@ const settingsCache = require('../../../../../core/shared/settings-cache'); const logging = require('@tryghost/logging'); const { Settings } = require('../../../../../core/server/models/settings'); const adapterManager = require('../../../../../core/server/services/adapter-manager').default; -const limits = require('../../../../../core/server/services/limits'); +const { limitService: limits } = require('../../../../../core/server/services/limits'); describe('Settings Service', function () { let settingsService; diff --git a/ghost/core/test/unit/server/web/api/middleware/upload.test.js b/ghost/core/test/unit/server/web/api/middleware/upload.test.js index c0104e8de08..fd274cde99c 100644 --- a/ghost/core/test/unit/server/web/api/middleware/upload.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/upload.test.js @@ -6,6 +6,7 @@ const os = require('os'); const path = require('path'); const assert = require('node:assert/strict'); const config = require('../../../../../../core/shared/config'); +const configUtils = require('../../../../../utils/config-utils'); describe('web utils', function () { describe('checkFileExists', function () { @@ -194,14 +195,18 @@ describe('web utils', function () { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - const runMediaValidation = async (thumbnailFixture, originalname = thumbnailFixture) => { + const runMediaValidation = async ( + thumbnailFixture, + originalname = thumbnailFixture, + mimetype = 'image/svg+xml', + ) => { const thumbnailPath = path.join(tmpDir, thumbnailFixture); fs.copyFileSync(path.join(__dirname, imageFixturePath, thumbnailFixture), thumbnailPath); const req = { files: { file: [{ originalname: 'video.mp4', mimetype: 'video/mp4', path: 'video.mp4' }], - thumbnail: [{ originalname, mimetype: 'image/svg+xml', path: thumbnailPath }], + thumbnail: [{ originalname, mimetype, path: thumbnailPath }], }, }; const nextArgs = await new Promise((resolve) => { @@ -244,5 +249,93 @@ describe('web utils', function () { assert.equal(nextArgs[0].errorType, 'UnsupportedMediaTypeError'); assert.equal(nextArgs[0].message, 'Please select a valid SVG image'); }); + + it('accepts thumbnails whose contents match an allowed format', async function () { + const { nextArgs } = await runMediaValidation('ghost-logo.png', 'thumbnail.png', 'image/png'); + + assert.deepEqual(nextArgs, []); + }); + + it('ignores non-image content types configured for thumbnails', async function () { + const thumbnails = config.get('uploads').thumbnails; + configUtils.set('uploads:thumbnails:contentTypes', [...thumbnails.contentTypes, 'text/html']); + + try { + const { nextArgs } = await runMediaValidation( + 'ghost-logo.png', + 'thumbnail.png', + 'text/html', + ); + + assert.equal(nextArgs[0].errorType, 'UnsupportedMediaTypeError'); + } finally { + await configUtils.restore(); + } + }); + + it('rejects thumbnails whose contents are not an allowed format', async function () { + const { nextArgs } = await runMediaValidation('ghosticon.avif', 'thumbnail.png', 'image/png'); + + assert.equal(nextArgs[0].errorType, 'UnsupportedMediaTypeError'); + }); + }); + + describe('validation', function () { + let tmpDir; + + beforeEach(function () { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-upload-validation-')); + }); + + afterEach(function () { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const runImageValidation = async (fixture, originalname, mimetype) => { + const filePath = path.join(tmpDir, fixture); + fs.copyFileSync(path.join(__dirname, imageFixturePath, fixture), filePath); + + const req = { file: { originalname, mimetype, path: filePath } }; + + return new Promise((resolve) => { + upload.validation({ type: 'images' })(req, {}, (...args) => resolve(args)); + }); + }; + + it('accepts images whose contents match an allowed format', async function () { + assert.deepEqual(await runImageValidation('ghosticon.jpg', 'photo.jpg', 'image/jpeg'), []); + assert.deepEqual(await runImageValidation('ghost-logo.png', 'logo.png', 'image/png'), []); + }); + + it('accepts images whose contents are a different allowed format to the extension', async function () { + assert.deepEqual(await runImageValidation('ghost-logo.png', 'logo.jpg', 'image/jpeg'), []); + }); + + it('rejects images whose contents are not an allowed format', async function () { + const nextArgs = await runImageValidation('ghosticon.avif', 'photo.jpg', 'image/jpeg'); + + assert.equal(nextArgs[0].errorType, 'UnsupportedMediaTypeError'); + }); + + describe('with non-image content types configured', function () { + beforeEach(function () { + const images = config.get('uploads').images; + configUtils.set('uploads:images:contentTypes', [...images.contentTypes, 'text/html']); + }); + + afterEach(async function () { + await configUtils.restore(); + }); + + it('ignores them', async function () { + const nextArgs = await runImageValidation('ghost-logo.png', 'logo.png', 'text/html'); + + assert.equal(nextArgs[0].errorType, 'UnsupportedMediaTypeError'); + }); + + it('still accepts image content types', async function () { + assert.deepEqual(await runImageValidation('ghost-logo.png', 'logo.png', 'image/png'), []); + }); + }); }); }); diff --git a/ghost/core/test/unit/server/web/parent/middleware/queue-request.test.ts b/ghost/core/test/unit/server/web/parent/middleware/queue-request.test.ts index 31d14fa45de..af9e8ffbf7d 100644 --- a/ghost/core/test/unit/server/web/parent/middleware/queue-request.test.ts +++ b/ghost/core/test/unit/server/web/parent/middleware/queue-request.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { EventEmitter } from 'node:events'; +import { EventEmitter, once } from 'node:events'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; import express from 'express'; @@ -30,9 +30,7 @@ describe('Queue request middleware', function () { }); server = app.listen(0, '127.0.0.1'); - await new Promise((resolve) => { - server.once('listening', resolve); - }); + await once(server, 'listening'); port = (server.address() as AddressInfo).port; } @@ -45,6 +43,16 @@ describe('Queue request middleware', function () { } function get(path: string) { + const serverReceivedRequest = new Promise((resolve) => { + const onRequest = (request: http.IncomingMessage, res: http.ServerResponse) => { + if (request.url === path) { + server.off('request', onRequest); + resolve(res); + } + }; + server.on('request', onRequest); + }); + // URL string: host/port options throw Invalid URL once another file has loaded Sentry's http wrapper const req = http.get(`http://127.0.0.1:${port}${path}`, { agent: false }); const response = new Promise<{ status?: number; body: string }>((resolve, reject) => { @@ -61,7 +69,7 @@ describe('Queue request middleware', function () { // callers that abort the request don't await the response response.catch(() => {}); - return { req, response }; + return { req, serverReceivedRequest, response }; } beforeEach(function () { @@ -144,15 +152,12 @@ describe('Queue request middleware', function () { it('limits concurrency and starts queued requests in arrival order', async function () { await listen(2); get('/hold/1'); + await heldCount(1); get('/hold/2'); await heldCount(2); - get('/hold/3'); - get('/hold/4'); - // give the queued requests time to arrive - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + await get('/hold/3').serverReceivedRequest; + await get('/hold/4').serverReceivedRequest; assert.equal(held.length, 2); assert.equal(held[0].req.queueDepth, 0); @@ -175,6 +180,7 @@ describe('Queue request middleware', function () { await heldCount(1); const queued = get('/instant'); + await queued.serverReceivedRequest; await new Promise((resolve) => { setTimeout(resolve, 50); }); @@ -193,17 +199,12 @@ describe('Queue request middleware', function () { await heldCount(1); const abandoned = get('/hold/abandoned'); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + const abandonedRes = await abandoned.serverReceivedRequest; const next = get('/instant'); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + await next.serverReceivedRequest; + const abandonedClosed = once(abandonedRes, 'close'); abandoned.req.destroy(); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + await abandonedClosed; held[0].res.end(); const { status, body } = await next.response; @@ -233,17 +234,16 @@ describe('Queue request middleware', function () { const first = get('/hold/1'); await heldCount(1); - get('/hold/2'); - get('/hold/3'); - await new Promise((resolve) => { - setTimeout(resolve, 50); - }); + await get('/hold/2').serverReceivedRequest; + await get('/hold/3').serverReceivedRequest; + const firstClosed = once(held[0].res, 'close'); held[0].res.end('done'); await first.response; + await firstClosed; await heldCount(2); await new Promise((resolve) => { - setTimeout(resolve, 50); + setImmediate(resolve); }); // a double release would have started request 3 alongside request 2 diff --git a/ghost/core/test/utils/e2e-framework-mock-manager.js b/ghost/core/test/utils/e2e-framework-mock-manager.js index 8a3e91f9a6c..e7617697981 100644 --- a/ghost/core/test/utils/e2e-framework-mock-manager.js +++ b/ghost/core/test/utils/e2e-framework-mock-manager.js @@ -27,7 +27,7 @@ const originalMailServiceSendMail = mailService.GhostMailer.prototype.sendMail; const labs = require('../../core/shared/labs'); const events = require('../../core/server/lib/common/events'); const settingsCache = require('../../core/shared/settings-cache'); -const limitService = require('../../core/server/services/limits'); +const { limitService } = require('../../core/server/services/limits'); const dns = require('dns'); const dnsPromises = dns.promises; const StripeMocker = require('./stripe-mocker'); diff --git a/ghost/core/test/utils/fixtures/images/ghosticon.avif b/ghost/core/test/utils/fixtures/images/ghosticon.avif new file mode 100644 index 00000000000..705b47b9599 Binary files /dev/null and b/ghost/core/test/utils/fixtures/images/ghosticon.avif differ diff --git a/ghost/core/test/utils/fixtures/import/zips/zip-content-dir/content/image.jpg b/ghost/core/test/utils/fixtures/import/zips/zip-content-dir/content/image.jpg index e69de29bb2d..2eec85d6b64 100644 Binary files a/ghost/core/test/utils/fixtures/import/zips/zip-content-dir/content/image.jpg and b/ghost/core/test/utils/fixtures/import/zips/zip-content-dir/content/image.jpg differ diff --git a/ghost/core/test/utils/fixtures/import/zips/zip-content-images-subdir/content/images/image.jpg b/ghost/core/test/utils/fixtures/import/zips/zip-content-images-subdir/content/images/image.jpg index e69de29bb2d..2eec85d6b64 100644 Binary files a/ghost/core/test/utils/fixtures/import/zips/zip-content-images-subdir/content/images/image.jpg and b/ghost/core/test/utils/fixtures/import/zips/zip-content-images-subdir/content/images/image.jpg differ diff --git a/ghost/core/test/utils/fixtures/import/zips/zip-image-dir/images/image.jpg b/ghost/core/test/utils/fixtures/import/zips/zip-image-dir/images/image.jpg index e69de29bb2d..2eec85d6b64 100644 Binary files a/ghost/core/test/utils/fixtures/import/zips/zip-image-dir/images/image.jpg and b/ghost/core/test/utils/fixtures/import/zips/zip-image-dir/images/image.jpg differ diff --git a/ghost/core/test/utils/fixtures/import/zips/zip-uppercase-extensions/image.JPG b/ghost/core/test/utils/fixtures/import/zips/zip-uppercase-extensions/image.JPG index e69de29bb2d..2eec85d6b64 100644 Binary files a/ghost/core/test/utils/fixtures/import/zips/zip-uppercase-extensions/image.JPG and b/ghost/core/test/utils/fixtures/import/zips/zip-uppercase-extensions/image.JPG differ diff --git a/ghost/core/test/utils/host-limits-utils.ts b/ghost/core/test/utils/host-limits-utils.ts index 44f7104e926..5217101f1b7 100644 --- a/ghost/core/test/utils/host-limits-utils.ts +++ b/ghost/core/test/utils/host-limits-utils.ts @@ -1,16 +1,12 @@ -// Both of these are CommonJS with no types of their own, so the shape this file relies on -// is stated here rather than inferred as `any`. +import * as limits from '../../core/server/services/limits'; + +// Config utils is CommonJS with no types of its own, so state its shape here. interface ConfigUtils { set(config: Record): void; restore(): Promise; } -interface LimitService { - init(): void; -} - const configUtils = require('./config-utils') as ConfigUtils; -const limits = require('../../core/server/services/limits') as LimitService; /** One limit as a host configures it: a value, never a function. */ export interface HostLimitConfig { diff --git a/package.json b/package.json index 719bb92009f..8f2a53d2df2 100644 --- a/package.json +++ b/package.json @@ -69,8 +69,8 @@ "preinstall": "node ./scripts/enforce-package-manager.js", "prepare": "husky .github/hooks", "query-parameter-policy:export": "pnpm --filter ghost query-parameter-policy:export", - "tb": "tb local start && cd ghost/core/core/server/data/tinybird && tb dev", - "tb:install": "curl https://tinybird.co | sh", + "tb": "docker compose -f compose.dev.yaml -f compose.dev.analytics.yaml run --rm --no-deps --entrypoint tb tb-cli --local", + "tb:test": "pnpm tb test run", "data:analytics:generate": "node ghost/core/core/server/data/tinybird/scripts/docker-analytics-manager.js generate", "data:analytics:clear": "node ghost/core/core/server/data/tinybird/scripts/docker-analytics-manager.js clear", "release": "node scripts/release.js" diff --git a/packages/testing/test-data/src/selectors/migrate.ts b/packages/testing/test-data/src/selectors/migrate.ts new file mode 100644 index 00000000000..970ad08f9ad --- /dev/null +++ b/packages/testing/test-data/src/selectors/migrate.ts @@ -0,0 +1,8 @@ +/** + * Migration screen selector strings, consumed by the admin screen helpers and + * the e2e page objects. Source of truth: apps/admin/src/migrate. + */ + +// accessible names +export const migrateFrame = 'Migrate'; +export const closeMigrateButton = 'Close'; diff --git a/packages/testing/test-data/src/selectors/view-site.ts b/packages/testing/test-data/src/selectors/view-site.ts new file mode 100644 index 00000000000..57084241239 --- /dev/null +++ b/packages/testing/test-data/src/selectors/view-site.ts @@ -0,0 +1,8 @@ +/** + * View site selector strings, consumed by the admin screen helpers and the + * e2e page objects. Source of truth: apps/admin/src/view-site. + */ + +// accessible names +export const sitePreviewFrame = 'Site preview'; +export const viewSiteNavLink = 'View site'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5724a93570c..110af8a7430 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,8 +633,8 @@ catalogs: specifier: 7.8.5 version: 7.8.5 sharp: - specifier: 0.35.4 - version: 0.35.4 + specifier: 0.35.5 + version: 0.35.5 shell-quote: specifier: 1.10.0 version: 1.10.0 @@ -2935,7 +2935,7 @@ importers: version: 7.8.5 sharp: specifier: 'catalog:' - version: 0.35.4(@types/node@22.20.1) + version: 0.35.5(@types/node@22.20.1) simple-dom: specifier: 1.4.0 version: 1.4.0 @@ -6453,160 +6453,160 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] libc: [glibc] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] libc: [glibc] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] libc: [glibc] - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] libc: [glibc] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [glibc] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] libc: [musl] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] libc: [musl] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -20668,8 +20668,8 @@ packages: engines: {node: '>= 0.10'} hasBin: true - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -25163,108 +25163,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.3.4 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.4 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': + '@img/sharp-freebsd-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-arm64@1.3.4': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.3.4': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-arm@1.3.4': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.3.4': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-riscv64@1.3.4': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linux-s390x@1.3.4': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linux-x64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.3.4': optional: true - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.4 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.4 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.4 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-riscv64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.4 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-s390x@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.4 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linux-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.4 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': + '@img/sharp-linuxmusl-x64@0.35.5': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 optional: true - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.35.5': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': + '@img/sharp-webcontainers-wasm32@0.35.5': dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-wasm32': 0.35.5 optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-arm64@0.35.5': optional: true - '@img/sharp-win32-ia32@0.35.4': + '@img/sharp-win32-ia32@0.35.5': optional: true - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.35.5': optional: true '@inquirer/ansi@2.0.7': {} @@ -28738,7 +28738,7 @@ snapshots: '@tryghost/errors': 3.3.14 fs-extra: 11.4.0 optionalDependencies: - sharp: 0.35.4(@types/node@22.20.1) + sharp: 0.35.5(@types/node@22.20.1) transitivePeerDependencies: - '@types/node' @@ -44114,37 +44114,37 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.2 - sharp@0.35.4(@types/node@22.20.1): + sharp@0.35.5(@types/node@22.20.1): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 '@types/node': 22.20.1 shebang-command@1.2.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 843df869223..d3b701d8942 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,7 +32,7 @@ allowBuilds: 'nx@23.2.0': true 'protobufjs@7.6.6': true 're2@1.26.1': true - 'sharp@0.35.4': true + 'sharp@0.35.5': true ssh2: false catalog: @@ -173,7 +173,7 @@ catalog: react-dom: 18.3.1 react-router: 7.18.4 semver: 7.8.5 - sharp: 0.35.4 + sharp: 0.35.5 should: 13.2.3 sinon: 22.1.0 sonner: 2.0.7 @@ -350,6 +350,34 @@ minimumReleaseAgeExclude: - 'knex-migrator' # Renovate security update: file-type@21.3.1 || 21.3.2 - file-type@21.3.1 || 21.3.2 + # sharp 0.35.5 bundles libvips 8.18.7 with updated librsvg and libheif + - sharp@0.35.5 + - '@img/sharp-darwin-arm64@0.35.5' + - '@img/sharp-darwin-x64@0.35.5' + - '@img/sharp-freebsd-wasm32@0.35.5' + - '@img/sharp-linux-arm@0.35.5' + - '@img/sharp-linux-arm64@0.35.5' + - '@img/sharp-linux-ppc64@0.35.5' + - '@img/sharp-linux-riscv64@0.35.5' + - '@img/sharp-linux-s390x@0.35.5' + - '@img/sharp-linux-x64@0.35.5' + - '@img/sharp-linuxmusl-arm64@0.35.5' + - '@img/sharp-linuxmusl-x64@0.35.5' + - '@img/sharp-wasm32@0.35.5' + - '@img/sharp-webcontainers-wasm32@0.35.5' + - '@img/sharp-win32-arm64@0.35.5' + - '@img/sharp-win32-ia32@0.35.5' + - '@img/sharp-win32-x64@0.35.5' + - '@img/sharp-libvips-darwin-arm64@1.3.4' + - '@img/sharp-libvips-darwin-x64@1.3.4' + - '@img/sharp-libvips-linux-arm@1.3.4' + - '@img/sharp-libvips-linux-arm64@1.3.4' + - '@img/sharp-libvips-linux-ppc64@1.3.4' + - '@img/sharp-libvips-linux-riscv64@1.3.4' + - '@img/sharp-libvips-linux-s390x@1.3.4' + - '@img/sharp-libvips-linux-x64@1.3.4' + - '@img/sharp-libvips-linuxmusl-arm64@1.3.4' + - '@img/sharp-libvips-linuxmusl-x64@1.3.4' # configuration for pnpm's changeset functionality versioning: diff --git a/scripts/build-public-apps-matrix.js b/scripts/build-public-apps-matrix.js index f5e6a8c3d3d..819cfab006a 100644 --- a/scripts/build-public-apps-matrix.js +++ b/scripts/build-public-apps-matrix.js @@ -4,7 +4,7 @@ // // Matrix context isn't available in a job-level `if:`, so the affected gate // can't live on the publish job — instead we compute the set here in job_setup -// (mirroring the affected_playwright_projects dynamic matrix) and the job skips +// (mirroring the affected_playwright_matrix dynamic matrix) and the job skips // itself when the result is `[]`. // // public-apps.json says *which* apps are public (apps/ also holds admin, shade