This commit is contained in:
2025-08-25 20:24:23 +08:00
parent 30106e0129
commit 0ae8d7a709
1044 changed files with 321581 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
# optional, defaults to current directory
GIT_REPO_DIRECTORY=FULL_PATH_TO_LOCAL_GIT_REPO
# optional, defaults to 8081
PORT=CUSTOM_PORT
# optional, defaults to false
LOG_LEVEL=info

View File

@@ -0,0 +1,298 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.3.1](https://github.com/decaporg/decap-cms/compare/decap-server@3.3.0...decap-server@3.3.1) (2025-07-31)
**Note:** Version bump only for package decap-server
# [3.3.0](https://github.com/decaporg/decap-cms/compare/decap-server@3.2.0...decap-server@3.3.0) (2025-06-26)
**Note:** Version bump only for package decap-server
# [3.2.0](https://github.com/decaporg/decap-cms/compare/decap-server@3.1.2...decap-server@3.2.0) (2025-01-29)
**Note:** Version bump only for package decap-server
## [3.1.2](https://github.com/decaporg/decap-cms/compare/decap-server@3.1.1...decap-server@3.1.2) (2024-08-13)
### Reverts
- Revert "Update dependencies (#7264)" ([22d483a](https://github.com/decaporg/decap-cms/commit/22d483a5b0c654071ae05735ac4f49abdc13d38c)), closes [#7264](https://github.com/decaporg/decap-cms/issues/7264)
## [3.1.1](https://github.com/decaporg/decap-cms/compare/decap-server@3.1.0...decap-server@3.1.1) (2024-08-13)
**Note:** Version bump only for package decap-server
# [3.1.0](https://github.com/decaporg/decap-cms/compare/decap-server@3.0.4...decap-server@3.1.0) (2024-08-07)
**Note:** Version bump only for package decap-server
## [3.0.4](https://github.com/decaporg/decap-cms/compare/decap-server@3.0.3...decap-server@3.0.4) (2024-04-03)
**Note:** Version bump only for package decap-server
## [3.0.3](https://github.com/decaporg/decap-cms/compare/decap-server@3.0.2...decap-server@3.0.3) (2024-03-21)
**Note:** Version bump only for package decap-server
## [3.0.2](https://github.com/decaporg/decap-cms/compare/decap-server@3.0.2-beta.0...decap-server@3.0.2) (2024-02-01)
**Note:** Version bump only for package decap-server
## [3.0.2-beta.0](https://github.com/decaporg/decap-cms/compare/decap-server@3.0.1...decap-server@3.0.2-beta.0) (2024-01-31)
**Note:** Version bump only for package decap-server
## [3.0.1](https://github.com/decaporg/decap-cms/compare/decap-server@3.0.0...decap-server@3.0.1) (2023-09-06)
**Note:** Version bump only for package decap-server
# [3.0.0](https://github.com/decaporg/decap-cms/compare/decap-server@1.4.0...decap-server@3.0.0) (2023-08-18)
**Note:** Version bump only for package decap-server
# [1.4.0](https://github.com/decaporg/decap-cms/compare/decap-server@1.4.0-beta.0...decap-server@1.4.0) (2023-08-18)
**Note:** Version bump only for package decap-server
# 1.4.0-beta.0 (2023-08-18)
### Features
- rename packages ([#6863](https://github.com/decaporg/decap-cms/issues/6863)) ([d515e7b](https://github.com/decaporg/decap-cms/commit/d515e7bd33216a775d96887b08c4f7b1962941bb))
## [1.3.25-beta.0](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.24...decap-server@1.3.25-beta.0) (2023-07-27)
**Note:** Version bump only for package decap-server
## [1.3.24](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.23...decap-server@1.3.24) (2022-04-13)
### Bug Fixes
- **deps:** update dependency simple-git to v3 [security] ([#6305](https://github.com/decaporg/decap-cms/issues/6305)) ([e997101](https://github.com/decaporg/decap-cms/commit/e99710116d015ca47b5cbe114710fdf93fabfe61))
## [1.3.23](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.22...decap-server@1.3.23) (2021-12-28)
**Note:** Version bump only for package decap-server
## [1.3.22](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.21...decap-server@1.3.22) (2021-11-01)
**Note:** Version bump only for package decap-server
## [1.3.21](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.20...decap-server@1.3.21) (2021-10-18)
**Note:** Version bump only for package decap-server
## [1.3.20](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.19...decap-server@1.3.20) (2021-08-17)
**Note:** Version bump only for package decap-server
## [1.3.19](https://github.com/decaporg/decap-cms/compare/decap-server@1.3.18...decap-server@1.3.19) (2021-07-14)
**Note:** Version bump only for package decap-server
## [1.3.18](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.17...decap-server@1.3.18) (2021-06-01)
**Note:** Version bump only for package decap-server
## [1.3.17](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.16...decap-server@1.3.17) (2021-05-31)
**Note:** Version bump only for package decap-server
## [1.3.16](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.15...decap-server@1.3.16) (2021-05-24)
### Bug Fixes
- **deps:** update dependency dotenv to v10 ([#5433](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/5433)) ([1d44a5c](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/1d44a5c0b94ab8512278ffc877f2daeeb8159593))
## [1.3.15](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.14...decap-server@1.3.15) (2021-05-19)
**Note:** Version bump only for package decap-server
## [1.3.14](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.13...decap-server@1.3.14) (2021-05-10)
### Bug Fixes
- **deps:** update dependency dotenv to v9 ([#5361](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/5361)) ([3e65080](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/3e6508046f15672ca97cfe223ac9ea76b43121c9))
## [1.3.13](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.12...decap-server@1.3.13) (2021-04-29)
**Note:** Version bump only for package decap-server
## [1.3.12](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.11...decap-server@1.3.12) (2021-04-04)
**Note:** Version bump only for package decap-server
## [1.3.11](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.10...decap-server@1.3.11) (2021-02-15)
**Note:** Version bump only for package decap-server
## [1.3.10](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.9...decap-server@1.3.10) (2021-02-10)
### Bug Fixes
- **deps:** update dependency async-mutex to ^0.3.0 ([#4932](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/4932)) ([f608a81](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/f608a81f19681a4265ea8875ad50c585926c9113))
## [1.3.9](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.8...decap-server@1.3.9) (2021-02-01)
**Note:** Version bump only for package decap-server
## [1.3.8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.7...decap-server@1.3.8) (2020-12-13)
**Note:** Version bump only for package decap-server
## [1.3.7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.6...decap-server@1.3.7) (2020-11-26)
**Note:** Version bump only for package decap-server
## [1.3.6](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.5...decap-server@1.3.6) (2020-09-28)
**Note:** Version bump only for package decap-server
## [1.3.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.4...decap-server@1.3.5) (2020-09-20)
**Note:** Version bump only for package decap-server
## [1.3.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.3.3...decap-server@1.3.4) (2020-09-15)
**Note:** Version bump only for package decap-server
## 1.3.3 (2020-09-08)
### Reverts
- Revert "chore(release): publish" ([828bb16](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/828bb16415b8c22a34caa19c50c38b24ffe9ceae))
## 1.3.2 (2020-08-20)
### Reverts
- Revert "chore(release): publish" ([8262487](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/82624879ccbcb16610090041db28f00714d924c8))
## 1.3.1 (2020-07-27)
### Reverts
- Revert "chore(release): publish" ([118d50a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/118d50a7a70295f25073e564b5161aa2b9883056))
# [1.3.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.9...decap-server@1.3.0) (2020-07-14)
### Bug Fixes
- **proxy-server:** change request logging level to debug ([#3993](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3993)) ([784c1fe](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/784c1fe4801c89f1908d1b5e96cd7e847ec10fa0))
### Features
- **proxy-server:** allow setting proxy log level ([#3989](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3989)) ([3e0d088](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/3e0d0886d930a17c7b77d995cc0fa628ef3ecdc2))
## [1.2.9](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.8...decap-server@1.2.9) (2020-06-18)
**Note:** Version bump only for package decap-server
## [1.2.8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.7...decap-server@1.2.8) (2020-06-01)
**Note:** Version bump only for package decap-server
## [1.2.7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.6...decap-server@1.2.7) (2020-05-19)
### Bug Fixes
- **deps:** update dependency simple-git to v2 ([#3730](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3730)) ([1c7ef1c](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/1c7ef1c457612242257d96af53a57c1460e45b31))
## [1.2.6](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.5...decap-server@1.2.6) (2020-04-21)
**Note:** Version bump only for package decap-server
## [1.2.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.4...decap-server@1.2.5) (2020-04-14)
**Note:** Version bump only for package decap-server
## [1.2.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.3...decap-server@1.2.4) (2020-04-01)
### Bug Fixes
- move common api functions to a separate file ([#3511](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3511)) ([49098de](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/49098de27f053e51aa3d936d09adae3a7186c6ae))
## [1.2.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.2...decap-server@1.2.3) (2020-04-01)
**Note:** Version bump only for package decap-server
## [1.2.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.1...decap-server@1.2.2) (2020-03-19)
**Note:** Version bump only for package decap-server
## [1.2.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.2.0...decap-server@1.2.1) (2020-03-12)
**Note:** Version bump only for package decap-server
# [1.2.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.1.5...decap-server@1.2.0) (2020-03-03)
### Features
- **proxy-server:** export proxy middlewares ([#3361](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3361)) ([4e1e5a9](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/4e1e5a9bd54f9fcfcfd7e971729c8d2d796f9a1b))
## [1.1.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.1.4...decap-server@1.1.5) (2020-02-25)
**Note:** Version bump only for package decap-server
## [1.1.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.1.3...decap-server@1.1.4) (2020-02-17)
**Note:** Version bump only for package decap-server
## [1.1.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.1.2...decap-server@1.1.3) (2020-02-14)
### Bug Fixes
- **proxy-server:** add missing labels to file collections ([#3250](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3250)) ([8d67de0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/8d67de0e681e02ddbf811eecc5e4744b324b7e61))
## [1.1.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.1.1...decap-server@1.1.2) (2020-02-12)
### Bug Fixes
- **proxy-server:** better handle files based collections ([#3237](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3237)) ([3678053](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/3678053f0c419718000a21768790618749df762c))
## [1.1.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.1.0...decap-server@1.1.1) (2020-02-11)
**Note:** Version bump only for package decap-server
# [1.1.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.0.7...decap-server@1.1.0) (2020-02-10)
### Bug Fixes
- fs proxy info action ([#3225](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3225)) ([4522739](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/45227392315f4b8b7566ffe65756ca13dc55b9f8))
### Features
- **proxy-server:** add local fs middleware and make it the default ([#3217](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3217)) ([31dbd72](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/31dbd72273b723bb6bbb551641a6e4bcc1f0314b))
### Reverts
- Revert "chore(release): publish" ([a015d1d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/a015d1d92a4b1c0130c44fcef1c9ecdb157a0f07))
## [1.0.7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.0.6...decap-server@1.0.7) (2020-02-06)
**Note:** Version bump only for package decap-server
## [1.0.6](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.0.5...decap-server@1.0.6) (2020-01-30)
**Note:** Version bump only for package decap-server
## [1.0.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.0.4...decap-server@1.0.5) (2020-01-29)
### Bug Fixes
- **proxy-server:** fix entriesByFiles implementation ([#3161](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/issues/3161)) ([22df7f7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/commit/22df7f7ae1c63d5275156c13202ee2cba8edfe30))
## [1.0.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.0.3...decap-server@1.0.4) (2020-01-26)
**Note:** Version bump only for package decap-server
## [1.0.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-server/compare/decap-server@1.0.2...decap-server@1.0.3) (2020-01-24)
**Note:** Version bump only for package decap-server
## 1.0.2 (2020-01-22)
**Note:** Version bump only for package decap-server

View File

@@ -0,0 +1,30 @@
# Decap CMS Proxy Server
Decap CMS Proxy Server is an express server created to facilitate local development.
## How It Works
1. Navigate to a local Git repository configured with the CMS.
2. Run `npx decap-server` from the root directory of the above repository.
3. Update your `config.yml` to connect to the server:
```yaml
backend:
name: proxy
proxy_url: http://localhost:8081/api/v1
branch: master # optional, defaults to master
```
4. Start you local development server (e.g. run `gatsby develop`).
## Custom Configuration
1. Create a `.env` file in the root directory of your local Git repository.
2. Update the file as follows:
```bash
# optional, defaults to current directory
GIT_REPO_DIRECTORY=FULL_PATH_TO_LOCAL_GIT_REPO
# optional, defaults to 8081
PORT=CUSTOM_PORT
```

View File

@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'decap-cms-lib-util': '<rootDir>/../decap-cms-lib-util/dist/esm',
},
};

View File

@@ -0,0 +1,58 @@
{
"name": "decap-server",
"description": "Proxy server to be used with Decap CMS proxy backend",
"version": "3.3.1",
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-server",
"bugs": "https://github.com/decaporg/decap-cms/issues",
"license": "MIT",
"main": "dist/index",
"keywords": [
"decap-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",
"async-mutex": "^0.3.0",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"express": "^4.18.2",
"morgan": "^1.9.1",
"simple-git": "^3.0.0",
"what-the-diff": "^0.6.0",
"winston": "^3.3.3"
},
"devDependencies": {
"@types/cors": "^2.8.6",
"@types/express": "^4.17.15",
"@types/hapi__joi": "17.1.8",
"@types/jest": "^27.0.0",
"@types/morgan": "^1.7.37",
"@types/node": "^16.0.0",
"@types/vfile-message": "^2.0.0",
"decap-cms-lib-util": "^3.3.1",
"jest": "^27.0.0",
"nodemon": "^2.0.2",
"ts-jest": "^27.0.0",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^3.2.0",
"webpack": "^5.75.0",
"webpack-node-externals": "^3.0.0"
},
"engines": {
"node": ">=v10.22.1"
},
"bin": {
"decap-server": "./dist/index.js"
}
}

View File

@@ -0,0 +1,4 @@
type LocalForage = {
getItem: <T>(key: string) => Promise<T>;
setItem: <T>(key: string, value: T) => Promise<void>;
};

View File

@@ -0,0 +1,39 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('dotenv').config();
import express from 'express';
import { registerCommonMiddlewares } from './middlewares/common';
import { registerMiddleware as registerLocalGit } from './middlewares/localGit';
import { registerMiddleware as registerLocalFs } from './middlewares/localFs';
import { createLogger } from './logger';
const app = express();
const port = process.env.PORT || 8081;
const level = process.env.LOG_LEVEL || 'info';
(async () => {
const logger = createLogger({ level });
const options = {
logger,
};
registerCommonMiddlewares(app, options);
try {
const mode = process.env.MODE || 'fs';
if (mode === 'fs') {
registerLocalFs(app, options);
} else if (mode === 'git') {
registerLocalGit(app, options);
} else {
throw new Error(`Unknown proxy mode '${mode}'`);
}
} catch (e) {
logger.error(e instanceof Error ? e.message : 'Unknown error');
process.exit(1);
}
return app.listen(port, () => {
logger.info(`Decap CMS Proxy Server listening on port ${port}`);
});
})();

View File

@@ -0,0 +1,15 @@
import winston from 'winston';
const { combine, colorize, simple } = winston.format;
type LogOptions = {
level: string;
};
export function createLogger({ level }: LogOptions) {
return winston.createLogger({
level,
format: combine(colorize(), simple()),
transports: [new winston.transports.Console()],
});
}

View File

@@ -0,0 +1,28 @@
import { registerCommonMiddlewares } from './middlewares/common';
import { registerMiddleware as localGit } from './middlewares/localGit';
import { registerMiddleware as localFs } from './middlewares/localFs';
import { createLogger } from './logger';
import type express from 'express';
type Options = {
logLevel?: string;
};
function createOptions(options: Options) {
return {
logger: createLogger({ level: options.logLevel || 'info' }),
};
}
export async function registerLocalGit(app: express.Express, options: Options = {}) {
const opts = createOptions(options);
registerCommonMiddlewares(app, opts);
await localGit(app, opts);
}
export async function registerLocalFs(app: express.Express, options: Options = {}) {
const opts = createOptions(options);
registerCommonMiddlewares(app, opts);
await localFs(app, opts);
}

View File

@@ -0,0 +1,21 @@
import express from 'express';
import morgan from 'morgan';
import cors from 'cors';
import type winston from 'winston';
export type Options = {
logger: winston.Logger;
};
export function registerCommonMiddlewares(app: express.Express, options: Options) {
const { logger } = options;
const stream = {
write: (message: string) => {
logger.debug(String(message).trim());
},
};
app.use(morgan('combined', { stream }));
app.use(cors());
app.use(express.json({ limit: '50mb' }));
}

View File

@@ -0,0 +1,18 @@
import Joi from '@hapi/joi';
import path from 'path';
export function pathTraversal(repoPath: string) {
return 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') };
}
},
}).path();
}

View File

@@ -0,0 +1,617 @@
import { defaultSchema, joi } from '.';
import type express from 'express';
import type Joi from '@hapi/joi';
function 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, unpublishedEntryDataFile, unpublishedEntryMediaFile, deleteUnpublishedEntry, persistEntry, updateUnpublishedEntryStatus, publishUnpublishedEntry, getMedia, getMediaFile, persistMedia, deleteFile, deleteFiles, 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: {} }),
'"params.branch" is required',
);
});
it('should pass on valid collection and slug', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action: 'unpublishedEntry',
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
});
expect(error).toBeUndefined();
});
it('should pass on valid id', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action: 'unpublishedEntry',
params: { ...defaultParams, id: 'id' },
});
expect(error).toBeUndefined();
});
});
['unpublishedEntryDataFile', 'unpublishedEntryMediaFile'].forEach(action => {
describe(action, () => {
it('should fail on invalid params', () => {
const schema = defaultSchema();
assetFailure(
schema.validate({ action, params: { ...defaultParams } }),
'"params.collection" is required',
);
assetFailure(
schema.validate({
action,
params: { ...defaultParams, collection: 'collection' },
}),
'"params.slug" is required',
);
assetFailure(
schema.validate({
action,
params: { ...defaultParams, collection: 'collection', slug: 'slug' },
}),
'"params.id" is required',
);
assetFailure(
schema.validate({
action,
params: { ...defaultParams, collection: 'collection', slug: 'slug', id: 'id' },
}),
'"params.path" is required',
);
});
it('should pass on valid params', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action,
params: {
...defaultParams,
collection: 'collection',
slug: 'slug',
id: 'id',
path: 'path',
},
});
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,
assets: [],
options: {
commitMessage: 'commitMessage',
useWorkflow: true,
status: 'draft',
},
},
}),
'"params" must contain at least one of [entry, dataFiles]',
);
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 (entry argument)', () => {
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();
});
it('should pass on valid params (dataFiles argument)', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action: 'persistEntry',
params: {
...defaultParams,
dataFiles: [{ 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('deleteFiles', () => {
it('should fail on invalid params', () => {
const schema = defaultSchema();
assetFailure(
schema.validate({ action: 'deleteFiles', params: { ...defaultParams } }),
'"params.paths" is required',
);
});
it('should pass on valid params', () => {
const schema = defaultSchema();
const { error } = schema.validate({
action: 'deleteFiles',
params: {
...defaultParams,
paths: ['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' });
});
});

View File

@@ -0,0 +1,246 @@
import Joi from '@hapi/joi';
import type express from 'express';
const allowedActions = [
'info',
'entriesByFolder',
'entriesByFiles',
'getEntry',
'unpublishedEntries',
'unpublishedEntry',
'unpublishedEntryDataFile',
'unpublishedEntryMediaFile',
'deleteUnpublishedEntry',
'persistEntry',
'updateUnpublishedEntryStatus',
'publishUnpublishedEntry',
'getMedia',
'getMediaFile',
'persistMedia',
'deleteFile',
'deleteFiles',
'getDeployPreview',
];
const requiredString = Joi.string().required();
const requiredNumber = Joi.number().required();
const requiredBool = Joi.bool().required();
const collection = requiredString;
const slug = requiredString;
export function defaultSchema({ path = requiredString } = {}) {
const defaultParams = Joi.object({
branch: requiredString,
});
const asset = Joi.object({
path,
content: requiredString,
encoding: requiredString.valid('base64'),
});
const dataFile = Joi.object({
slug: requiredString,
path,
raw: requiredString,
newPath: path.optional(),
});
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, label: Joi.string() }))
.required(),
}),
},
{
is: 'getEntry',
then: defaultParams
.keys({
path,
})
.required(),
},
{
is: 'unpublishedEntries',
then: defaultParams.keys({ branch: requiredString }).required(),
},
{
is: 'unpublishedEntry',
then: defaultParams
.keys({
id: Joi.string().optional(),
collection: Joi.string().optional(),
slug: Joi.string().optional(),
cmsLabelPrefix: Joi.string().optional(),
})
.required(),
},
{
is: 'unpublishedEntryDataFile',
then: defaultParams
.keys({
collection,
slug,
id: requiredString,
path: requiredString,
})
.required(),
},
{
is: 'unpublishedEntryMediaFile',
then: defaultParams
.keys({
collection,
slug,
id: requiredString,
path: requiredString,
})
.required(),
},
{
is: 'deleteUnpublishedEntry',
then: defaultParams
.keys({
collection,
slug,
})
.required(),
},
{
is: 'persistEntry',
then: defaultParams
.keys({
cmsLabelPrefix: Joi.string().optional(),
entry: dataFile, // entry is kept for backwards compatibility
dataFiles: Joi.array().items(dataFile),
assets: Joi.array().items(asset).required(),
options: Joi.object({
collectionName: Joi.string(),
commitMessage: requiredString,
useWorkflow: requiredBool,
status: requiredString,
}).required(),
})
.xor('entry', 'dataFiles')
.required(),
},
{
is: 'updateUnpublishedEntryStatus',
then: defaultParams
.keys({
collection,
slug,
newStatus: requiredString,
cmsLabelPrefix: Joi.string().optional(),
})
.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: 'deleteFiles',
then: defaultParams
.keys({
paths: Joi.array().items(path).min(1).required(),
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 function joi(schema: Joi.Schema) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
const { error } = schema.validate(req.body, { allowUnknown: true });
if (error) {
const { details } = error;
const message = details.map(i => i.message).join(',');
res.status(422).json({ error: message });
} else {
next();
}
};
}

View File

@@ -0,0 +1,92 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { getSchema } from '.';
import type Joi from '@hapi/joi';
function 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('localFsMiddleware', () => {
beforeEach(() => {
jest.clearAllMocks();
});
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();
});
});
});

View File

@@ -0,0 +1,163 @@
import path from 'path';
import { defaultSchema, joi } from '../joi';
import { pathTraversal } from '../joi/customValidators';
import { listRepoFiles, deleteFile, writeFile, move } from '../utils/fs';
import { entriesFromFiles, readMediaFile } from '../utils/entries';
import type {
EntriesByFolderParams,
EntriesByFilesParams,
GetEntryParams,
PersistEntryParams,
GetMediaParams,
GetMediaFileParams,
PersistMediaParams,
DeleteFileParams,
DeleteFilesParams,
DataFile,
} from '../types';
import type express from 'express';
import type winston from 'winston';
type FsOptions = {
repoPath: string;
logger: winston.Logger;
};
export function localFsMiddleware({ repoPath, logger }: FsOptions) {
return async function (req: express.Request, res: express.Response) {
try {
const { body } = req;
switch (body.action) {
case 'info': {
res.json({
repo: path.basename(repoPath),
publish_modes: ['simple'],
type: 'local_fs',
});
break;
}
case 'entriesByFolder': {
const payload = body.params as EntriesByFolderParams;
const { folder, extension, depth } = payload;
const entries = await listRepoFiles(repoPath, folder, extension, depth).then(files =>
entriesFromFiles(
repoPath,
files.map(file => ({ path: file })),
),
);
res.json(entries);
break;
}
case 'entriesByFiles': {
const payload = body.params as EntriesByFilesParams;
const entries = await entriesFromFiles(repoPath, payload.files);
res.json(entries);
break;
}
case 'getEntry': {
const payload = body.params as GetEntryParams;
const [entry] = await entriesFromFiles(repoPath, [{ path: payload.path }]);
res.json(entry);
break;
}
case 'persistEntry': {
const {
entry,
dataFiles = [entry as DataFile],
assets,
} = body.params as PersistEntryParams;
await Promise.all(
dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
);
// save assets
await Promise.all(
assets.map(a =>
writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding)),
),
);
if (dataFiles.every(dataFile => dataFile.newPath)) {
dataFiles.forEach(async dataFile => {
await move(
path.join(repoPath, dataFile.path),
path.join(repoPath, dataFile.newPath!),
);
});
}
res.json({ message: 'entry persisted' });
break;
}
case 'getMedia': {
const { mediaFolder } = body.params as GetMediaParams;
const files = await listRepoFiles(repoPath, mediaFolder, '', 1);
const mediaFiles = await Promise.all(files.map(file => readMediaFile(repoPath, file)));
res.json(mediaFiles);
break;
}
case 'getMediaFile': {
const { path } = body.params as GetMediaFileParams;
const mediaFile = await readMediaFile(repoPath, path);
res.json(mediaFile);
break;
}
case 'persistMedia': {
const { asset } = body.params as PersistMediaParams;
await writeFile(
path.join(repoPath, asset.path),
Buffer.from(asset.content, asset.encoding),
);
const file = await readMediaFile(repoPath, asset.path);
res.json(file);
break;
}
case 'deleteFile': {
const { path: filePath } = body.params as DeleteFileParams;
await deleteFile(repoPath, filePath);
res.json({ message: `deleted file ${filePath}` });
break;
}
case 'deleteFiles': {
const { paths } = body.params as DeleteFilesParams;
await Promise.all(paths.map(filePath => deleteFile(repoPath, filePath)));
res.json({ message: `deleted files ${paths.join(', ')}` });
break;
}
case 'getDeployPreview': {
res.json(null);
break;
}
default: {
const message = `Unknown action ${body.action}`;
res.status(422).json({ error: message });
break;
}
}
} catch (e) {
logger.error(
`Error handling ${JSON.stringify(req.body)}: ${
e instanceof Error ? e.message : 'Unknown error'
}`,
);
res.status(500).json({ error: 'Unknown error' });
}
};
}
export function getSchema({ repoPath }: { repoPath: string }) {
const schema = defaultSchema({ path: pathTraversal(repoPath) });
return schema;
}
type Options = {
logger: winston.Logger;
};
export async function registerMiddleware(app: express.Express, options: Options) {
const { logger } = options;
const repoPath = path.resolve(process.env.GIT_REPO_DIRECTORY || process.cwd());
app.post('/api/v1', joi(getSchema({ repoPath })));
app.post('/api/v1', localFsMiddleware({ repoPath, logger }));
logger.info(`Decap CMS File System Proxy Server configured with ${repoPath}`);
}

View File

@@ -0,0 +1,154 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import winston from 'winston';
import { validateRepo, getSchema, localGitMiddleware } from '.';
import type Joi from '@hapi/joi';
import type express from 'express';
jest.mock('decap-cms-lib-util', () => jest.fn());
jest.mock('simple-git');
function 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');
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, logger: winston.createLogger() })(req, res);
expect(status).toHaveBeenCalledTimes(1);
expect(status).toHaveBeenCalledWith(422);
expect(json).toHaveBeenCalledTimes(1);
expect(json).toHaveBeenCalledWith({ error: "Default branch 'develop' doesn't exist" });
});
});
});

View File

@@ -0,0 +1,463 @@
import path from 'path';
import { promises as fs } from 'fs';
import {
branchFromContentKey,
generateContentKey,
contentKeyFromBranch,
CMS_BRANCH_PREFIX,
statusToLabel,
labelToStatus,
parseContentKey,
} from 'decap-cms-lib-util/src/APIUtils';
import { parse } from 'what-the-diff';
// eslint-disable-next-line import/no-named-as-default
import simpleGit from 'simple-git';
import { Mutex, withTimeout } from 'async-mutex';
import { defaultSchema, joi } from '../joi';
import { pathTraversal } from '../joi/customValidators';
import { listRepoFiles, writeFile, move, deleteFile, getUpdateDate } from '../utils/fs';
import { entriesFromFiles, readMediaFile } from '../utils/entries';
import type {
EntriesByFolderParams,
EntriesByFilesParams,
GetEntryParams,
DefaultParams,
UnpublishedEntryParams,
PersistEntryParams,
GetMediaParams,
Asset,
PublishUnpublishedEntryParams,
PersistMediaParams,
DeleteFileParams,
UpdateUnpublishedEntryStatusParams,
DataFile,
GetMediaFileParams,
DeleteEntryParams,
DeleteFilesParams,
UnpublishedEntryDataFileParams,
UnpublishedEntryMediaFileParams,
} from '../types';
import type express from 'express';
import type winston from 'winston';
import type { SimpleGit } from 'simple-git';
async function commit(git: SimpleGit, commitMessage: string) {
await git.add('.');
await git.commit(commitMessage, undefined, {
// setting the value to a string passes name=value
// any other value passes just the key
'--no-verify': null,
'--no-gpg-sign': null,
});
}
async function getCurrentBranch(git: SimpleGit) {
const currentBranch = await git.branchLocal().then(summary => summary.current);
return currentBranch;
}
async function runOnBranch<T>(git: 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);
}
}
function branchDescription(branch: string) {
return `branch.${branch}.description`;
}
type GitOptions = {
repoPath: string;
logger: winston.Logger;
};
async function commitEntry(
git: SimpleGit,
repoPath: string,
dataFiles: DataFile[],
assets: Asset[],
commitMessage: string,
) {
// save entry content
await Promise.all(
dataFiles.map(dataFile => writeFile(path.join(repoPath, dataFile.path), dataFile.raw)),
);
// save assets
await Promise.all(
assets.map(a => writeFile(path.join(repoPath, a.path), Buffer.from(a.content, a.encoding))),
);
if (dataFiles.every(dataFile => dataFile.newPath)) {
dataFiles.forEach(async dataFile => {
await move(path.join(repoPath, dataFile.path), path.join(repoPath, dataFile.newPath!));
});
}
// commits files
await commit(git, commitMessage);
}
async function rebase(git: SimpleGit, branch: string) {
const gpgSign = await git.raw(['config', 'commit.gpgsign']);
try {
if (gpgSign === 'true') {
await git.addConfig('commit.gpgsign', 'false');
}
await git.rebase([branch, '--no-verify']);
} finally {
if (gpgSign === 'true') {
await git.addConfig('commit.gpgsign', gpgSign);
}
}
}
async function merge(git: SimpleGit, from: string, to: string) {
const gpgSign = await git.raw(['config', 'commit.gpgsign']);
try {
if (gpgSign === 'true') {
await git.addConfig('commit.gpgsign', 'false');
}
await git.mergeFromTo(from, to);
} finally {
if (gpgSign === 'true') {
await git.addConfig('commit.gpgsign', gpgSign);
}
}
}
async function isBranchExists(git: SimpleGit, branch: string) {
const branchExists = await git.branchLocal().then(({ all }) => all.includes(branch));
return branchExists;
}
async function getDiffs(git: SimpleGit, source: string, dest: string) {
const rawDiff = await git.diff([source, dest]);
const diffs = parse(rawDiff).map(d => {
const oldPath = d.oldPath?.replace(/b\//, '') || '';
const newPath = d.newPath?.replace(/b\//, '') || '';
const path = newPath || (oldPath as string);
return {
oldPath,
newPath,
status: d.status,
newFile: d.status === 'added',
path,
id: path,
binary: d.binary || /.svg$/.test(path),
};
});
return diffs;
}
export async function validateRepo({ repoPath }: { repoPath: string }) {
const git = simpleGit(repoPath);
const isRepo = await git.checkIsRepo();
if (!isRepo) {
throw Error(`${repoPath} is not a valid git repository`);
}
}
export function getSchema({ repoPath }: { repoPath: string }) {
const schema = defaultSchema({ path: pathTraversal(repoPath) });
return schema;
}
export function localGitMiddleware({ repoPath, logger }: GitOptions) {
const git = simpleGit(repoPath);
// we can only perform a single git operation at any given time
const mutex = withTimeout(new Mutex(), 3000, new Error('Request timed out'));
return async function (req: express.Request, res: express.Response) {
let release;
try {
release = await mutex.acquire();
const { body } = req;
if (body.action === 'info') {
res.json({
repo: path.basename(repoPath),
publish_modes: ['simple', 'editorial_workflow'],
type: 'local_git',
});
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.map(file => ({ path: file })),
),
),
);
res.json(entries);
break;
}
case 'entriesByFiles': {
const payload = body.params as EntriesByFilesParams;
const entries = await runOnBranch(git, branch, () =>
entriesFromFiles(repoPath, payload.files),
);
res.json(entries);
break;
}
case 'getEntry': {
const payload = body.params as GetEntryParams;
const [entry] = await runOnBranch(git, branch, () =>
entriesFromFiles(repoPath, [{ path: payload.path }]),
);
res.json(entry);
break;
}
case 'unpublishedEntries': {
const cmsBranches = await git
.branchLocal()
.then(result => result.all.filter(b => b.startsWith(`${CMS_BRANCH_PREFIX}/`)));
res.json(cmsBranches.map(contentKeyFromBranch));
break;
}
case 'unpublishedEntry': {
let { id, collection, slug, cmsLabelPrefix } = body.params as UnpublishedEntryParams;
if (id) {
({ collection, slug } = parseContentKey(id));
}
const contentKey = generateContentKey(collection as string, slug as string);
const cmsBranch = branchFromContentKey(contentKey);
const branchExists = await isBranchExists(git, cmsBranch);
if (branchExists) {
const diffs = await getDiffs(git, branch, cmsBranch);
const label = await git.raw(['config', branchDescription(cmsBranch)]);
const status = label && labelToStatus(label.trim(), cmsLabelPrefix || '');
const updatedAt =
diffs.length >= 0
? await runOnBranch(git, cmsBranch, async () => {
const dates = await Promise.all(
diffs.map(({ newPath }) => getUpdateDate(repoPath, newPath)),
);
return dates.reduce((a, b) => {
return a > b ? a : b;
});
})
: new Date();
const unpublishedEntry = {
collection,
slug,
status,
diffs,
updatedAt,
};
res.json(unpublishedEntry);
} else {
return res.status(404).json({ message: 'Not Found' });
}
break;
}
case 'unpublishedEntryDataFile': {
const { path, collection, slug } = body.params as UnpublishedEntryDataFileParams;
const contentKey = generateContentKey(collection as string, slug as string);
const cmsBranch = branchFromContentKey(contentKey);
const [entry] = await runOnBranch(git, cmsBranch, () =>
entriesFromFiles(repoPath, [{ path }]),
);
res.json({ data: entry.data });
break;
}
case 'unpublishedEntryMediaFile': {
const { path, collection, slug } = body.params as UnpublishedEntryMediaFileParams;
const contentKey = generateContentKey(collection as string, slug as string);
const cmsBranch = branchFromContentKey(contentKey);
const file = await runOnBranch(git, cmsBranch, () => readMediaFile(repoPath, path));
res.json(file);
break;
}
case 'deleteUnpublishedEntry': {
const { collection, slug } = body.params as DeleteEntryParams;
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 {
cmsLabelPrefix,
entry,
dataFiles = [entry as DataFile],
assets,
options,
} = body.params as PersistEntryParams;
if (!options.useWorkflow) {
await runOnBranch(git, branch, async () => {
await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
});
} else {
const slug = dataFiles[0].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 rebase(git, branch);
const diffs = await getDiffs(git, branch, cmsBranch);
// delete media files that have been removed from the entry
const toDelete = diffs.filter(
d => d.binary && !assets.map(a => a.path).includes(d.path),
);
await Promise.all(toDelete.map(f => fs.unlink(path.join(repoPath, f.path))));
await commitEntry(git, repoPath, dataFiles, assets, options.commitMessage);
// add status for new entries
if (!branchExists) {
const description = statusToLabel(options.status, cmsLabelPrefix || '');
await git.addConfig(branchDescription(cmsBranch), description);
}
});
}
res.json({ message: 'entry persisted' });
break;
}
case 'updateUnpublishedEntryStatus': {
const { collection, slug, newStatus, cmsLabelPrefix } =
body.params as UpdateUnpublishedEntryStatusParams;
const contentKey = generateContentKey(collection, slug);
const cmsBranch = branchFromContentKey(contentKey);
const description = statusToLabel(newStatus, cmsLabelPrefix || '');
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 merge(git, 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);
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 deleteFile(repoPath, filePath);
await commit(git, commitMessage);
});
res.json({ message: `deleted file ${filePath}` });
break;
}
case 'deleteFiles': {
const {
paths,
options: { commitMessage },
} = body.params as DeleteFilesParams;
await runOnBranch(git, branch, async () => {
await Promise.all(paths.map(filePath => deleteFile(repoPath, filePath)));
await commit(git, commitMessage);
});
res.json({ message: `deleted files ${paths.join(', ')}` });
break;
}
case 'getDeployPreview': {
res.json(null);
break;
}
default: {
const message = `Unknown action ${body.action}`;
res.status(422).json({ error: message });
break;
}
}
} catch (e) {
logger.error(
`Error handling ${JSON.stringify(req.body)}: ${
e instanceof Error ? e.message : 'Unknown error'
}`,
);
res.status(500).json({ error: 'Unknown error' });
} finally {
release && release();
}
};
}
type Options = {
logger: winston.Logger;
};
export async function registerMiddleware(app: express.Express, options: Options) {
const { logger } = options;
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, logger }));
logger.info(`Decap CMS Git Proxy Server configured with ${repoPath}`);
}

View File

@@ -0,0 +1,101 @@
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 = {
id?: string;
collection?: string;
slug?: string;
cmsLabelPrefix?: string;
};
export type UnpublishedEntryDataFileParams = {
collection: string;
slug: string;
id: string;
path: string;
};
export type UnpublishedEntryMediaFileParams = {
collection: string;
slug: string;
id: string;
path: string;
};
export type DeleteEntryParams = {
collection: string;
slug: string;
};
export type UpdateUnpublishedEntryStatusParams = {
collection: string;
slug: string;
newStatus: string;
cmsLabelPrefix?: string;
};
export type PublishUnpublishedEntryParams = {
collection: string;
slug: string;
};
export type DataFile = { slug: string; path: string; raw: string; newPath?: string };
export type Asset = { path: string; content: string; encoding: 'base64' };
export type PersistEntryParams = {
cmsLabelPrefix?: string;
entry?: DataFile;
dataFiles?: DataFile[];
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;
};
};
export type DeleteFilesParams = {
paths: string[];
options: {
commitMessage: string;
};
};

View File

@@ -0,0 +1,48 @@
import crypto from 'crypto';
import path from 'path';
import { promises as fs } from 'fs';
function sha256(buffer: Buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
// normalize windows os path format
function normalizePath(path: string) {
return path.replace(/\\/g, '/');
}
export async function entriesFromFiles(
repoPath: string,
files: { path: string; label?: string }[],
) {
return Promise.all(
files.map(async file => {
try {
const content = await fs.readFile(path.join(repoPath, file.path));
return {
data: content.toString(),
file: { path: normalizePath(file.path), label: file.label, id: sha256(content) },
};
} catch (e) {
return {
data: null,
file: { path: normalizePath(file.path), label: file.label, id: null },
};
}
}),
);
}
export async function readMediaFile(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: normalizePath(file),
name: path.basename(file),
};
}

View File

@@ -0,0 +1,65 @@
import path from 'path';
import { promises as fs } from 'fs';
async function listFiles(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 [];
}
}
export async function listRepoFiles(
repoPath: string,
folder: string,
extension: string,
depth: number,
) {
const files = await listFiles(path.join(repoPath, folder), extension, depth);
return files.map(f => f.slice(repoPath.length + 1));
}
export async function writeFile(filePath: string, content: Buffer | string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
}
export async function deleteFile(repoPath: string, filePath: string) {
await fs.unlink(path.join(repoPath, filePath)).catch(() => undefined);
}
async function moveFile(from: string, to: string) {
await fs.mkdir(path.dirname(to), { recursive: true });
await fs.rename(from, to);
}
export async function move(from: string, to: string) {
// move file
await moveFile(from, to);
// move children
const sourceDir = path.dirname(from);
const destDir = path.dirname(to);
const allFiles = await listFiles(sourceDir, '', 100);
await Promise.all(allFiles.map(file => moveFile(file, file.replace(sourceDir, destDir))));
}
export async function getUpdateDate(repoPath: string, filePath: string) {
return fs
.stat(path.join(repoPath, filePath))
.then(stat => stat.mtime)
.catch(() => new Date());
}

View File

@@ -0,0 +1,5 @@
declare module 'what-the-diff' {
export const parse: (
rawDiff: string,
) => { oldPath?: string; newPath?: string; binary: boolean; status: string }[];
}

View 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"]
}

View File

@@ -0,0 +1,39 @@
const path = require('path');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
const { NODE_ENV = 'production' } = process.env;
const allowlist = [/^decap-cms-lib-util/];
module.exports = {
entry: { index: path.join('src', 'index.ts'), middlewares: path.join('src', 'middlewares.ts') },
mode: NODE_ENV,
target: 'node',
devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].js',
libraryTarget: 'commonjs2',
},
resolve: {
plugins: [new TsconfigPathsPlugin()],
extensions: ['.ts', '.js'],
},
module: {
rules: [
{
test: /\.ts$/,
use: ['ts-loader'],
},
],
},
externals: [
nodeExternals({ allowlist }),
nodeExternals({
allowlist,
modulesDir: path.resolve(__dirname, path.join('..', '..', 'node_modules')),
}),
],
plugins: [new webpack.BannerPlugin({ banner: '#!/usr/bin/env node', raw: true })],
};