diff --git a/.changeset/brown-rules-cut.md b/.changeset/brown-rules-cut.md deleted file mode 100644 index 3c6251b5d0e..00000000000 --- a/.changeset/brown-rules-cut.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@tryghost/adapter-base-scheduling": patch -"@tryghost/adapter-base-sso": patch ---- - -Bumped framework dependencies diff --git a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md b/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md deleted file mode 100644 index 5a7b781d07b..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-scheduling@0.2.3.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.2.3 - -### Patch Changes - -- Bumped framework dependencies diff --git a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md b/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md deleted file mode 100644 index 2c89801d072..00000000000 --- a/.changeset/changelogs/@tryghost!adapter-base-sso@0.1.3.md +++ /dev/null @@ -1,5 +0,0 @@ -## 0.1.3 - -### Patch Changes - -- Bumped framework dependencies diff --git a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md b/.changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md deleted file mode 100644 index 3c51919165f..00000000000 --- a/.changeset/changelogs/@tryghost!koenig-lexical@1.9.3.md +++ /dev/null @@ -1,5 +0,0 @@ -## 1.9.3 - -### Patch Changes - -- Improved the emoji picker search field, which had text that was too light to read. Clicking outside the picker now closes it. diff --git a/.changeset/plenty-moons-smile.md b/.changeset/plenty-moons-smile.md deleted file mode 100644 index ec0389c7b31..00000000000 --- a/.changeset/plenty-moons-smile.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tryghost/koenig-lexical": patch ---- - -Improved the emoji picker search field, which had text that was too light to read. Clicking outside the picker now closes it. diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index f5fa52610d6..2ff1b9922dd 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,244 +1,244 @@ { - "name": "ghost-admin", - "version": "6.60.1-rc.0", - "description": "Ember.js admin client for Ghost", - "author": "Ghost Foundation", - "homepage": "http://ghost.org", - "repository": { - "type": "git", - "url": "git://github.com/TryGhost/Admin.git" - }, - "bugs": "https://github.com/TryGhost/Ghost/issues", - "contributors": "https://github.com/TryGhost/Admin/graphs/contributors", - "license": "MIT", - "private": true, - "directories": { - "test": "tests" - }, - "scripts": { - "dev": "SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve", - "build": "ember build --environment=production --silent", - "build:dev": "SKIP_DEPENDENCY_CHECKER=true pnpm build --environment=development", - "test": "ember exam --split 2 --parallel", - "lint:js": "eslint . --cache", - "lint:hbs": "ember-template-lint .", - "lint": "pnpm run '/^lint:/'" - }, - "engines": { - "node": "^22.23.1" - }, - "devDependencies": { - "@babel/core": "7.29.7", - "@babel/eslint-parser": "7.28.6", - "@babel/plugin-proposal-class-properties": "7.18.6", - "@babel/plugin-proposal-decorators": "7.29.0", - "@ember/jquery": "2.0.0", - "@ember/optional-features": "2.1.0", - "@ember/render-modifiers": "2.1.0", - "@ember/test-helpers": "2.9.6", - "@ember/test-waiters": "3.1.0", - "@embroider/macros": "1.16.13", - "@eslint/js": "catalog:", - "@faker-js/faker": "10.4.0", - "@glimmer/component": "1.1.2", - "@html-next/vertical-collection": "3.0.0", - "@internal/cfg-eslint": "workspace:*", - "@sentry/ember": "7.120.3", - "@sentry/integrations": "7.114.0", - "@sentry/replay": "7.116.0", - "@tryghost/admin-x-framework": "workspace:*", - "@tryghost/color-utils": "catalog:", - "@tryghost/ember-promise-modals": "2.0.1", - "@tryghost/helpers": "catalog:", - "@tryghost/kg-clean-basic-html": "workspace:*", - "@tryghost/kg-converters": "workspace:*", - "@tryghost/koenig-lexical": "workspace:*", - "@tryghost/limit-service": "catalog:", - "@tryghost/nql": "catalog:", - "@tryghost/nql-string": "workspace:*", - "@tryghost/string": "catalog:", - "@tryghost/timezone-data": "catalog:", - "animejs": "3.2.2", - "autoprefixer": "9.8.6", - "babel-plugin-transform-class-properties": "6.24.1", - "babel-plugin-transform-react-jsx": "6.24.1", - "broccoli-asset-rev": "3.0.0", - "broccoli-concat": "4.2.7", - "broccoli-funnel": "3.0.8", - "broccoli-merge-trees": "4.2.0", - "broccoli-terser-sourcemap": "4.1.1", - "chai": "catalog:", - "chai-dom": "1.12.1", - "chalk": "catalog:", - "codemirror": "5.65.21", - "cssnano": "4.1.10", - "element-resize-detector": "1.2.4", - "ember-ajax": "5.1.2", - "ember-assign-helper": "0.5.0", - "ember-auto-import": "2.10.0", - "ember-classic-decorator": "3.0.1", - "ember-cli": "3.24.0", - "ember-cli-app-version": "5.0.0", - "ember-cli-babel": "8.2.0", - "ember-cli-chart": "3.7.2", - "ember-cli-code-coverage": "1.0.3", - "ember-cli-dependency-checker": "3.3.2", - "ember-cli-deprecation-workflow": "2.2.0", - "ember-cli-htmlbars": "6.3.0", - "ember-cli-inject-live-reload": "2.1.0", - "ember-cli-mirage": "2.4.0", - "ember-cli-node-assets": "0.2.2", - "ember-cli-postcss": "6.0.1", - "ember-cli-shims": "1.2.0", - "ember-cli-string-helpers": "6.1.0", - "ember-cli-terser": "4.0.1", - "ember-cli-test-loader": "3.1.0", - "ember-composable-helpers": "5.0.0", - "ember-concurrency": "2.3.7", - "ember-could-get-used-to-this": "1.0.1", - "ember-css-transitions": "4.4.1", - "ember-data": "3.24.0", - "ember-decorators": "6.1.1", - "ember-drag-drop": "0.4.8", - "ember-exam": "6.0.1", - "ember-export-application-global": "2.0.1", - "ember-fetch": "8.1.2", - "ember-in-viewport": "4.1.0", - "ember-infinity": "2.3.0", - "ember-keyboard": "8.2.1", - "ember-load": "0.0.17", - "ember-load-initializers": "2.1.2", - "ember-mocha": "0.16.2", - "ember-modifier": "4.2.0", - "ember-moment": "10.0.1", - "ember-one-way-select": "4.0.1", - "ember-power-datepicker": "0.8.1", - "ember-power-select": "6.0.1", - "ember-resolver": "8.1.0", - "ember-simple-auth": "5.0.0", - "ember-sinon": "5.0.0", - "ember-source": "3.24.0", - "ember-svg-jar": "2.7.1", - "ember-template-lint": "5.13.0", - "ember-test-selectors": "6.0.0", - "ember-tooltips": "3.6.0", - "ember-truth-helpers": "3.1.1", - "eslint": "catalog:", - "eslint-plugin-babel": "5.3.1", - "eslint-plugin-ghost": "3.5.0", - "eslint-plugin-react": "7.37.5", - "flexsearch": "0.7.43", - "fs-extra": "catalog:", - "ghost": "workspace:*", - "globals": "17.6.0", - "google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost", - "keymaster": "https://github.com/madrobby/keymaster.git", - "liquid-fire": "0.34.0", - "liquid-wormhole": "3.0.1", - "loader.js": "4.7.0", - "microdiff": "1.5.0", - "miragejs": "0.1.48", - "moment-timezone": "0.5.45", - "normalize.css": "3.0.3", - "postcss-color-mod-function": "3.0.3", - "postcss-custom-media": "7.0.8", - "postcss-custom-properties": "10.0.0", - "postcss-import": "12.0.1", - "pretender": "3.4.7", - "process": "0.11.10", - "react": "catalog:", - "react-dom": "catalog:", - "reframe.js": "4.0.2", - "semver": "7.7.4", - "sentry-testkit": "6.4.1", - "sinon-chai": "4.0.1", - "testem": "3.19.1", - "tracked-built-ins": "3.4.0", - "util": "0.12.5", - "validator": "catalog:", - "walk-sync": "4.0.2" - }, - "ember-addon": { - "paths": [ - "lib/asset-delivery", - "lib/ember-power-calendar-moment", - "lib/ember-power-calendar-utils" + "name": "ghost-admin", + "version": "6.61.1-rc.0", + "description": "Ember.js admin client for Ghost", + "author": "Ghost Foundation", + "homepage": "http://ghost.org", + "repository": { + "type": "git", + "url": "git://github.com/TryGhost/Admin.git" + }, + "bugs": "https://github.com/TryGhost/Ghost/issues", + "contributors": "https://github.com/TryGhost/Admin/graphs/contributors", + "license": "MIT", + "private": true, + "directories": { + "test": "tests" + }, + "scripts": { + "dev": "SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve", + "build": "ember build --environment=production --silent", + "build:dev": "SKIP_DEPENDENCY_CHECKER=true pnpm build --environment=development", + "test": "ember exam --split 2 --parallel", + "lint:js": "eslint . --cache", + "lint:hbs": "ember-template-lint .", + "lint": "pnpm run '/^lint:/'" + }, + "engines": { + "node": "^22.23.1" + }, + "devDependencies": { + "@babel/core": "7.29.7", + "@babel/eslint-parser": "7.28.6", + "@babel/plugin-proposal-class-properties": "7.18.6", + "@babel/plugin-proposal-decorators": "7.29.0", + "@ember/jquery": "2.0.0", + "@ember/optional-features": "2.1.0", + "@ember/render-modifiers": "2.1.0", + "@ember/test-helpers": "2.9.6", + "@ember/test-waiters": "3.1.0", + "@embroider/macros": "1.16.13", + "@eslint/js": "catalog:", + "@faker-js/faker": "10.4.0", + "@glimmer/component": "1.1.2", + "@html-next/vertical-collection": "3.0.0", + "@internal/cfg-eslint": "workspace:*", + "@sentry/ember": "7.120.3", + "@sentry/integrations": "7.114.0", + "@sentry/replay": "7.116.0", + "@tryghost/admin-x-framework": "workspace:*", + "@tryghost/color-utils": "catalog:", + "@tryghost/ember-promise-modals": "2.0.1", + "@tryghost/helpers": "catalog:", + "@tryghost/kg-clean-basic-html": "workspace:*", + "@tryghost/kg-converters": "workspace:*", + "@tryghost/koenig-lexical": "workspace:*", + "@tryghost/limit-service": "catalog:", + "@tryghost/nql": "catalog:", + "@tryghost/nql-string": "workspace:*", + "@tryghost/string": "catalog:", + "@tryghost/timezone-data": "catalog:", + "animejs": "3.2.2", + "autoprefixer": "9.8.6", + "babel-plugin-transform-class-properties": "6.24.1", + "babel-plugin-transform-react-jsx": "6.24.1", + "broccoli-asset-rev": "3.0.0", + "broccoli-concat": "4.2.7", + "broccoli-funnel": "3.0.8", + "broccoli-merge-trees": "4.2.0", + "broccoli-terser-sourcemap": "4.1.1", + "chai": "catalog:", + "chai-dom": "1.12.1", + "chalk": "catalog:", + "codemirror": "5.65.21", + "cssnano": "4.1.10", + "element-resize-detector": "1.2.4", + "ember-ajax": "5.1.2", + "ember-assign-helper": "0.5.0", + "ember-auto-import": "2.10.0", + "ember-classic-decorator": "3.0.1", + "ember-cli": "3.24.0", + "ember-cli-app-version": "5.0.0", + "ember-cli-babel": "8.2.0", + "ember-cli-chart": "3.7.2", + "ember-cli-code-coverage": "1.0.3", + "ember-cli-dependency-checker": "3.3.2", + "ember-cli-deprecation-workflow": "2.2.0", + "ember-cli-htmlbars": "6.3.0", + "ember-cli-inject-live-reload": "2.1.0", + "ember-cli-mirage": "2.4.0", + "ember-cli-node-assets": "0.2.2", + "ember-cli-postcss": "6.0.1", + "ember-cli-shims": "1.2.0", + "ember-cli-string-helpers": "6.1.0", + "ember-cli-terser": "4.0.1", + "ember-cli-test-loader": "3.1.0", + "ember-composable-helpers": "5.0.0", + "ember-concurrency": "2.3.7", + "ember-could-get-used-to-this": "1.0.1", + "ember-css-transitions": "4.4.1", + "ember-data": "3.24.0", + "ember-decorators": "6.1.1", + "ember-drag-drop": "0.4.8", + "ember-exam": "6.0.1", + "ember-export-application-global": "2.0.1", + "ember-fetch": "8.1.2", + "ember-in-viewport": "4.1.0", + "ember-infinity": "2.3.0", + "ember-keyboard": "8.2.1", + "ember-load": "0.0.17", + "ember-load-initializers": "2.1.2", + "ember-mocha": "0.16.2", + "ember-modifier": "4.2.0", + "ember-moment": "10.0.1", + "ember-one-way-select": "4.0.1", + "ember-power-datepicker": "0.8.1", + "ember-power-select": "6.0.1", + "ember-resolver": "8.1.0", + "ember-simple-auth": "5.0.0", + "ember-sinon": "5.0.0", + "ember-source": "3.24.0", + "ember-svg-jar": "2.7.1", + "ember-template-lint": "5.13.0", + "ember-test-selectors": "6.0.0", + "ember-tooltips": "3.6.0", + "ember-truth-helpers": "3.1.1", + "eslint": "catalog:", + "eslint-plugin-babel": "5.3.1", + "eslint-plugin-ghost": "3.5.0", + "eslint-plugin-react": "7.37.5", + "flexsearch": "0.7.43", + "fs-extra": "catalog:", + "ghost": "workspace:*", + "globals": "17.6.0", + "google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost", + "keymaster": "https://github.com/madrobby/keymaster.git", + "liquid-fire": "0.34.0", + "liquid-wormhole": "3.0.1", + "loader.js": "4.7.0", + "microdiff": "1.5.0", + "miragejs": "0.1.48", + "moment-timezone": "0.5.45", + "normalize.css": "3.0.3", + "postcss-color-mod-function": "3.0.3", + "postcss-custom-media": "7.0.8", + "postcss-custom-properties": "10.0.0", + "postcss-import": "12.0.1", + "pretender": "3.4.7", + "process": "0.11.10", + "react": "catalog:", + "react-dom": "catalog:", + "reframe.js": "4.0.2", + "semver": "7.7.4", + "sentry-testkit": "6.4.1", + "sinon-chai": "4.0.1", + "testem": "3.19.1", + "tracked-built-ins": "3.4.0", + "util": "0.12.5", + "validator": "catalog:", + "walk-sync": "4.0.2" + }, + "ember-addon": { + "paths": [ + "lib/asset-delivery", + "lib/ember-power-calendar-moment", + "lib/ember-power-calendar-utils" + ] + }, + "ember": { + "edition": "octane" + }, + "lint-staged": { + "*.hbs": "ember-template-lint", + "*.js": "eslint" + }, + "dependencies": { + "dompurify": "catalog:", + "lodash": "catalog:", + "path-browserify": "1.0.1", + "webpack": "5.105.4" + }, + "nx": { + "implicitDependencies": [ + "!ghost" + ], + "targets": { + "dev": { + "continuous": true, + "executor": "nx:run-commands", + "options": { + "cwd": "apps/ember-admin", + "command": "JOBS=4 SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve" + }, + "dependsOn": [ + "^build" ] - }, - "ember": { - "edition": "octane" - }, - "lint-staged": { - "*.hbs": "ember-template-lint", - "*.js": "eslint" - }, - "dependencies": { - "dompurify": "catalog:", - "lodash": "catalog:", - "path-browserify": "1.0.1", - "webpack": "5.105.4" - }, - "nx": { - "implicitDependencies": [ - "!ghost" + }, + "build:dev": { + "dependsOn": [ + "build:dev", + "^build", + { + "projects": [ + "@tryghost/admin-x-framework", + "@tryghost/activitypub" + ], + "target": "build" + } + ] + }, + "build": { + "inputs": [ + { + "env": "GHOST_CDN_URL" + }, + { + "env": "IS_SHIPPING" + }, + "default", + "^default" + ], + "outputs": [ + "{projectRoot}/dist", + "{workspaceRoot}/ghost/core/core/built/admin" ], - "targets": { - "dev": { - "continuous": true, - "executor": "nx:run-commands", - "options": { - "cwd": "apps/ember-admin", - "command": "JOBS=4 SKIP_DEPENDENCY_CHECKER=true NODE_OPTIONS=--disable-warning=DEP0179 ember serve" - }, - "dependsOn": [ - "^build" - ] - }, - "build:dev": { - "dependsOn": [ - "build:dev", - "^build", - { - "projects": [ - "@tryghost/admin-x-framework", - "@tryghost/activitypub" - ], - "target": "build" - } - ] - }, - "build": { - "inputs": [ - { - "env": "GHOST_CDN_URL" - }, - { - "env": "IS_SHIPPING" - }, - "default", - "^default" - ], - "outputs": [ - "{projectRoot}/dist", - "{workspaceRoot}/ghost/core/core/built/admin" - ], - "dependsOn": [ - "build", - "^build", - { - "projects": [ - "@tryghost/admin-x-framework", - "@tryghost/activitypub" - ], - "target": "build" - } - ] - }, - "test": { - "dependsOn": [ - "^build" - ] - } - } + "dependsOn": [ + "build", + "^build", + { + "projects": [ + "@tryghost/admin-x-framework", + "@tryghost/activitypub" + ], + "target": "build" + } + ] + }, + "test": { + "dependsOn": [ + "^build" + ] + } } + } } diff --git a/ghost/core/content/themes/casper b/ghost/core/content/themes/casper index bb3d0de3c70..f968f19dc9b 160000 --- a/ghost/core/content/themes/casper +++ b/ghost/core/content/themes/casper @@ -1 +1 @@ -Subproject commit bb3d0de3c7065677bb18b38d8185a8a980b163bd +Subproject commit f968f19dc9b2fa8cda4508c40d82d86c03bb6c2f diff --git a/ghost/core/content/themes/source b/ghost/core/content/themes/source index 3a5643b42a4..fb629deb392 160000 --- a/ghost/core/content/themes/source +++ b/ghost/core/content/themes/source @@ -1 +1 @@ -Subproject commit 3a5643b42a4700e961e65ebda92f6a6c56bf8c86 +Subproject commit fb629deb3922e24bc27ab8aea566ddc76db93ceb diff --git a/ghost/core/core/boot.js b/ghost/core/core/boot.js index e0cdb1026fa..5a3a4216094 100644 --- a/ghost/core/core/boot.js +++ b/ghost/core/core/boot.js @@ -653,10 +653,19 @@ async function bootGhost({ backend = true, frontend = true, server = true } = {} await initServices({ ghostServer, config, prometheusClient }); debug('Begin: Register job handlers'); - const jobsService = require('./server/services/jobs-service'); - const service = jobsService.init(); - require('./server/services/jobs-service/register-job-handlers').default(); - await service.start(); + const jobsServiceWrapper = require('./server/services/jobs-service'); + const registerJobHandlers = + require('./server/services/jobs-service/register-job-handlers').default; + const mediaInliner = require('./server/services/media-inliner'); + const db = require('./server/data/db'); + const jobsService = jobsServiceWrapper.init(); + registerJobHandlers({ + jobsService, + db, + logging, + mediaInliner: mediaInliner.getInstance(), + }); + await jobsService.start(); debug('End: Register job handlers'); debug('End: Load Ghost Services & Apps'); diff --git a/ghost/core/core/server/api/endpoints/db.js b/ghost/core/core/server/api/endpoints/db.js index 376155a555e..48ac0d6a960 100644 --- a/ghost/core/core/server/api/endpoints/db.js +++ b/ghost/core/core/server/api/endpoints/db.js @@ -3,12 +3,17 @@ const path = require('path'); const dbBackup = require('../../data/db/backup'); const exporter = require('../../data/exporter'); const importer = require('../../data/importer'); -const mediaInliner = require('../../services/media-inliner'); +const jobsService = require('../../services/jobs-service'); +const ExternalMediaInlinerJob = + require('../../services/media-inliner/external-media-inliner-job').default; const errors = require('@tryghost/errors'); +const logging = require('@tryghost/logging'); const { promisePool } = require('../../lib/promise-pool'); const models = require('../../models'); const settingsCache = require('../../../shared/settings-cache'); +const DEFAULT_MEDIA_INLINER_DOMAINS = ['https://s3.amazonaws.com/revue', 'https://substackcdn.com']; + /** @type {import('@tryghost/api-framework').Controller} */ const controller = { docName: 'db', @@ -125,7 +130,16 @@ const controller = { }, }, async query(frame) { - return mediaInliner.api.startMediaInliner(frame.data.domains); + const domains = frame.data.domains?.length + ? frame.data.domains + : DEFAULT_MEDIA_INLINER_DOMAINS; + + logging.info('[Background Job] external-media-inliner queued'); + await jobsService.getInstance().dispatch(new ExternalMediaInlinerJob({ domains })); + + return { + status: 'success', + }; }, }, diff --git a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts index 1960eeacf91..932b5a1f87d 100644 --- a/ghost/core/core/server/services/jobs-service/register-job-handlers.ts +++ b/ghost/core/core/server/services/jobs-service/register-job-handlers.ts @@ -1,16 +1,25 @@ import errors from '@tryghost/errors'; -import { getInstance } from './index'; +import { JobsService } from './jobs-service'; import CleanTokensJob from '../members/jobs/clean-tokens-job'; import cleanTokens from '../members/jobs/clean-tokens-task'; import * as gifts from '../gifts'; import CleanGiftsJob from '../gifts/jobs/clean-gifts-job'; +import ExternalMediaInliner from '../media-inliner/external-media-inliner'; +import ExternalMediaInlinerJob from '../media-inliner/external-media-inliner-job'; -const logging = require('@tryghost/logging'); - -export default function registerJobHandlers(): void { - const jobsService = getInstance(); - const db = require('../../data/db'); +interface RegisterJobHandlersDependencies { + jobsService: JobsService; + db: typeof import('../../data/db'); + logging: typeof import('@tryghost/logging'); + mediaInliner: ExternalMediaInliner; +} +export default function registerJobHandlers({ + jobsService, + db, + logging, + mediaInliner, +}: RegisterJobHandlersDependencies): void { jobsService.handle(CleanTokensJob, async () => { await cleanTokens({ db, logging }); }); @@ -23,4 +32,8 @@ export default function registerJobHandlers(): void { } await gifts.service.cleanup(); }); + + jobsService.handle(ExternalMediaInlinerJob, async (job) => { + await mediaInliner.inline(job.domains); + }); } diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts b/ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts new file mode 100644 index 00000000000..b0856e03ce7 --- /dev/null +++ b/ghost/core/core/server/services/media-inliner/external-media-inliner-job.ts @@ -0,0 +1,12 @@ +import { Job } from '../jobs-service/job'; + +export default class ExternalMediaInlinerJob extends Job { + static type = 'external-media-inliner'; + + readonly domains: string[]; + + constructor({ domains }: { domains: string[] }) { + super(); + this.domains = domains; + } +} diff --git a/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts b/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts new file mode 100644 index 00000000000..845343d5dfd --- /dev/null +++ b/ghost/core/core/server/services/media-inliner/external-media-inliner.d.ts @@ -0,0 +1,10 @@ +import type { ExternalMediaImportResult } from './types'; + +declare class ExternalMediaInliner { + constructor(deps: object); + + importUrl(sourceUrl: string): Promise; + inline(domains: string[]): Promise; +} + +export = ExternalMediaInliner; 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 c31029de91e..2d14dcfd891 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 @@ -177,6 +177,96 @@ class ExternalMediaInliner { } } + /** + * Download and store one external media URL without deciding where it came from + * or where its replacement belongs. Callers own discovery and replacement. + * + * @param {string} sourceUrl + * @returns {Promise} + */ + async importUrl(sourceUrl) { + let response; + try { + response = await this.getRemoteMedia(sourceUrl); + } catch (error) { + return { + status: 'failed', + sourceUrl, + stage: 'download', + reason: 'The media file could not be downloaded.', + error, + }; + } + + if (!response) { + return { + status: 'failed', + sourceUrl, + stage: 'download', + reason: 'The media file could not be downloaded.', + }; + } + + let media; + try { + media = await this.extractFileDataFromResponse(sourceUrl, response); + } catch (error) { + return { + status: 'failed', + sourceUrl, + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error, + }; + } + + try { + const storedUrl = await this.storeMediaLocally(media); + if (!storedUrl) { + return { + status: 'failed', + sourceUrl, + stage: 'unsupported', + reason: 'No configured storage accepts this media file.', + }; + } + + return { + status: 'stored', + sourceUrl, + storedUrl, + }; + } catch (error) { + return { + status: 'failed', + sourceUrl, + stage: 'storage', + reason: 'The media file could not be stored in Ghost.', + error, + }; + } + } + + /** + * Convert an import result into the replacement URL expected by the existing + * content and field processing. Expected download and unsupported-file failures + * return null so the original URL remains unchanged. Results containing an + * underlying processing error are rethrown so the established per-resource + * catch boundary continues to log and isolate them. + * + * @param {import('./types').ExternalMediaImportResult} result + * @returns {string|null} + */ + #replacementUrlFromImportResult(result) { + if (result.status === 'stored') { + return result.storedUrl; + } + if ('error' in result) { + throw result.error; + } + return null; + } + static findMatches(content, domain) { // NOTE: the src could end with a quote, bracket, apostrophe, double-backslash, or encoded quote. // Backlashes are added to content as an escape character @@ -208,22 +298,14 @@ class ExternalMediaInliner { const matches = this.constructor.findMatches(content, domain); for (const src of matches) { - const response = await this.getRemoteMedia(src); - - let media; - if (response) { - media = await this.extractFileDataFromResponse(src, response); - } - - if (media) { - const inlinedSrc = await this.storeMediaLocally(media); - - if (inlinedSrc) { - // NOTE: does not account for duplicate images in content - // in those cases would be processed twice - content = content.replace(src, inlinedSrc); - logging.info(`Inlined media: ${src} -> ${inlinedSrc}`); - } + const result = await this.importUrl(src); + const replacementUrl = this.#replacementUrlFromImportResult(result); + + if (replacementUrl) { + // NOTE: does not account for duplicate images in content + // in those cases would be processed twice + content = content.replace(src, replacementUrl); + logging.info(`Inlined media: ${src} -> ${replacementUrl}`); } } } @@ -246,20 +328,12 @@ class ExternalMediaInliner { const src = resourceModel.get(field); if (src && src.startsWith(domain)) { - const response = await this.getRemoteMedia(src); - - let media; - if (response) { - media = await this.extractFileDataFromResponse(src, response); - } - - if (media) { - const inlinedSrc = await this.storeMediaLocally(media); + const result = await this.importUrl(src); + const replacementUrl = this.#replacementUrlFromImportResult(result); - if (inlinedSrc) { - updatedFields[field] = inlinedSrc; - logging.info(`Added media to inline: ${src} -> ${inlinedSrc}`); - } + if (replacementUrl) { + updatedFields[field] = replacementUrl; + logging.info(`Added media to inline: ${src} -> ${replacementUrl}`); } } } diff --git a/ghost/core/core/server/services/media-inliner/service.js b/ghost/core/core/server/services/media-inliner/service.js index 33ebee88304..df2e05de32d 100644 --- a/ghost/core/core/server/services/media-inliner/service.js +++ b/ghost/core/core/server/services/media-inliner/service.js @@ -1,10 +1,11 @@ +const errors = require('@tryghost/errors'); + +let instance; + module.exports = { async init() { - const debug = require('@tryghost/debug')('mediaInliner'); const MediaInliner = require('./external-media-inliner'); - const logging = require('@tryghost/logging'); const models = require('../../models'); - const jobsService = require('../jobs'); const adapterManager = require('../../services/adapter-manager').default; const mediaStorage = adapterManager.getAdapter('storage:media'); @@ -31,45 +32,16 @@ module.exports = { }, }); - this.api = { - startMediaInliner: async (domains) => { - if (!domains || !domains.length) { - // default domains to inline from if none are provided - domains = ['https://s3.amazonaws.com/revue', 'https://substackcdn.com']; - } - - debug('[Inliner] Starting media inlining job for domains: ', domains); + instance = mediaInliner; + }, - // @NOTE: the job is "inline" (aka non-offloaded into a thread), because usecases are currently - // limited to migrational, so there is no expectations for site's availability etc. - logging.info('[Background Job] external-media-inliner queued'); - await jobsService.addJob({ - name: 'external-media-inliner', - job: async (data) => { - const startedAt = Date.now(); - logging.info('[Background Job] external-media-inliner started'); - try { - const result = await mediaInliner.inline(data.domains); - logging.info( - `[Background Job] external-media-inliner completed in ${Date.now() - startedAt}ms`, - ); - return result; - } catch (err) { - logging.error( - err, - `[Background Job] external-media-inliner failed after ${Date.now() - startedAt}ms`, - ); - throw err; - } - }, - data: { domains }, - offloaded: false, - }); + getInstance() { + if (!instance) { + throw new errors.IncorrectUsageError({ + message: 'Media inliner used before init(). Call init() from boot first.', + }); + } - return { - status: 'success', - }; - }, - }; + return instance; }, }; diff --git a/ghost/core/core/server/services/media-inliner/types.ts b/ghost/core/core/server/services/media-inliner/types.ts new file mode 100644 index 00000000000..792cdebf750 --- /dev/null +++ b/ghost/core/core/server/services/media-inliner/types.ts @@ -0,0 +1,21 @@ +export type ExternalMediaFailureStage = 'download' | 'extract' | 'unsupported' | 'storage'; + +export interface ExternalMediaStored { + status: 'stored'; + sourceUrl: string; + storedUrl: string; +} + +export interface ExternalMediaFailed { + status: 'failed'; + sourceUrl: string; + stage: ExternalMediaFailureStage; + reason: string; + error?: unknown; +} + +export type ExternalMediaImportResult = ExternalMediaStored | ExternalMediaFailed; + +export interface ExternalMediaImporter { + importUrl(sourceUrl: string): Promise; +} diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 4e8f3bb15ba..888208f51e2 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -27,7 +27,7 @@ const messages = { }; // flags in this list always return `true`, allows quick global enable prior to full flag removal -const GA_FEATURES = ['automationAnalytics']; +const GA_FEATURES = ['automationAnalytics', 'giftSubCustomization']; // These features are considered publicly available and can be enabled/disabled by users const PUBLIC_BETA_FEATURES = [ @@ -53,7 +53,6 @@ const PRIVATE_FEATURES = [ 'getHelperDeduplication', 'membersCustomFields', 'paywallImprovements', - 'giftSubCustomization', 'tagDetailsReact', 'selfServeArchives', 'machinePayments', diff --git a/ghost/core/package.json b/ghost/core/package.json index 2ebf7832eb4..64badeefb61 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "6.61.0-rc.0", + "version": "6.61.1-rc.0", "description": "The professional publishing platform", "keywords": [ "blog", diff --git a/ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts b/ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts new file mode 100644 index 00000000000..f8232931ffb --- /dev/null +++ b/ghost/core/test/integration/services/media-inliner/external-media-inliner-job.test.ts @@ -0,0 +1,84 @@ +import { describe, it, beforeAll, afterAll } from 'vitest'; +import assert from 'node:assert/strict'; +import sinon from 'sinon'; +import nock from 'nock'; + +const logging = require('@tryghost/logging'); +const { agentProvider, fixtureManager } = require('../../../utils/e2e-framework'); +const models = require('../../../../core/server/models'); +const { getInstance: getJobsService } = require('../../../../core/server/services/jobs-service'); +const ExternalMediaInlinerJob = + require('../../../../core/server/services/media-inliner/external-media-inliner-job').default; + +async function waitFor( + check: () => boolean | Promise, + { timeoutMs = 5000, intervalMs = 25 } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await check()) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + return false; +} + +describe('Job: External media inliner', function () { + beforeAll(async function () { + const agent = await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('posts'); + await agent.loginAsOwner(); + }); + + afterAll(function () { + sinon.restore(); + nock.cleanAll(); + }); + + it('inlines external media when the dispatched job runs', async function () { + const GIF1x1 = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'); + nock('https://external-media.example.com').get('/image.gif').reply(200, GIF1x1); + + const post = await models.Post.add( + { + title: 'Post with external feature image', + status: 'draft', + feature_image: 'https://external-media.example.com/image.gif', + }, + { context: { internal: true } }, + ); + + const loggingInfoSpy = sinon.spy(logging, 'info'); + + await getJobsService().dispatch( + new ExternalMediaInlinerJob({ domains: ['https://external-media.example.com'] }), + ); + + const completed = await waitFor(() => { + return loggingInfoSpy.getCalls().some((call) => { + return ( + call.args[0]?.system?.event === 'job.completed' && + call.args[0]?.system?.job_type === 'external-media-inliner' + ); + }); + }); + assert.ok(completed, 'The external-media-inliner job completed'); + + const updated = await models.Post.findOne({ id: post.id, status: 'all' }); + assert.ok( + updated.get('feature_image')?.includes('/content/images/'), + 'The feature image is replaced with a locally stored copy', + ); + + const lifecycleLog = loggingInfoSpy.getCalls().find((call) => { + return ( + call.args[0]?.system?.event === 'job.completed' && + call.args[0]?.system?.job_type === 'external-media-inliner' + ); + }); + assert.equal(typeof lifecycleLog!.args[0].system.duration_ms, 'number'); + }); +}); diff --git a/ghost/core/test/unit/api/endpoints/db.test.js b/ghost/core/test/unit/api/endpoints/db.test.js index 0063d4b205e..0a6f876f3c4 100644 --- a/ghost/core/test/unit/api/endpoints/db.test.js +++ b/ghost/core/test/unit/api/endpoints/db.test.js @@ -1,11 +1,18 @@ +const assert = require('node:assert/strict'); const sinon = require('sinon'); const models = require('../../../../core/server/models'); -const dbController = require('../../../../core/server/api/endpoints/db'); +const dbControllerPath = require.resolve('../../../../core/server/api/endpoints/db'); +const jobsServicePath = require.resolve('../../../../core/server/services/jobs-service'); +const dbController = require(dbControllerPath); +const jobsService = require(jobsServicePath); +const ExternalMediaInlinerJob = + require('../../../../core/server/services/media-inliner/external-media-inliner-job').default; describe('DB controller', function () { - let settingsCache, importer; + let settingsCache, importer, jobsServiceInitialised; beforeEach(function () { + jobsServiceInitialised = false; settingsCache = require('../../../../core/shared/settings-cache'); importer = require('../../../../core/server/data/importer'); @@ -16,10 +23,19 @@ describe('DB controller', function () { }); }); - afterEach(function () { + afterEach(async function () { + if (jobsServiceInitialised) { + await jobsService.shutdown({ timeoutMs: 100 }); + jobsServiceInitialised = false; + } sinon.restore(); }); + afterAll(function () { + delete require.cache[dbControllerPath]; + delete require.cache[jobsServicePath]; + }); + describe('importContent', function () { it('uses frame.user.email when frame.user is present', async function () { const mockUser = { @@ -68,4 +84,44 @@ describe('DB controller', function () { ); }); }); + + describe('inlineMedia', function () { + let dispatch; + + beforeEach(function () { + const service = jobsService.init(); + jobsServiceInitialised = true; + dispatch = sinon.stub(service, 'dispatch').resolves(); + }); + + it('dispatches explicit domains', async function () { + const result = await dbController.inlineMedia.query({ + data: { domains: ['https://example.com'] }, + }); + + sinon.assert.calledOnce(dispatch); + const job = dispatch.firstCall.firstArg; + assert.ok(job instanceof ExternalMediaInlinerJob); + assert.deepEqual(job.domains, ['https://example.com']); + assert.deepEqual(result, { status: 'success' }); + }); + + it('dispatches the default domains when domains are missing', async function () { + await dbController.inlineMedia.query({ data: {} }); + + assert.deepEqual(dispatch.firstCall.firstArg.domains, [ + 'https://s3.amazonaws.com/revue', + 'https://substackcdn.com', + ]); + }); + + it('dispatches the default domains when domains are empty', async function () { + await dbController.inlineMedia.query({ data: { domains: [] } }); + + assert.deepEqual(dispatch.firstCall.firstArg.domains, [ + 'https://s3.amazonaws.com/revue', + 'https://substackcdn.com', + ]); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts index a4fe6b1bd2e..e5961891449 100644 --- a/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts +++ b/ghost/core/test/unit/server/services/jobs-service/register-job-handlers.test.ts @@ -1,32 +1,30 @@ import assert from 'node:assert/strict'; import sinon from 'sinon'; import { describe, it, beforeEach, afterEach } from 'vitest'; +import logging from '@tryghost/logging'; +import { JobsService } from '../../../../../core/server/services/jobs-service/jobs-service'; +import ExternalMediaInliner from '../../../../../core/server/services/media-inliner/external-media-inliner'; +import ExternalMediaInlinerJob from '../../../../../core/server/services/media-inliner/external-media-inliner-job'; -// require, not import: these must resolve to the same CommonJS module instances -// that register-job-handlers.ts reaches for at execution time. -const jobsService = require('../../../../../core/server/services/jobs-service'); -const adapterManager = require('../../../../../core/server/services/adapter-manager').default; const registerJobHandlers = require('../../../../../core/server/services/jobs-service/register-job-handlers').default; -type Processor = (envelope: { type: string; payload: string }) => Promise; - describe('register-job-handlers', function () { - let deliver: Processor; + let jobsService: sinon.SinonStubbedInstance; + let db: { knex: sinon.SinonStub }; + let loggingStub: sinon.SinonStubbedInstance; + let mediaInliner: sinon.SinonStubbedInstance; - beforeEach(async function () { - jobsService.init(); - const backend = adapterManager.getAdapter('jobs'); - sinon.stub(backend, 'start').callsFake((...args: unknown[]) => { - deliver = (args[0] as { processor: Processor }).processor; - }); + beforeEach(function () { + jobsService = sinon.createStubInstance(JobsService); + db = { knex: sinon.stub() }; + loggingStub = sinon.stub(logging); + mediaInliner = sinon.createStubInstance(ExternalMediaInliner); - registerJobHandlers(); - await jobsService.getInstance().start(); + registerJobHandlers({ jobsService, db, logging: loggingStub, mediaInliner }); }); - afterEach(async function () { - await jobsService.shutdown({ timeoutMs: 100 }); + afterEach(function () { sinon.restore(); }); @@ -34,9 +32,46 @@ describe('register-job-handlers', function () { // exists for: a dispatch that lands before boot has built the service must // fail loudly rather than reading undefined off the module. it('fails a clean-gifts delivery when the gift service is not initialised', async function () { - await assert.rejects( - () => deliver({ type: 'clean-gifts', payload: '{}' }), - /clean-gifts ran before the gifts service was initialised/, - ); + const cleanGiftsHandler = jobsService.handle.secondCall.args[1]; + + await assert.rejects(async () => { + await cleanGiftsHandler({}); + }, /clean-gifts ran before the gifts service was initialised/); + }); + + it('runs clean-tokens with the injected database and logger', async function () { + const deleteStub = sinon.stub().resolves(2); + const whereStub = sinon.stub().returns({ delete: deleteStub }); + db.knex.withArgs('tokens').returns({ where: whereStub }); + const cleanTokensHandler = jobsService.handle.firstCall.args[1]; + + await cleanTokensHandler({}); + + assert.ok(db.knex.calledOnceWithExactly('tokens')); + assert.ok(loggingStub.info.calledOnce); + const metadata = loggingStub.info.firstCall.args[0] as { + system: { deleted_count: number }; + }; + assert.equal(metadata.system.deleted_count, 2); + }); + + it('runs external-media-inliner with the injected media inliner', async function () { + const externalMediaInlinerHandler = jobsService.handle.thirdCall.args[1]; + const job = new ExternalMediaInlinerJob({ domains: ['https://example.com'] }); + + await externalMediaInlinerHandler(job); + + assert.ok(mediaInliner.inline.calledOnceWithExactly(['https://example.com'])); + }); + + it('propagates external-media-inliner failures', async function () { + const error = new Error('Inlining failed'); + mediaInliner.inline.rejects(error); + const externalMediaInlinerHandler = jobsService.handle.thirdCall.args[1]; + const job = new ExternalMediaInlinerJob({ domains: ['https://example.com'] }); + + await assert.rejects(async () => { + await externalMediaInlinerHandler(job); + }, error); }); }); diff --git a/ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts b/ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts new file mode 100644 index 00000000000..212aceab42a --- /dev/null +++ b/ghost/core/test/unit/server/services/media-inliner/external-media-inliner-job.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'vitest'; +import ExternalMediaInlinerJob from '../../../../../core/server/services/media-inliner/external-media-inliner-job'; + +describe('ExternalMediaInlinerJob', function () { + it('keeps the legacy job name as its type', function () { + assert.equal(ExternalMediaInlinerJob.type, 'external-media-inliner'); + }); + + it('survives the payload JSON round-trip', function () { + const job = new ExternalMediaInlinerJob({ domains: ['https://example.com'] }); + + const rehydrated = new ExternalMediaInlinerJob(JSON.parse(JSON.stringify(job))); + + assert.deepEqual(rehydrated.domains, ['https://example.com']); + }); +}); 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 b601c0c571e..f024481e9cc 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 @@ -67,6 +67,160 @@ describe('ExternalMediaInliner', function () { assert.ok(new ExternalMediaInliner({})); }); + describe('importUrl', function () { + function harness() { + const response = { body: Buffer.from('media') }; + const media = { fileBuffer: response.body, filename: 'media.jpg', extension: '.jpg' }; + const inliner = new ExternalMediaInliner({}); + const getRemoteMedia = sinon.stub(inliner, 'getRemoteMedia').resolves(response); + const extractFileDataFromResponse = sinon + .stub(inliner, 'extractFileDataFromResponse') + .resolves(media); + const storeMediaLocally = sinon + .stub(inliner, 'storeMediaLocally') + .resolves('__GHOST_URL__/content/images/media.jpg'); + + return { + inliner, + response, + media, + getRemoteMedia, + extractFileDataFromResponse, + storeMediaLocally, + }; + } + + it('downloads, extracts, and stores one source URL', async function () { + const h = harness(); + const sourceUrl = 'https://example.com/media.jpg'; + + const result = await h.inliner.importUrl(sourceUrl); + + assert.deepEqual(result, { + status: 'stored', + sourceUrl, + storedUrl: '__GHOST_URL__/content/images/media.jpg', + }); + sinon.assert.calledWithExactly(h.getRemoteMedia, sourceUrl); + sinon.assert.calledWithExactly(h.extractFileDataFromResponse, sourceUrl, h.response); + sinon.assert.calledWithExactly(h.storeMediaLocally, h.media); + }); + + it('returns a typed download failure when fetching throws', async function () { + const h = harness(); + const error = new Error('network unavailable'); + h.getRemoteMedia.rejects(error); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'download', + reason: 'The media file could not be downloaded.', + error, + }); + sinon.assert.notCalled(h.extractFileDataFromResponse); + }); + + it('returns a typed download failure when fetching produces no response', async function () { + const h = harness(); + h.getRemoteMedia.resolves(null); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'download', + reason: 'The media file could not be downloaded.', + }); + sinon.assert.notCalled(h.extractFileDataFromResponse); + }); + + it('returns a typed extraction failure', async function () { + const h = harness(); + const error = new Error('invalid media'); + h.extractFileDataFromResponse.rejects(error); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error, + }); + sinon.assert.notCalled(h.storeMediaLocally); + }); + + it('returns a typed unsupported-media failure', async function () { + const h = harness(); + h.storeMediaLocally.resolves(null); + + const result = await h.inliner.importUrl('https://example.com/media.exe'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.exe', + stage: 'unsupported', + reason: 'No configured storage accepts this media file.', + }); + }); + + it('returns a typed storage failure', async function () { + const h = harness(); + const error = new Error('storage unavailable'); + h.storeMediaLocally.rejects(error); + + const result = await h.inliner.importUrl('https://example.com/media.jpg'); + + assert.deepEqual(result, { + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'storage', + reason: 'The media file could not be stored in Ghost.', + error, + }); + }); + + it('preserves legacy processing errors at the existing catch boundary', async function () { + const inliner = new ExternalMediaInliner({}); + const error = new Error('could not inspect media'); + sinon.stub(inliner, 'importUrl').resolves({ + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error, + }); + + await assert.rejects( + inliner.inlineContent('{"src":"https://example.com/media.jpg"}', ['https://example.com']), + (thrown) => thrown === error, + ); + }); + + it('preserves legacy falsy thrown values at the existing catch boundary', async function () { + const inliner = new ExternalMediaInliner({}); + sinon.stub(inliner, 'importUrl').resolves({ + status: 'failed', + sourceUrl: 'https://example.com/media.jpg', + stage: 'extract', + reason: 'The downloaded media file could not be read.', + error: null, + }); + + await inliner + .inlineContent('{"src":"https://example.com/media.jpg"}', ['https://example.com']) + .then( + () => assert.fail('Expected inlineContent to reject'), + (error) => assert.equal(error, null), + ); + }); + }); + describe('inline', function () { it("inlines image in the post's mobiledoc content", async function () { const imageURL = 'https://img.stockfresh.com/files/f/image.jpg';