chore: add proxy backend (#3126)
* feat(backends): add proxy backend * feat: add proxy server initial commit * fix: move from joi to @hapi/joi * test: add joi validation tests * feat: proxy server initial implementations * test: add tests, fix build * chore: update yarn.lock * build: fix develop command * fix(back-proxy): fix bugs * test(backend-proxy): add cypress tests * chore: cleanup * chore: support node 10 * chore: code cleanup * chore: run cypress on ubuntu 16.04 * test(e2e): fix proxy backend cypress tests * chore: don't start proxy server on yarn develop
This commit is contained in:
committed by
Shawn Erquhart
parent
cf57da223d
commit
7e8084be87
11
packages/netlify-cms-proxy-server/README.md
Normal file
11
packages/netlify-cms-proxy-server/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Docs coming soon!
|
||||
|
||||
Netlify CMS was recently converted from a single npm package to a "monorepo" of over 20 packages.
|
||||
That's over 20 Readme's! We haven't created one for this package yet, but we will soon.
|
||||
|
||||
In the meantime, you can:
|
||||
|
||||
1. Check out the [main readme](https://github.com/netlify/netlify-cms/#readme) or the [documentation
|
||||
site](https://www.netlifycms.org) for more info.
|
||||
2. Reach out to the [community chat](https://netlifycms.org/chat/) if you need help.
|
||||
3. Help out and [write the readme yourself](https://github.com/netlify/netlify-cms/edit/master/packages/netlify-cms-proxy-server/README.md)!
|
7
packages/netlify-cms-proxy-server/jest.config.js
Normal file
7
packages/netlify-cms-proxy-server/jest.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'netlify-cms-lib-util': '<rootDir>/../netlify-cms-lib-util/dist/esm',
|
||||
},
|
||||
};
|
54
packages/netlify-cms-proxy-server/package.json
Normal file
54
packages/netlify-cms-proxy-server/package.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "netlify-cms-proxy-server",
|
||||
"description": "Proxy server to be used with Netlify CMS proxy backend",
|
||||
"version": "1.0.1",
|
||||
"repository": "https://github.com/netlify/netlify-cms/tree/master/packages/netlify-cms-proxy-server",
|
||||
"bugs": "https://github.com/netlify/netlify-cms/issues",
|
||||
"license": "MIT",
|
||||
"main": "dist/index",
|
||||
"keywords": [
|
||||
"netlify",
|
||||
"netlify-cms",
|
||||
"backend"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"prestart": "yarn build",
|
||||
"start": "node dist/index.js",
|
||||
"develop": "nodemon --watch 'src/**/*.ts' --ignore 'src/**/*.spec.ts' --exec 'ts-node' --files src/index.ts",
|
||||
"test": "jest",
|
||||
"test:watch": "yarn test --watch",
|
||||
"test:coverage": "yarn test --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/joi": "^17.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"joi": "^14.3.1",
|
||||
"morgan": "^1.9.1",
|
||||
"netlify-cms-lib-util": "^2.3.0",
|
||||
"simple-git": "^1.129.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.2",
|
||||
"@types/hapi__joi": "^16.0.6",
|
||||
"@types/jest": "^24.9.0",
|
||||
"@types/morgan": "^1.7.37",
|
||||
"@types/node": "^13.1.7",
|
||||
"@types/vfile-message": "^2.0.0",
|
||||
"jest": "^24.9.0",
|
||||
"nodemon": "^2.0.2",
|
||||
"ts-jest": "^24.3.0",
|
||||
"ts-loader": "^6.2.1",
|
||||
"ts-node": "^8.6.2",
|
||||
"tsconfig-paths-webpack-plugin": "^3.2.0",
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-node-externals": "^1.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v10"
|
||||
}
|
||||
}
|
4
packages/netlify-cms-proxy-server/src/global.d.ts
vendored
Normal file
4
packages/netlify-cms-proxy-server/src/global.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
type LocalForage = {
|
||||
getItem: <T>(key: string) => Promise<T>;
|
||||
setItem: <T>(key: string, value: T) => Promise<void>;
|
||||
};
|
25
packages/netlify-cms-proxy-server/src/index.ts
Normal file
25
packages/netlify-cms-proxy-server/src/index.ts
Normal file
@ -0,0 +1,25 @@
|
||||
require('dotenv').config();
|
||||
import express from 'express';
|
||||
import morgan from 'morgan';
|
||||
import cors from 'cors';
|
||||
import { registerMiddleware as registerLocalGit } from './middlewares/localGit';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 8081;
|
||||
|
||||
(async () => {
|
||||
app.use(morgan('combined'));
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
try {
|
||||
await registerLocalGit(app);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return app.listen(port, () => {
|
||||
console.log(`Netlify CMS Proxy Server listening on port ${port}`);
|
||||
});
|
||||
})();
|
@ -0,0 +1,516 @@
|
||||
import { defaultSchema, joi } from '.';
|
||||
import express from 'express';
|
||||
import Joi from '@hapi/joi';
|
||||
|
||||
const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => {
|
||||
const { error } = result;
|
||||
expect(error).not.toBeNull();
|
||||
expect(error.details).toHaveLength(1);
|
||||
const message = error.details.map(({ message }) => message)[0];
|
||||
expect(message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
const defaultParams = {
|
||||
branch: 'master',
|
||||
};
|
||||
|
||||
describe('defaultSchema', () => {
|
||||
it('should fail on unsupported body', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(schema.validate({}), '"action" is required');
|
||||
});
|
||||
|
||||
it('should fail on unsupported action', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'unknown', params: {} }),
|
||||
'"action" must be one of [info, entriesByFolder, entriesByFiles, getEntry, unpublishedEntries, unpublishedEntry, deleteUnpublishedEntry, persistEntry, updateUnpublishedEntryStatus, publishUnpublishedEntry, getMedia, getMediaFile, persistMedia, deleteFile, getDeployPreview]',
|
||||
);
|
||||
});
|
||||
|
||||
describe('info', () => {
|
||||
it('should pass with no params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'info',
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('entriesByFolder', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'entriesByFolder', params: { ...defaultParams } }),
|
||||
'"params.folder" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'entriesByFolder',
|
||||
params: { ...defaultParams, folder: 'folder' },
|
||||
}),
|
||||
'"params.extension" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'entriesByFolder',
|
||||
params: { ...defaultParams, folder: 'folder', extension: 'md' },
|
||||
}),
|
||||
'"params.depth" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'entriesByFolder',
|
||||
params: { ...defaultParams, folder: 'folder', extension: 'md', depth: 1 },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('entriesByFiles', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'entriesByFiles', params: { ...defaultParams } }),
|
||||
'"params.files" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({ action: 'entriesByFiles', params: { ...defaultParams, files: {} } }),
|
||||
'"params.files" must be an array',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'entriesByFiles',
|
||||
params: { ...defaultParams, files: [{ id: 'id' }] },
|
||||
}),
|
||||
'"params.files[0].path" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'entriesByFiles',
|
||||
params: { ...defaultParams, files: [{ path: 'path' }] },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntry', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'getEntry', params: { ...defaultParams } }),
|
||||
'"params.path" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({ action: 'getEntry', params: { ...defaultParams, path: 1 } }),
|
||||
'"params.path" must be a string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'getEntry',
|
||||
params: { ...defaultParams, path: 'path' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishedEntries', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'unpublishedEntries', params: {} }),
|
||||
'"params.branch" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'unpublishedEntries',
|
||||
params: { ...defaultParams, branch: 'master' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishedEntry', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'unpublishedEntry', params: { ...defaultParams } }),
|
||||
'"params.collection" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'unpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection' },
|
||||
}),
|
||||
'"params.slug" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'unpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 1 },
|
||||
}),
|
||||
'"params.slug" must be a string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'unpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUnpublishedEntry', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'deleteUnpublishedEntry', params: { ...defaultParams } }),
|
||||
'"params.collection" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'deleteUnpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection' },
|
||||
}),
|
||||
'"params.slug" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'deleteUnpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 1 },
|
||||
}),
|
||||
'"params.slug" must be a string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'deleteUnpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistEntry', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'persistEntry', params: { ...defaultParams } }),
|
||||
'"params.entry" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'persistEntry',
|
||||
params: { ...defaultParams, entry: { slug: 'slug', path: 'path', raw: 'content' } },
|
||||
}),
|
||||
'"params.assets" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
...defaultParams,
|
||||
entry: { slug: 'slug', path: 'path', raw: 'content' },
|
||||
assets: [],
|
||||
},
|
||||
}),
|
||||
'"params.options" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
...defaultParams,
|
||||
entry: { slug: 'slug', path: 'path', raw: 'content' },
|
||||
assets: [],
|
||||
options: {},
|
||||
},
|
||||
}),
|
||||
'"params.options.commitMessage" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'persistEntry',
|
||||
params: {
|
||||
...defaultParams,
|
||||
entry: { slug: 'slug', path: 'path', raw: 'content' },
|
||||
assets: [{ path: 'path', content: 'content', encoding: 'base64' }],
|
||||
options: {
|
||||
commitMessage: 'commitMessage',
|
||||
useWorkflow: true,
|
||||
status: 'draft',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUnpublishedEntryStatus', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'updateUnpublishedEntryStatus', params: { ...defaultParams } }),
|
||||
'"params.collection" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'updateUnpublishedEntryStatus',
|
||||
params: { ...defaultParams, collection: 'collection' },
|
||||
}),
|
||||
'"params.slug" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'updateUnpublishedEntryStatus',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
|
||||
}),
|
||||
'"params.newStatus" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'updateUnpublishedEntryStatus',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 'slug', newStatus: 'draft' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishUnpublishedEntry', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'publishUnpublishedEntry', params: { ...defaultParams } }),
|
||||
'"params.collection" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'publishUnpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection' },
|
||||
}),
|
||||
'"params.slug" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'publishUnpublishedEntry',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMedia', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'getMedia', params: { ...defaultParams } }),
|
||||
'"params.mediaFolder" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'getMedia',
|
||||
params: { ...defaultParams, mediaFolder: 'src/static/images' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaFile', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'getMediaFile', params: { ...defaultParams } }),
|
||||
'"params.path" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'getMediaFile',
|
||||
params: { ...defaultParams, path: 'src/static/images/image.png' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistMedia', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'persistMedia', params: { ...defaultParams } }),
|
||||
'"params.asset" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'persistMedia',
|
||||
params: { ...defaultParams, asset: { path: 'path' } },
|
||||
}),
|
||||
'"params.asset.content" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'persistMedia',
|
||||
params: {
|
||||
...defaultParams,
|
||||
asset: { path: 'path', content: 'content', encoding: 'base64' },
|
||||
options: { commitMessage: 'commitMessage' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'deleteFile', params: { ...defaultParams } }),
|
||||
'"params.path" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'deleteFile',
|
||||
params: {
|
||||
...defaultParams,
|
||||
path: 'src/static/images/image.png',
|
||||
options: { commitMessage: 'commitMessage' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeployPreview', () => {
|
||||
it('should fail on invalid params', () => {
|
||||
const schema = defaultSchema();
|
||||
|
||||
assetFailure(
|
||||
schema.validate({ action: 'getDeployPreview', params: { ...defaultParams } }),
|
||||
'"params.collection" is required',
|
||||
);
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'getDeployPreview',
|
||||
params: { ...defaultParams, collection: 'collection' },
|
||||
}),
|
||||
'"params.slug" is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass on valid params', () => {
|
||||
const schema = defaultSchema();
|
||||
const { error } = schema.validate({
|
||||
action: 'getDeployPreview',
|
||||
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('joi', () => {
|
||||
it('should call next on valid schema', () => {
|
||||
const next = jest.fn();
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
action: 'entriesByFolder',
|
||||
params: { branch: 'master', folder: 'folder', extension: 'md', depth: 1 },
|
||||
},
|
||||
} as express.Request;
|
||||
const res: express.Response = {} as express.Response;
|
||||
joi(defaultSchema())(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should send error on invalid schema', () => {
|
||||
const next = jest.fn();
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
action: 'entriesByFolder',
|
||||
},
|
||||
} as express.Request;
|
||||
const json = jest.fn();
|
||||
const status = jest.fn(() => ({ json }));
|
||||
const res: express.Response = ({ status } as unknown) as express.Response;
|
||||
|
||||
joi(defaultSchema())(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(0);
|
||||
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
expect(json).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(422);
|
||||
expect(json).toHaveBeenCalledWith({ error: '"params" is required' });
|
||||
});
|
||||
});
|
202
packages/netlify-cms-proxy-server/src/middlewares/joi/index.ts
Normal file
202
packages/netlify-cms-proxy-server/src/middlewares/joi/index.ts
Normal file
@ -0,0 +1,202 @@
|
||||
import express from 'express';
|
||||
import Joi from '@hapi/joi';
|
||||
|
||||
const allowedActions = [
|
||||
'info',
|
||||
'entriesByFolder',
|
||||
'entriesByFiles',
|
||||
'getEntry',
|
||||
'unpublishedEntries',
|
||||
'unpublishedEntry',
|
||||
'deleteUnpublishedEntry',
|
||||
'persistEntry',
|
||||
'updateUnpublishedEntryStatus',
|
||||
'publishUnpublishedEntry',
|
||||
'getMedia',
|
||||
'getMediaFile',
|
||||
'persistMedia',
|
||||
'deleteFile',
|
||||
'getDeployPreview',
|
||||
];
|
||||
|
||||
const requiredString = Joi.string().required();
|
||||
const requiredNumber = Joi.number().required();
|
||||
const requiredBool = Joi.bool().required();
|
||||
|
||||
const collection = requiredString;
|
||||
const slug = requiredString;
|
||||
|
||||
export const defaultSchema = ({ path = requiredString } = {}) => {
|
||||
const defaultParams = Joi.object({
|
||||
branch: requiredString,
|
||||
});
|
||||
|
||||
const asset = Joi.object({
|
||||
path,
|
||||
content: requiredString,
|
||||
encoding: requiredString.valid('base64'),
|
||||
});
|
||||
|
||||
const params = Joi.when('action', {
|
||||
switch: [
|
||||
{
|
||||
is: 'info',
|
||||
then: Joi.allow(),
|
||||
},
|
||||
{
|
||||
is: 'entriesByFolder',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
folder: path,
|
||||
extension: requiredString,
|
||||
depth: requiredNumber,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'entriesByFiles',
|
||||
then: defaultParams.keys({
|
||||
files: Joi.array()
|
||||
.items(Joi.object({ path }))
|
||||
.required(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
is: 'getEntry',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
path,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'unpublishedEntries',
|
||||
then: defaultParams.keys({ branch: requiredString }).required(),
|
||||
},
|
||||
{
|
||||
is: 'unpublishedEntry',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
collection,
|
||||
slug,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'deleteUnpublishedEntry',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
collection,
|
||||
slug,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'persistEntry',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
entry: Joi.object({ slug: requiredString, path, raw: requiredString }).required(),
|
||||
assets: Joi.array()
|
||||
.items(asset)
|
||||
.required(),
|
||||
options: Joi.object({
|
||||
collectionName: Joi.string(),
|
||||
commitMessage: requiredString,
|
||||
useWorkflow: requiredBool,
|
||||
status: requiredString,
|
||||
}).required(),
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'updateUnpublishedEntryStatus',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
collection,
|
||||
slug,
|
||||
newStatus: requiredString,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'publishUnpublishedEntry',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
collection,
|
||||
slug,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'getMedia',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
mediaFolder: path,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'getMediaFile',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
path,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'persistMedia',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
asset: asset.required(),
|
||||
options: Joi.object({
|
||||
commitMessage: requiredString,
|
||||
}).required(),
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'deleteFile',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
path,
|
||||
options: Joi.object({
|
||||
commitMessage: requiredString,
|
||||
}).required(),
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
{
|
||||
is: 'getDeployPreview',
|
||||
then: defaultParams
|
||||
.keys({
|
||||
collection,
|
||||
slug,
|
||||
})
|
||||
.required(),
|
||||
},
|
||||
],
|
||||
otherwise: Joi.forbidden(),
|
||||
});
|
||||
|
||||
return Joi.object({
|
||||
action: Joi.valid(...allowedActions).required(),
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const joi = (schema: Joi.Schema) => (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const { error } = schema.validate(req.body, { allowUnknown: true });
|
||||
const valid = error == null;
|
||||
|
||||
if (valid) {
|
||||
next();
|
||||
} else {
|
||||
const { details } = error;
|
||||
const message = details.map(i => i.message).join(',');
|
||||
res.status(422).json({ error: message });
|
||||
}
|
||||
};
|
@ -0,0 +1,151 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import Joi from '@hapi/joi';
|
||||
import express from 'express';
|
||||
import { validateRepo, getSchema, localGitMiddleware } from '.';
|
||||
|
||||
jest.mock('netlify-cms-lib-util', () => jest.fn());
|
||||
jest.mock('simple-git/promise');
|
||||
|
||||
const assetFailure = (result: Joi.ValidationResult, expectedMessage: string) => {
|
||||
const { error } = result;
|
||||
expect(error).not.toBeNull();
|
||||
expect(error.details).toHaveLength(1);
|
||||
const message = error.details.map(({ message }) => message)[0];
|
||||
expect(message).toBe(expectedMessage);
|
||||
};
|
||||
|
||||
const defaultParams = {
|
||||
branch: 'master',
|
||||
};
|
||||
|
||||
describe('localGitMiddleware', () => {
|
||||
const simpleGit = require('simple-git/promise');
|
||||
|
||||
const git = {
|
||||
checkIsRepo: jest.fn(),
|
||||
silent: jest.fn(),
|
||||
branchLocal: jest.fn(),
|
||||
checkout: jest.fn(),
|
||||
};
|
||||
git.silent.mockReturnValue(git);
|
||||
|
||||
simpleGit.mockReturnValue(git);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('validateRepo', () => {
|
||||
it('should throw on non valid git repo', async () => {
|
||||
git.checkIsRepo.mockResolvedValue(false);
|
||||
await expect(validateRepo({ repoPath: '/Users/user/code/repo' })).rejects.toEqual(
|
||||
new Error('/Users/user/code/repo is not a valid git repository'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw on valid git repo', async () => {
|
||||
git.checkIsRepo.mockResolvedValue(true);
|
||||
await expect(validateRepo({ repoPath: '/Users/user/code/repo' })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSchema', () => {
|
||||
it('should throw on path traversal', () => {
|
||||
const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
|
||||
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'getEntry',
|
||||
params: { ...defaultParams, path: '../' },
|
||||
}),
|
||||
'"params.path" must resolve to a path under the configured repository',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw on valid path', () => {
|
||||
const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
|
||||
|
||||
const { error } = schema.validate({
|
||||
action: 'getEntry',
|
||||
params: { ...defaultParams, path: 'src/content/posts/title.md' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw on folder traversal', () => {
|
||||
const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
|
||||
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'entriesByFolder',
|
||||
params: { ...defaultParams, folder: '../', extension: 'md', depth: 1 },
|
||||
}),
|
||||
'"params.folder" must resolve to a path under the configured repository',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw on valid folder', () => {
|
||||
const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
|
||||
|
||||
const { error } = schema.validate({
|
||||
action: 'entriesByFolder',
|
||||
params: { ...defaultParams, folder: 'src/posts', extension: 'md', depth: 1 },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw on media folder traversal', () => {
|
||||
const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
|
||||
|
||||
assetFailure(
|
||||
schema.validate({
|
||||
action: 'getMedia',
|
||||
params: { ...defaultParams, mediaFolder: '../' },
|
||||
}),
|
||||
'"params.mediaFolder" must resolve to a path under the configured repository',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw on valid folder', () => {
|
||||
const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
|
||||
const { error } = schema.validate({
|
||||
action: 'getMedia',
|
||||
params: { ...defaultParams, mediaFolder: 'static/images' },
|
||||
});
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localGitMiddleware', () => {
|
||||
const json = jest.fn();
|
||||
const status = jest.fn(() => ({ json }));
|
||||
const res: express.Response = ({ status } as unknown) as express.Response;
|
||||
|
||||
const repoPath = '.';
|
||||
|
||||
it("should return error when default branch doesn't exist", async () => {
|
||||
git.branchLocal.mockResolvedValue({ all: ['master'] });
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
action: 'getMedia',
|
||||
params: {
|
||||
mediaFolder: 'mediaFolder',
|
||||
branch: 'develop',
|
||||
},
|
||||
},
|
||||
} as express.Request;
|
||||
|
||||
await localGitMiddleware({ repoPath })(req, res);
|
||||
|
||||
expect(status).toHaveBeenCalledTimes(1);
|
||||
expect(status).toHaveBeenCalledWith(422);
|
||||
|
||||
expect(json).toHaveBeenCalledTimes(1);
|
||||
expect(json).toHaveBeenCalledWith({ error: "Default branch 'develop' doesn't exist" });
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,478 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import Joi from '@hapi/joi';
|
||||
import {
|
||||
parseContentKey,
|
||||
branchFromContentKey,
|
||||
generateContentKey,
|
||||
contentKeyFromBranch,
|
||||
CMS_BRANCH_PREFIX,
|
||||
statusToLabel,
|
||||
labelToStatus,
|
||||
} from 'netlify-cms-lib-util/src/API';
|
||||
|
||||
import { defaultSchema, joi } from '../joi';
|
||||
import {
|
||||
EntriesByFolderParams,
|
||||
EntriesByFilesParams,
|
||||
GetEntryParams,
|
||||
DefaultParams,
|
||||
UnpublishedEntryParams,
|
||||
PersistEntryParams,
|
||||
GetMediaParams,
|
||||
Asset,
|
||||
PublishUnpublishedEntryParams,
|
||||
PersistMediaParams,
|
||||
DeleteFileParams,
|
||||
UpdateUnpublishedEntryStatusParams,
|
||||
Entry,
|
||||
GetMediaFileParams,
|
||||
} from '../types';
|
||||
// eslint-disable-next-line import/default
|
||||
import simpleGit from 'simple-git/promise';
|
||||
|
||||
const sha256 = (buffer: Buffer) => {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(buffer)
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
const writeFile = async (filePath: string, content: Buffer | string) => {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content);
|
||||
};
|
||||
|
||||
const commit = async (git: simpleGit.SimpleGit, commitMessage: string, files: string[]) => {
|
||||
await git.add(files);
|
||||
await git.commit(commitMessage, files, {
|
||||
'--no-verify': true,
|
||||
'--no-gpg-sign': true,
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentBranch = async (git: simpleGit.SimpleGit) => {
|
||||
const currentBranch = await git.branchLocal().then(summary => summary.current);
|
||||
return currentBranch;
|
||||
};
|
||||
|
||||
const runOnBranch = async <T>(git: simpleGit.SimpleGit, branch: string, func: () => Promise<T>) => {
|
||||
const currentBranch = await getCurrentBranch(git);
|
||||
try {
|
||||
if (currentBranch !== branch) {
|
||||
await git.checkout(branch);
|
||||
}
|
||||
const result = await func();
|
||||
return result;
|
||||
} finally {
|
||||
await git.checkout(currentBranch);
|
||||
}
|
||||
};
|
||||
|
||||
const listFiles = async (dir: string, extension: string, depth: number): Promise<string[]> => {
|
||||
if (depth <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
dirents.map(dirent => {
|
||||
const res = path.join(dir, dirent.name);
|
||||
return dirent.isDirectory()
|
||||
? listFiles(res, extension, depth - 1)
|
||||
: [res].filter(f => f.endsWith(extension));
|
||||
}),
|
||||
);
|
||||
return ([] as string[]).concat(...files);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const listRepoFiles = async (
|
||||
repoPath: string,
|
||||
folder: string,
|
||||
extension: string,
|
||||
depth: number,
|
||||
) => {
|
||||
const files = await listFiles(path.join(repoPath, folder), extension, depth);
|
||||
return files.map(f => f.substr(repoPath.length + 1));
|
||||
};
|
||||
|
||||
const entriesFromFiles = async (repoPath: string, files: string[]) => {
|
||||
return Promise.all(
|
||||
files.map(async file => {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(repoPath, file));
|
||||
return {
|
||||
data: content.toString(),
|
||||
file: { path: file, id: sha256(content) },
|
||||
};
|
||||
} catch (e) {
|
||||
return { data: null, file: { path: file, id: null } };
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const branchDescription = (branch: string) => `branch.${branch}.description`;
|
||||
|
||||
const getEntryDataFromDiff = async (git: simpleGit.SimpleGit, branch: string, diff: string[]) => {
|
||||
const contentKey = contentKeyFromBranch(branch);
|
||||
const { collection, slug } = parseContentKey(contentKey);
|
||||
const path = diff.find(d => d.includes(slug)) as string;
|
||||
const mediaFiles = diff.filter(d => d !== path);
|
||||
const label = await git.raw(['config', branchDescription(branch)]);
|
||||
const status = label && labelToStatus(label.trim());
|
||||
|
||||
return {
|
||||
slug,
|
||||
metaData: { branch, collection, objects: { entry: { path, mediaFiles } }, status },
|
||||
};
|
||||
};
|
||||
|
||||
type Options = {
|
||||
repoPath: string;
|
||||
};
|
||||
|
||||
const entriesFromDiffs = async (
|
||||
git: simpleGit.SimpleGit,
|
||||
branch: string,
|
||||
repoPath: string,
|
||||
cmsBranches: string[],
|
||||
diffs: simpleGit.DiffResult[],
|
||||
) => {
|
||||
const entries = [];
|
||||
for (let i = 0; i < diffs.length; i++) {
|
||||
const cmsBranch = cmsBranches[i];
|
||||
const diff = diffs[i];
|
||||
const data = await getEntryDataFromDiff(
|
||||
git,
|
||||
cmsBranch,
|
||||
diff.files.map(f => f.file),
|
||||
);
|
||||
const entryPath = data.metaData.objects.entry.path;
|
||||
const [entry] = await runOnBranch(git, cmsBranch, () =>
|
||||
entriesFromFiles(repoPath, [entryPath]),
|
||||
);
|
||||
|
||||
const rawDiff = await git.diff([branch, cmsBranch, '--', entryPath]);
|
||||
entries.push({
|
||||
...data,
|
||||
...entry,
|
||||
isModification: !rawDiff.includes('new file'),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
const readMediaFile = async (repoPath: string, file: string) => {
|
||||
const encoding = 'base64';
|
||||
const buffer = await fs.readFile(path.join(repoPath, file));
|
||||
const id = sha256(buffer);
|
||||
|
||||
return {
|
||||
id,
|
||||
content: buffer.toString(encoding),
|
||||
encoding,
|
||||
path: file,
|
||||
name: path.basename(file),
|
||||
};
|
||||
};
|
||||
|
||||
const getEntryMediaFiles = async (
|
||||
git: simpleGit.SimpleGit,
|
||||
repoPath: string,
|
||||
cmsBranch: string,
|
||||
files: string[],
|
||||
) => {
|
||||
const mediaFiles = await runOnBranch(git, cmsBranch, async () => {
|
||||
const serializedFiles = await Promise.all(files.map(file => readMediaFile(repoPath, file)));
|
||||
return serializedFiles;
|
||||
});
|
||||
return mediaFiles;
|
||||
};
|
||||
|
||||
const commitEntry = async (
|
||||
git: simpleGit.SimpleGit,
|
||||
repoPath: string,
|
||||
entry: Entry,
|
||||
assets: Asset[],
|
||||
commitMessage: string,
|
||||
) => {
|
||||
// save entry content
|
||||
await writeFile(path.join(repoPath, entry.path), entry.raw);
|
||||
// save assets
|
||||
await Promise.all(
|
||||
assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding))),
|
||||
);
|
||||
// commits files
|
||||
await commit(git, commitMessage, [entry.path, ...assets.map(a => a.path)]);
|
||||
};
|
||||
|
||||
const isBranchExists = async (git: simpleGit.SimpleGit, branch: string) => {
|
||||
const branchExists = await git.branchLocal().then(({ all }) => all.includes(branch));
|
||||
return branchExists;
|
||||
};
|
||||
|
||||
export const validateRepo = async ({ repoPath }: Options) => {
|
||||
const git = simpleGit(repoPath).silent(false);
|
||||
const isRepo = await git.checkIsRepo();
|
||||
if (!isRepo) {
|
||||
throw Error(`${repoPath} is not a valid git repository`);
|
||||
}
|
||||
};
|
||||
|
||||
export const getSchema = ({ repoPath }: Options) => {
|
||||
const custom = Joi.extend({
|
||||
type: 'path',
|
||||
base: Joi.string().required(),
|
||||
messages: {
|
||||
'path.invalid': '{{#label}} must resolve to a path under the configured repository',
|
||||
},
|
||||
validate(value, helpers) {
|
||||
const resolvedPath = path.join(repoPath, value);
|
||||
if (!resolvedPath.startsWith(repoPath)) {
|
||||
return { value, errors: helpers.error('path.invalid') };
|
||||
}
|
||||
},
|
||||
});
|
||||
const schema = defaultSchema({ path: custom.path() });
|
||||
return schema;
|
||||
};
|
||||
|
||||
export const localGitMiddleware = ({ repoPath }: Options) => {
|
||||
const git = simpleGit(repoPath).silent(false);
|
||||
|
||||
return async function(req: express.Request, res: express.Response) {
|
||||
try {
|
||||
const { body } = req;
|
||||
if (body.action === 'info') {
|
||||
res.json({ repo: path.basename(repoPath) });
|
||||
return;
|
||||
}
|
||||
const { branch } = body.params as DefaultParams;
|
||||
|
||||
const branchExists = await isBranchExists(git, branch);
|
||||
if (!branchExists) {
|
||||
const message = `Default branch '${branch}' doesn't exist`;
|
||||
res.status(422).json({ error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (body.action) {
|
||||
case 'entriesByFolder': {
|
||||
const payload = body.params as EntriesByFolderParams;
|
||||
const { folder, extension, depth } = payload;
|
||||
const entries = await runOnBranch(git, branch, () =>
|
||||
listRepoFiles(repoPath, folder, extension, depth).then(files =>
|
||||
entriesFromFiles(repoPath, files),
|
||||
),
|
||||
);
|
||||
res.json(entries);
|
||||
break;
|
||||
}
|
||||
case 'entriesByFiles': {
|
||||
const payload = body.params as EntriesByFilesParams;
|
||||
const entries = await runOnBranch(git, branch, () =>
|
||||
entriesFromFiles(
|
||||
repoPath,
|
||||
payload.files.map(file => path.join(repoPath, file.path)),
|
||||
),
|
||||
);
|
||||
res.json(entries);
|
||||
break;
|
||||
}
|
||||
case 'getEntry': {
|
||||
const payload = body.params as GetEntryParams;
|
||||
const [entry] = await runOnBranch(git, branch, () =>
|
||||
entriesFromFiles(repoPath, [payload.path]),
|
||||
);
|
||||
res.json(entry);
|
||||
break;
|
||||
}
|
||||
case 'unpublishedEntries': {
|
||||
const cmsBranches = await git
|
||||
.branchLocal()
|
||||
.then(result => result.all.filter(b => b.startsWith(`${CMS_BRANCH_PREFIX}/`)));
|
||||
|
||||
const diffs = await Promise.all(
|
||||
cmsBranches.map(cmsBranch => git.diffSummary([branch, cmsBranch])),
|
||||
);
|
||||
const entries = await entriesFromDiffs(git, branch, repoPath, cmsBranches, diffs);
|
||||
res.json(entries);
|
||||
break;
|
||||
}
|
||||
case 'unpublishedEntry': {
|
||||
const { collection, slug } = body.params as UnpublishedEntryParams;
|
||||
const contentKey = generateContentKey(collection, slug);
|
||||
const cmsBranch = branchFromContentKey(contentKey);
|
||||
const branchExists = await isBranchExists(git, cmsBranch);
|
||||
if (branchExists) {
|
||||
const diff = await git.diffSummary([branch, cmsBranch]);
|
||||
const [entry] = await entriesFromDiffs(git, branch, repoPath, [cmsBranch], [diff]);
|
||||
const mediaFiles = await getEntryMediaFiles(
|
||||
git,
|
||||
repoPath,
|
||||
cmsBranch,
|
||||
entry.metaData.objects.entry.mediaFiles,
|
||||
);
|
||||
res.json({ ...entry, mediaFiles });
|
||||
} else {
|
||||
return res.status(404).json({ message: 'Not Found' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'deleteUnpublishedEntry': {
|
||||
const { collection, slug } = body.params as UnpublishedEntryParams;
|
||||
const contentKey = generateContentKey(collection, slug);
|
||||
const cmsBranch = branchFromContentKey(contentKey);
|
||||
const currentBranch = await getCurrentBranch(git);
|
||||
if (currentBranch === cmsBranch) {
|
||||
await git.checkoutLocalBranch(branch);
|
||||
}
|
||||
await git.branch(['-D', cmsBranch]);
|
||||
res.json({ message: `deleted branch: ${cmsBranch}` });
|
||||
break;
|
||||
}
|
||||
case 'persistEntry': {
|
||||
const { entry, assets, options } = body.params as PersistEntryParams;
|
||||
if (!options.useWorkflow) {
|
||||
runOnBranch(git, branch, async () => {
|
||||
await commitEntry(git, repoPath, entry, assets, options.commitMessage);
|
||||
});
|
||||
} else {
|
||||
const slug = entry.slug;
|
||||
const collection = options.collectionName as string;
|
||||
const contentKey = generateContentKey(collection, slug);
|
||||
const cmsBranch = branchFromContentKey(contentKey);
|
||||
await runOnBranch(git, branch, async () => {
|
||||
const branchExists = await isBranchExists(git, cmsBranch);
|
||||
if (branchExists) {
|
||||
await git.checkout(cmsBranch);
|
||||
} else {
|
||||
await git.checkoutLocalBranch(cmsBranch);
|
||||
}
|
||||
await git.rebase([branch, '--no-gpg-sign', '--no-gpg-sign']);
|
||||
const diff = await git.diffSummary([branch, cmsBranch]);
|
||||
const data = await getEntryDataFromDiff(
|
||||
git,
|
||||
branch,
|
||||
diff.files.map(f => f.file),
|
||||
);
|
||||
// delete media files that have been removed from the entry
|
||||
const toDelete = data.metaData.objects.entry.mediaFiles.filter(
|
||||
f => !assets.map(a => a.path).includes(f),
|
||||
);
|
||||
await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f))));
|
||||
await commitEntry(git, repoPath, entry, assets, options.commitMessage);
|
||||
|
||||
// add status for new entries
|
||||
if (!data.metaData.status) {
|
||||
const description = statusToLabel(options.status);
|
||||
await git.addConfig(branchDescription(cmsBranch), description);
|
||||
}
|
||||
// set path for new entries
|
||||
if (!data.metaData.objects.entry.path) {
|
||||
data.metaData.objects.entry.path = entry.path;
|
||||
}
|
||||
});
|
||||
}
|
||||
res.json({ message: 'entry persisted' });
|
||||
break;
|
||||
}
|
||||
case 'updateUnpublishedEntryStatus': {
|
||||
const { collection, slug, newStatus } = body.params as UpdateUnpublishedEntryStatusParams;
|
||||
const contentKey = generateContentKey(collection, slug);
|
||||
const cmsBranch = branchFromContentKey(contentKey);
|
||||
const description = statusToLabel(newStatus);
|
||||
await git.addConfig(branchDescription(cmsBranch), description);
|
||||
res.json({ message: `${branch} description was updated to ${description}` });
|
||||
break;
|
||||
}
|
||||
case 'publishUnpublishedEntry': {
|
||||
const { collection, slug } = body.params as PublishUnpublishedEntryParams;
|
||||
const contentKey = generateContentKey(collection, slug);
|
||||
const cmsBranch = branchFromContentKey(contentKey);
|
||||
await git.mergeFromTo(cmsBranch, branch);
|
||||
await git.deleteLocalBranch(cmsBranch);
|
||||
res.json({ message: `branch ${cmsBranch} merged to ${branch}` });
|
||||
break;
|
||||
}
|
||||
case 'getMedia': {
|
||||
const { mediaFolder } = body.params as GetMediaParams;
|
||||
const mediaFiles = await runOnBranch(git, branch, async () => {
|
||||
const files = await listRepoFiles(repoPath, mediaFolder, '', 1);
|
||||
const serializedFiles = await Promise.all(
|
||||
files.map(file => readMediaFile(repoPath, file)),
|
||||
);
|
||||
return serializedFiles;
|
||||
});
|
||||
res.json(mediaFiles);
|
||||
break;
|
||||
}
|
||||
case 'getMediaFile': {
|
||||
const { path } = body.params as GetMediaFileParams;
|
||||
const mediaFile = await runOnBranch(git, branch, () => {
|
||||
return readMediaFile(repoPath, path);
|
||||
});
|
||||
res.json(mediaFile);
|
||||
break;
|
||||
}
|
||||
case 'persistMedia': {
|
||||
const {
|
||||
asset,
|
||||
options: { commitMessage },
|
||||
} = body.params as PersistMediaParams;
|
||||
|
||||
const file = await runOnBranch(git, branch, async () => {
|
||||
await writeFile(
|
||||
path.join(repoPath, asset.path),
|
||||
Buffer.from(asset.content, asset.encoding),
|
||||
);
|
||||
await commit(git, commitMessage, [asset.path]);
|
||||
return readMediaFile(repoPath, asset.path);
|
||||
});
|
||||
res.json(file);
|
||||
break;
|
||||
}
|
||||
case 'deleteFile': {
|
||||
const {
|
||||
path: filePath,
|
||||
options: { commitMessage },
|
||||
} = body.params as DeleteFileParams;
|
||||
await runOnBranch(git, branch, async () => {
|
||||
await fs.unlink(path.join(repoPath, filePath));
|
||||
await commit(git, commitMessage, [filePath]);
|
||||
});
|
||||
res.json({ message: `deleted file ${filePath}` });
|
||||
break;
|
||||
}
|
||||
case 'getDeployPreview': {
|
||||
res.json(null);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const message = `Unknown action ${body.action}`;
|
||||
res.status(422).json({ error: message });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error handling ${JSON.stringify(req.body)}: ${e.message}`);
|
||||
res.status(500).json({ error: 'Unknown error' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const registerMiddleware = async (app: express.Express) => {
|
||||
const repoPath = path.resolve(process.env.GIT_REPO_DIRECTORY || process.cwd());
|
||||
await validateRepo({ repoPath });
|
||||
app.post('/api/v1', joi(getSchema({ repoPath })));
|
||||
app.post('/api/v1', localGitMiddleware({ repoPath }));
|
||||
console.log(`Netlify CMS Proxy Server configured with ${repoPath}`);
|
||||
};
|
70
packages/netlify-cms-proxy-server/src/middlewares/types.ts
Normal file
70
packages/netlify-cms-proxy-server/src/middlewares/types.ts
Normal file
@ -0,0 +1,70 @@
|
||||
export type DefaultParams = {
|
||||
branch: string;
|
||||
};
|
||||
|
||||
export type EntriesByFolderParams = {
|
||||
folder: string;
|
||||
extension: string;
|
||||
depth: 1;
|
||||
};
|
||||
|
||||
export type EntriesByFilesParams = {
|
||||
files: { path: string }[];
|
||||
};
|
||||
|
||||
export type GetEntryParams = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type UnpublishedEntryParams = {
|
||||
collection: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type UpdateUnpublishedEntryStatusParams = {
|
||||
collection: string;
|
||||
slug: string;
|
||||
newStatus: string;
|
||||
};
|
||||
|
||||
export type PublishUnpublishedEntryParams = {
|
||||
collection: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type Entry = { slug: string; path: string; raw: string };
|
||||
|
||||
export type Asset = { path: string; content: string; encoding: 'base64' };
|
||||
|
||||
export type PersistEntryParams = {
|
||||
entry: Entry;
|
||||
assets: Asset[];
|
||||
options: {
|
||||
collectionName?: string;
|
||||
commitMessage: string;
|
||||
useWorkflow: boolean;
|
||||
status: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetMediaParams = {
|
||||
mediaFolder: string;
|
||||
};
|
||||
|
||||
export type GetMediaFileParams = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type PersistMediaParams = {
|
||||
asset: Asset;
|
||||
options: {
|
||||
commitMessage: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteFileParams = {
|
||||
path: string;
|
||||
options: {
|
||||
commitMessage: string;
|
||||
};
|
||||
};
|
19
packages/netlify-cms-proxy-server/tsconfig.json
Normal file
19
packages/netlify-cms-proxy-server/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2018",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*spec.ts"]
|
||||
}
|
36
packages/netlify-cms-proxy-server/webpack.config.js
Normal file
36
packages/netlify-cms-proxy-server/webpack.config.js
Normal file
@ -0,0 +1,36 @@
|
||||
const path = require('path');
|
||||
const nodeExternals = require('webpack-node-externals');
|
||||
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
|
||||
const { NODE_ENV = 'production' } = process.env;
|
||||
|
||||
const allowList = [/^netlify-cms-lib-util/];
|
||||
|
||||
module.exports = {
|
||||
entry: path.join('src', 'index.ts'),
|
||||
mode: NODE_ENV,
|
||||
target: 'node',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'index.js',
|
||||
},
|
||||
resolve: {
|
||||
plugins: [new TsconfigPathsPlugin()],
|
||||
extensions: ['.ts', '.js'],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: ['ts-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
externals: [
|
||||
nodeExternals({ whitelist: allowList }),
|
||||
nodeExternals({
|
||||
whitelist: allowList,
|
||||
modulesDir: path.resolve(__dirname, path.join('..', '..', 'node_modules')),
|
||||
}),
|
||||
],
|
||||
};
|
Reference in New Issue
Block a user