decap-server 1.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ import Joi from '@hapi/joi';
2
+
3
+ import type express from 'express';
4
+
5
+ const allowedActions = [
6
+ 'info',
7
+ 'entriesByFolder',
8
+ 'entriesByFiles',
9
+ 'getEntry',
10
+ 'unpublishedEntries',
11
+ 'unpublishedEntry',
12
+ 'unpublishedEntryDataFile',
13
+ 'unpublishedEntryMediaFile',
14
+ 'deleteUnpublishedEntry',
15
+ 'persistEntry',
16
+ 'updateUnpublishedEntryStatus',
17
+ 'publishUnpublishedEntry',
18
+ 'getMedia',
19
+ 'getMediaFile',
20
+ 'persistMedia',
21
+ 'deleteFile',
22
+ 'deleteFiles',
23
+ 'getDeployPreview',
24
+ ];
25
+
26
+ const requiredString = Joi.string().required();
27
+ const requiredNumber = Joi.number().required();
28
+ const requiredBool = Joi.bool().required();
29
+
30
+ const collection = requiredString;
31
+ const slug = requiredString;
32
+
33
+ export function defaultSchema({ path = requiredString } = {}) {
34
+ const defaultParams = Joi.object({
35
+ branch: requiredString,
36
+ });
37
+
38
+ const asset = Joi.object({
39
+ path,
40
+ content: requiredString,
41
+ encoding: requiredString.valid('base64'),
42
+ });
43
+
44
+ const dataFile = Joi.object({
45
+ slug: requiredString,
46
+ path,
47
+ raw: requiredString,
48
+ newPath: path.optional(),
49
+ });
50
+
51
+ const params = Joi.when('action', {
52
+ switch: [
53
+ {
54
+ is: 'info',
55
+ then: Joi.allow(),
56
+ },
57
+ {
58
+ is: 'entriesByFolder',
59
+ then: defaultParams
60
+ .keys({
61
+ folder: path,
62
+ extension: requiredString,
63
+ depth: requiredNumber,
64
+ })
65
+ .required(),
66
+ },
67
+ {
68
+ is: 'entriesByFiles',
69
+ then: defaultParams.keys({
70
+ files: Joi.array()
71
+ .items(Joi.object({ path, label: Joi.string() }))
72
+ .required(),
73
+ }),
74
+ },
75
+ {
76
+ is: 'getEntry',
77
+ then: defaultParams
78
+ .keys({
79
+ path,
80
+ })
81
+ .required(),
82
+ },
83
+ {
84
+ is: 'unpublishedEntries',
85
+ then: defaultParams.keys({ branch: requiredString }).required(),
86
+ },
87
+ {
88
+ is: 'unpublishedEntry',
89
+ then: defaultParams
90
+ .keys({
91
+ id: Joi.string().optional(),
92
+ collection: Joi.string().optional(),
93
+ slug: Joi.string().optional(),
94
+ cmsLabelPrefix: Joi.string().optional(),
95
+ })
96
+ .required(),
97
+ },
98
+ {
99
+ is: 'unpublishedEntryDataFile',
100
+ then: defaultParams
101
+ .keys({
102
+ collection,
103
+ slug,
104
+ id: requiredString,
105
+ path: requiredString,
106
+ })
107
+ .required(),
108
+ },
109
+ {
110
+ is: 'unpublishedEntryMediaFile',
111
+ then: defaultParams
112
+ .keys({
113
+ collection,
114
+ slug,
115
+ id: requiredString,
116
+ path: requiredString,
117
+ })
118
+ .required(),
119
+ },
120
+ {
121
+ is: 'deleteUnpublishedEntry',
122
+ then: defaultParams
123
+ .keys({
124
+ collection,
125
+ slug,
126
+ })
127
+ .required(),
128
+ },
129
+ {
130
+ is: 'persistEntry',
131
+ then: defaultParams
132
+ .keys({
133
+ cmsLabelPrefix: Joi.string().optional(),
134
+ entry: dataFile, // entry is kept for backwards compatibility
135
+ dataFiles: Joi.array().items(dataFile),
136
+ assets: Joi.array().items(asset).required(),
137
+ options: Joi.object({
138
+ collectionName: Joi.string(),
139
+ commitMessage: requiredString,
140
+ useWorkflow: requiredBool,
141
+ status: requiredString,
142
+ }).required(),
143
+ })
144
+ .xor('entry', 'dataFiles')
145
+ .required(),
146
+ },
147
+ {
148
+ is: 'updateUnpublishedEntryStatus',
149
+ then: defaultParams
150
+ .keys({
151
+ collection,
152
+ slug,
153
+ newStatus: requiredString,
154
+ cmsLabelPrefix: Joi.string().optional(),
155
+ })
156
+ .required(),
157
+ },
158
+ {
159
+ is: 'publishUnpublishedEntry',
160
+ then: defaultParams
161
+ .keys({
162
+ collection,
163
+ slug,
164
+ })
165
+ .required(),
166
+ },
167
+ {
168
+ is: 'getMedia',
169
+ then: defaultParams
170
+ .keys({
171
+ mediaFolder: path,
172
+ })
173
+ .required(),
174
+ },
175
+ {
176
+ is: 'getMediaFile',
177
+ then: defaultParams
178
+ .keys({
179
+ path,
180
+ })
181
+ .required(),
182
+ },
183
+ {
184
+ is: 'persistMedia',
185
+ then: defaultParams
186
+ .keys({
187
+ asset: asset.required(),
188
+ options: Joi.object({
189
+ commitMessage: requiredString,
190
+ }).required(),
191
+ })
192
+ .required(),
193
+ },
194
+ {
195
+ is: 'deleteFile',
196
+ then: defaultParams
197
+ .keys({
198
+ path,
199
+ options: Joi.object({
200
+ commitMessage: requiredString,
201
+ }).required(),
202
+ })
203
+ .required(),
204
+ },
205
+ {
206
+ is: 'deleteFiles',
207
+ then: defaultParams
208
+ .keys({
209
+ paths: Joi.array().items(path).min(1).required(),
210
+ options: Joi.object({
211
+ commitMessage: requiredString,
212
+ }).required(),
213
+ })
214
+ .required(),
215
+ },
216
+ {
217
+ is: 'getDeployPreview',
218
+ then: defaultParams
219
+ .keys({
220
+ collection,
221
+ slug,
222
+ })
223
+ .required(),
224
+ },
225
+ ],
226
+ otherwise: Joi.forbidden(),
227
+ });
228
+
229
+ return Joi.object({
230
+ action: Joi.valid(...allowedActions).required(),
231
+ params,
232
+ });
233
+ }
234
+
235
+ export function joi(schema: Joi.Schema) {
236
+ return (req: express.Request, res: express.Response, next: express.NextFunction) => {
237
+ const { error } = schema.validate(req.body, { allowUnknown: true });
238
+ if (error) {
239
+ const { details } = error;
240
+ const message = details.map(i => i.message).join(',');
241
+ res.status(422).json({ error: message });
242
+ } else {
243
+ next();
244
+ }
245
+ };
246
+ }
@@ -0,0 +1,92 @@
1
+ /* eslint-disable @typescript-eslint/no-var-requires */
2
+ import { getSchema } from '.';
3
+
4
+ import type Joi from '@hapi/joi';
5
+
6
+ function assetFailure(result: Joi.ValidationResult, expectedMessage: string) {
7
+ const { error } = result;
8
+ expect(error).not.toBeNull();
9
+ expect(error!.details).toHaveLength(1);
10
+ const message = error!.details.map(({ message }) => message)[0];
11
+ expect(message).toBe(expectedMessage);
12
+ }
13
+
14
+ const defaultParams = {
15
+ branch: 'master',
16
+ };
17
+
18
+ describe('localFsMiddleware', () => {
19
+ beforeEach(() => {
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ describe('getSchema', () => {
24
+ it('should throw on path traversal', () => {
25
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
26
+
27
+ assetFailure(
28
+ schema.validate({
29
+ action: 'getEntry',
30
+ params: { ...defaultParams, path: '../' },
31
+ }),
32
+ '"params.path" must resolve to a path under the configured repository',
33
+ );
34
+ });
35
+
36
+ it('should not throw on valid path', () => {
37
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
38
+
39
+ const { error } = schema.validate({
40
+ action: 'getEntry',
41
+ params: { ...defaultParams, path: 'src/content/posts/title.md' },
42
+ });
43
+
44
+ expect(error).toBeUndefined();
45
+ });
46
+
47
+ it('should throw on folder traversal', () => {
48
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
49
+
50
+ assetFailure(
51
+ schema.validate({
52
+ action: 'entriesByFolder',
53
+ params: { ...defaultParams, folder: '../', extension: 'md', depth: 1 },
54
+ }),
55
+ '"params.folder" must resolve to a path under the configured repository',
56
+ );
57
+ });
58
+
59
+ it('should not throw on valid folder', () => {
60
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
61
+
62
+ const { error } = schema.validate({
63
+ action: 'entriesByFolder',
64
+ params: { ...defaultParams, folder: 'src/posts', extension: 'md', depth: 1 },
65
+ });
66
+
67
+ expect(error).toBeUndefined();
68
+ });
69
+
70
+ it('should throw on media folder traversal', () => {
71
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
72
+
73
+ assetFailure(
74
+ schema.validate({
75
+ action: 'getMedia',
76
+ params: { ...defaultParams, mediaFolder: '../' },
77
+ }),
78
+ '"params.mediaFolder" must resolve to a path under the configured repository',
79
+ );
80
+ });
81
+
82
+ it('should not throw on valid folder', () => {
83
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
84
+ const { error } = schema.validate({
85
+ action: 'getMedia',
86
+ params: { ...defaultParams, mediaFolder: 'static/images' },
87
+ });
88
+
89
+ expect(error).toBeUndefined();
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,163 @@
1
+ import path from 'path';
2
+
3
+ import { defaultSchema, joi } from '../joi';
4
+ import { pathTraversal } from '../joi/customValidators';
5
+ import { listRepoFiles, deleteFile, writeFile, move } from '../utils/fs';
6
+ import { entriesFromFiles, readMediaFile } from '../utils/entries';
7
+
8
+ import type {
9
+ EntriesByFolderParams,
10
+ EntriesByFilesParams,
11
+ GetEntryParams,
12
+ PersistEntryParams,
13
+ GetMediaParams,
14
+ GetMediaFileParams,
15
+ PersistMediaParams,
16
+ DeleteFileParams,
17
+ DeleteFilesParams,
18
+ DataFile,
19
+ } from '../types';
20
+ import type express from 'express';
21
+ import type winston from 'winston';
22
+
23
+ type FsOptions = {
24
+ repoPath: string;
25
+ logger: winston.Logger;
26
+ };
27
+
28
+ export function localFsMiddleware({ repoPath, logger }: FsOptions) {
29
+ return async function (req: express.Request, res: express.Response) {
30
+ try {
31
+ const { body } = req;
32
+
33
+ switch (body.action) {
34
+ case 'info': {
35
+ res.json({
36
+ repo: path.basename(repoPath),
37
+ publish_modes: ['simple'],
38
+ type: 'local_fs',
39
+ });
40
+ break;
41
+ }
42
+ case 'entriesByFolder': {
43
+ const payload = body.params as EntriesByFolderParams;
44
+ const { folder, extension, depth } = payload;
45
+ const entries = await listRepoFiles(repoPath, folder, extension, depth).then(files =>
46
+ entriesFromFiles(
47
+ repoPath,
48
+ files.map(file => ({ path: file })),
49
+ ),
50
+ );
51
+ res.json(entries);
52
+ break;
53
+ }
54
+ case 'entriesByFiles': {
55
+ const payload = body.params as EntriesByFilesParams;
56
+ const entries = await entriesFromFiles(repoPath, payload.files);
57
+ res.json(entries);
58
+ break;
59
+ }
60
+ case 'getEntry': {
61
+ const payload = body.params as GetEntryParams;
62
+ const [entry] = await entriesFromFiles(repoPath, [{ path: payload.path }]);
63
+ res.json(entry);
64
+ break;
65
+ }
66
+ case 'persistEntry': {
67
+ const {
68
+ entry,
69
+ dataFiles = [entry as DataFile],
70
+ assets,
71
+ } = body.params as PersistEntryParams;
72
+ await Promise.all(
73
+ dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
74
+ );
75
+ // save assets
76
+ await Promise.all(
77
+ assets.map(a =>
78
+ writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding)),
79
+ ),
80
+ );
81
+ if (dataFiles.every(dataFile => dataFile.newPath)) {
82
+ dataFiles.forEach(async dataFile => {
83
+ await move(
84
+ path.join(repoPath, dataFile.path),
85
+ path.join(repoPath, dataFile.newPath!),
86
+ );
87
+ });
88
+ }
89
+ res.json({ message: 'entry persisted' });
90
+ break;
91
+ }
92
+ case 'getMedia': {
93
+ const { mediaFolder } = body.params as GetMediaParams;
94
+ const files = await listRepoFiles(repoPath, mediaFolder, '', 1);
95
+ const mediaFiles = await Promise.all(files.map(file => readMediaFile(repoPath, file)));
96
+ res.json(mediaFiles);
97
+ break;
98
+ }
99
+ case 'getMediaFile': {
100
+ const { path } = body.params as GetMediaFileParams;
101
+ const mediaFile = await readMediaFile(repoPath, path);
102
+ res.json(mediaFile);
103
+ break;
104
+ }
105
+ case 'persistMedia': {
106
+ const { asset } = body.params as PersistMediaParams;
107
+ await writeFile(
108
+ path.join(repoPath, asset.path),
109
+ Buffer.from(asset.content, asset.encoding),
110
+ );
111
+ const file = await readMediaFile(repoPath, asset.path);
112
+ res.json(file);
113
+ break;
114
+ }
115
+ case 'deleteFile': {
116
+ const { path: filePath } = body.params as DeleteFileParams;
117
+ await deleteFile(repoPath, filePath);
118
+ res.json({ message: `deleted file ${filePath}` });
119
+ break;
120
+ }
121
+ case 'deleteFiles': {
122
+ const { paths } = body.params as DeleteFilesParams;
123
+ await Promise.all(paths.map(filePath => deleteFile(repoPath, filePath)));
124
+ res.json({ message: `deleted files ${paths.join(', ')}` });
125
+ break;
126
+ }
127
+ case 'getDeployPreview': {
128
+ res.json(null);
129
+ break;
130
+ }
131
+ default: {
132
+ const message = `Unknown action ${body.action}`;
133
+ res.status(422).json({ error: message });
134
+ break;
135
+ }
136
+ }
137
+ } catch (e) {
138
+ logger.error(
139
+ `Error handling ${JSON.stringify(req.body)}: ${
140
+ e instanceof Error ? e.message : 'Unknown error'
141
+ }`,
142
+ );
143
+ res.status(500).json({ error: 'Unknown error' });
144
+ }
145
+ };
146
+ }
147
+
148
+ export function getSchema({ repoPath }: { repoPath: string }) {
149
+ const schema = defaultSchema({ path: pathTraversal(repoPath) });
150
+ return schema;
151
+ }
152
+
153
+ type Options = {
154
+ logger: winston.Logger;
155
+ };
156
+
157
+ export async function registerMiddleware(app: express.Express, options: Options) {
158
+ const { logger } = options;
159
+ const repoPath = path.resolve(process.env.GIT_REPO_DIRECTORY || process.cwd());
160
+ app.post('/api/v1', joi(getSchema({ repoPath })));
161
+ app.post('/api/v1', localFsMiddleware({ repoPath, logger }));
162
+ logger.info(`Decap CMS File System Proxy Server configured with ${repoPath}`);
163
+ }
@@ -0,0 +1,154 @@
1
+ /* eslint-disable @typescript-eslint/no-var-requires */
2
+ import winston from 'winston';
3
+
4
+ import { validateRepo, getSchema, localGitMiddleware } from '.';
5
+
6
+ import type Joi from '@hapi/joi';
7
+ import type express from 'express';
8
+
9
+ jest.mock('decap-cms-lib-util', () => jest.fn());
10
+ jest.mock('simple-git');
11
+
12
+ function assetFailure(result: Joi.ValidationResult, expectedMessage: string) {
13
+ const { error } = result;
14
+ expect(error).not.toBeNull();
15
+ expect(error!.details).toHaveLength(1);
16
+ const message = error!.details.map(({ message }) => message)[0];
17
+ expect(message).toBe(expectedMessage);
18
+ }
19
+
20
+ const defaultParams = {
21
+ branch: 'master',
22
+ };
23
+
24
+ describe('localGitMiddleware', () => {
25
+ const simpleGit = require('simple-git');
26
+
27
+ const git = {
28
+ checkIsRepo: jest.fn(),
29
+ silent: jest.fn(),
30
+ branchLocal: jest.fn(),
31
+ checkout: jest.fn(),
32
+ };
33
+ git.silent.mockReturnValue(git);
34
+
35
+ simpleGit.mockReturnValue(git);
36
+
37
+ beforeEach(() => {
38
+ jest.clearAllMocks();
39
+ });
40
+
41
+ describe('validateRepo', () => {
42
+ it('should throw on non valid git repo', async () => {
43
+ git.checkIsRepo.mockResolvedValue(false);
44
+ await expect(validateRepo({ repoPath: '/Users/user/code/repo' })).rejects.toEqual(
45
+ new Error('/Users/user/code/repo is not a valid git repository'),
46
+ );
47
+ });
48
+
49
+ it('should not throw on valid git repo', async () => {
50
+ git.checkIsRepo.mockResolvedValue(true);
51
+ await expect(validateRepo({ repoPath: '/Users/user/code/repo' })).resolves.toBeUndefined();
52
+ });
53
+ });
54
+
55
+ describe('getSchema', () => {
56
+ it('should throw on path traversal', () => {
57
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
58
+
59
+ assetFailure(
60
+ schema.validate({
61
+ action: 'getEntry',
62
+ params: { ...defaultParams, path: '../' },
63
+ }),
64
+ '"params.path" must resolve to a path under the configured repository',
65
+ );
66
+ });
67
+
68
+ it('should not throw on valid path', () => {
69
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
70
+
71
+ const { error } = schema.validate({
72
+ action: 'getEntry',
73
+ params: { ...defaultParams, path: 'src/content/posts/title.md' },
74
+ });
75
+
76
+ expect(error).toBeUndefined();
77
+ });
78
+
79
+ it('should throw on folder traversal', () => {
80
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
81
+
82
+ assetFailure(
83
+ schema.validate({
84
+ action: 'entriesByFolder',
85
+ params: { ...defaultParams, folder: '../', extension: 'md', depth: 1 },
86
+ }),
87
+ '"params.folder" must resolve to a path under the configured repository',
88
+ );
89
+ });
90
+
91
+ it('should not throw on valid folder', () => {
92
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
93
+
94
+ const { error } = schema.validate({
95
+ action: 'entriesByFolder',
96
+ params: { ...defaultParams, folder: 'src/posts', extension: 'md', depth: 1 },
97
+ });
98
+
99
+ expect(error).toBeUndefined();
100
+ });
101
+
102
+ it('should throw on media folder traversal', () => {
103
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
104
+
105
+ assetFailure(
106
+ schema.validate({
107
+ action: 'getMedia',
108
+ params: { ...defaultParams, mediaFolder: '../' },
109
+ }),
110
+ '"params.mediaFolder" must resolve to a path under the configured repository',
111
+ );
112
+ });
113
+
114
+ it('should not throw on valid folder', () => {
115
+ const schema = getSchema({ repoPath: '/Users/user/documents/code/repo' });
116
+ const { error } = schema.validate({
117
+ action: 'getMedia',
118
+ params: { ...defaultParams, mediaFolder: 'static/images' },
119
+ });
120
+
121
+ expect(error).toBeUndefined();
122
+ });
123
+ });
124
+
125
+ describe('localGitMiddleware', () => {
126
+ const json = jest.fn();
127
+ const status = jest.fn(() => ({ json }));
128
+ const res: express.Response = { status } as unknown as express.Response;
129
+
130
+ const repoPath = '.';
131
+
132
+ it("should return error when default branch doesn't exist", async () => {
133
+ git.branchLocal.mockResolvedValue({ all: ['master'] });
134
+
135
+ const req = {
136
+ body: {
137
+ action: 'getMedia',
138
+ params: {
139
+ mediaFolder: 'mediaFolder',
140
+ branch: 'develop',
141
+ },
142
+ },
143
+ } as express.Request;
144
+
145
+ await localGitMiddleware({ repoPath, logger: winston.createLogger() })(req, res);
146
+
147
+ expect(status).toHaveBeenCalledTimes(1);
148
+ expect(status).toHaveBeenCalledWith(422);
149
+
150
+ expect(json).toHaveBeenCalledTimes(1);
151
+ expect(json).toHaveBeenCalledWith({ error: "Default branch 'develop' doesn't exist" });
152
+ });
153
+ });
154
+ });