diff --git a/apps/admin-x-framework/src/api/images.ts b/apps/admin-x-framework/src/api/images.ts index 93ddf1de8c..5c087dd3b7 100644 --- a/apps/admin-x-framework/src/api/images.ts +++ b/apps/admin-x-framework/src/api/images.ts @@ -7,13 +7,20 @@ export interface ImagesResponseType { }[]; } -export const useUploadImage = createMutation({ +// eslint-disable-next-line no-shadow +export enum ImageStatus { + NEW = 'new', + EDITED = 'edited', +} + +export const useUploadImage = createMutation({ method: 'POST', path: () => '/images/upload/', - body: ({file}) => { + body: ({file, status = ImageStatus.NEW}) => { const formData = new FormData(); formData.append('file', file); formData.append('purpose', 'image'); + formData.append('status', status); return formData; } }); diff --git a/apps/admin-x-settings/src/components/settings/general/Facebook.tsx b/apps/admin-x-settings/src/components/settings/general/Facebook.tsx index bd0dbee0ed..43ce587dc4 100644 --- a/apps/admin-x-settings/src/components/settings/general/Facebook.tsx +++ b/apps/admin-x-settings/src/components/settings/general/Facebook.tsx @@ -4,7 +4,7 @@ import usePinturaEditor from '../../../hooks/usePinturaEditor'; import useSettingGroup from '../../../hooks/useSettingGroup'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {FacebookLogo, ImageUpload, SettingGroupContent, TextField, withErrorBoundary} from '@tryghost/admin-x-design-system'; -import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; +import {getImageUrl, ImageStatus, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -86,7 +86,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => { openEditor: async () => editor.openEditor({ image: facebookImage || '', handleSave: async (file:File) => { - const imageUrl = getImageUrl(await uploadImage({file})); + const imageUrl = getImageUrl(await uploadImage({file, status: ImageStatus.EDITED})); updateSetting('og_image', imageUrl); } }) diff --git a/apps/admin-x-settings/src/components/settings/general/Twitter.tsx b/apps/admin-x-settings/src/components/settings/general/Twitter.tsx index be40650c32..f82e618612 100644 --- a/apps/admin-x-settings/src/components/settings/general/Twitter.tsx +++ b/apps/admin-x-settings/src/components/settings/general/Twitter.tsx @@ -4,7 +4,7 @@ import usePinturaEditor from '../../../hooks/usePinturaEditor'; import useSettingGroup from '../../../hooks/useSettingGroup'; import {APIError} from '@tryghost/admin-x-framework/errors'; import {ImageUpload, SettingGroupContent, TextField, XLogo, withErrorBoundary} from '@tryghost/admin-x-design-system'; -import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; +import {getImageUrl, ImageStatus, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; @@ -82,7 +82,7 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => { openEditor: async () => editor.openEditor({ image: twitterImage || '', handleSave: async (file:File) => { - const imageUrl = getImageUrl(await uploadImage({file})); + const imageUrl = getImageUrl(await uploadImage({file, status: ImageStatus.EDITED})); updateSetting('twitter_image', imageUrl); } }) diff --git a/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx b/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx index 27e492db49..ad3d4cddb9 100644 --- a/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx @@ -15,7 +15,7 @@ import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framewor import {HostLimitError, useLimiter} from '../../../hooks/useLimiter'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {User, canAccessSettings, hasAdminAccess, isAdminUser, isAuthorOrContributor, isEditorUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner} from '@tryghost/admin-x-framework/api/users'; -import {getImageUrl, useUploadImage} from '@tryghost/admin-x-framework/api/images'; +import {getImageUrl, ImageStatus, useUploadImage} from '@tryghost/admin-x-framework/api/images'; import {useGlobalData} from '../../providers/GlobalDataProvider'; import {validateFacebookUrl, validateTwitterUrl} from '../../../utils/socialUrls'; @@ -246,9 +246,9 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { }); }; - const handleImageUpload = async (image: string, file: File) => { + const handleImageUpload = async (image: string, file: File, status: ImageStatus = ImageStatus.NEW) => { try { - const imageUrl = getImageUrl(await uploadImage({file})); + const imageUrl = getImageUrl(await uploadImage({file, status})); switch (image) { case 'cover_image': @@ -383,7 +383,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { openEditor: async () => editor.openEditor({ image: formState.profile_image || '', handleSave: async (file:File) => { - handleImageUpload('profile_image', file); + handleImageUpload('profile_image', file, ImageStatus.EDITED); } }) } @@ -421,7 +421,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { openEditor: async () => editor.openEditor({ image: formState.cover_image || '', handleSave: async (file:File) => { - handleImageUpload('cover_image', file); + handleImageUpload('cover_image', file, ImageStatus.EDITED); } }) } diff --git a/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx b/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx index 9b2346b7d6..c11bf33cc9 100644 --- a/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/designAndBranding/BrandSettings.tsx @@ -131,7 +131,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: image: values.coverImage || '', handleSave: async (file:File) => { try { - updateSetting('cover_image', getImageUrl(await uploadImage({file}))); + updateSetting('cover_image', getImageUrl(await uploadImage({file, original: values.coverImage}))); } catch (e) { handleError(e); } diff --git a/ghost/admin/app/components/gh-image-uploader.js b/ghost/admin/app/components/gh-image-uploader.js index 6da150ecfe..e2181bad41 100644 --- a/ghost/admin/app/components/gh-image-uploader.js +++ b/ghost/admin/app/components/gh-image-uploader.js @@ -15,13 +15,16 @@ import {isBlank} from '@ember/utils'; import {run} from '@ember/runloop'; import {inject as service} from '@ember/service'; +export const IMAGE_STATUS_NEW = 'new'; +export const IMAGE_STATUS_EDITED = 'edited'; + export const IMAGE_MIME_TYPES = 'image/gif,image/jpg,image/jpeg,image/png,image/svg+xml,image/webp'; export const IMAGE_EXTENSIONS = ['gif', 'jpg', 'jpeg', 'png', 'svg', 'svgz', 'webp']; -export const IMAGE_PARAMS = {purpose: 'image'}; +export const IMAGE_PARAMS = {purpose: 'image', status: IMAGE_STATUS_NEW}; export const ICON_EXTENSIONS = ['gif', 'ico', 'jpg', 'jpeg', 'png', 'svg', 'svgz', 'webp']; export const ICON_MIME_TYPES = 'image/x-icon,image/vnd.microsoft.icon,image/gif,image/jpg,image/jpeg,image/png,image/svg+xml,image/webp'; -export const ICON_PARAMS = {purpose: 'icon'}; +export const ICON_PARAMS = {purpose: 'icon', status: IMAGE_STATUS_NEW}; export default Component.extend({ ajax: service(), diff --git a/ghost/admin/app/components/gh-uploader.js b/ghost/admin/app/components/gh-uploader.js index 1f22fb36a4..0c30027360 100644 --- a/ghost/admin/app/components/gh-uploader.js +++ b/ghost/admin/app/components/gh-uploader.js @@ -6,7 +6,9 @@ import { ICON_EXTENSIONS, ICON_MIME_TYPES, IMAGE_EXTENSIONS, - IMAGE_MIME_TYPES + IMAGE_MIME_TYPES, + IMAGE_STATUS_EDITED, + IMAGE_STATUS_NEW } from 'ghost-admin/components/gh-image-uploader'; import {all, task} from 'ember-concurrency'; import {isArray} from '@ember/array'; @@ -95,7 +97,7 @@ export default Component.extend({ this._uploadTrackers = []; if (!this.paramsHash) { - this.set('paramsHash', {purpose: 'image'}); + this.set('paramsHash', {purpose: 'image', status: IMAGE_STATUS_NEW}); } this.set('imageExtensions', IMAGE_EXTENSIONS); @@ -333,6 +335,10 @@ export default Component.extend({ formData.append(key, this.paramsHash[key]); }); + if (file.edited) { + formData.set('status', IMAGE_STATUS_EDITED); + } + return formData; }, diff --git a/ghost/admin/app/components/koenig-image-editor.js b/ghost/admin/app/components/koenig-image-editor.js index 1ab92083b2..40b80d3f23 100644 --- a/ghost/admin/app/components/koenig-image-editor.js +++ b/ghost/admin/app/components/koenig-image-editor.js @@ -230,6 +230,7 @@ export default class KoenigImageEditor extends Component { editor.on('process', (result) => { // save edited image + result.dest.edited = true; try { if (this.args.saveImage) { this.args.saveImage(result.dest); diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js index 18e6d2df19..9e434e57c4 100644 --- a/ghost/admin/app/components/koenig-lexical-editor.js +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -9,12 +9,16 @@ import {didCancel, task} from 'ember-concurrency'; import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; +const IMAGE_STATUS_NEW = 'new'; +const IMAGE_STATUS_EDITED = 'edited'; + export const fileTypes = { image: { mimeTypes: ['image/gif', 'image/jpg', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp'], extensions: ['gif', 'jpg', 'jpeg', 'png', 'svg', 'svgz', 'webp'], endpoint: '/images/upload/', - resourceName: 'images' + resourceName: 'images', + uploadParams: {purpose: 'image', status: IMAGE_STATUS_NEW} }, video: { mimeTypes: ['video/mp4', 'video/webm', 'video/ogg'], @@ -537,10 +541,16 @@ export default class KoenigLexicalEditor extends Component { const fileFormData = new FormData(); fileFormData.append('file', file, file.name); + formData = {...fileTypes[type].uploadParams, ...formData}; + Object.keys(formData || {}).forEach((key) => { fileFormData.append(key, formData[key]); }); + if (type === 'image' && file.edited) { + fileFormData.set('status', IMAGE_STATUS_EDITED); + } + const url = `${ghostPaths().apiRoot}${fileTypes[type].endpoint}`; try { diff --git a/ghost/core/core/server/api/endpoints/images.js b/ghost/core/core/server/api/endpoints/images.js index b95dedc0ab..4f6d4bac06 100644 --- a/ghost/core/core/server/api/endpoints/images.js +++ b/ghost/core/core/server/api/endpoints/images.js @@ -1,11 +1,14 @@ /* eslint-disable ghost/ghost-custom/max-api-complexity */ const path = require('path'); +const uuid = require('uuid'); const errors = require('@tryghost/errors'); const imageTransform = require('@tryghost/image-transform'); const storage = require('../../adapters/storage'); const config = require('../../../shared/config'); +const IMAGE_STATUS_EDITED = 'edited'; + /** @type {import('@tryghost/api-framework').Controller} */ const controller = { docName: 'images', @@ -24,6 +27,13 @@ const controller = { // Trim _o from file name (not allowed suffix) frame.file.name = frame.file.name.replace(/_o(\.\w+?)$/, '$1'); + // If this an edited version of an existing image, anonymise the name + if (frame.data.status === IMAGE_STATUS_EDITED) { + const ext = path.extname(frame.file.name); + + frame.file.name = `${uuid.v4()}${ext}`; + } + // CASE: image transform is not capable of transforming file (e.g. .gif) if (imageTransform.shouldResizeFileExtension(frame.file.ext) && imageOptimizationOptions.resize) { const out = `${frame.file.path}_processed`; diff --git a/ghost/core/test/e2e-api/admin/images.test.js b/ghost/core/test/e2e-api/admin/images.test.js index ee882b35cb..acd8ae1e43 100644 --- a/ghost/core/test/e2e-api/admin/images.test.js +++ b/ghost/core/test/e2e-api/admin/images.test.js @@ -14,6 +14,9 @@ const {imageSize} = require('../../../core/server/lib/image'); const configUtils = require('../../utils/configUtils'); const logging = require('@tryghost/logging'); +const IMAGE_STATUS_NEW = 'new'; +const IMAGE_STATUS_EDITED = 'edited'; + const images = []; let agent, frontendAgent, ghostServer; /** @@ -23,9 +26,10 @@ let agent, frontendAgent, ghostServer; * @param {string} options.filename * @param {string} options.contentType * @param {string} [options.ref] + * @param {string} [options.status] * @returns */ -const uploadImageRequest = ({fileContents, filename, contentType, ref}) => { +const uploadImageRequest = ({fileContents, filename, contentType, ref, status = IMAGE_STATUS_NEW}) => { const form = new FormData(); form.append('file', fileContents, { filename, @@ -33,6 +37,8 @@ const uploadImageRequest = ({fileContents, filename, contentType, ref}) => { }); form.append('purpose', 'image'); + form.append('status', status); + if (ref) { form.append('ref', ref); } @@ -400,4 +406,21 @@ describe('Images API', function () { }); sinon.assert.calledOnce(loggingStub); }); + + it('Anonymizes the name of an edited image', async function () { + const originalFilePath = p.join(__dirname, '/../../utils/fixtures/images/ghost-logo.png'); + const fileContents = await fs.readFile(originalFilePath); + const filename = 'foobarbaz.png'; + + const {body} = await uploadImageRequest({fileContents, filename, contentType: 'image/png', status: IMAGE_STATUS_EDITED}) + .expectStatus(201); + + const name = body.images[0].url.split('/').pop(); + + // Assert that name has been changed + assert.equal(name !== filename, true); + + // Assert that name is a UUID v4 + assert.match(name, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.png$/); + }); });