Merge pull request #13087 from strapi/menu-logo/update-project-settings-route

Menu logo/update project settings route
This commit is contained in:
Vincent 2022-04-15 17:12:49 +02:00 committed by GitHub
commit d4c9cbfe47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 533 additions and 0 deletions

View File

@ -8,6 +8,11 @@ const { ValidationError } = require('@strapi/utils').errors;
// eslint-disable-next-line node/no-extraneous-require
const ee = require('@strapi/strapi/lib/utils/ee');
const {
validateUpdateProjectSettings,
validateUpdateProjectSettingsFiles,
validateUpdateProjectSettingsImagesDimensions,
} = require('../validation/project-settings');
const { getService } = require('../utils');
const PLUGIN_NAME_REGEX = /^[A-Za-z][A-Za-z0-9-_]+$/;
@ -43,6 +48,22 @@ module.exports = {
return { data: { uuid, hasAdmin } };
},
async updateProjectSettings(ctx) {
const projectSettingsService = getService('project-settings');
const {
request: { files, body },
} = ctx;
await validateUpdateProjectSettings(body);
await validateUpdateProjectSettingsFiles(files);
const formatedFiles = await projectSettingsService.parseFilesData(files);
await validateUpdateProjectSettingsImagesDimensions(formatedFiles);
return projectSettingsService.updateProjectSettings({ ...body, ...formatedFiles });
},
async information() {
const currentEnvironment = strapi.config.get('environment');
const autoReload = strapi.config.get('autoReload', false);

View File

@ -7,6 +7,20 @@ module.exports = [
handler: 'admin.init',
config: { auth: false },
},
{
method: 'POST',
path: '/project-settings',
handler: 'admin.updateProjectSettings',
config: {
policies: [
'admin::isAuthenticatedAdmin',
{
name: 'admin::hasPermissions',
config: { actions: ['admin::project-settings.update'] },
},
],
},
},
{
method: 'GET',
path: '/project-type',

View File

@ -0,0 +1,294 @@
'use strict';
const {
parseFilesData,
getProjectSettings,
deleteOldFiles,
updateProjectSettings,
} = require('../project-settings');
jest.mock('fs', () => ({
...jest.requireActual('fs'),
createReadStream: () => null,
}));
const storeSet = jest.fn();
const providerDelete = jest.fn();
global.strapi = {
dirs: {
public: 'publicDir',
},
config: {
get: () => ({ provider: 'local' }),
},
plugins: {
upload: {
provider: {
async uploadStream(file) {
file.url = `/uploads/${file.hash}`;
},
delete: providerDelete,
},
services: {
upload: {
formatFileInfo: () => ({
name: 'filename.png',
alternativeText: null,
caption: null,
hash: 'filename_123',
ext: '.png',
mime: 'image/png',
size: 123,
}),
},
'image-manipulation': {
getDimensions: () => ({ width: 100, height: 100 }),
},
},
},
},
store: () => ({
get: () => ({
menuLogo: {
name: 'name',
url: 'file/url',
width: 100,
height: 100,
ext: 'png',
size: 123,
provider: 'local',
},
}),
set: storeSet,
}),
};
describe('Project setting', () => {
beforeEach(jest.resetAllMocks);
describe('parseFilesData', () => {
it('Should parse valid files object', async () => {
const files = {
menuLogo: {
size: 123,
path: '/tmp/filename_123',
name: 'file.png',
type: 'image/png',
},
};
const expectedOutput = {
menuLogo: {
name: 'filename.png',
alternativeText: null,
caption: null,
hash: 'filename_123',
ext: '.png',
mime: 'image/png',
provider: 'local',
size: 123,
stream: null,
width: 100,
height: 100,
tmpPath: '/tmp/filename_123',
},
};
const parsedFiles = await parseFilesData(files);
expect(parsedFiles).toEqual(expectedOutput);
});
it('Should skip empty files object with no error', async () => {
const files = {};
const expectedOutput = {};
const parsedFiles = await parseFilesData(files);
expect(parsedFiles).toEqual(expectedOutput);
});
});
describe('getProjectSettings', () => {
it('Should return project settings from store (only the right subset of fields)', async () => {
const projectSettings = await getProjectSettings();
const expectedOutput = {
menuLogo: {
name: 'name',
url: 'file/url',
width: 100,
height: 100,
ext: 'png',
size: 123,
},
};
expect(projectSettings).toStrictEqual(expectedOutput);
});
});
describe('deleteOldFiles', () => {
it('Does not delete when there was no previous file', async () => {
const previousSettings = {
menuLogo: null,
};
const newSettings = {
menuLogo: {
size: 24085,
name: 'file.png',
type: 'image/png',
url: 'file/url',
},
};
await deleteOldFiles({ previousSettings, newSettings });
expect(providerDelete).not.toBeCalled();
});
it('Does not delete when there is no new file uploaded', async () => {
const previousSettings = {
menuLogo: {
size: 24085,
name: 'file.png',
type: 'image/png',
provider: 'local',
url: 'file/url',
},
};
const newSettings = previousSettings;
await deleteOldFiles({ previousSettings, newSettings });
expect(providerDelete).not.toBeCalled();
});
it('Deletes when inputs are explicitely set to null', async () => {
const previousSettings = {
menuLogo: {
size: 24085,
name: 'file.png',
type: 'image/png',
provider: 'local',
url: 'file/url',
},
};
const newSettings = { menuLogo: null };
await deleteOldFiles({ previousSettings, newSettings });
expect(providerDelete).toBeCalledTimes(1);
});
it('Deletes when new files are uploaded', async () => {
const previousSettings = {
menuLogo: {
size: 24085,
name: 'file.png',
type: 'image/png',
provider: 'local',
url: 'file/url',
hash: '123',
},
};
const newSettings = {
menuLogo: {
...previousSettings.menuLogo,
hash: '456',
},
};
await deleteOldFiles({ previousSettings, newSettings });
expect(providerDelete).toBeCalledTimes(1);
});
});
describe('updateProjectSettings', () => {
it('Updates the project settings', async () => {
const body = {};
const files = {
menuLogo: {
name: 'filename.png',
alternativeText: null,
caption: null,
hash: 'filename_123',
ext: '.png',
mime: 'image/png',
size: 123,
stream: null,
width: 100,
height: 100,
tmpPath: '/tmp/filename_123',
url: '/uploads/filename_123.png',
},
};
const expectedOutput = {
menuLogo: {
name: 'filename.png',
hash: 'filename_123',
url: '/uploads/filename_123.png',
width: 100,
height: 100,
ext: '.png',
size: 123,
},
};
await updateProjectSettings({ ...body, ...files });
expect(storeSet).toBeCalledTimes(1);
expect(storeSet).toBeCalledWith({
key: 'project-settings',
value: expectedOutput,
});
});
it('Updates the project settings (delete)', async () => {
const body = { menuLogo: '' };
const files = {};
const expectedOutput = {
menuLogo: null,
};
await updateProjectSettings({ ...body, ...files });
expect(storeSet).toBeCalledTimes(1);
expect(storeSet).toBeCalledWith({
key: 'project-settings',
value: expectedOutput,
});
});
it('Keeps the previous project settings', async () => {
const body = {};
const files = {};
const expectedOutput = {
menuLogo: {
name: 'name',
url: 'file/url',
width: 100,
height: 100,
ext: 'png',
size: 123,
provider: 'local',
},
};
await updateProjectSettings({ ...body, ...files });
expect(storeSet).toBeCalledTimes(1);
expect(storeSet).toBeCalledWith({
key: 'project-settings',
value: expectedOutput,
});
});
});
});

View File

@ -13,4 +13,5 @@ module.exports = {
auth: require('./auth'),
action: require('./action'),
'api-token': require('./api-token'),
'project-settings': require('./project-settings'),
};

View File

@ -0,0 +1,162 @@
'use strict';
const fs = require('fs');
const { pick } = require('lodash');
const PROJECT_SETTINGS_FILE_INPUTS = ['menuLogo'];
const parseFilesData = async files => {
const formatedFilesData = {};
await Promise.all(
PROJECT_SETTINGS_FILE_INPUTS.map(async inputName => {
const file = files[inputName];
// Skip empty file inputs
if (!file) {
return;
}
const getStream = () => fs.createReadStream(file.path);
// Add formated data for the upload provider
formatedFilesData[inputName] = strapi
.plugin('upload')
.service('upload')
.formatFileInfo({
filename: file.name,
type: file.type,
size: file.size,
});
// Add image dimensions
Object.assign(
formatedFilesData[inputName],
await strapi
.plugin('upload')
.service('image-manipulation')
.getDimensions({ getStream })
);
// Add file path, and stream
Object.assign(formatedFilesData[inputName], {
stream: getStream(),
tmpPath: file.path,
provider: strapi.config.get('plugin.upload').provider,
});
})
);
return formatedFilesData;
};
const getProjectSettings = async () => {
const store = strapi.store({ type: 'core', name: 'admin' });
const projectSettings = await store.get({ key: 'project-settings' });
// Filter file input fields
PROJECT_SETTINGS_FILE_INPUTS.forEach(inputName => {
if (!projectSettings[inputName]) {
return;
}
projectSettings[inputName] = pick(projectSettings[inputName], [
'name',
'url',
'width',
'height',
'ext',
'size',
]);
});
return projectSettings;
};
const uploadFiles = async (files = {}) => {
// Call the provider upload function for each file
return Promise.all(
Object.values(files)
.filter(file => file.stream instanceof fs.ReadStream)
.map(file => strapi.plugin('upload').provider.uploadStream(file))
);
};
const deleteOldFiles = async ({ previousSettings, newSettings }) => {
return Promise.all(
PROJECT_SETTINGS_FILE_INPUTS.map(async inputName => {
// Skip if there was no previous file
if (!previousSettings[inputName]) {
return;
}
// Skip if the file was not changed
if (
newSettings[inputName] &&
previousSettings[inputName].hash === newSettings[inputName].hash
) {
return;
}
// Skip if the file was not uploaded with the current provider
if (strapi.config.get('plugin.upload').provider !== previousSettings[inputName].provider) {
return;
}
// There was a previous file and an new file was uploaded
// Remove the previous file
strapi.plugin('upload').provider.delete(previousSettings[inputName]);
})
);
};
const updateProjectSettings = async newSettings => {
const store = strapi.store({ type: 'core', name: 'admin' });
const previousSettings = await store.get({ key: 'project-settings' });
const files = pick(newSettings, PROJECT_SETTINGS_FILE_INPUTS);
await uploadFiles(files);
PROJECT_SETTINGS_FILE_INPUTS.forEach(inputName => {
// If the user input exists but is not a formdata "file" remove it
if (newSettings[inputName] !== undefined && !(typeof newSettings[inputName] === 'object')) {
newSettings[inputName] = null;
return;
}
// If the user input is undefined reuse previous setting (do not update field)
if (!newSettings[inputName]) {
newSettings[inputName] = previousSettings[inputName];
return;
}
// Update the file
newSettings[inputName] = pick(newSettings[inputName], [
'name',
'hash',
'url',
'width',
'height',
'ext',
'size',
'provider',
]);
});
// No await to proceed asynchronously
deleteOldFiles({ previousSettings, newSettings });
await store.set({
key: 'project-settings',
value: { ...previousSettings, ...newSettings },
});
return getProjectSettings();
};
module.exports = {
deleteOldFiles,
parseFilesData,
getProjectSettings,
updateProjectSettings,
};

View File

@ -6,6 +6,7 @@ import * as metrics from '../services/metrics';
import * as token from '../services/token';
import * as auth from '../services/auth';
import * as apiToken from '../services/api-token';
import * as projectSettings from '../services/project-settings';
type S = {
role: typeof role;
@ -16,6 +17,7 @@ type S = {
auth: typeof auth;
metrics: typeof metrics;
'api-token': typeof apiToken;
'project-settings': typeof projectSettings;
};
export function getService<T extends keyof S>(name: T): S[T];

View File

@ -0,0 +1,39 @@
'use strict';
const { yup, validateYupSchemaSync } = require('@strapi/utils');
const MAX_IMAGE_WIDTH = 750;
const MAX_IMAGE_HEIGHT = MAX_IMAGE_WIDTH;
const MAX_IMAGE_FILE_SIZE = 1024 * 1024; // 1Mo
const ALLOWED_IMAGE_FILE_TYPES = ['image/jpeg', 'image/png', 'image/svg+xml'];
const updateProjectSettings = yup
.object({
menuLogo: yup.string(),
})
.noUnknown();
const updateProjectSettingsFiles = yup
.object({
menuLogo: yup.object({
name: yup.string(),
type: yup.string().oneOf(ALLOWED_IMAGE_FILE_TYPES),
size: yup.number().max(MAX_IMAGE_FILE_SIZE),
}),
})
.noUnknown();
const updateProjectSettingsImagesDimensions = yup.object({
menuLogo: yup.object({
width: yup.number().max(MAX_IMAGE_WIDTH),
height: yup.number().max(MAX_IMAGE_HEIGHT),
}),
});
module.exports = {
validateUpdateProjectSettings: validateYupSchemaSync(updateProjectSettings),
validateUpdateProjectSettingsFiles: validateYupSchemaSync(updateProjectSettingsFiles),
validateUpdateProjectSettingsImagesDimensions: validateYupSchemaSync(
updateProjectSettingsImagesDimensions
),
};