add-cms
This commit is contained in:
6
source/admin/packages/decap-server/.env.example
Normal file
6
source/admin/packages/decap-server/.env.example
Normal 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
|
||||
298
source/admin/packages/decap-server/CHANGELOG.md
Normal file
298
source/admin/packages/decap-server/CHANGELOG.md
Normal 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
|
||||
30
source/admin/packages/decap-server/README.md
Normal file
30
source/admin/packages/decap-server/README.md
Normal 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
|
||||
```
|
||||
7
source/admin/packages/decap-server/jest.config.js
Normal file
7
source/admin/packages/decap-server/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'decap-cms-lib-util': '<rootDir>/../decap-cms-lib-util/dist/esm',
|
||||
},
|
||||
};
|
||||
58
source/admin/packages/decap-server/package.json
Normal file
58
source/admin/packages/decap-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
4
source/admin/packages/decap-server/src/global.d.ts
vendored
Normal file
4
source/admin/packages/decap-server/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
type LocalForage = {
|
||||
getItem: <T>(key: string) => Promise<T>;
|
||||
setItem: <T>(key: string, value: T) => Promise<void>;
|
||||
};
|
||||
39
source/admin/packages/decap-server/src/index.ts
Normal file
39
source/admin/packages/decap-server/src/index.ts
Normal 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}`);
|
||||
});
|
||||
})();
|
||||
15
source/admin/packages/decap-server/src/logger.ts
Normal file
15
source/admin/packages/decap-server/src/logger.ts
Normal 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()],
|
||||
});
|
||||
}
|
||||
28
source/admin/packages/decap-server/src/middlewares.ts
Normal file
28
source/admin/packages/decap-server/src/middlewares.ts
Normal 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);
|
||||
}
|
||||
@@ -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' }));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
246
source/admin/packages/decap-server/src/middlewares/joi/index.ts
Normal file
246
source/admin/packages/decap-server/src/middlewares/joi/index.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
101
source/admin/packages/decap-server/src/middlewares/types.ts
Normal file
101
source/admin/packages/decap-server/src/middlewares/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
5
source/admin/packages/decap-server/src/what-the-diff.d.ts
vendored
Normal file
5
source/admin/packages/decap-server/src/what-the-diff.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module 'what-the-diff' {
|
||||
export const parse: (
|
||||
rawDiff: string,
|
||||
) => { oldPath?: string; newPath?: string; binary: boolean; status: string }[];
|
||||
}
|
||||
19
source/admin/packages/decap-server/tsconfig.json
Normal file
19
source/admin/packages/decap-server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2018",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/*spec.ts"]
|
||||
}
|
||||
39
source/admin/packages/decap-server/webpack.config.js
Normal file
39
source/admin/packages/decap-server/webpack.config.js
Normal 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 })],
|
||||
};
|
||||
Reference in New Issue
Block a user