diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js
index 72e807acc3..8ccf99fd22 100644
--- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/index.js
@@ -9,6 +9,7 @@ module.exports = {
emailFailures: require('./email-failures'),
images: require('./images'),
integrations: require('./integrations'),
+ oembed: require('./oembed'),
pages: require('./pages'),
posts: require('./posts'),
settings: require('./settings'),
diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/oembed.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/oembed.js
new file mode 100644
index 0000000000..35fef8e62f
--- /dev/null
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/oembed.js
@@ -0,0 +1,5 @@
+const url = require('../utils/url');
+
+module.exports = (path) => {
+ return url.forImage(path);
+};
diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js
index aa2eb73abc..1a8f45f30b 100644
--- a/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/oembed.js
@@ -1,8 +1,15 @@
const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output:oembed');
+const mappers = require('./mappers');
module.exports = {
all(data, apiConfig, frame) {
debug('all');
+ if (data?.metadata?.thumbnail) {
+ data.metadata.thumbnail = mappers.oembed(data.metadata.thumbnail);
+ }
+ if (data?.metadata?.icon) {
+ data.metadata.icon = mappers.oembed(data.metadata.icon);
+ }
frame.response = data;
}
};
diff --git a/ghost/core/core/server/services/oembed/service.js b/ghost/core/core/server/services/oembed/service.js
index 77b7cd72d2..25f55e88b1 100644
--- a/ghost/core/core/server/services/oembed/service.js
+++ b/ghost/core/core/server/services/oembed/service.js
@@ -1,8 +1,9 @@
const config = require('../../../shared/config');
+const storage = require('../../adapters/storage');
const externalRequest = require('../../lib/request-external');
const OEmbed = require('@tryghost/oembed-service');
-const oembed = new OEmbed({config, externalRequest});
+const oembed = new OEmbed({config, externalRequest, storage});
const NFT = require('./NFTOEmbedProvider');
const nft = new NFT({
diff --git a/ghost/core/test/e2e-api/admin/oembed.test.js b/ghost/core/test/e2e-api/admin/oembed.test.js
index 39a6184baf..65be48d4ab 100644
--- a/ghost/core/test/e2e-api/admin/oembed.test.js
+++ b/ghost/core/test/e2e-api/admin/oembed.test.js
@@ -6,6 +6,8 @@ const testUtils = require('../../utils/index');
const config = require('../../../core/shared/config/index');
const localUtils = require('./utils');
const {mockManager} = require('../../utils/e2e-framework');
+const oembed = require('../../../../core/core/server/services/oembed');
+const urlUtils = require('../../../core/shared/url-utils');
// for sinon stubs
const dnsPromises = require('dns').promises;
@@ -19,9 +21,18 @@ describe('Oembed API', function () {
await localUtils.doAuth(request);
});
+ let processImageFromUrlStub;
+
beforeEach(function () {
// ensure sure we're not network dependent
mockManager.disableNetwork();
+ processImageFromUrlStub = sinon.stub(oembed, 'processImageFromUrl');
+ processImageFromUrlStub.callsFake(async function (imageUrl, imageType) {
+ if (imageUrl === 'http://example.com/bad-image') {
+ throw new Error('Failed to process image');
+ }
+ return `/content/images/${imageType}/image-01.png`;
+ });
});
afterEach(function () {
@@ -228,15 +239,10 @@ describe('Oembed API', function () {
.get('/page-with-icon')
.reply(
200,
- '
TESTING',
+ 'TESTING',
{'content-type': 'text/html'}
);
- // Mock the icon URL to return 404
- nock('http://example.com/')
- .head('/icon.svg')
- .reply(404);
-
const url = encodeURIComponent(' http://example.com/page-with-icon\t '); // Whitespaces are to make sure urls are trimmed
const res = await request.get(localUtils.API.getApiQuery(`oembed/?url=${url}&type=bookmark`))
.set('Origin', config.get('url'))
@@ -252,6 +258,54 @@ describe('Oembed API', function () {
});
});
+ it('should fetch and store icons', async function () {
+ // Mock the page to contain a readable icon URL
+ const pageMock = nock('http://example.com')
+ .get('/page-with-icon')
+ .reply(
+ 200,
+ 'TESTING',
+ {'content-type': 'text/html'}
+ );
+
+ const url = encodeURIComponent(' http://example.com/page-with-icon\t '); // Whitespaces are to make sure urls are trimmed
+ const res = await request.get(localUtils.API.getApiQuery(`oembed/?url=${url}&type=bookmark`))
+ .set('Origin', config.get('url'))
+ .expect('Content-Type', /json/)
+ .expect('Cache-Control', testUtils.cacheRules.private)
+ .expect(200);
+
+ // Check that the icon URL mock was loaded
+ pageMock.isDone().should.be.true();
+
+ // Check that the substitute icon URL is returned in place of the original
+ res.body.metadata.icon.should.eql(`${urlUtils.urlFor('home', true)}content/images/icon/image-01.png`);
+ });
+
+ it('should fetch and store thumbnails', async function () {
+ // Mock the page to contain a readable icon URL
+ const pageMock = nock('http://example.com')
+ .get('/page-with-thumbnail')
+ .reply(
+ 200,
+ 'TESTING',
+ {'content-type': 'text/html'}
+ );
+
+ const url = encodeURIComponent(' http://example.com/page-with-thumbnail\t '); // Whitespaces are to make sure urls are trimmed
+ const res = await request.get(localUtils.API.getApiQuery(`oembed/?url=${url}&type=bookmark`))
+ .set('Origin', config.get('url'))
+ .expect('Content-Type', /json/)
+ .expect('Cache-Control', testUtils.cacheRules.private)
+ .expect(200);
+
+ // Check that the thumbnail URL mock was loaded
+ pageMock.isDone().should.be.true();
+
+ // Check that the substitute thumbnail URL is returned in place of the original
+ res.body.metadata.thumbnail.should.eql(`${urlUtils.urlFor('home', true)}content/images/thumbnail/image-01.png`);
+ });
+
describe('with unknown provider', function () {
it('fetches url and follows redirects', async function () {
const redirectMock = nock('http://test.com/')
diff --git a/ghost/oembed-service/lib/OEmbedService.js b/ghost/oembed-service/lib/OEmbedService.js
index 85e1330b71..b58b4eaa9c 100644
--- a/ghost/oembed-service/lib/OEmbedService.js
+++ b/ghost/oembed-service/lib/OEmbedService.js
@@ -4,6 +4,7 @@ const logging = require('@tryghost/logging');
const _ = require('lodash');
const charset = require('charset');
const iconv = require('iconv-lite');
+const path = require('path');
// Some sites block non-standard user agents so we need to mimic a typical browser
const USER_AGENT = 'Mozilla/5.0 (compatible; Ghost/5.0; +https://ghost.org/)';
@@ -49,6 +50,12 @@ const findUrlWithProvider = (url) => {
/**
* @typedef {Object} IConfig
* @prop {(key: string) => string} get
+ * @prop {(key: string) => string} getContentPath
+ */
+
+/**
+ * @typedef {Object} IStorage
+ * @prop {(feature: string) => Object} getStorage
*/
/**
@@ -66,10 +73,12 @@ class OEmbedService {
*
* @param {Object} dependencies
* @param {IConfig} dependencies.config
+ * @param {IStorage} dependencies.storage
* @param {IExternalRequest} dependencies.externalRequest
*/
- constructor({config, externalRequest}) {
+ constructor({config, externalRequest, storage}) {
this.config = config;
+ this.storage = storage;
/** @type {IExternalRequest} */
this.externalRequest = externalRequest;
@@ -118,6 +127,55 @@ class OEmbedService {
}
}
+ /**
+ * Fetches the image buffer from a URL using fetch
+ * @param {String} imageUrl - URL of the image to fetch
+ * @returns {Promise} - Promise resolving to the image buffer
+ */
+ async fetchImageBuffer(imageUrl) {
+ const response = await fetch(imageUrl);
+
+ if (!response.ok) {
+ throw Error(`Failed to fetch image: ${response.statusText}`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+
+ const buffer = Buffer.from(arrayBuffer);
+ return buffer;
+ }
+
+ /**
+ * Process and store image from a URL
+ * @param {String} imageUrl - URL of the image to process
+ * @param {String} imageType - What is the image used for. Example - icon, thumbnail
+ * @returns {Promise} - URL where the image is stored
+ */
+ async processImageFromUrl(imageUrl, imageType) {
+ // Fetch image buffer from the URL
+ const imageBuffer = await this.fetchImageBuffer(imageUrl);
+ const store = this.storage.getStorage('images');
+
+ // Extract file name from URL
+ const fileName = path.basename(new URL(imageUrl).pathname);
+ let ext = path.extname(fileName);
+ let name;
+
+ if (ext) {
+ name = store.getSanitizedFileName(path.basename(fileName, ext));
+ } else {
+ name = store.getSanitizedFileName(path.basename(fileName));
+ }
+
+ let targetDir = path.join(this.config.getContentPath('images'), imageType);
+ const uniqueFilePath = await store.generateUnique(targetDir, name, ext, 0);
+ const targetPath = path.join(imageType, path.basename(uniqueFilePath));
+
+ const imageStoredUrl = await store.saveRaw(imageBuffer, targetPath);
+
+ return imageStoredUrl;
+ }
+
/**
* @param {string} url
* @param {Object} options
@@ -271,14 +329,20 @@ class OEmbedService {
});
}
- if (metadata.icon) {
- try {
- await this.externalRequest.head(metadata.icon);
- } catch (err) {
+ await this.processImageFromUrl(metadata.icon, 'icon')
+ .then((processedImageUrl) => {
+ metadata.icon = processedImageUrl;
+ }).catch((err) => {
metadata.icon = 'https://static.ghost.org/v5.0.0/images/link-icon.svg';
logging.error(err);
- }
- }
+ });
+
+ await this.processImageFromUrl(metadata.thumbnail, 'thumbnail')
+ .then((processedImageUrl) => {
+ metadata.thumbnail = processedImageUrl;
+ }).catch((err) => {
+ logging.error(err);
+ });
return {
version: '1.0',