Skip to content
This repository was archived by the owner on Sep 9, 2024. It is now read-only.

Commit 0635204

Browse files
authored
feat: git backend (#45)
1 parent b237f21 commit 0635204

File tree

12 files changed

+882
-123
lines changed

12 files changed

+882
-123
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@
2626
"test": "jest"
2727
},
2828
"dependencies": {
29+
"async-mutex": "0.4.0",
2930
"cors": "2.8.5",
3031
"dotenv": "16.0.3",
3132
"express": "4.18.2",
3233
"joi": "17.8.4",
3334
"morgan": "1.10.0",
35+
"simple-git": "3.20.0",
36+
"what-the-diff": "0.6.0",
3437
"winston": "3.8.2"
3538
},
3639
"devDependencies": {

src/global.d.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
type LocalForage = {
2-
getItem: <T>(key: string) => Promise<T>;
3-
setItem: <T>(key: string, value: T) => Promise<void>;
4-
};
1+
type LocalForage = {
2+
getItem: <T>(key: string) => Promise<T>;
3+
setItem: <T>(key: string, value: T) => Promise<void>;
4+
};

src/index.ts

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,39 @@
1-
// eslint-disable-next-line @typescript-eslint/no-var-requires
2-
require('dotenv').config();
3-
import express from 'express';
4-
5-
import { registerCommonMiddlewares } from './middlewares/common';
6-
import { registerMiddleware as registerLocalFs } from './middlewares/localFs';
7-
import { createLogger } from './logger';
8-
9-
const app = express();
10-
const port = process.env.PORT || 8081;
11-
const level = process.env.LOG_LEVEL || 'info';
12-
13-
(async () => {
14-
const logger = createLogger({ level });
15-
const options = {
16-
logger,
17-
};
18-
19-
registerCommonMiddlewares(app, options);
20-
21-
try {
22-
registerLocalFs(app, options);
23-
} catch (e: any) {
24-
logger.error(e.message);
25-
process.exit(1);
26-
}
27-
28-
return app.listen(port, () => {
29-
logger.info(`Static CMS Proxy Server listening on port ${port}`);
30-
});
31-
})();
1+
// eslint-disable-next-line @typescript-eslint/no-var-requires
2+
require('dotenv').config();
3+
import express from 'express';
4+
5+
import { registerCommonMiddlewares } from './middlewares/common';
6+
import { registerMiddleware as registerLocalGit } from './middlewares/localGit';
7+
import { registerMiddleware as registerLocalFs } from './middlewares/localFs';
8+
import { createLogger } from './logger';
9+
10+
const app = express();
11+
const port = process.env.PORT || 8081;
12+
const level = process.env.LOG_LEVEL || 'info';
13+
14+
(async () => {
15+
const logger = createLogger({ level });
16+
const options = {
17+
logger,
18+
};
19+
20+
registerCommonMiddlewares(app, options);
21+
22+
try {
23+
const mode = process.env.MODE || 'fs';
24+
if (mode === 'fs') {
25+
registerLocalFs(app, options);
26+
} else if (mode === 'git') {
27+
registerLocalGit(app, options);
28+
} else {
29+
throw new Error(`Unknown proxy mode '${mode}'`);
30+
}
31+
} catch (e: any) {
32+
logger.error(e.message);
33+
process.exit(1);
34+
}
35+
36+
return app.listen(port, () => {
37+
logger.info(`Static CMS Proxy Server listening on port ${port}`);
38+
});
39+
})();

src/middlewares.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
1-
import { registerCommonMiddlewares } from './middlewares/common';
2-
import { registerMiddleware as localFs } from './middlewares/localFs';
3-
import { createLogger } from './logger';
4-
5-
import type express from 'express';
6-
7-
type Options = {
8-
logLevel?: string;
9-
};
10-
11-
function createOptions(options: Options) {
12-
return {
13-
logger: createLogger({ level: options.logLevel || 'info' }),
14-
};
15-
}
16-
17-
export async function registerLocalFs(app: express.Express, options: Options = {}) {
18-
const opts = createOptions(options);
19-
registerCommonMiddlewares(app, opts);
20-
await localFs(app, opts);
21-
}
1+
import { registerCommonMiddlewares } from './middlewares/common';
2+
import { registerMiddleware as localGit } from './middlewares/localGit';
3+
import { registerMiddleware as localFs } from './middlewares/localFs';
4+
import { createLogger } from './logger';
5+
6+
import type express from 'express';
7+
8+
type Options = {
9+
logLevel?: string;
10+
};
11+
12+
function createOptions(options: Options) {
13+
return {
14+
logger: createLogger({ level: options.logLevel || 'info' }),
15+
};
16+
}
17+
18+
export async function registerLocalGit(app: express.Express, options: Options = {}) {
19+
const opts = createOptions(options);
20+
registerCommonMiddlewares(app, opts);
21+
await localGit(app, opts);
22+
}
23+
24+
export async function registerLocalFs(app: express.Express, options: Options = {}) {
25+
const opts = createOptions(options);
26+
registerCommonMiddlewares(app, opts);
27+
await localFs(app, opts);
28+
}
Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import Joi from 'joi';
2-
import path from 'path';
3-
4-
export function pathTraversal(repoPath: string) {
5-
return Joi.extend({
6-
type: 'path',
7-
base: Joi.string().required(),
8-
messages: {
9-
'path.invalid': '{{#label}} must resolve to a path under the configured repository',
10-
},
11-
validate(value, helpers) {
12-
const resolvedPath = path.join(repoPath, value);
13-
if (!resolvedPath.startsWith(repoPath)) {
14-
return { value, errors: helpers.error('path.invalid') };
15-
}
16-
},
17-
}).path();
18-
}
1+
import Joi from 'joi';
2+
import path from 'path';
3+
4+
export function pathTraversal(repoPath: string) {
5+
return Joi.extend({
6+
type: 'path',
7+
base: Joi.string().required(),
8+
messages: {
9+
'path.invalid': '{{#label}} must resolve to a path under the configured repository',
10+
},
11+
validate(value, helpers) {
12+
const resolvedRepoPath = path.join(repoPath, '');
13+
const resolvedPath = path.join(repoPath, value);
14+
if (!resolvedPath.startsWith(resolvedRepoPath)) {
15+
return { value, errors: helpers.error('path.invalid') };
16+
}
17+
},
18+
}).path();
19+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 'joi';
7+
import type express from 'express';
8+
9+
jest.mock('../utils/APIUtils', () => 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+
});

0 commit comments

Comments
 (0)