mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-12 22:26:08 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
6
backend/.dockerignore
Normal file
6
backend/.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
dist
|
||||
.pnp.*
|
29
backend/.editorconfig
Normal file
29
backend/.editorconfig
Normal file
|
@ -0,0 +1,29 @@
|
|||
# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[{*.html,*.ejs}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[{.travis.yml,npm-shrinkwrap.json,package.json}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[locales/*.json]
|
||||
# this is the exact style poeditor.com exports, so this should prevent churn.
|
||||
insert_final_newline = false
|
||||
indent_style = space
|
||||
indent_size = 4
|
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
|
@ -0,0 +1,9 @@
|
|||
# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
HD_DOMAIN="http://localhost"
|
||||
HD_MEDIA_BACKEND="filesystem"
|
||||
HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH="uploads/"
|
||||
HD_DATABASE_TYPE="sqlite"
|
||||
HD_DATABASE_NAME="./hedgedoc.sqlite"
|
89
backend/.eslintrc.js
Normal file
89
backend/.eslintrc.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
/* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: CC0-1.0
|
||||
*/
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
sourceType: 'module',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['test/**', 'src/**/*.spec.ts'],
|
||||
extends: ['plugin:jest/recommended'],
|
||||
rules: {
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jest/unbound-method': 'error',
|
||||
'jest/expect-expect': [
|
||||
'error',
|
||||
{
|
||||
assertFunctionNames: [
|
||||
'expect',
|
||||
'request.**.expect',
|
||||
'agent[0-9]?.**.expect',
|
||||
],
|
||||
},
|
||||
],
|
||||
'jest/no-standalone-expect': [
|
||||
'error',
|
||||
{
|
||||
additionalTestBlockFunctions: ['afterEach', 'beforeAll'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['@typescript-eslint', 'jest', 'eslint-plugin-local-rules'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
'local-rules/correct-logger-context': 'error',
|
||||
'func-style': ['error', 'declaration'],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_+$' },
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
'no-return-await': 'off',
|
||||
'@typescript-eslint/return-await': ['error', 'always'],
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'default',
|
||||
format: ['camelCase'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['camelCase', 'UPPER_CASE'],
|
||||
leadingUnderscore: 'allow',
|
||||
trailingUnderscore: 'allow',
|
||||
},
|
||||
|
||||
{
|
||||
selector: 'typeLike',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
27
backend/.gitignore
vendored
Normal file
27
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
# SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
# ignore config files
|
||||
config.json
|
||||
.sequelizerc
|
||||
.env
|
||||
|
||||
# ignore webpack build
|
||||
public/build
|
||||
public/views/build
|
||||
|
||||
# ignore TypeScript built
|
||||
built/
|
||||
dist
|
||||
|
||||
# Tests
|
||||
coverage
|
||||
coverage-e2e
|
||||
.nyc_output
|
||||
|
||||
public/uploads/*
|
||||
!public/uploads/.gitkeep
|
||||
!public/.gitkeep
|
||||
uploads
|
||||
test_uploads
|
10
backend/.prettierrc
Normal file
10
backend/.prettierrc
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"plugins": ["@trivago/prettier-plugin-sort-imports"],
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"importOrder": ["^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true,
|
||||
"importOrderCaseInsensitive": true,
|
||||
"importOrderParserPlugins": ["typescript","classProperties","decorators-legacy"]
|
||||
}
|
3
backend/.prettierrc.license
Normal file
3
backend/.prettierrc.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
11
backend/.yarnrc.yml
Normal file
11
backend/.yarnrc.yml
Normal file
|
@ -0,0 +1,11 @@
|
|||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: ../.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
- path: ../.yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
spec: "@yarnpkg/plugin-typescript"
|
||||
- path: ../.yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
|
||||
spec: "@yarnpkg/plugin-workspace-tools"
|
||||
|
||||
yarnPath: ../.yarn/releases/yarn-3.2.4.cjs
|
3
backend/.yarnrc.yml.license
Normal file
3
backend/.yarnrc.yml.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
13
backend/CHANGELOG.md
Normal file
13
backend/CHANGELOG.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
-->
|
||||
|
||||
# CHANGELOG
|
||||
|
||||
Please refer to the release notes published on
|
||||
[our releases page](https://hedgedoc.org/releases/) or [on GitHub](https://github.com/hedgedoc/hedgedoc/releases).
|
||||
|
||||
These are also available on each HedgeDoc instance under
|
||||
https://[domain-name]/release-notes
|
11
backend/codecov.yml
Normal file
11
backend/codecov.yml
Normal file
|
@ -0,0 +1,11 @@
|
|||
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
ignore:
|
||||
- "src/utils/test-utils"
|
||||
- "src/realtime/realtime-note/test-utils"
|
||||
|
||||
codecov:
|
||||
notify:
|
||||
# We currently have integration tests and E2E tests. Codecov should wait until both are done.
|
||||
after_n_builds: 2
|
75
backend/docker/Dockerfile
Normal file
75
backend/docker/Dockerfile
Normal file
|
@ -0,0 +1,75 @@
|
|||
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
# This Dockerfile uses features which are only available in BuildKit - see
|
||||
# https://docs.docker.com/go/buildkit/ for more information.
|
||||
#
|
||||
# To build the image, run `docker build` command from the root of the
|
||||
# repository:
|
||||
#
|
||||
# DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile .
|
||||
|
||||
## Stage 0: Base image with only yarn and package.json
|
||||
FROM docker.io/node:19-alpine@sha256:1a04e2ec39cc0c3a9657c1d6f8291ea2f5ccadf6ef4521dec946e522833e87ea as base
|
||||
# Add tini to handle signals
|
||||
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals
|
||||
RUN apk add --no-cache tini
|
||||
ENTRYPOINT ["tini"]
|
||||
|
||||
USER node
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --chown=node .yarn ../.yarn
|
||||
COPY --chown=node backend/package.json backend/yarn.lock backend/.yarnrc.yml ./
|
||||
|
||||
|
||||
## Stage 1: Code with all dependencies
|
||||
FROM base as code-with-deps
|
||||
USER node
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install dependencies first to not invalidate the cache on every source change
|
||||
RUN --mount=type=cache,sharing=locked,uid=1000,gid=1000,target=/tmp/.yarn \
|
||||
YARN_CACHE_FOLDER=/tmp/.yarn yarn install --immutable
|
||||
|
||||
COPY --chown=node backend/nest-cli.json backend/tsconfig.json backend/tsconfig.build.json ./
|
||||
COPY --chown=node backend/src src
|
||||
|
||||
|
||||
## Stage 2a: Dev config files and tests
|
||||
FROM code-with-deps as development
|
||||
USER node
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --chown=node backend/.eslintrc.js backend/eslint-local-rules.js backend/.prettierrc backend/jest-e2e.json ./
|
||||
COPY --chown=node backend/test test
|
||||
|
||||
CMD ["node", "-r", "ts-node/register", "src/main.ts"]
|
||||
|
||||
## Stage 2b: Compile TypeScript
|
||||
FROM code-with-deps as builder
|
||||
USER node
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN yarn run build
|
||||
|
||||
|
||||
## Stage 3: Final image, only production dependencies
|
||||
FROM base as prod
|
||||
|
||||
LABEL org.opencontainers.image.title='HedgeDoc production image'
|
||||
LABEL org.opencontainers.image.url='https://hedgedoc.org'
|
||||
LABEL org.opencontainers.image.source='https://github.com/hedgedoc/hedgedoc'
|
||||
LABEL org.opencontainers.image.documentation='https://github.com/hedgedoc/hedgedoc/blob/develop/docker/README.md'
|
||||
LABEL org.opencontainers.image.licenses='AGPL-3.0'
|
||||
|
||||
USER node
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --chown=node --from=builder /usr/src/app/dist ./dist
|
||||
|
||||
RUN --mount=type=cache,sharing=locked,uid=1000,gid=1000,target=/tmp/.yarn \
|
||||
YARN_CACHE_FOLDER=/tmp/.yarn yarn workspaces focus --all --production
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
32
backend/docker/README.md
Normal file
32
backend/docker/README.md
Normal file
|
@ -0,0 +1,32 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
-->
|
||||
|
||||
# Using HedgeDoc with Docker
|
||||
|
||||
**Important:** This README does **not** refer to HedgeDoc 1. For setting up HedgeDoc 1 with Docker, see https://docs.hedgedoc.org/setup/docker/.
|
||||
|
||||
The `Dockerfile` in this repo uses multiple stages and can be used to create both images for development
|
||||
and images with only production dependencies.
|
||||
It uses features which are only available in BuildKit - see https://docs.docker.com/go/buildkit/ for more information.
|
||||
|
||||
## Build a production image
|
||||
**Note:** This does not include any frontend!
|
||||
|
||||
To build a production image, run the following command *from the root of the repository*:
|
||||
`docker buildx build -t hedgedoc-prod -f backend/docker/Dockerfile .`
|
||||
|
||||
When you run the image, you need to provide environment variables to configure HedgeDoc.
|
||||
See [the config docs](../../docs/content/config/index.md) for more information.
|
||||
This example starts HedgeDoc on localhost, with non-persistent storage:
|
||||
`docker run -e HD_DOMAIN=http://localhost -e HD_MEDIA_BACKEND=filesystem -e HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH=uploads -e HD_DATABASE_TYPE=sqlite -e HD_DATABASE_NAME=hedgedoc.sqlite -e HD_SESSION_SECRET=foobar -e HD_LOGLEVEL=debug -p 3000:3000 hedgedoc-prod`
|
||||
|
||||
|
||||
## Build a development image
|
||||
You can build a development image using the `development` target:
|
||||
`docker buildx build -t hedgedoc-dev -f backend/docker/Dockerfile --target development .`
|
||||
|
||||
You can then, e.g. run tests inside the image:
|
||||
`docker run hedgedoc-dev yarn run test:e2e`
|
58
backend/eslint-local-rules.js
Normal file
58
backend/eslint-local-rules.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const loggerFunctions = ['error', 'log', 'warn', 'debug', 'verbose'];
|
||||
|
||||
module.exports = {
|
||||
'correct-logger-context': {
|
||||
meta: {
|
||||
fixable: 'code',
|
||||
type: 'problem',
|
||||
docs: {
|
||||
recommended: true
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create: function (context) {
|
||||
return {
|
||||
CallExpression: function (node) {
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'MemberExpression' &&
|
||||
node.callee.object.property.name === 'logger' &&
|
||||
loggerFunctions.includes(node.callee.property.name) &&
|
||||
!!node.arguments &&
|
||||
node.arguments.length === 2
|
||||
) {
|
||||
const usedContext = node.arguments[1].value;
|
||||
let correctContext = 'undefined';
|
||||
const ancestors = context.getAncestors();
|
||||
for (let index = ancestors.length - 1; index >= 0; index--) {
|
||||
if (ancestors[index].type === 'MethodDefinition') {
|
||||
correctContext = ancestors[index].key.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (usedContext !== correctContext) {
|
||||
context.report({
|
||||
node: node,
|
||||
message: `Used wrong context in log statement`,
|
||||
fix: function (fixer) {
|
||||
return fixer.replaceText(
|
||||
node.arguments[1],
|
||||
`'${correctContext}'`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
23
backend/jest-e2e.json
Normal file
23
backend/jest-e2e.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testMatch": [
|
||||
"<rootDir>/test/**/*.e2e-spec.{ts,js}"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": "test/tsconfig.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"coverageDirectory": "./coverage-e2e",
|
||||
"testTimeout": 10000,
|
||||
"reporters": ["default", "github-actions"]
|
||||
}
|
3
backend/jest-e2e.json.license
Normal file
3
backend/jest-e2e.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
14
backend/nest-cli.json
Normal file
14
backend/nest-cli.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@nestjs/swagger",
|
||||
"options": {
|
||||
"introspectComments": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
3
backend/nest-cli.json.license
Normal file
3
backend/nest-cli.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
148
backend/package.json
Normal file
148
backend/package.json
Normal file
|
@ -0,0 +1,148 @@
|
|||
{
|
||||
"name": "hedgedoc",
|
||||
"version": "2.0.0",
|
||||
"description": "Realtime collaborative markdown notes on all platforms.",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && nest build",
|
||||
"format": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"format:fix": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "rimraf dist && nest start",
|
||||
"start:dev": "rimraf dist && nest start --watch",
|
||||
"start:debug": "rimraf dist && nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint --max-warnings 0 \"{src,apps,libs,test}/**/*.ts\"",
|
||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config jest-e2e.json && rimraf test_uploads*",
|
||||
"test:e2e:cov": "jest --config jest-e2e.json --coverage && rimraf test_uploads*",
|
||||
"seed": "ts-node src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/storage-blob": "12.12.0",
|
||||
"@hedgedoc/realtime": "0.3.0",
|
||||
"@mrdrogdrog/optional": "1.0.0",
|
||||
"@nestjs/common": "9.1.6",
|
||||
"@nestjs/config": "2.2.0",
|
||||
"@nestjs/core": "9.1.6",
|
||||
"@nestjs/event-emitter": "1.3.1",
|
||||
"@nestjs/passport": "9.0.0",
|
||||
"@nestjs/platform-express": "9.1.6",
|
||||
"@nestjs/platform-ws": "9.1.6",
|
||||
"@nestjs/schedule": "2.1.0",
|
||||
"@nestjs/swagger": "6.1.3",
|
||||
"@nestjs/typeorm": "9.0.1",
|
||||
"@nestjs/websockets": "9.1.6",
|
||||
"@types/bcrypt": "5.0.0",
|
||||
"@types/cron": "2.0.0",
|
||||
"@types/minio": "7.0.14",
|
||||
"@types/node-fetch": "2.6.2",
|
||||
"@types/passport-http-bearer": "1.0.37",
|
||||
"@zxcvbn-ts/core": "2.1.0",
|
||||
"@zxcvbn-ts/language-common": "2.0.1",
|
||||
"@zxcvbn-ts/language-en": "2.1.0",
|
||||
"base32-encode": "1.2.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.13.2",
|
||||
"cli-color": "2.0.3",
|
||||
"connect-typeorm": "2.0.0",
|
||||
"cookie": "0.5.0",
|
||||
"diff": "5.1.0",
|
||||
"express-session": "1.17.3",
|
||||
"file-type": "16.5.4",
|
||||
"joi": "17.6.4",
|
||||
"ldapauth-fork": "5.0.5",
|
||||
"lib0": "0.2.52",
|
||||
"minio": "7.0.32",
|
||||
"mysql": "2.18.1",
|
||||
"nest-router": "1.0.9",
|
||||
"node-fetch": "2.6.7",
|
||||
"passport": "0.6.0",
|
||||
"passport-custom": "1.1.1",
|
||||
"passport-http-bearer": "1.0.1",
|
||||
"passport-local": "1.0.0",
|
||||
"pg": "8.8.0",
|
||||
"raw-body": "2.5.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rimraf": "3.0.2",
|
||||
"rxjs": "7.5.7",
|
||||
"sqlite3": "5.1.2",
|
||||
"typeorm": "0.3.7",
|
||||
"ws": "8.10.0",
|
||||
"y-protocols": "1.0.5",
|
||||
"yjs": "13.5.42"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "9.1.5",
|
||||
"@nestjs/schematics": "9.0.3",
|
||||
"@nestjs/testing": "9.1.6",
|
||||
"@trivago/prettier-plugin-sort-imports": "3.4.0",
|
||||
"@tsconfig/node12": "1.0.11",
|
||||
"@types/cli-color": "2.0.2",
|
||||
"@types/cookie": "0.5.1",
|
||||
"@types/cookie-signature": "1.0.4",
|
||||
"@types/diff": "5.0.2",
|
||||
"@types/express": "4.17.14",
|
||||
"@types/express-session": "1.17.5",
|
||||
"@types/jest": "29.2.0",
|
||||
"@types/mysql": "2.15.21",
|
||||
"@types/node": "18.11.8",
|
||||
"@types/passport-local": "1.0.34",
|
||||
"@types/pg": "8.6.5",
|
||||
"@types/source-map-support": "0.5.6",
|
||||
"@types/supertest": "2.0.12",
|
||||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.41.0",
|
||||
"@typescript-eslint/parser": "5.41.0",
|
||||
"eslint": "8.26.0",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"eslint-plugin-jest": "27.1.3",
|
||||
"eslint-plugin-local-rules": "1.3.2",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"http-proxy-middleware": "2.0.6",
|
||||
"jest": "29.2.2",
|
||||
"mocked-env": "1.3.5",
|
||||
"prettier": "2.7.1",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "6.3.1",
|
||||
"ts-jest": "29.0.3",
|
||||
"ts-mockery": "1.2.0",
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "4.8.4"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": [
|
||||
"ts-jest",
|
||||
{
|
||||
"tsconfig": "test/tsconfig.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"reporters": [
|
||||
"default",
|
||||
"github-actions"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@3.2.4",
|
||||
"resolutions": {
|
||||
"yjs": "13.5.42"
|
||||
}
|
||||
}
|
3
backend/package.json.license
Normal file
3
backend/package.json.license
Normal file
|
@ -0,0 +1,3 @@
|
|||
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
0
backend/public/.gitkeep
Normal file
0
backend/public/.gitkeep
Normal file
3
backend/public/intro.md
Normal file
3
backend/public/intro.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
:::success
|
||||
You're connected to a real backend! :party:
|
||||
:::
|
2
backend/public/motd.md
Normal file
2
backend/public/motd.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
This is the test motd text
|
||||
:smile:
|
99
backend/src/api/private/alias/alias.controller.ts
Normal file
99
backend/src/api/private/alias/alias.controller.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { AliasCreateDto } from '../../../notes/alias-create.dto';
|
||||
import { AliasUpdateDto } from '../../../notes/alias-update.dto';
|
||||
import { AliasDto } from '../../../notes/alias.dto';
|
||||
import { AliasService } from '../../../notes/alias.service';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { PermissionsService } from '../../../permissions/permissions.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('alias')
|
||||
@Controller('alias')
|
||||
export class AliasController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private aliasService: AliasService,
|
||||
private noteService: NotesService,
|
||||
private userService: UsersService,
|
||||
private permissionsService: PermissionsService,
|
||||
) {
|
||||
this.logger.setContext(AliasController.name);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@OpenApi(201, 400, 404, 409)
|
||||
async addAlias(
|
||||
@RequestUser() user: User,
|
||||
@Body() newAliasDto: AliasCreateDto,
|
||||
): Promise<AliasDto> {
|
||||
const note = await this.noteService.getNoteByIdOrAlias(
|
||||
newAliasDto.noteIdOrAlias,
|
||||
);
|
||||
if (!(await this.permissionsService.isOwner(user, note))) {
|
||||
throw new UnauthorizedException('Reading note denied!');
|
||||
}
|
||||
const updatedAlias = await this.aliasService.addAlias(
|
||||
note,
|
||||
newAliasDto.newAlias,
|
||||
);
|
||||
return this.aliasService.toAliasDto(updatedAlias, note);
|
||||
}
|
||||
|
||||
@Put(':alias')
|
||||
@OpenApi(200, 400, 404)
|
||||
async makeAliasPrimary(
|
||||
@RequestUser() user: User,
|
||||
@Param('alias') alias: string,
|
||||
@Body() changeAliasDto: AliasUpdateDto,
|
||||
): Promise<AliasDto> {
|
||||
if (!changeAliasDto.primaryAlias) {
|
||||
throw new BadRequestException(
|
||||
`The field 'primaryAlias' must be set to 'true'.`,
|
||||
);
|
||||
}
|
||||
const note = await this.noteService.getNoteByIdOrAlias(alias);
|
||||
if (!(await this.permissionsService.isOwner(user, note))) {
|
||||
throw new UnauthorizedException('Reading note denied!');
|
||||
}
|
||||
const updatedAlias = await this.aliasService.makeAliasPrimary(note, alias);
|
||||
return this.aliasService.toAliasDto(updatedAlias, note);
|
||||
}
|
||||
|
||||
@Delete(':alias')
|
||||
@OpenApi(204, 400, 404)
|
||||
async removeAlias(
|
||||
@RequestUser() user: User,
|
||||
@Param('alias') alias: string,
|
||||
): Promise<void> {
|
||||
const note = await this.noteService.getNoteByIdOrAlias(alias);
|
||||
if (!(await this.permissionsService.isOwner(user, note))) {
|
||||
throw new UnauthorizedException('Reading note denied!');
|
||||
}
|
||||
await this.aliasService.removeAlias(note, alias);
|
||||
return;
|
||||
}
|
||||
}
|
126
backend/src/api/private/auth/auth.controller.ts
Normal file
126
backend/src/api/private/auth/auth.controller.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Session } from 'express-session';
|
||||
|
||||
import { IdentityService } from '../../../identity/identity.service';
|
||||
import { LdapLoginDto } from '../../../identity/ldap/ldap-login.dto';
|
||||
import { LdapAuthGuard } from '../../../identity/ldap/ldap.strategy';
|
||||
import { LocalAuthGuard } from '../../../identity/local/local.strategy';
|
||||
import { LoginDto } from '../../../identity/local/login.dto';
|
||||
import { RegisterDto } from '../../../identity/local/register.dto';
|
||||
import { UpdatePasswordDto } from '../../../identity/local/update-password.dto';
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { LoginEnabledGuard } from '../../utils/login-enabled.guard';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RegistrationEnabledGuard } from '../../utils/registration-enabled.guard';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
type RequestWithSession = Request & {
|
||||
session: {
|
||||
authProvider: string;
|
||||
user: string;
|
||||
};
|
||||
};
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
private identityService: IdentityService,
|
||||
) {
|
||||
this.logger.setContext(AuthController.name);
|
||||
}
|
||||
|
||||
@UseGuards(RegistrationEnabledGuard)
|
||||
@Post('local')
|
||||
@OpenApi(201, 400, 409)
|
||||
async registerUser(@Body() registerDto: RegisterDto): Promise<void> {
|
||||
const user = await this.usersService.createUser(
|
||||
registerDto.username,
|
||||
registerDto.displayName,
|
||||
);
|
||||
// ToDo: Figure out how to rollback user if anything with this calls goes wrong
|
||||
await this.identityService.createLocalIdentity(user, registerDto.password);
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard, SessionGuard)
|
||||
@Put('local')
|
||||
@OpenApi(200, 400, 401)
|
||||
async updatePassword(
|
||||
@RequestUser() user: User,
|
||||
@Body() changePasswordDto: UpdatePasswordDto,
|
||||
): Promise<void> {
|
||||
await this.identityService.checkLocalPassword(
|
||||
user,
|
||||
changePasswordDto.currentPassword,
|
||||
);
|
||||
await this.identityService.updateLocalPassword(
|
||||
user,
|
||||
changePasswordDto.newPassword,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@UseGuards(LoginEnabledGuard, LocalAuthGuard)
|
||||
@Post('local/login')
|
||||
@OpenApi(201, 400, 401)
|
||||
login(
|
||||
@Req()
|
||||
request: RequestWithSession,
|
||||
@Body() loginDto: LoginDto,
|
||||
): void {
|
||||
// There is no further testing needed as we only get to this point if LocalAuthGuard was successful
|
||||
request.session.user = loginDto.username;
|
||||
request.session.authProvider = 'local';
|
||||
}
|
||||
|
||||
@UseGuards(LdapAuthGuard)
|
||||
@Post('ldap/:ldapIdentifier')
|
||||
@OpenApi(201, 400, 401)
|
||||
loginWithLdap(
|
||||
@Req()
|
||||
request: RequestWithSession,
|
||||
@Param('ldapIdentifier') ldapIdentifier: string,
|
||||
@Body() loginDto: LdapLoginDto,
|
||||
): void {
|
||||
// There is no further testing needed as we only get to this point if LocalAuthGuard was successful
|
||||
request.session.user = loginDto.username;
|
||||
request.session.authProvider = 'ldap';
|
||||
}
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@Delete('logout')
|
||||
@OpenApi(204, 400, 401)
|
||||
logout(@Req() request: Request & { session: Session }): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.session.destroy((err) => {
|
||||
if (err) {
|
||||
this.logger.error('Encountered an error while logging out: ${err}');
|
||||
reject(new BadRequestException('Unable to log out'));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
29
backend/src/api/private/config/config.controller.ts
Normal file
29
backend/src/api/private/config/config.controller.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FrontendConfigDto } from '../../../frontend-config/frontend-config.dto';
|
||||
import { FrontendConfigService } from '../../../frontend-config/frontend-config.service';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
|
||||
@ApiTags('config')
|
||||
@Controller('config')
|
||||
export class ConfigController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private frontendConfigService: FrontendConfigService,
|
||||
) {
|
||||
this.logger.setContext(ConfigController.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@OpenApi(200)
|
||||
async getFrontendConfig(): Promise<FrontendConfigDto> {
|
||||
return await this.frontendConfigService.getFrontendConfig();
|
||||
}
|
||||
}
|
34
backend/src/api/private/groups/groups.controller.ts
Normal file
34
backend/src/api/private/groups/groups.controller.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { GroupInfoDto } from '../../../groups/group-info.dto';
|
||||
import { GroupsService } from '../../../groups/groups.service';
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401, 403)
|
||||
@ApiTags('groups')
|
||||
@Controller('groups')
|
||||
export class GroupsController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private groupService: GroupsService,
|
||||
) {
|
||||
this.logger.setContext(GroupsController.name);
|
||||
}
|
||||
|
||||
@Get(':groupName')
|
||||
@OpenApi(200)
|
||||
async getGroup(@Param('groupName') groupName: string): Promise<GroupInfoDto> {
|
||||
return this.groupService.toGroupDto(
|
||||
await this.groupService.getGroupByName(groupName),
|
||||
);
|
||||
}
|
||||
}
|
92
backend/src/api/private/me/history/history.controller.ts
Normal file
92
backend/src/api/private/me/history/history.controller.ts
Normal file
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { HistoryEntryImportListDto } from '../../../../history/history-entry-import.dto';
|
||||
import { HistoryEntryUpdateDto } from '../../../../history/history-entry-update.dto';
|
||||
import { HistoryEntryDto } from '../../../../history/history-entry.dto';
|
||||
import { HistoryService } from '../../../../history/history.service';
|
||||
import { SessionGuard } from '../../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
|
||||
import { Note } from '../../../../notes/note.entity';
|
||||
import { User } from '../../../../users/user.entity';
|
||||
import { GetNoteInterceptor } from '../../../utils/get-note.interceptor';
|
||||
import { OpenApi } from '../../../utils/openapi.decorator';
|
||||
import { RequestNote } from '../../../utils/request-note.decorator';
|
||||
import { RequestUser } from '../../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('history')
|
||||
@Controller('/me/history')
|
||||
export class HistoryController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private historyService: HistoryService,
|
||||
) {
|
||||
this.logger.setContext(HistoryController.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@OpenApi(200, 404)
|
||||
async getHistory(@RequestUser() user: User): Promise<HistoryEntryDto[]> {
|
||||
const foundEntries = await this.historyService.getEntriesByUser(user);
|
||||
return await Promise.all(
|
||||
foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@OpenApi(201, 404)
|
||||
async setHistory(
|
||||
@RequestUser() user: User,
|
||||
@Body() historyImport: HistoryEntryImportListDto,
|
||||
): Promise<void> {
|
||||
await this.historyService.setHistory(user, historyImport.history);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@OpenApi(204, 404)
|
||||
async deleteHistory(@RequestUser() user: User): Promise<void> {
|
||||
await this.historyService.deleteHistory(user);
|
||||
}
|
||||
|
||||
@Put(':noteIdOrAlias')
|
||||
@OpenApi(200, 404)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async updateHistoryEntry(
|
||||
@RequestNote() note: Note,
|
||||
@RequestUser() user: User,
|
||||
@Body() entryUpdateDto: HistoryEntryUpdateDto,
|
||||
): Promise<HistoryEntryDto> {
|
||||
const newEntry = await this.historyService.updateHistoryEntry(
|
||||
note,
|
||||
user,
|
||||
entryUpdateDto,
|
||||
);
|
||||
return await this.historyService.toHistoryEntryDto(newEntry);
|
||||
}
|
||||
|
||||
@Delete(':noteIdOrAlias')
|
||||
@OpenApi(204, 404)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async deleteHistoryEntry(
|
||||
@RequestNote() note: Note,
|
||||
@RequestUser() user: User,
|
||||
): Promise<void> {
|
||||
await this.historyService.deleteHistoryEntry(note, user);
|
||||
}
|
||||
}
|
80
backend/src/api/private/me/me.controller.ts
Normal file
80
backend/src/api/private/me/me.controller.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Body, Controller, Delete, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBody, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||
import { MediaService } from '../../../media/media.service';
|
||||
import { UserLoginInfoDto } from '../../../users/user-info.dto';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
import { SessionAuthProvider } from '../../utils/session-authprovider.decorator';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('me')
|
||||
@Controller('me')
|
||||
export class MeController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private userService: UsersService,
|
||||
private mediaService: MediaService,
|
||||
) {
|
||||
this.logger.setContext(MeController.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@OpenApi(200)
|
||||
getMe(
|
||||
@RequestUser() user: User,
|
||||
@SessionAuthProvider() authProvider: string,
|
||||
): UserLoginInfoDto {
|
||||
return this.userService.toUserLoginInfoDto(user, authProvider);
|
||||
}
|
||||
|
||||
@Get('media')
|
||||
@OpenApi(200)
|
||||
async getMyMedia(@RequestUser() user: User): Promise<MediaUploadDto[]> {
|
||||
const media = await this.mediaService.listUploadsByUser(user);
|
||||
return await Promise.all(
|
||||
media.map((media) => this.mediaService.toMediaUploadDto(media)),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@OpenApi(204, 404, 500)
|
||||
async deleteUser(@RequestUser() user: User): Promise<void> {
|
||||
const mediaUploads = await this.mediaService.listUploadsByUser(user);
|
||||
for (const mediaUpload of mediaUploads) {
|
||||
await this.mediaService.deleteFile(mediaUpload);
|
||||
}
|
||||
this.logger.debug(`Deleted all media uploads of ${user.username}`);
|
||||
await this.userService.deleteUser(user);
|
||||
this.logger.debug(`Deleted ${user.username}`);
|
||||
}
|
||||
|
||||
@Post('profile')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayName: { type: 'string', nullable: false },
|
||||
},
|
||||
required: ['displayName'],
|
||||
},
|
||||
})
|
||||
@OpenApi(200)
|
||||
async updateDisplayName(
|
||||
@RequestUser() user: User,
|
||||
@Body('displayName') newDisplayName: string,
|
||||
): Promise<void> {
|
||||
await this.userService.changeDisplayName(user, newDisplayName);
|
||||
}
|
||||
}
|
111
backend/src/api/private/media/media.controller.ts
Normal file
111
backend/src/api/private/media/media.controller.ts
Normal file
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { PermissionError } from '../../../errors/errors';
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||
import { MediaService } from '../../../media/media.service';
|
||||
import { MulterFile } from '../../../media/multer-file.interface';
|
||||
import { Note } from '../../../notes/note.entity';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestNote } from '../../utils/request-note.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('media')
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private mediaService: MediaService,
|
||||
private noteService: NotesService,
|
||||
) {
|
||||
this.logger.setContext(MediaController.name);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'HedgeDoc-Note',
|
||||
description: 'ID or alias of the parent note',
|
||||
})
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@UseInterceptors(NoteHeaderInterceptor)
|
||||
@OpenApi(
|
||||
{
|
||||
code: 201,
|
||||
description: 'The file was uploaded successfully',
|
||||
dto: MediaUploadDto,
|
||||
},
|
||||
400,
|
||||
403,
|
||||
404,
|
||||
500,
|
||||
)
|
||||
async uploadMedia(
|
||||
@UploadedFile() file: MulterFile,
|
||||
@RequestNote() note: Note,
|
||||
@RequestUser() user: User,
|
||||
): Promise<MediaUploadDto> {
|
||||
this.logger.debug(
|
||||
`Recieved filename '${file.originalname}' for note '${note.id}' from user '${user.username}'`,
|
||||
'uploadMedia',
|
||||
);
|
||||
const upload = await this.mediaService.saveFile(file.buffer, user, note);
|
||||
return await this.mediaService.toMediaUploadDto(upload);
|
||||
}
|
||||
|
||||
@Delete(':filename')
|
||||
@OpenApi(204, 403, 404, 500)
|
||||
async deleteMedia(
|
||||
@RequestUser() user: User,
|
||||
@Param('filename') filename: string,
|
||||
): Promise<void> {
|
||||
const username = user.username;
|
||||
this.logger.debug(
|
||||
`Deleting '${filename}' for user '${username}'`,
|
||||
'deleteMedia',
|
||||
);
|
||||
const mediaUpload = await this.mediaService.findUploadByFilename(filename);
|
||||
if ((await mediaUpload.user).username !== username) {
|
||||
this.logger.warn(
|
||||
`${username} tried to delete '${filename}', but is not the owner`,
|
||||
'deleteMedia',
|
||||
);
|
||||
throw new PermissionError(
|
||||
`File '${filename}' is not owned by '${username}'`,
|
||||
);
|
||||
}
|
||||
await this.mediaService.deleteFile(mediaUpload);
|
||||
}
|
||||
}
|
294
backend/src/api/private/notes/notes.controller.ts
Normal file
294
backend/src/api/private/notes/notes.controller.ts
Normal file
|
@ -0,0 +1,294 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TokenAuthGuard } from '../../../auth/token.strategy';
|
||||
import { NotInDBError } from '../../../errors/errors';
|
||||
import { GroupsService } from '../../../groups/groups.service';
|
||||
import { HistoryService } from '../../../history/history.service';
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||
import { MediaService } from '../../../media/media.service';
|
||||
import { NoteMetadataDto } from '../../../notes/note-metadata.dto';
|
||||
import { NotePermissionsDto } from '../../../notes/note-permissions.dto';
|
||||
import { NoteDto } from '../../../notes/note.dto';
|
||||
import { Note } from '../../../notes/note.entity';
|
||||
import { NoteMediaDeletionDto } from '../../../notes/note.media-deletion.dto';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { Permission } from '../../../permissions/permissions.enum';
|
||||
import { PermissionsService } from '../../../permissions/permissions.service';
|
||||
import { RevisionMetadataDto } from '../../../revisions/revision-metadata.dto';
|
||||
import { RevisionDto } from '../../../revisions/revision.dto';
|
||||
import { RevisionsService } from '../../../revisions/revisions.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { GetNoteInterceptor } from '../../utils/get-note.interceptor';
|
||||
import { MarkdownBody } from '../../utils/markdown-body.decorator';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { Permissions } from '../../utils/permissions.decorator';
|
||||
import { PermissionsGuard } from '../../utils/permissions.guard';
|
||||
import { RequestNote } from '../../utils/request-note.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(SessionGuard, PermissionsGuard)
|
||||
@OpenApi(401, 403)
|
||||
@ApiTags('notes')
|
||||
@Controller('notes')
|
||||
export class NotesController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private noteService: NotesService,
|
||||
private historyService: HistoryService,
|
||||
private userService: UsersService,
|
||||
private mediaService: MediaService,
|
||||
private revisionsService: RevisionsService,
|
||||
private permissionService: PermissionsService,
|
||||
private groupService: GroupsService,
|
||||
) {
|
||||
this.logger.setContext(NotesController.name);
|
||||
}
|
||||
|
||||
@Get(':noteIdOrAlias')
|
||||
@OpenApi(200)
|
||||
@Permissions(Permission.READ)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async getNote(
|
||||
@RequestUser({ guestsAllowed: true }) user: User | null,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<NoteDto> {
|
||||
await this.historyService.updateHistoryEntryTimestamp(note, user);
|
||||
return await this.noteService.toNoteDto(note);
|
||||
}
|
||||
|
||||
@Get(':noteIdOrAlias/media')
|
||||
@OpenApi(200)
|
||||
@Permissions(Permission.READ)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async getNotesMedia(@RequestNote() note: Note): Promise<MediaUploadDto[]> {
|
||||
const media = await this.mediaService.listUploadsByNote(note);
|
||||
return await Promise.all(
|
||||
media.map((media) => this.mediaService.toMediaUploadDto(media)),
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@OpenApi(201, 413)
|
||||
@Permissions(Permission.CREATE)
|
||||
async createNote(
|
||||
@RequestUser({ guestsAllowed: true }) user: User | null,
|
||||
@MarkdownBody() text: string,
|
||||
): Promise<NoteDto> {
|
||||
this.logger.debug('Got raw markdown:\n' + text, 'createNote');
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.noteService.createNote(text, user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':noteAlias')
|
||||
@OpenApi(201, 400, 404, 409, 413)
|
||||
@Permissions(Permission.CREATE)
|
||||
async createNamedNote(
|
||||
@RequestUser({ guestsAllowed: true }) user: User | null,
|
||||
@Param('noteAlias') noteAlias: string,
|
||||
@MarkdownBody() text: string,
|
||||
): Promise<NoteDto> {
|
||||
this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote');
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.noteService.createNote(text, user, noteAlias),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':noteIdOrAlias')
|
||||
@OpenApi(204, 404, 500)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async deleteNote(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Body() noteMediaDeletionDto: NoteMediaDeletionDto,
|
||||
): Promise<void> {
|
||||
const mediaUploads = await this.mediaService.listUploadsByNote(note);
|
||||
for (const mediaUpload of mediaUploads) {
|
||||
if (!noteMediaDeletionDto.keepMedia) {
|
||||
await this.mediaService.deleteFile(mediaUpload);
|
||||
} else {
|
||||
await this.mediaService.removeNoteFromMediaUpload(mediaUpload);
|
||||
}
|
||||
}
|
||||
this.logger.debug(`Deleting note: ${note.id}`, 'deleteNote');
|
||||
await this.noteService.deleteNote(note);
|
||||
this.logger.debug(`Successfully deleted ${note.id}`, 'deleteNote');
|
||||
return;
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias/metadata')
|
||||
async getNoteMetadata(
|
||||
@RequestUser({ guestsAllowed: true }) user: User | null,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<NoteMetadataDto> {
|
||||
return await this.noteService.toNoteMetadataDto(note);
|
||||
}
|
||||
|
||||
@Get(':noteIdOrAlias/revisions')
|
||||
@OpenApi(200, 404)
|
||||
@Permissions(Permission.READ)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async getNoteRevisions(
|
||||
@RequestUser({ guestsAllowed: true }) user: User | null,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<RevisionMetadataDto[]> {
|
||||
const revisions = await this.revisionsService.getAllRevisions(note);
|
||||
return await Promise.all(
|
||||
revisions.map((revision) =>
|
||||
this.revisionsService.toRevisionMetadataDto(revision),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':noteIdOrAlias/revisions')
|
||||
@OpenApi(204, 404)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async purgeNoteRevisions(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<void> {
|
||||
this.logger.debug(
|
||||
`Purging history of note: ${note.id}`,
|
||||
'purgeNoteRevisions',
|
||||
);
|
||||
await this.revisionsService.purgeRevisions(note);
|
||||
this.logger.debug(
|
||||
`Successfully purged history of note ${note.id}`,
|
||||
'purgeNoteRevisions',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@Get(':noteIdOrAlias/revisions/:revisionId')
|
||||
@OpenApi(200, 404)
|
||||
@Permissions(Permission.READ)
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
async getNoteRevision(
|
||||
@RequestUser({ guestsAllowed: true }) user: User | null,
|
||||
@RequestNote() note: Note,
|
||||
@Param('revisionId') revisionId: number,
|
||||
): Promise<RevisionDto> {
|
||||
return await this.revisionsService.toRevisionDto(
|
||||
await this.revisionsService.getRevision(note, revisionId),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
async setUserPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('userName') username: string,
|
||||
@Body() canEdit: boolean,
|
||||
): Promise<NotePermissionsDto> {
|
||||
const permissionUser = await this.userService.getUserByUsername(username);
|
||||
const returnedNote = await this.permissionService.setUserPermission(
|
||||
note,
|
||||
permissionUser,
|
||||
canEdit,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Delete(':noteIdOrAlias/metadata/permissions/users/:userName')
|
||||
async removeUserPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('userName') username: string,
|
||||
): Promise<NotePermissionsDto> {
|
||||
try {
|
||||
const permissionUser = await this.userService.getUserByUsername(username);
|
||||
const returnedNote = await this.permissionService.removeUserPermission(
|
||||
note,
|
||||
permissionUser,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
} catch (e) {
|
||||
if (e instanceof NotInDBError) {
|
||||
throw new BadRequestException(
|
||||
"Can't remove user from permissions. User not known.",
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Put(':noteIdOrAlias/metadata/permissions/groups/:groupName')
|
||||
async setGroupPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('groupName') groupName: string,
|
||||
@Body() canEdit: boolean,
|
||||
): Promise<NotePermissionsDto> {
|
||||
const permissionGroup = await this.groupService.getGroupByName(groupName);
|
||||
const returnedNote = await this.permissionService.setGroupPermission(
|
||||
note,
|
||||
permissionGroup,
|
||||
canEdit,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName')
|
||||
async removeGroupPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('groupName') groupName: string,
|
||||
): Promise<NotePermissionsDto> {
|
||||
const permissionGroup = await this.groupService.getGroupByName(groupName);
|
||||
const returnedNote = await this.permissionService.removeGroupPermission(
|
||||
note,
|
||||
permissionGroup,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Put(':noteIdOrAlias/metadata/permissions/owner')
|
||||
async changeOwner(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Body() newOwner: string,
|
||||
): Promise<NoteDto> {
|
||||
const owner = await this.userService.getUserByUsername(newOwner);
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.permissionService.changeOwner(note, owner),
|
||||
);
|
||||
}
|
||||
}
|
57
backend/src/api/private/private-api.module.ts
Normal file
57
backend/src/api/private/private-api.module.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
|
||||
import { GroupsModule } from '../../groups/groups.module';
|
||||
import { HistoryModule } from '../../history/history.module';
|
||||
import { IdentityModule } from '../../identity/identity.module';
|
||||
import { LoggerModule } from '../../logger/logger.module';
|
||||
import { MediaModule } from '../../media/media.module';
|
||||
import { NotesModule } from '../../notes/notes.module';
|
||||
import { PermissionsModule } from '../../permissions/permissions.module';
|
||||
import { RevisionsModule } from '../../revisions/revisions.module';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
import { AliasController } from './alias/alias.controller';
|
||||
import { AuthController } from './auth/auth.controller';
|
||||
import { ConfigController } from './config/config.controller';
|
||||
import { GroupsController } from './groups/groups.controller';
|
||||
import { HistoryController } from './me/history/history.controller';
|
||||
import { MeController } from './me/me.controller';
|
||||
import { MediaController } from './media/media.controller';
|
||||
import { NotesController } from './notes/notes.controller';
|
||||
import { TokensController } from './tokens/tokens.controller';
|
||||
import { UsersController } from './users/users.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
FrontendConfigModule,
|
||||
HistoryModule,
|
||||
PermissionsModule,
|
||||
NotesModule,
|
||||
MediaModule,
|
||||
RevisionsModule,
|
||||
IdentityModule,
|
||||
GroupsModule,
|
||||
],
|
||||
controllers: [
|
||||
TokensController,
|
||||
ConfigController,
|
||||
MediaController,
|
||||
HistoryController,
|
||||
MeController,
|
||||
NotesController,
|
||||
AliasController,
|
||||
AuthController,
|
||||
UsersController,
|
||||
GroupsController,
|
||||
],
|
||||
})
|
||||
export class PrivateApiModule {}
|
79
backend/src/api/private/tokens/tokens.controller.ts
Normal file
79
backend/src/api/private/tokens/tokens.controller.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
AuthTokenCreateDto,
|
||||
AuthTokenDto,
|
||||
AuthTokenWithSecretDto,
|
||||
} from '../../../auth/auth-token.dto';
|
||||
import { AuthService } from '../../../auth/auth.service';
|
||||
import { SessionGuard } from '../../../identity/session.guard';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(SessionGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('tokens')
|
||||
@Controller('tokens')
|
||||
export class TokensController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
this.logger.setContext(TokensController.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@OpenApi(200)
|
||||
async getUserTokens(@RequestUser() user: User): Promise<AuthTokenDto[]> {
|
||||
return (await this.authService.getTokensByUser(user)).map((token) =>
|
||||
this.authService.toAuthTokenDto(token),
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@OpenApi(201)
|
||||
async postTokenRequest(
|
||||
@Body() createDto: AuthTokenCreateDto,
|
||||
@RequestUser() user: User,
|
||||
): Promise<AuthTokenWithSecretDto> {
|
||||
return await this.authService.addToken(
|
||||
user,
|
||||
createDto.label,
|
||||
createDto.validUntil,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('/:keyId')
|
||||
@OpenApi(204, 404)
|
||||
async deleteToken(
|
||||
@RequestUser() user: User,
|
||||
@Param('keyId') keyId: string,
|
||||
): Promise<void> {
|
||||
const tokens = await this.authService.getTokensByUser(user);
|
||||
for (const token of tokens) {
|
||||
if (token.keyId == keyId) {
|
||||
return await this.authService.removeToken(keyId);
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException(
|
||||
'User is not authorized to delete this token',
|
||||
);
|
||||
}
|
||||
}
|
31
backend/src/api/private/users/users.controller.ts
Normal file
31
backend/src/api/private/users/users.controller.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { UserInfoDto } from '../../../users/user-info.dto';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private userService: UsersService,
|
||||
) {
|
||||
this.logger.setContext(UsersController.name);
|
||||
}
|
||||
|
||||
@Get(':username')
|
||||
@OpenApi(200)
|
||||
async getUser(@Param('username') username: string): Promise<UserInfoDto> {
|
||||
return this.userService.toUserDto(
|
||||
await this.userService.getUserByUsername(username),
|
||||
);
|
||||
}
|
||||
}
|
121
backend/src/api/public/alias/alias.controller.ts
Normal file
121
backend/src/api/public/alias/alias.controller.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TokenAuthGuard } from '../../../auth/token.strategy';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { AliasCreateDto } from '../../../notes/alias-create.dto';
|
||||
import { AliasUpdateDto } from '../../../notes/alias-update.dto';
|
||||
import { AliasDto } from '../../../notes/alias.dto';
|
||||
import { AliasService } from '../../../notes/alias.service';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { PermissionsService } from '../../../permissions/permissions.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(TokenAuthGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('alias')
|
||||
@ApiSecurity('token')
|
||||
@Controller('alias')
|
||||
export class AliasController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private aliasService: AliasService,
|
||||
private noteService: NotesService,
|
||||
private permissionsService: PermissionsService,
|
||||
) {
|
||||
this.logger.setContext(AliasController.name);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The new alias',
|
||||
dto: AliasDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async addAlias(
|
||||
@RequestUser() user: User,
|
||||
@Body() newAliasDto: AliasCreateDto,
|
||||
): Promise<AliasDto> {
|
||||
const note = await this.noteService.getNoteByIdOrAlias(
|
||||
newAliasDto.noteIdOrAlias,
|
||||
);
|
||||
if (!(await this.permissionsService.isOwner(user, note))) {
|
||||
throw new UnauthorizedException('Reading note denied!');
|
||||
}
|
||||
const updatedAlias = await this.aliasService.addAlias(
|
||||
note,
|
||||
newAliasDto.newAlias,
|
||||
);
|
||||
return this.aliasService.toAliasDto(updatedAlias, note);
|
||||
}
|
||||
|
||||
@Put(':alias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The updated alias',
|
||||
dto: AliasDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async makeAliasPrimary(
|
||||
@RequestUser() user: User,
|
||||
@Param('alias') alias: string,
|
||||
@Body() changeAliasDto: AliasUpdateDto,
|
||||
): Promise<AliasDto> {
|
||||
if (!changeAliasDto.primaryAlias) {
|
||||
throw new BadRequestException(
|
||||
`The field 'primaryAlias' must be set to 'true'.`,
|
||||
);
|
||||
}
|
||||
const note = await this.noteService.getNoteByIdOrAlias(alias);
|
||||
if (!(await this.permissionsService.isOwner(user, note))) {
|
||||
throw new UnauthorizedException('Reading note denied!');
|
||||
}
|
||||
const updatedAlias = await this.aliasService.makeAliasPrimary(note, alias);
|
||||
return this.aliasService.toAliasDto(updatedAlias, note);
|
||||
}
|
||||
|
||||
@Delete(':alias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 204,
|
||||
description: 'The alias was deleted',
|
||||
},
|
||||
400,
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async removeAlias(
|
||||
@RequestUser() user: User,
|
||||
@Param('alias') alias: string,
|
||||
): Promise<void> {
|
||||
const note = await this.noteService.getNoteByIdOrAlias(alias);
|
||||
if (!(await this.permissionsService.isOwner(user, note))) {
|
||||
throw new UnauthorizedException('Reading note denied!');
|
||||
}
|
||||
await this.aliasService.removeAlias(note, alias);
|
||||
}
|
||||
}
|
152
backend/src/api/public/me/me.controller.ts
Normal file
152
backend/src/api/public/me/me.controller.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Put,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TokenAuthGuard } from '../../../auth/token.strategy';
|
||||
import { HistoryEntryUpdateDto } from '../../../history/history-entry-update.dto';
|
||||
import { HistoryEntryDto } from '../../../history/history-entry.dto';
|
||||
import { HistoryService } from '../../../history/history.service';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||
import { MediaService } from '../../../media/media.service';
|
||||
import { NoteMetadataDto } from '../../../notes/note-metadata.dto';
|
||||
import { Note } from '../../../notes/note.entity';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { FullUserInfoDto } from '../../../users/user-info.dto';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { GetNoteInterceptor } from '../../utils/get-note.interceptor';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestNote } from '../../utils/request-note.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(TokenAuthGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('me')
|
||||
@ApiSecurity('token')
|
||||
@Controller('me')
|
||||
export class MeController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
private historyService: HistoryService,
|
||||
private notesService: NotesService,
|
||||
private mediaService: MediaService,
|
||||
) {
|
||||
this.logger.setContext(MeController.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@OpenApi({
|
||||
code: 200,
|
||||
description: 'The user information',
|
||||
dto: FullUserInfoDto,
|
||||
})
|
||||
getMe(@RequestUser() user: User): FullUserInfoDto {
|
||||
return this.usersService.toFullUserDto(user);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@OpenApi({
|
||||
code: 200,
|
||||
description: 'The history entries of the user',
|
||||
isArray: true,
|
||||
dto: HistoryEntryDto,
|
||||
})
|
||||
async getUserHistory(@RequestUser() user: User): Promise<HistoryEntryDto[]> {
|
||||
const foundEntries = await this.historyService.getEntriesByUser(user);
|
||||
return await Promise.all(
|
||||
foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Get('history/:noteIdOrAlias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The history entry of the user which points to the note',
|
||||
dto: HistoryEntryDto,
|
||||
},
|
||||
404,
|
||||
)
|
||||
async getHistoryEntry(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<HistoryEntryDto> {
|
||||
const foundEntry = await this.historyService.getEntryByNote(note, user);
|
||||
return await this.historyService.toHistoryEntryDto(foundEntry);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Put('history/:noteIdOrAlias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The updated history entry',
|
||||
dto: HistoryEntryDto,
|
||||
},
|
||||
404,
|
||||
)
|
||||
async updateHistoryEntry(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Body() entryUpdateDto: HistoryEntryUpdateDto,
|
||||
): Promise<HistoryEntryDto> {
|
||||
// ToDo: Check if user is allowed to pin this history entry
|
||||
return await this.historyService.toHistoryEntryDto(
|
||||
await this.historyService.updateHistoryEntry(note, user, entryUpdateDto),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Delete('history/:noteIdOrAlias')
|
||||
@OpenApi(204, 404)
|
||||
async deleteHistoryEntry(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<void> {
|
||||
// ToDo: Check if user is allowed to delete note
|
||||
await this.historyService.deleteHistoryEntry(note, user);
|
||||
}
|
||||
|
||||
@Get('notes')
|
||||
@OpenApi({
|
||||
code: 200,
|
||||
description: 'Metadata of all notes of the user',
|
||||
isArray: true,
|
||||
dto: NoteMetadataDto,
|
||||
})
|
||||
async getMyNotes(@RequestUser() user: User): Promise<NoteMetadataDto[]> {
|
||||
const notes = this.notesService.getUserNotes(user);
|
||||
return await Promise.all(
|
||||
(await notes).map((note) => this.notesService.toNoteMetadataDto(note)),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('media')
|
||||
@OpenApi({
|
||||
code: 200,
|
||||
description: 'All media uploads of the user',
|
||||
isArray: true,
|
||||
dto: MediaUploadDto,
|
||||
})
|
||||
async getMyMedia(@RequestUser() user: User): Promise<MediaUploadDto[]> {
|
||||
const media = await this.mediaService.listUploadsByUser(user);
|
||||
return await Promise.all(
|
||||
media.map((media) => this.mediaService.toMediaUploadDto(media)),
|
||||
);
|
||||
}
|
||||
}
|
118
backend/src/api/public/media/media.controller.ts
Normal file
118
backend/src/api/public/media/media.controller.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Param,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiHeader,
|
||||
ApiSecurity,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { TokenAuthGuard } from '../../../auth/token.strategy';
|
||||
import { PermissionError } from '../../../errors/errors';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||
import { MediaService } from '../../../media/media.service';
|
||||
import { MulterFile } from '../../../media/multer-file.interface';
|
||||
import { Note } from '../../../notes/note.entity';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { RequestNote } from '../../utils/request-note.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(TokenAuthGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('media')
|
||||
@ApiSecurity('token')
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private mediaService: MediaService,
|
||||
private noteService: NotesService,
|
||||
) {
|
||||
this.logger.setContext(MediaController.name);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: {
|
||||
type: 'string',
|
||||
format: 'binary',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'HedgeDoc-Note',
|
||||
description: 'ID or alias of the parent note',
|
||||
})
|
||||
@OpenApi(
|
||||
{
|
||||
code: 201,
|
||||
description: 'The file was uploaded successfully',
|
||||
dto: MediaUploadDto,
|
||||
},
|
||||
400,
|
||||
403,
|
||||
404,
|
||||
500,
|
||||
)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@UseInterceptors(NoteHeaderInterceptor)
|
||||
async uploadMedia(
|
||||
@RequestUser() user: User,
|
||||
@UploadedFile() file: MulterFile,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<MediaUploadDto> {
|
||||
this.logger.debug(
|
||||
`Recieved filename '${file.originalname}' for note '${note.id}' from user '${user.username}'`,
|
||||
'uploadMedia',
|
||||
);
|
||||
const upload = await this.mediaService.saveFile(file.buffer, user, note);
|
||||
return await this.mediaService.toMediaUploadDto(upload);
|
||||
}
|
||||
|
||||
@Delete(':filename')
|
||||
@OpenApi(204, 403, 404, 500)
|
||||
async deleteMedia(
|
||||
@RequestUser() user: User,
|
||||
@Param('filename') filename: string,
|
||||
): Promise<void> {
|
||||
const username = user.username;
|
||||
this.logger.debug(
|
||||
`Deleting '${filename}' for user '${username}'`,
|
||||
'deleteMedia',
|
||||
);
|
||||
const mediaUpload = await this.mediaService.findUploadByFilename(filename);
|
||||
if ((await mediaUpload.user).username !== username) {
|
||||
this.logger.warn(
|
||||
`${username} tried to delete '${filename}', but is not the owner`,
|
||||
'deleteMedia',
|
||||
);
|
||||
throw new PermissionError(
|
||||
`File '${filename}' is not owned by '${username}'`,
|
||||
);
|
||||
}
|
||||
await this.mediaService.deleteFile(mediaUpload);
|
||||
}
|
||||
}
|
48
backend/src/api/public/monitoring/monitoring.controller.ts
Normal file
48
backend/src/api/public/monitoring/monitoring.controller.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TokenAuthGuard } from '../../../auth/token.strategy';
|
||||
import { MonitoringService } from '../../../monitoring/monitoring.service';
|
||||
import { ServerStatusDto } from '../../../monitoring/server-status.dto';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
|
||||
@UseGuards(TokenAuthGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('monitoring')
|
||||
@ApiSecurity('token')
|
||||
@Controller('monitoring')
|
||||
export class MonitoringController {
|
||||
constructor(private monitoringService: MonitoringService) {}
|
||||
|
||||
@Get()
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The server info',
|
||||
dto: ServerStatusDto,
|
||||
},
|
||||
403,
|
||||
)
|
||||
getStatus(): Promise<ServerStatusDto> {
|
||||
// TODO: toServerStatusDto.
|
||||
return this.monitoringService.getServerStatus();
|
||||
}
|
||||
|
||||
@Get('prometheus')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Prometheus compatible monitoring data',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
403,
|
||||
)
|
||||
getPrometheusStatus(): string {
|
||||
return '';
|
||||
}
|
||||
}
|
459
backend/src/api/public/notes/notes.controller.ts
Normal file
459
backend/src/api/public/notes/notes.controller.ts
Normal file
|
@ -0,0 +1,459 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { TokenAuthGuard } from '../../../auth/token.strategy';
|
||||
import { NotInDBError } from '../../../errors/errors';
|
||||
import { GroupsService } from '../../../groups/groups.service';
|
||||
import { HistoryService } from '../../../history/history.service';
|
||||
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||
import { MediaService } from '../../../media/media.service';
|
||||
import { NoteMetadataDto } from '../../../notes/note-metadata.dto';
|
||||
import {
|
||||
NotePermissionsDto,
|
||||
NotePermissionsUpdateDto,
|
||||
} from '../../../notes/note-permissions.dto';
|
||||
import { NoteDto } from '../../../notes/note.dto';
|
||||
import { Note } from '../../../notes/note.entity';
|
||||
import { NoteMediaDeletionDto } from '../../../notes/note.media-deletion.dto';
|
||||
import { NotesService } from '../../../notes/notes.service';
|
||||
import { Permission } from '../../../permissions/permissions.enum';
|
||||
import { PermissionsService } from '../../../permissions/permissions.service';
|
||||
import { RevisionMetadataDto } from '../../../revisions/revision-metadata.dto';
|
||||
import { RevisionDto } from '../../../revisions/revision.dto';
|
||||
import { RevisionsService } from '../../../revisions/revisions.service';
|
||||
import { User } from '../../../users/user.entity';
|
||||
import { UsersService } from '../../../users/users.service';
|
||||
import { GetNoteInterceptor } from '../../utils/get-note.interceptor';
|
||||
import { MarkdownBody } from '../../utils/markdown-body.decorator';
|
||||
import { OpenApi } from '../../utils/openapi.decorator';
|
||||
import { Permissions } from '../../utils/permissions.decorator';
|
||||
import { PermissionsGuard } from '../../utils/permissions.guard';
|
||||
import { RequestNote } from '../../utils/request-note.decorator';
|
||||
import { RequestUser } from '../../utils/request-user.decorator';
|
||||
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@OpenApi(401)
|
||||
@ApiTags('notes')
|
||||
@ApiSecurity('token')
|
||||
@Controller('notes')
|
||||
export class NotesController {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private noteService: NotesService,
|
||||
private userService: UsersService,
|
||||
private groupService: GroupsService,
|
||||
private revisionsService: RevisionsService,
|
||||
private historyService: HistoryService,
|
||||
private mediaService: MediaService,
|
||||
private permissionService: PermissionsService,
|
||||
) {
|
||||
this.logger.setContext(NotesController.name);
|
||||
}
|
||||
|
||||
@Permissions(Permission.CREATE)
|
||||
@Post()
|
||||
@OpenApi(201, 403, 409, 413)
|
||||
async createNote(
|
||||
@RequestUser() user: User,
|
||||
@MarkdownBody() text: string,
|
||||
): Promise<NoteDto> {
|
||||
this.logger.debug('Got raw markdown:\n' + text);
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.noteService.createNote(text, user),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Get information about the newly created note',
|
||||
dto: NoteDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async getNote(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<NoteDto> {
|
||||
await this.historyService.updateHistoryEntryTimestamp(note, user);
|
||||
return await this.noteService.toNoteDto(note);
|
||||
}
|
||||
|
||||
@Permissions(Permission.CREATE)
|
||||
@UseGuards(PermissionsGuard)
|
||||
@Post(':noteAlias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 201,
|
||||
description: 'Get information about the newly created note',
|
||||
dto: NoteDto,
|
||||
},
|
||||
400,
|
||||
403,
|
||||
409,
|
||||
413,
|
||||
)
|
||||
async createNamedNote(
|
||||
@RequestUser() user: User,
|
||||
@Param('noteAlias') noteAlias: string,
|
||||
@MarkdownBody() text: string,
|
||||
): Promise<NoteDto> {
|
||||
this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote');
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.noteService.createNote(text, user, noteAlias),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@Delete(':noteIdOrAlias')
|
||||
@OpenApi(204, 403, 404, 500)
|
||||
async deleteNote(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Body() noteMediaDeletionDto: NoteMediaDeletionDto,
|
||||
): Promise<void> {
|
||||
const mediaUploads = await this.mediaService.listUploadsByNote(note);
|
||||
for (const mediaUpload of mediaUploads) {
|
||||
if (!noteMediaDeletionDto.keepMedia) {
|
||||
await this.mediaService.deleteFile(mediaUpload);
|
||||
} else {
|
||||
await this.mediaService.removeNoteFromMediaUpload(mediaUpload);
|
||||
}
|
||||
}
|
||||
this.logger.debug(`Deleting note: ${note.id}`, 'deleteNote');
|
||||
await this.noteService.deleteNote(note);
|
||||
this.logger.debug(`Successfully deleted ${note.id}`, 'deleteNote');
|
||||
return;
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.WRITE)
|
||||
@Put(':noteIdOrAlias')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The new, changed note',
|
||||
dto: NoteDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async updateNote(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@MarkdownBody() text: string,
|
||||
): Promise<NoteDto> {
|
||||
this.logger.debug('Got raw markdown:\n' + text, 'updateNote');
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.noteService.updateNote(note, text),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias/content')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The raw markdown content of the note',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async getNoteContent(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<string> {
|
||||
return await this.noteService.getNoteContent(note);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias/metadata')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The metadata of the note',
|
||||
dto: NoteMetadataDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async getNoteMetadata(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<NoteMetadataDto> {
|
||||
return await this.noteService.toNoteMetadataDto(note);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@Put(':noteIdOrAlias/metadata/permissions')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'The updated permissions of the note',
|
||||
dto: NotePermissionsDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async updateNotePermissions(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Body() updateDto: NotePermissionsUpdateDto,
|
||||
): Promise<NotePermissionsDto> {
|
||||
return await this.noteService.toNotePermissionsDto(
|
||||
await this.permissionService.updateNotePermissions(note, updateDto),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Get(':noteIdOrAlias/metadata/permissions')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Get the permissions for a note',
|
||||
dto: NotePermissionsDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async getPermissions(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<NotePermissionsDto> {
|
||||
return await this.noteService.toNotePermissionsDto(note);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Set the permissions for a user on a note',
|
||||
dto: NotePermissionsDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async setUserPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('userName') username: string,
|
||||
@Body() canEdit: boolean,
|
||||
): Promise<NotePermissionsDto> {
|
||||
const permissionUser = await this.userService.getUserByUsername(username);
|
||||
const returnedNote = await this.permissionService.setUserPermission(
|
||||
note,
|
||||
permissionUser,
|
||||
canEdit,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Delete(':noteIdOrAlias/metadata/permissions/users/:userName')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Remove the permission for a user on a note',
|
||||
dto: NotePermissionsDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async removeUserPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('userName') username: string,
|
||||
): Promise<NotePermissionsDto> {
|
||||
try {
|
||||
const permissionUser = await this.userService.getUserByUsername(username);
|
||||
const returnedNote = await this.permissionService.removeUserPermission(
|
||||
note,
|
||||
permissionUser,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
} catch (e) {
|
||||
if (e instanceof NotInDBError) {
|
||||
throw new BadRequestException(
|
||||
"Can't remove user from permissions. User not known.",
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Put(':noteIdOrAlias/metadata/permissions/groups/:groupName')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Set the permissions for a user on a note',
|
||||
dto: NotePermissionsDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async setGroupPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('groupName') groupName: string,
|
||||
@Body() canEdit: boolean,
|
||||
): Promise<NotePermissionsDto> {
|
||||
const permissionGroup = await this.groupService.getGroupByName(groupName);
|
||||
const returnedNote = await this.permissionService.setGroupPermission(
|
||||
note,
|
||||
permissionGroup,
|
||||
canEdit,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Remove the permission for a group on a note',
|
||||
dto: NotePermissionsDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async removeGroupPermission(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('groupName') groupName: string,
|
||||
): Promise<NotePermissionsDto> {
|
||||
const permissionGroup = await this.groupService.getGroupByName(groupName);
|
||||
const returnedNote = await this.permissionService.removeGroupPermission(
|
||||
note,
|
||||
permissionGroup,
|
||||
);
|
||||
return await this.noteService.toNotePermissionsDto(returnedNote);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.OWNER)
|
||||
@UseGuards(TokenAuthGuard, PermissionsGuard)
|
||||
@Put(':noteIdOrAlias/metadata/permissions/owner')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Changes the owner of the note',
|
||||
dto: NoteDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async changeOwner(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Body() newOwner: string,
|
||||
): Promise<NoteDto> {
|
||||
const owner = await this.userService.getUserByUsername(newOwner);
|
||||
return await this.noteService.toNoteDto(
|
||||
await this.permissionService.changeOwner(note, owner),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias/revisions')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Revisions of the note',
|
||||
isArray: true,
|
||||
dto: RevisionMetadataDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async getNoteRevisions(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<RevisionMetadataDto[]> {
|
||||
const revisions = await this.revisionsService.getAllRevisions(note);
|
||||
return await Promise.all(
|
||||
revisions.map((revision) =>
|
||||
this.revisionsService.toRevisionMetadataDto(revision),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias/revisions/:revisionId')
|
||||
@OpenApi(
|
||||
{
|
||||
code: 200,
|
||||
description: 'Revision of the note for the given id or alias',
|
||||
dto: RevisionDto,
|
||||
},
|
||||
403,
|
||||
404,
|
||||
)
|
||||
async getNoteRevision(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
@Param('revisionId') revisionId: number,
|
||||
): Promise<RevisionDto> {
|
||||
return await this.revisionsService.toRevisionDto(
|
||||
await this.revisionsService.getRevision(note, revisionId),
|
||||
);
|
||||
}
|
||||
|
||||
@UseInterceptors(GetNoteInterceptor)
|
||||
@Permissions(Permission.READ)
|
||||
@Get(':noteIdOrAlias/media')
|
||||
@OpenApi({
|
||||
code: 200,
|
||||
description: 'All media uploads of the note',
|
||||
isArray: true,
|
||||
dto: MediaUploadDto,
|
||||
})
|
||||
async getNotesMedia(
|
||||
@RequestUser() user: User,
|
||||
@RequestNote() note: Note,
|
||||
): Promise<MediaUploadDto[]> {
|
||||
const media = await this.mediaService.listUploadsByNote(note);
|
||||
return await Promise.all(
|
||||
media.map((media) => this.mediaService.toMediaUploadDto(media)),
|
||||
);
|
||||
}
|
||||
}
|
43
backend/src/api/public/public-api.module.ts
Normal file
43
backend/src/api/public/public-api.module.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { GroupsModule } from '../../groups/groups.module';
|
||||
import { HistoryModule } from '../../history/history.module';
|
||||
import { LoggerModule } from '../../logger/logger.module';
|
||||
import { MediaModule } from '../../media/media.module';
|
||||
import { MonitoringModule } from '../../monitoring/monitoring.module';
|
||||
import { NotesModule } from '../../notes/notes.module';
|
||||
import { PermissionsModule } from '../../permissions/permissions.module';
|
||||
import { RevisionsModule } from '../../revisions/revisions.module';
|
||||
import { UsersModule } from '../../users/users.module';
|
||||
import { AliasController } from './alias/alias.controller';
|
||||
import { MeController } from './me/me.controller';
|
||||
import { MediaController } from './media/media.controller';
|
||||
import { MonitoringController } from './monitoring/monitoring.controller';
|
||||
import { NotesController } from './notes/notes.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GroupsModule,
|
||||
UsersModule,
|
||||
HistoryModule,
|
||||
NotesModule,
|
||||
RevisionsModule,
|
||||
MonitoringModule,
|
||||
LoggerModule,
|
||||
MediaModule,
|
||||
PermissionsModule,
|
||||
],
|
||||
controllers: [
|
||||
AliasController,
|
||||
MeController,
|
||||
NotesController,
|
||||
MediaController,
|
||||
MonitoringController,
|
||||
],
|
||||
})
|
||||
export class PublicApiModule {}
|
28
backend/src/api/utils/descriptions.ts
Normal file
28
backend/src/api/utils/descriptions.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const okDescription = 'This request was successful';
|
||||
export const createdDescription =
|
||||
'The requested resource was successfully created';
|
||||
export const noContentDescription =
|
||||
'The requested resource was successfully deleted';
|
||||
export const badRequestDescription =
|
||||
"The request is malformed and can't be processed";
|
||||
export const unauthorizedDescription =
|
||||
'Authorization information is missing or invalid';
|
||||
export const forbiddenDescription =
|
||||
'Access to the requested resource is not permitted';
|
||||
export const notFoundDescription = 'The requested resource was not found';
|
||||
export const successfullyDeletedDescription =
|
||||
'The requested resource was sucessfully deleted';
|
||||
export const unprocessableEntityDescription =
|
||||
"The request change can't be processed";
|
||||
export const conflictDescription =
|
||||
'The request conflicts with the current state of the application';
|
||||
export const payloadTooLargeDescription =
|
||||
'The note is longer than the maximal allowed length of a note';
|
||||
export const internalServerErrorDescription =
|
||||
'The request triggered an internal server error.';
|
45
backend/src/api/utils/get-note.interceptor.ts
Normal file
45
backend/src/api/utils/get-note.interceptor.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { NotesService } from '../../notes/notes.service';
|
||||
import { User } from '../../users/user.entity';
|
||||
|
||||
/**
|
||||
* Saves the note identified by the `noteIdOrAlias` URL parameter
|
||||
* under the `note` property of the request object.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetNoteInterceptor implements NestInterceptor {
|
||||
constructor(private noteService: NotesService) {}
|
||||
|
||||
async intercept<T>(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Promise<Observable<T>> {
|
||||
const request: Request & { user: User; note: Note } = context
|
||||
.switchToHttp()
|
||||
.getRequest();
|
||||
const noteIdOrAlias = request.params['noteIdOrAlias'];
|
||||
request.note = await getNote(this.noteService, noteIdOrAlias);
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNote(
|
||||
noteService: NotesService,
|
||||
noteIdOrAlias: string,
|
||||
): Promise<Note> {
|
||||
return await noteService.getNoteByIdOrAlias(noteIdOrAlias);
|
||||
}
|
33
backend/src/api/utils/login-enabled.guard.ts
Normal file
33
backend/src/api/utils/login-enabled.guard.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import authConfiguration, { AuthConfig } from '../../config/auth.config';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class LoginEnabledGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
this.logger.setContext(LoginEnabledGuard.name);
|
||||
}
|
||||
|
||||
canActivate(): boolean {
|
||||
if (!this.authConfig.local.enableLogin) {
|
||||
this.logger.debug('Local auth is disabled.', 'canActivate');
|
||||
throw new BadRequestException('Local auth is disabled.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
60
backend/src/api/utils/markdown-body.decorator.ts
Normal file
60
backend/src/api/utils/markdown-body.decorator.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import getRawBody from 'raw-body';
|
||||
|
||||
/**
|
||||
* Extract the raw markdown from the request body and create a new note with it
|
||||
*
|
||||
* Implementation inspired by https://stackoverflow.com/questions/52283713/how-do-i-pass-plain-text-as-my-request-body-using-nestjs
|
||||
*/
|
||||
// Override naming convention as decorators are in PascalCase
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const MarkdownBody = createParamDecorator(
|
||||
async (_, context: ExecutionContext) => {
|
||||
// we have to check req.readable because of raw-body issue #57
|
||||
// https://github.com/stream-utils/raw-body/issues/57
|
||||
const req = context.switchToHttp().getRequest<import('express').Request>();
|
||||
// Here the Content-Type of the http request is checked to be text/markdown
|
||||
// because we dealing with markdown. Technically by now there can be any content which can be encoded.
|
||||
// There could be features in the software which do not work properly if the text can't be parsed as markdown.
|
||||
if (req.get('Content-Type') === 'text/markdown') {
|
||||
if (req.readable) {
|
||||
return (await getRawBody(req)).toString().trim();
|
||||
} else {
|
||||
throw new InternalServerErrorException('Failed to parse request body!');
|
||||
}
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
'Body Content-Type has to be text/markdown!',
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
(target, key): void => {
|
||||
const ownPropertyDescriptor = Object.getOwnPropertyDescriptor(
|
||||
target,
|
||||
key,
|
||||
);
|
||||
if (!ownPropertyDescriptor) {
|
||||
throw new Error(
|
||||
`Could not get property descriptor for target ${target.toString()} and key ${key.toString()}`,
|
||||
);
|
||||
}
|
||||
ApiConsumes('text/markdown')(target, key, ownPropertyDescriptor);
|
||||
ApiBody({
|
||||
required: true,
|
||||
schema: { example: '# Markdown Body' },
|
||||
})(target, key, ownPropertyDescriptor);
|
||||
},
|
||||
],
|
||||
);
|
37
backend/src/api/utils/note-header.interceptor.ts
Normal file
37
backend/src/api/utils/note-header.interceptor.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
import { NotesService } from '../../notes/notes.service';
|
||||
|
||||
/**
|
||||
* Saves the note identified by the `HedgeDoc-Note` header
|
||||
* under the `note` property of the request object.
|
||||
*/
|
||||
@Injectable()
|
||||
export class NoteHeaderInterceptor implements NestInterceptor {
|
||||
constructor(private noteService: NotesService) {}
|
||||
|
||||
async intercept<T>(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Promise<Observable<T>> {
|
||||
const request: Request & {
|
||||
note: Note;
|
||||
} = context.switchToHttp().getRequest();
|
||||
const noteId: string = request.headers['hedgedoc-note'] as string;
|
||||
request.note = await this.noteService.getNoteByIdOrAlias(noteId);
|
||||
return next.handle();
|
||||
}
|
||||
}
|
179
backend/src/api/utils/openapi.decorator.ts
Normal file
179
backend/src/api/utils/openapi.decorator.ts
Normal file
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { applyDecorators, Header, HttpCode } from '@nestjs/common';
|
||||
import {
|
||||
ApiBadRequestResponse,
|
||||
ApiConflictResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiInternalServerErrorResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiProduces,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { BaseDto } from '../../utils/base.dto.';
|
||||
import {
|
||||
badRequestDescription,
|
||||
conflictDescription,
|
||||
createdDescription,
|
||||
internalServerErrorDescription,
|
||||
noContentDescription,
|
||||
notFoundDescription,
|
||||
okDescription,
|
||||
payloadTooLargeDescription,
|
||||
unauthorizedDescription,
|
||||
} from './descriptions';
|
||||
|
||||
export type HttpStatusCodes =
|
||||
| 200
|
||||
| 201
|
||||
| 204
|
||||
| 400
|
||||
| 401
|
||||
| 403
|
||||
| 404
|
||||
| 409
|
||||
| 413
|
||||
| 500;
|
||||
|
||||
/**
|
||||
* Defines what the open api route should document.
|
||||
*
|
||||
* This makes it possible to document
|
||||
* - description
|
||||
* - return object
|
||||
* - if the return object is an array
|
||||
* - the mimeType of the response
|
||||
*/
|
||||
export interface HttpStatusCodeWithExtraInformation {
|
||||
code: HttpStatusCodes;
|
||||
description?: string;
|
||||
isArray?: boolean;
|
||||
dto?: BaseDto;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This decorator is used to document what an api route returns.
|
||||
*
|
||||
* The decorator can be used on a controller method or on a whole controller class (if one wants to document that every method of the controller returns something).
|
||||
*
|
||||
* @param httpStatusCodesMaybeWithExtraInformation - list of parameters can either be just the {@link HttpStatusCodes} or a {@link HttpStatusCodeWithExtraInformation}.
|
||||
* If only a {@link HttpStatusCodes} is provided a default description will be used.
|
||||
*
|
||||
* For non-200 successful responses the appropriate {@link HttpCode} decorator is set
|
||||
* @constructor
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention,func-style
|
||||
export const OpenApi = (
|
||||
...httpStatusCodesMaybeWithExtraInformation: (
|
||||
| HttpStatusCodes
|
||||
| HttpStatusCodeWithExtraInformation
|
||||
)[]
|
||||
): // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
(<TFunction extends Function, Y>(
|
||||
target: object | TFunction,
|
||||
propertyKey?: string | symbol,
|
||||
descriptor?: TypedPropertyDescriptor<Y>,
|
||||
) => void) => {
|
||||
const decoratorsToApply: (MethodDecorator | ClassDecorator)[] = [];
|
||||
for (const entry of httpStatusCodesMaybeWithExtraInformation) {
|
||||
let code: HttpStatusCodes = 200;
|
||||
let description: string | undefined = undefined;
|
||||
let isArray: boolean | undefined = undefined;
|
||||
let dto: BaseDto | undefined = undefined;
|
||||
if (typeof entry == 'number') {
|
||||
code = entry;
|
||||
} else {
|
||||
// We've got a HttpStatusCodeWithExtraInformation
|
||||
code = entry.code;
|
||||
description = entry.description;
|
||||
isArray = entry.isArray;
|
||||
dto = entry.dto;
|
||||
if (entry.mimeType) {
|
||||
decoratorsToApply.push(
|
||||
ApiProduces(entry.mimeType),
|
||||
Header('Content-Type', entry.mimeType),
|
||||
);
|
||||
}
|
||||
}
|
||||
switch (code) {
|
||||
case 200:
|
||||
decoratorsToApply.push(
|
||||
ApiOkResponse({
|
||||
description: description ?? okDescription,
|
||||
isArray: isArray,
|
||||
type: dto ? (): BaseDto => dto as BaseDto : undefined,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 201:
|
||||
decoratorsToApply.push(
|
||||
ApiCreatedResponse({
|
||||
description: description ?? createdDescription,
|
||||
isArray: isArray,
|
||||
type: dto ? (): BaseDto => dto as BaseDto : undefined,
|
||||
}),
|
||||
HttpCode(201),
|
||||
);
|
||||
break;
|
||||
case 204:
|
||||
decoratorsToApply.push(
|
||||
ApiNoContentResponse({
|
||||
description: description ?? noContentDescription,
|
||||
}),
|
||||
HttpCode(204),
|
||||
);
|
||||
break;
|
||||
case 400:
|
||||
decoratorsToApply.push(
|
||||
ApiBadRequestResponse({
|
||||
description: description ?? badRequestDescription,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 401:
|
||||
decoratorsToApply.push(
|
||||
ApiUnauthorizedResponse({
|
||||
description: description ?? unauthorizedDescription,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 404:
|
||||
decoratorsToApply.push(
|
||||
ApiNotFoundResponse({
|
||||
description: description ?? notFoundDescription,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 409:
|
||||
decoratorsToApply.push(
|
||||
ApiConflictResponse({
|
||||
description: description ?? conflictDescription,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 413:
|
||||
decoratorsToApply.push(
|
||||
ApiConflictResponse({
|
||||
description: description ?? payloadTooLargeDescription,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 500:
|
||||
decoratorsToApply.push(
|
||||
ApiInternalServerErrorResponse({
|
||||
description: internalServerErrorDescription,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return applyDecorators(...decoratorsToApply);
|
||||
};
|
17
backend/src/api/utils/permissions.decorator.ts
Normal file
17
backend/src/api/utils/permissions.decorator.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CustomDecorator, SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { Permission } from '../../permissions/permissions.enum';
|
||||
|
||||
/**
|
||||
* This decorator gathers the {@link Permission Permission} a user must hold for the {@link PermissionsGuard}
|
||||
* @param permissions - an array of permissions. In practice this should always contain exactly one {@link Permission}
|
||||
* @constructor
|
||||
*/
|
||||
// eslint-disable-next-line func-style,@typescript-eslint/naming-convention
|
||||
export const Permissions = (...permissions: Permission[]): CustomDecorator =>
|
||||
SetMetadata('permissions', permissions);
|
66
backend/src/api/utils/permissions.guard.ts
Normal file
66
backend/src/api/utils/permissions.guard.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
import { NotesService } from '../../notes/notes.service';
|
||||
import { Permission } from '../../permissions/permissions.enum';
|
||||
import { PermissionsService } from '../../permissions/permissions.service';
|
||||
import { User } from '../../users/user.entity';
|
||||
import { getNote } from './get-note.interceptor';
|
||||
|
||||
/**
|
||||
* This guards controller methods from access, if the user has not the appropriate permissions.
|
||||
* The permissions are set via the {@link Permissions} decorator in addition to this guard.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private reflector: Reflector,
|
||||
private permissionsService: PermissionsService,
|
||||
private noteService: NotesService,
|
||||
) {
|
||||
this.logger.setContext(PermissionsGuard.name);
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const permissions = this.reflector.get<Permission[]>(
|
||||
'permissions',
|
||||
context.getHandler(),
|
||||
);
|
||||
// If no permissions are set this is probably an error and this guard should not let the request pass
|
||||
if (!permissions) {
|
||||
this.logger.error(
|
||||
'Could not find permission metadata. This should never happen. If you see this, please open an issue at https://github.com/hedgedoc/hedgedoc/issues',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const request: Request & { user: User } = context
|
||||
.switchToHttp()
|
||||
.getRequest();
|
||||
const user = request.user;
|
||||
// handle CREATE permissions, as this does not need any note
|
||||
if (permissions[0] === Permission.CREATE) {
|
||||
return this.permissionsService.mayCreate(user);
|
||||
}
|
||||
// Get the note from the parameter noteIdOrAlias
|
||||
// Attention: This gets the note an additional time if used in conjunction with GetNoteInterceptor
|
||||
const noteIdOrAlias = request.params['noteIdOrAlias'];
|
||||
const note = await getNote(this.noteService, noteIdOrAlias);
|
||||
switch (permissions[0]) {
|
||||
case Permission.READ:
|
||||
return await this.permissionsService.mayRead(user, note);
|
||||
case Permission.WRITE:
|
||||
return await this.permissionsService.mayWrite(user, note);
|
||||
case Permission.OWNER:
|
||||
return await this.permissionsService.isOwner(user, note);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
33
backend/src/api/utils/registration-enabled.guard.ts
Normal file
33
backend/src/api/utils/registration-enabled.guard.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import authConfiguration, { AuthConfig } from '../../config/auth.config';
|
||||
import { ConsoleLoggerService } from '../../logger/console-logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class RegistrationEnabledGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
) {
|
||||
this.logger.setContext(RegistrationEnabledGuard.name);
|
||||
}
|
||||
|
||||
canActivate(): boolean {
|
||||
if (!this.authConfig.local.enableRegister) {
|
||||
this.logger.debug('User registration is disabled.', 'canActivate');
|
||||
throw new BadRequestException('User registration is disabled.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
32
backend/src/api/utils/request-note.decorator.ts
Normal file
32
backend/src/api/utils/request-note.decorator.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { Note } from '../../notes/note.entity';
|
||||
|
||||
/**
|
||||
* Extracts the {@link Note} object from a request
|
||||
*
|
||||
* Will throw an {@link InternalServerErrorException} if no note is present
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const RequestNote = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request: Request & { note: Note } = ctx.switchToHttp().getRequest();
|
||||
if (!request.note) {
|
||||
// We should have a note here, otherwise something is wrong
|
||||
throw new InternalServerErrorException(
|
||||
'Request is missing a note object',
|
||||
);
|
||||
}
|
||||
return request.note;
|
||||
},
|
||||
);
|
43
backend/src/api/utils/request-user.decorator.ts
Normal file
43
backend/src/api/utils/request-user.decorator.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { User } from '../../users/user.entity';
|
||||
|
||||
type RequestUserParameter = {
|
||||
guestsAllowed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Trys to extract the {@link User} object from a request
|
||||
*
|
||||
* If a user is present in the request, returns the user object.
|
||||
* If no user is present and guests are allowed, returns `null`.
|
||||
* If no user is present and guests are not allowed, throws {@link UnauthorizedException}.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const RequestUser = createParamDecorator(
|
||||
(
|
||||
data: RequestUserParameter = { guestsAllowed: false },
|
||||
ctx: ExecutionContext,
|
||||
) => {
|
||||
const request: Request & { user: User | null } = ctx
|
||||
.switchToHttp()
|
||||
.getRequest();
|
||||
if (!request.user) {
|
||||
if (data.guestsAllowed) {
|
||||
return null;
|
||||
}
|
||||
throw new UnauthorizedException("You're not logged in");
|
||||
}
|
||||
return request.user;
|
||||
},
|
||||
);
|
34
backend/src/api/utils/session-authprovider.decorator.ts
Normal file
34
backend/src/api/utils/session-authprovider.decorator.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Extracts the auth provider identifier from a session inside a request
|
||||
*
|
||||
* Will throw an {@link InternalServerErrorException} if no identifier is present
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export const SessionAuthProvider = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request: Request & {
|
||||
session: {
|
||||
authProvider: string;
|
||||
};
|
||||
} = ctx.switchToHttp().getRequest();
|
||||
if (!request.session?.authProvider) {
|
||||
// We should have an auth provider here, otherwise something is wrong
|
||||
throw new InternalServerErrorException(
|
||||
'Session is missing an auth provider identifier',
|
||||
);
|
||||
}
|
||||
return request.session.authProvider;
|
||||
},
|
||||
);
|
86
backend/src/app-init.ts
Normal file
86
backend/src/app-init.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
|
||||
import { AppConfig } from './config/app.config';
|
||||
import { AuthConfig } from './config/auth.config';
|
||||
import { MediaConfig } from './config/media.config';
|
||||
import { ErrorExceptionMapping } from './errors/error-mapping';
|
||||
import { ConsoleLoggerService } from './logger/console-logger.service';
|
||||
import { BackendType } from './media/backends/backend-type.enum';
|
||||
import { SessionService } from './session/session.service';
|
||||
import { setupSpecialGroups } from './utils/createSpecialGroups';
|
||||
import { setupSessionMiddleware } from './utils/session';
|
||||
import { setupValidationPipe } from './utils/setup-pipes';
|
||||
import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger';
|
||||
|
||||
/**
|
||||
* Common setup function which is called by main.ts and the E2E tests.
|
||||
*/
|
||||
export async function setupApp(
|
||||
app: NestExpressApplication,
|
||||
appConfig: AppConfig,
|
||||
authConfig: AuthConfig,
|
||||
mediaConfig: MediaConfig,
|
||||
logger: ConsoleLoggerService,
|
||||
): Promise<void> {
|
||||
setupPublicApiDocs(app);
|
||||
logger.log(
|
||||
`Serving OpenAPI docs for public api under '/apidoc'`,
|
||||
'AppBootstrap',
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setupPrivateApiDocs(app);
|
||||
logger.log(
|
||||
`Serving OpenAPI docs for private api under '/private/apidoc'`,
|
||||
'AppBootstrap',
|
||||
);
|
||||
}
|
||||
|
||||
await setupSpecialGroups(app);
|
||||
|
||||
setupSessionMiddleware(
|
||||
app,
|
||||
authConfig,
|
||||
app.get(SessionService).getTypeormStore(),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: appConfig.rendererBaseUrl,
|
||||
});
|
||||
logger.log(
|
||||
`Enabling CORS for '${appConfig.rendererBaseUrl}'`,
|
||||
'AppBootstrap',
|
||||
);
|
||||
|
||||
app.useGlobalPipes(setupValidationPipe(logger));
|
||||
|
||||
if (mediaConfig.backend.use === BackendType.FILESYSTEM) {
|
||||
logger.log(
|
||||
`Serving the local folder '${mediaConfig.backend.filesystem.uploadPath}' under '/uploads'`,
|
||||
'AppBootstrap',
|
||||
);
|
||||
app.useStaticAssets(mediaConfig.backend.filesystem.uploadPath, {
|
||||
prefix: '/uploads/',
|
||||
});
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`Serving the local folder 'public' under '/public'`,
|
||||
'AppBootstrap',
|
||||
);
|
||||
app.useStaticAssets('public', {
|
||||
prefix: '/public/',
|
||||
});
|
||||
|
||||
const { httpAdapter } = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new ErrorExceptionMapping(httpAdapter));
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
app.enableShutdownHooks();
|
||||
}
|
115
backend/src/app.module.ts
Normal file
115
backend/src/app.module.ts
Normal file
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RouterModule, Routes } from 'nest-router';
|
||||
|
||||
import { PrivateApiModule } from './api/private/private-api.module';
|
||||
import { PublicApiModule } from './api/public/public-api.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AuthorsModule } from './authors/authors.module';
|
||||
import appConfig from './config/app.config';
|
||||
import authConfig from './config/auth.config';
|
||||
import cspConfig from './config/csp.config';
|
||||
import customizationConfig from './config/customization.config';
|
||||
import databaseConfig, { DatabaseConfig } from './config/database.config';
|
||||
import externalConfig from './config/external-services.config';
|
||||
import hstsConfig from './config/hsts.config';
|
||||
import mediaConfig from './config/media.config';
|
||||
import noteConfig from './config/note.config';
|
||||
import { eventModuleConfig } from './events';
|
||||
import { FrontendConfigModule } from './frontend-config/frontend-config.module';
|
||||
import { FrontendConfigService } from './frontend-config/frontend-config.service';
|
||||
import { GroupsModule } from './groups/groups.module';
|
||||
import { HistoryModule } from './history/history.module';
|
||||
import { IdentityModule } from './identity/identity.module';
|
||||
import { LoggerModule } from './logger/logger.module';
|
||||
import { TypeormLoggerService } from './logger/typeorm-logger.service';
|
||||
import { MediaModule } from './media/media.module';
|
||||
import { MonitoringModule } from './monitoring/monitoring.module';
|
||||
import { NotesModule } from './notes/notes.module';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { WebsocketModule } from './realtime/websocket/websocket.module';
|
||||
import { RevisionsModule } from './revisions/revisions.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '/api/v2',
|
||||
module: PublicApiModule,
|
||||
},
|
||||
{
|
||||
path: '/api/private',
|
||||
module: PrivateApiModule,
|
||||
},
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
RouterModule.forRoutes(routes),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule, LoggerModule],
|
||||
inject: [databaseConfig.KEY, TypeormLoggerService],
|
||||
useFactory: (
|
||||
databaseConfig: DatabaseConfig,
|
||||
logger: TypeormLoggerService,
|
||||
) => {
|
||||
return {
|
||||
type: databaseConfig.type,
|
||||
host: databaseConfig.host,
|
||||
port: databaseConfig.port,
|
||||
username: databaseConfig.username,
|
||||
password: databaseConfig.password,
|
||||
database: databaseConfig.database,
|
||||
autoLoadEntities: true,
|
||||
synchronize: true, // ToDo: Remove this before release
|
||||
logging: true,
|
||||
logger: logger,
|
||||
};
|
||||
},
|
||||
}),
|
||||
ConfigModule.forRoot({
|
||||
load: [
|
||||
appConfig,
|
||||
noteConfig,
|
||||
mediaConfig,
|
||||
hstsConfig,
|
||||
cspConfig,
|
||||
databaseConfig,
|
||||
authConfig,
|
||||
customizationConfig,
|
||||
externalConfig,
|
||||
],
|
||||
isGlobal: true,
|
||||
}),
|
||||
EventEmitterModule.forRoot(eventModuleConfig),
|
||||
ScheduleModule.forRoot(),
|
||||
NotesModule,
|
||||
UsersModule,
|
||||
RevisionsModule,
|
||||
AuthorsModule,
|
||||
PublicApiModule,
|
||||
PrivateApiModule,
|
||||
HistoryModule,
|
||||
MonitoringModule,
|
||||
PermissionsModule,
|
||||
GroupsModule,
|
||||
LoggerModule,
|
||||
MediaModule,
|
||||
AuthModule,
|
||||
FrontendConfigModule,
|
||||
WebsocketModule,
|
||||
IdentityModule,
|
||||
SessionModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [FrontendConfigService],
|
||||
})
|
||||
export class AppModule {}
|
44
backend/src/auth/auth-token.dto.ts
Normal file
44
backend/src/auth/auth-token.dto.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDate, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
import { TimestampMillis } from '../utils/timestamp';
|
||||
|
||||
export class AuthTokenDto extends BaseDto {
|
||||
@IsString()
|
||||
label: string;
|
||||
|
||||
@IsString()
|
||||
keyId: string;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
createdAt: Date;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
validUntil: Date;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@IsOptional()
|
||||
lastUsedAt: Date | null;
|
||||
}
|
||||
|
||||
export class AuthTokenWithSecretDto extends AuthTokenDto {
|
||||
@IsString()
|
||||
secret: string;
|
||||
}
|
||||
|
||||
export class AuthTokenCreateDto extends BaseDto {
|
||||
@IsString()
|
||||
label: string;
|
||||
|
||||
@IsNumber()
|
||||
validUntil: TimestampMillis;
|
||||
}
|
63
backend/src/auth/auth-token.entity.ts
Normal file
63
backend/src/auth/auth-token.entity.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Entity()
|
||||
export class AuthToken {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
keyId: string;
|
||||
|
||||
@ManyToOne((_) => User, (user) => user.authTokens, {
|
||||
onDelete: 'CASCADE', // This deletes the AuthToken, when the associated User is deleted
|
||||
})
|
||||
user: Promise<User>;
|
||||
|
||||
@Column()
|
||||
label: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ unique: true })
|
||||
accessTokenHash: string;
|
||||
|
||||
@Column()
|
||||
validUntil: Date;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'date',
|
||||
})
|
||||
lastUsedAt: Date | null;
|
||||
|
||||
public static create(
|
||||
keyId: string,
|
||||
user: User,
|
||||
label: string,
|
||||
tokenString: string,
|
||||
validUntil: Date,
|
||||
): Omit<AuthToken, 'id' | 'createdAt'> {
|
||||
const token = new AuthToken();
|
||||
token.keyId = keyId;
|
||||
token.user = Promise.resolve(user);
|
||||
token.label = label;
|
||||
token.accessTokenHash = tokenString;
|
||||
token.validUntil = validUntil;
|
||||
token.lastUsedAt = null;
|
||||
return token;
|
||||
}
|
||||
}
|
26
backend/src/auth/auth.module.ts
Normal file
26
backend/src/auth/auth.module.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthToken } from './auth-token.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
import { TokenStrategy } from './token.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
LoggerModule,
|
||||
TypeOrmModule.forFeature([AuthToken]),
|
||||
],
|
||||
providers: [AuthService, TokenStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
328
backend/src/auth/auth.service.spec.ts
Normal file
328
backend/src/auth/auth.service.spec.ts
Normal file
|
@ -0,0 +1,328 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import crypto from 'crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import { NotInDBError, TokenNotValidError } from '../errors/errors';
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { Session } from '../users/session.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthToken } from './auth-token.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let user: User;
|
||||
let authToken: AuthToken;
|
||||
let userRepo: Repository<User>;
|
||||
let authTokenRepo: Repository<AuthToken>;
|
||||
|
||||
class CreateQueryBuilderClass {
|
||||
leftJoinAndSelect: () => CreateQueryBuilderClass;
|
||||
where: () => CreateQueryBuilderClass;
|
||||
orWhere: () => CreateQueryBuilderClass;
|
||||
setParameter: () => CreateQueryBuilderClass;
|
||||
getOne: () => AuthToken;
|
||||
getMany: () => AuthToken[];
|
||||
}
|
||||
|
||||
let createQueryBuilderFunc: CreateQueryBuilderClass;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: getRepositoryToken(AuthToken),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfigMock],
|
||||
}),
|
||||
PassportModule,
|
||||
UsersModule,
|
||||
LoggerModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(Identity))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(User))
|
||||
.useClass(Repository)
|
||||
.overrideProvider(getRepositoryToken(Session))
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
userRepo = module.get<Repository<User>>(getRepositoryToken(User));
|
||||
authTokenRepo = module.get<Repository<AuthToken>>(
|
||||
getRepositoryToken(AuthToken),
|
||||
);
|
||||
|
||||
user = User.create('hardcoded', 'Testy') as User;
|
||||
authToken = AuthToken.create(
|
||||
'testKeyId',
|
||||
user,
|
||||
'testToken',
|
||||
'abc',
|
||||
new Date(new Date().getTime() + 60000), // make this AuthToken valid for 1min
|
||||
) as AuthToken;
|
||||
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
orWhere: () => createQueryBuilder,
|
||||
setParameter: () => createQueryBuilder,
|
||||
getOne: () => authToken,
|
||||
getMany: () => [authToken],
|
||||
};
|
||||
createQueryBuilderFunc = createQueryBuilder;
|
||||
jest
|
||||
.spyOn(authTokenRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getTokensByUser', () => {
|
||||
it('works', async () => {
|
||||
createQueryBuilderFunc.getMany = () => [authToken];
|
||||
const tokens = await service.getTokensByUser(user);
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens).toEqual([authToken]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAuthToken', () => {
|
||||
const token = 'testToken';
|
||||
it('works', async () => {
|
||||
const accessTokenHash = crypto
|
||||
.createHash('sha512')
|
||||
.update(token)
|
||||
.digest('hex');
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
||||
...authToken,
|
||||
user: Promise.resolve(user),
|
||||
accessTokenHash: accessTokenHash,
|
||||
});
|
||||
const authTokenFromCall = await service.getAuthToken(authToken.keyId);
|
||||
expect(authTokenFromCall).toEqual({
|
||||
...authToken,
|
||||
user: Promise.resolve(user),
|
||||
accessTokenHash: accessTokenHash,
|
||||
});
|
||||
});
|
||||
describe('fails:', () => {
|
||||
it('AuthToken could not be found', async () => {
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
await expect(service.getAuthToken(authToken.keyId)).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('checkToken', () => {
|
||||
it('works', () => {
|
||||
const [accessToken, secret] = service.createToken(
|
||||
user,
|
||||
'TestToken',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.checkToken(secret, accessToken as AuthToken),
|
||||
).not.toThrow();
|
||||
});
|
||||
it('AuthToken has wrong hash', () => {
|
||||
const [accessToken] = service.createToken(user, 'TestToken', undefined);
|
||||
expect(() =>
|
||||
service.checkToken('secret', accessToken as AuthToken),
|
||||
).toThrow(TokenNotValidError);
|
||||
});
|
||||
it('AuthToken has wrong validUntil Date', () => {
|
||||
const [accessToken, secret] = service.createToken(
|
||||
user,
|
||||
'Test',
|
||||
1549312452000,
|
||||
);
|
||||
expect(() =>
|
||||
service.checkToken(secret, accessToken as AuthToken),
|
||||
).toThrow(TokenNotValidError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastUsedToken', () => {
|
||||
it('works', async () => {
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce({
|
||||
...authToken,
|
||||
user: Promise.resolve(user),
|
||||
lastUsedAt: new Date(1549312452000),
|
||||
});
|
||||
jest
|
||||
.spyOn(authTokenRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (authTokenSaved, _): Promise<AuthToken> => {
|
||||
expect(authTokenSaved.keyId).toEqual(authToken.keyId);
|
||||
expect(authTokenSaved.lastUsedAt).not.toEqual(1549312452000);
|
||||
return authToken;
|
||||
},
|
||||
);
|
||||
await service.setLastUsedToken(authToken.keyId);
|
||||
});
|
||||
it('throws if the token is not in the database', async () => {
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
await expect(service.setLastUsedToken(authToken.keyId)).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('works', async () => {
|
||||
const testSecret =
|
||||
'gNrv_NJ4FHZ0UFZJQu_q_3i3-GP_d6tELVtkYiMFLkLWNl_dxEmPVAsCNKxP3N3DB9aGBVFYE1iptvw7hFMJvA';
|
||||
const accessTokenHash = crypto
|
||||
.createHash('sha512')
|
||||
.update(testSecret)
|
||||
.digest('hex');
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce({
|
||||
...user,
|
||||
authTokens: Promise.resolve([authToken]),
|
||||
});
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValue({
|
||||
...authToken,
|
||||
user: Promise.resolve(user),
|
||||
accessTokenHash: accessTokenHash,
|
||||
});
|
||||
jest
|
||||
.spyOn(authTokenRepo, 'save')
|
||||
.mockImplementationOnce(async (_, __): Promise<AuthToken> => {
|
||||
return authToken;
|
||||
});
|
||||
const userByToken = await service.validateToken(
|
||||
`${authToken.keyId}.${testSecret}`,
|
||||
);
|
||||
expect(userByToken).toEqual({
|
||||
...user,
|
||||
authTokens: Promise.resolve([authToken]),
|
||||
});
|
||||
});
|
||||
describe('fails:', () => {
|
||||
it('the secret is missing', async () => {
|
||||
await expect(
|
||||
service.validateToken(`${authToken.keyId}`),
|
||||
).rejects.toThrow(TokenNotValidError);
|
||||
});
|
||||
it('the secret is too long', async () => {
|
||||
await expect(
|
||||
service.validateToken(`${authToken.keyId}.${'a'.repeat(73)}`),
|
||||
).rejects.toThrow(TokenNotValidError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeToken', () => {
|
||||
it('works', async () => {
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValue({
|
||||
...authToken,
|
||||
user: Promise.resolve(user),
|
||||
});
|
||||
jest
|
||||
.spyOn(authTokenRepo, 'remove')
|
||||
.mockImplementationOnce(async (token, __): Promise<AuthToken> => {
|
||||
expect(token).toEqual({
|
||||
...authToken,
|
||||
user: Promise.resolve(user),
|
||||
});
|
||||
return authToken;
|
||||
});
|
||||
await service.removeToken(authToken.keyId);
|
||||
});
|
||||
it('throws if the token is not in the database', async () => {
|
||||
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
await expect(service.removeToken(authToken.keyId)).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToken', () => {
|
||||
describe('works', () => {
|
||||
const identifier = 'testIdentifier';
|
||||
it('with validUntil 0', async () => {
|
||||
jest.spyOn(authTokenRepo, 'find').mockResolvedValueOnce([authToken]);
|
||||
jest
|
||||
.spyOn(authTokenRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (authTokenSaved: AuthToken, _): Promise<AuthToken> => {
|
||||
expect(authTokenSaved.lastUsedAt).toBeNull();
|
||||
return authTokenSaved;
|
||||
},
|
||||
);
|
||||
const token = await service.addToken(user, identifier, 0);
|
||||
expect(token.label).toEqual(identifier);
|
||||
expect(
|
||||
token.validUntil.getTime() -
|
||||
(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000),
|
||||
).toBeLessThanOrEqual(10000);
|
||||
expect(token.lastUsedAt).toBeNull();
|
||||
expect(token.secret.startsWith(token.keyId)).toBeTruthy();
|
||||
});
|
||||
it('with validUntil not 0', async () => {
|
||||
jest.spyOn(authTokenRepo, 'find').mockResolvedValueOnce([authToken]);
|
||||
jest
|
||||
.spyOn(authTokenRepo, 'save')
|
||||
.mockImplementationOnce(
|
||||
async (authTokenSaved: AuthToken, _): Promise<AuthToken> => {
|
||||
expect(authTokenSaved.lastUsedAt).toBeNull();
|
||||
return authTokenSaved;
|
||||
},
|
||||
);
|
||||
const validUntil = new Date().getTime() + 30000;
|
||||
const token = await service.addToken(user, identifier, validUntil);
|
||||
expect(token.label).toEqual(identifier);
|
||||
expect(token.validUntil.getTime()).toEqual(validUntil);
|
||||
expect(token.lastUsedAt).toBeNull();
|
||||
expect(token.secret.startsWith(token.keyId)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAuthTokenDto', () => {
|
||||
it('works', () => {
|
||||
const authToken = new AuthToken();
|
||||
authToken.keyId = 'testKeyId';
|
||||
authToken.label = 'testLabel';
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() - 1);
|
||||
authToken.createdAt = date;
|
||||
authToken.validUntil = new Date();
|
||||
const tokenDto = service.toAuthTokenDto(authToken);
|
||||
expect(tokenDto.keyId).toEqual(authToken.keyId);
|
||||
expect(tokenDto.lastUsedAt).toBeNull();
|
||||
expect(tokenDto.label).toEqual(authToken.label);
|
||||
expect(tokenDto.validUntil.getTime()).toEqual(
|
||||
authToken.validUntil.getTime(),
|
||||
);
|
||||
expect(tokenDto.createdAt.getTime()).toEqual(
|
||||
authToken.createdAt.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
241
backend/src/auth/auth.service.ts
Normal file
241
backend/src/auth/auth.service.ts
Normal file
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, Timeout } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import crypto, { randomBytes } from 'crypto';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
NotInDBError,
|
||||
TokenNotValidError,
|
||||
TooManyTokensError,
|
||||
} from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { bufferToBase64Url } from '../utils/password';
|
||||
import { TimestampMillis } from '../utils/timestamp';
|
||||
import { AuthTokenDto, AuthTokenWithSecretDto } from './auth-token.dto';
|
||||
import { AuthToken } from './auth-token.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
private usersService: UsersService,
|
||||
@InjectRepository(AuthToken)
|
||||
private authTokenRepository: Repository<AuthToken>,
|
||||
) {
|
||||
this.logger.setContext(AuthService.name);
|
||||
}
|
||||
|
||||
async validateToken(tokenString: string): Promise<User> {
|
||||
const [keyId, secret] = tokenString.split('.');
|
||||
if (!secret) {
|
||||
throw new TokenNotValidError('Invalid AuthToken format');
|
||||
}
|
||||
if (secret.length != 86) {
|
||||
// We always expect 86 characters, as the secret is generated with 64 bytes
|
||||
// and then converted to a base64url string
|
||||
throw new TokenNotValidError(
|
||||
`AuthToken '${tokenString}' has incorrect length`,
|
||||
);
|
||||
}
|
||||
const token = await this.getAuthToken(keyId);
|
||||
this.checkToken(secret, token);
|
||||
await this.setLastUsedToken(keyId);
|
||||
return await token.user;
|
||||
}
|
||||
|
||||
createToken(
|
||||
user: User,
|
||||
identifier: string,
|
||||
validUntil: TimestampMillis | undefined,
|
||||
): [Omit<AuthToken, 'id' | 'createdAt'>, string] {
|
||||
const secret = bufferToBase64Url(randomBytes(64));
|
||||
const keyId = bufferToBase64Url(randomBytes(8));
|
||||
// More about the choice of SHA-512 in the dev docs
|
||||
const accessTokenHash = crypto
|
||||
.createHash('sha512')
|
||||
.update(secret)
|
||||
.digest('hex');
|
||||
let token;
|
||||
// Tokens can only be valid for a maximum of 2 years
|
||||
const maximumTokenValidity =
|
||||
new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000;
|
||||
if (!validUntil || validUntil === 0 || validUntil > maximumTokenValidity) {
|
||||
token = AuthToken.create(
|
||||
keyId,
|
||||
user,
|
||||
identifier,
|
||||
accessTokenHash,
|
||||
new Date(maximumTokenValidity),
|
||||
);
|
||||
} else {
|
||||
token = AuthToken.create(
|
||||
keyId,
|
||||
user,
|
||||
identifier,
|
||||
accessTokenHash,
|
||||
new Date(validUntil),
|
||||
);
|
||||
}
|
||||
return [token, secret];
|
||||
}
|
||||
|
||||
async addToken(
|
||||
user: User,
|
||||
identifier: string,
|
||||
validUntil: TimestampMillis | undefined,
|
||||
): Promise<AuthTokenWithSecretDto> {
|
||||
user.authTokens = this.getTokensByUser(user);
|
||||
|
||||
if ((await user.authTokens).length >= 200) {
|
||||
// This is a very high ceiling unlikely to hinder legitimate usage,
|
||||
// but should prevent possible attack vectors
|
||||
throw new TooManyTokensError(
|
||||
`User '${user.username}' has already 200 tokens and can't have anymore`,
|
||||
);
|
||||
}
|
||||
const [token, secret] = this.createToken(user, identifier, validUntil);
|
||||
const createdToken = (await this.authTokenRepository.save(
|
||||
token,
|
||||
)) as AuthToken;
|
||||
return this.toAuthTokenWithSecretDto(
|
||||
createdToken,
|
||||
`${createdToken.keyId}.${secret}`,
|
||||
);
|
||||
}
|
||||
|
||||
async setLastUsedToken(keyId: string): Promise<void> {
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
});
|
||||
if (token === null) {
|
||||
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
|
||||
}
|
||||
token.lastUsedAt = new Date();
|
||||
await this.authTokenRepository.save(token);
|
||||
}
|
||||
|
||||
async getAuthToken(keyId: string): Promise<AuthToken> {
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (token === null) {
|
||||
throw new NotInDBError(`AuthToken '${keyId}' not found`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
checkToken(secret: string, token: AuthToken): void {
|
||||
const userHash = Buffer.from(
|
||||
crypto.createHash('sha512').update(secret).digest('hex'),
|
||||
);
|
||||
const dbHash = Buffer.from(token.accessTokenHash);
|
||||
if (
|
||||
// Normally, both hashes have the same length, as they are both SHA512
|
||||
// This is only defense-in-depth, as timingSafeEqual throws if the buffers are not of the same length
|
||||
userHash.length !== dbHash.length ||
|
||||
!crypto.timingSafeEqual(userHash, dbHash)
|
||||
) {
|
||||
// hashes are not the same
|
||||
throw new TokenNotValidError(
|
||||
`Secret does not match Token ${token.label}.`,
|
||||
);
|
||||
}
|
||||
if (token.validUntil && token.validUntil.getTime() < new Date().getTime()) {
|
||||
// tokens validUntil Date lies in the past
|
||||
throw new TokenNotValidError(
|
||||
`AuthToken '${
|
||||
token.label
|
||||
}' is not valid since ${token.validUntil.toISOString()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getTokensByUser(user: User): Promise<AuthToken[]> {
|
||||
const tokens = await this.authTokenRepository
|
||||
.createQueryBuilder('token')
|
||||
.where('token.userId = :userId', { userId: user.id })
|
||||
.getMany();
|
||||
if (tokens === null) {
|
||||
return [];
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async removeToken(keyId: string): Promise<void> {
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
});
|
||||
if (token === null) {
|
||||
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
|
||||
}
|
||||
await this.authTokenRepository.remove(token);
|
||||
}
|
||||
|
||||
toAuthTokenDto(authToken: AuthToken): AuthTokenDto {
|
||||
const tokenDto: AuthTokenDto = {
|
||||
label: authToken.label,
|
||||
keyId: authToken.keyId,
|
||||
createdAt: authToken.createdAt,
|
||||
validUntil: authToken.validUntil,
|
||||
lastUsedAt: null,
|
||||
};
|
||||
|
||||
if (authToken.lastUsedAt) {
|
||||
tokenDto.lastUsedAt = new Date(authToken.lastUsedAt);
|
||||
}
|
||||
|
||||
return tokenDto;
|
||||
}
|
||||
|
||||
toAuthTokenWithSecretDto(
|
||||
authToken: AuthToken,
|
||||
secret: string,
|
||||
): AuthTokenWithSecretDto {
|
||||
const tokenDto = this.toAuthTokenDto(authToken);
|
||||
return {
|
||||
...tokenDto,
|
||||
secret: secret,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all non valid tokens every sunday on 3:00 AM
|
||||
@Cron('0 0 3 * * 0')
|
||||
async handleCron(): Promise<void> {
|
||||
return await this.removeInvalidTokens();
|
||||
}
|
||||
|
||||
// Delete all non valid tokens 5 sec after startup
|
||||
@Timeout(5000)
|
||||
async handleTimeout(): Promise<void> {
|
||||
return await this.removeInvalidTokens();
|
||||
}
|
||||
|
||||
async removeInvalidTokens(): Promise<void> {
|
||||
const currentTime = new Date().getTime();
|
||||
const tokens: AuthToken[] = await this.authTokenRepository.find();
|
||||
let removedTokens = 0;
|
||||
for (const token of tokens) {
|
||||
if (token.validUntil && token.validUntil.getTime() <= currentTime) {
|
||||
this.logger.debug(
|
||||
`AuthToken '${token.keyId}' was removed`,
|
||||
'removeInvalidTokens',
|
||||
);
|
||||
await this.authTokenRepository.remove(token);
|
||||
removedTokens++;
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`${removedTokens} invalid AuthTokens were purged from the DB.`,
|
||||
'removeInvalidTokens',
|
||||
);
|
||||
}
|
||||
}
|
32
backend/src/auth/mock-auth.guard.ts
Normal file
32
backend/src/auth/mock-auth.guard.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class MockAuthGuard {
|
||||
private user: User;
|
||||
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req: Request = context.switchToHttp().getRequest();
|
||||
if (!this.user) {
|
||||
// this assures that we can create the user 'hardcoded', if we need them before any calls are made or
|
||||
// create them on the fly when the first call to the api is made
|
||||
try {
|
||||
this.user = await this.usersService.getUserByUsername('hardcoded');
|
||||
} catch (e) {
|
||||
this.user = await this.usersService.createUser('hardcoded', 'Testy');
|
||||
}
|
||||
}
|
||||
req.user = this.user;
|
||||
return true;
|
||||
}
|
||||
}
|
36
backend/src/auth/token.strategy.ts
Normal file
36
backend/src/auth/token.strategy.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard, PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-http-bearer';
|
||||
|
||||
import { NotInDBError, TokenNotValidError } from '../errors/errors';
|
||||
import { User } from '../users/user.entity';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class TokenAuthGuard extends AuthGuard('token') {}
|
||||
|
||||
@Injectable()
|
||||
export class TokenStrategy extends PassportStrategy(Strategy, 'token') {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(token: string): Promise<User> {
|
||||
try {
|
||||
return await this.authService.validateToken(token);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof NotInDBError ||
|
||||
error instanceof TokenNotValidError
|
||||
) {
|
||||
throw new UnauthorizedException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
69
backend/src/authors/author.entity.ts
Normal file
69
backend/src/authors/author.entity.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Edit } from '../revisions/edit.entity';
|
||||
import { Session } from '../users/session.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
export type AuthorColor = number;
|
||||
|
||||
/**
|
||||
* The author represents a single user editing a note.
|
||||
* A 'user' can either be a registered and logged-in user or a browser session identified by its cookie.
|
||||
* All edits of one user in a note must belong to the same author, so that the same color can be displayed.
|
||||
*/
|
||||
@Entity()
|
||||
export class Author {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The id of the color of this author
|
||||
* The application maps the id to an actual color
|
||||
*/
|
||||
@Column({ type: 'int' })
|
||||
color: AuthorColor;
|
||||
|
||||
/**
|
||||
* A list of (browser) sessions this author has
|
||||
* Only contains sessions for anonymous users, which don't have a user set
|
||||
*/
|
||||
@OneToMany(() => Session, (session) => session.author)
|
||||
sessions: Promise<Session[]>;
|
||||
|
||||
/**
|
||||
* User that this author corresponds to
|
||||
* Only set when the user was identified (by a browser session) as a registered user at edit-time
|
||||
*/
|
||||
@ManyToOne(() => User, (user) => user.authors, { nullable: true })
|
||||
user: Promise<User | null>;
|
||||
|
||||
/**
|
||||
* List of edits that this author created
|
||||
* All edits must belong to the same note
|
||||
*/
|
||||
@OneToMany(() => Edit, (edit) => edit.author)
|
||||
edits: Promise<Edit[]>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
public static create(color: number): Omit<Author, 'id'> {
|
||||
const newAuthor = new Author();
|
||||
newAuthor.color = color;
|
||||
newAuthor.sessions = Promise.resolve([]);
|
||||
newAuthor.user = Promise.resolve(null);
|
||||
newAuthor.edits = Promise.resolve([]);
|
||||
return newAuthor;
|
||||
}
|
||||
}
|
14
backend/src/authors/authors.module.ts
Normal file
14
backend/src/authors/authors.module.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { Author } from './author.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Author])],
|
||||
})
|
||||
export class AuthorsModule {}
|
288
backend/src/config/app.config.spec.ts
Normal file
288
backend/src/config/app.config.spec.ts
Normal file
|
@ -0,0 +1,288 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import appConfig from './app.config';
|
||||
import { Loglevel } from './loglevel.enum';
|
||||
|
||||
describe('appConfig', () => {
|
||||
const domain = 'https://example.com';
|
||||
const invalidDomain = 'localhost';
|
||||
const rendererBaseUrl = 'https://render.example.com';
|
||||
const port = 3333;
|
||||
const negativePort = -9000;
|
||||
const floatPort = 3.14;
|
||||
const outOfRangePort = 1000000;
|
||||
const invalidPort = 'not-a-port';
|
||||
const loglevel = Loglevel.TRACE;
|
||||
const invalidLoglevel = 'not-a-loglevel';
|
||||
const invalidPersistInterval = -1;
|
||||
|
||||
describe('correctly parses config', () => {
|
||||
it('when given correct and complete environment variables', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
HD_RENDERER_BASE_URL: rendererBaseUrl,
|
||||
PORT: port.toString(),
|
||||
HD_LOGLEVEL: loglevel,
|
||||
HD_PERSIST_INTERVAL: '100',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = appConfig();
|
||||
expect(config.domain).toEqual(domain);
|
||||
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
|
||||
expect(config.port).toEqual(port);
|
||||
expect(config.loglevel).toEqual(loglevel);
|
||||
expect(config.persistInterval).toEqual(100);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_RENDER_ORIGIN is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: port.toString(),
|
||||
HD_LOGLEVEL: loglevel,
|
||||
HD_PERSIST_INTERVAL: '100',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = appConfig();
|
||||
expect(config.domain).toEqual(domain);
|
||||
expect(config.rendererBaseUrl).toEqual(domain);
|
||||
expect(config.port).toEqual(port);
|
||||
expect(config.loglevel).toEqual(loglevel);
|
||||
expect(config.persistInterval).toEqual(100);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no PORT is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
HD_RENDERER_BASE_URL: rendererBaseUrl,
|
||||
HD_LOGLEVEL: loglevel,
|
||||
HD_PERSIST_INTERVAL: '100',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = appConfig();
|
||||
expect(config.domain).toEqual(domain);
|
||||
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
|
||||
expect(config.port).toEqual(3000);
|
||||
expect(config.loglevel).toEqual(loglevel);
|
||||
expect(config.persistInterval).toEqual(100);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_LOGLEVEL is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
HD_RENDERER_BASE_URL: rendererBaseUrl,
|
||||
PORT: port.toString(),
|
||||
HD_PERSIST_INTERVAL: '100',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = appConfig();
|
||||
expect(config.domain).toEqual(domain);
|
||||
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
|
||||
expect(config.port).toEqual(port);
|
||||
expect(config.loglevel).toEqual(Loglevel.WARN);
|
||||
expect(config.persistInterval).toEqual(100);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_PERSIST_INTERVAL is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
HD_RENDERER_BASE_URL: rendererBaseUrl,
|
||||
HD_LOGLEVEL: loglevel,
|
||||
PORT: port.toString(),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = appConfig();
|
||||
expect(config.domain).toEqual(domain);
|
||||
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
|
||||
expect(config.port).toEqual(port);
|
||||
expect(config.loglevel).toEqual(Loglevel.TRACE);
|
||||
expect(config.persistInterval).toEqual(10);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_PERSIST_INTERVAL is zero', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
HD_RENDERER_BASE_URL: rendererBaseUrl,
|
||||
HD_LOGLEVEL: loglevel,
|
||||
PORT: port.toString(),
|
||||
HD_PERSIST_INTERVAL: '0',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = appConfig();
|
||||
expect(config.domain).toEqual(domain);
|
||||
expect(config.rendererBaseUrl).toEqual(rendererBaseUrl);
|
||||
expect(config.port).toEqual(port);
|
||||
expect(config.loglevel).toEqual(Loglevel.TRACE);
|
||||
expect(config.persistInterval).toEqual(0);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
describe('throws error', () => {
|
||||
it('when given a non-valid HD_DOMAIN', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: invalidDomain,
|
||||
PORT: port.toString(),
|
||||
HD_LOGLEVEL: loglevel,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow('HD_DOMAIN');
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a negative PORT', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: negativePort.toString(),
|
||||
HD_LOGLEVEL: loglevel,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow('"PORT" must be a positive number');
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a out-of-range PORT', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: outOfRangePort.toString(),
|
||||
HD_LOGLEVEL: loglevel,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow(
|
||||
'"PORT" must be less than or equal to 65535',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-integer PORT', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: floatPort.toString(),
|
||||
HD_LOGLEVEL: loglevel,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow('"PORT" must be an integer');
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-number PORT', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: invalidPort,
|
||||
HD_LOGLEVEL: loglevel,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow('"PORT" must be a number');
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-loglevel HD_LOGLEVEL', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: port.toString(),
|
||||
HD_LOGLEVEL: invalidLoglevel,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow('HD_LOGLEVEL');
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a negative HD_PERSIST_INTERVAL', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_DOMAIN: domain,
|
||||
PORT: port.toString(),
|
||||
HD_LOGLEVEL: invalidLoglevel,
|
||||
HD_PERSIST_INTERVAL: invalidPersistInterval.toString(),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => appConfig()).toThrow('HD_PERSIST_INTERVAL');
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
74
backend/src/config/app.config.ts
Normal file
74
backend/src/config/app.config.ts
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { Loglevel } from './loglevel.enum';
|
||||
import { buildErrorMessage, parseOptionalNumber } from './utils';
|
||||
|
||||
export interface AppConfig {
|
||||
domain: string;
|
||||
rendererBaseUrl: string;
|
||||
port: number;
|
||||
loglevel: Loglevel;
|
||||
persistInterval: number;
|
||||
}
|
||||
|
||||
const schema = Joi.object({
|
||||
domain: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.label('HD_DOMAIN'),
|
||||
rendererBaseUrl: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.default(Joi.ref('domain'))
|
||||
.optional()
|
||||
.label('HD_RENDERER_BASE_URL'),
|
||||
port: Joi.number()
|
||||
.positive()
|
||||
.integer()
|
||||
.default(3000)
|
||||
.max(65535)
|
||||
.optional()
|
||||
.label('PORT'),
|
||||
loglevel: Joi.string()
|
||||
.valid(...Object.values(Loglevel))
|
||||
.default(Loglevel.WARN)
|
||||
.optional()
|
||||
.label('HD_LOGLEVEL'),
|
||||
persistInterval: Joi.number()
|
||||
.integer()
|
||||
.min(0)
|
||||
.default(10)
|
||||
.optional()
|
||||
.label('HD_PERSIST_INTERVAL'),
|
||||
});
|
||||
|
||||
export default registerAs('appConfig', () => {
|
||||
const appConfig = schema.validate(
|
||||
{
|
||||
domain: process.env.HD_DOMAIN,
|
||||
rendererBaseUrl: process.env.HD_RENDERER_BASE_URL,
|
||||
port: parseOptionalNumber(process.env.PORT),
|
||||
loglevel: process.env.HD_LOGLEVEL,
|
||||
persistInterval: process.env.HD_PERSIST_INTERVAL,
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (appConfig.error) {
|
||||
const errorMessages = appConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return appConfig.value as AppConfig;
|
||||
});
|
522
backend/src/config/auth.config.spec.ts
Normal file
522
backend/src/config/auth.config.spec.ts
Normal file
|
@ -0,0 +1,522 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import authConfig from './auth.config';
|
||||
|
||||
describe('authConfig', () => {
|
||||
const secret = 'this-is-a-secret';
|
||||
const neededAuthConfig = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_SESSION_SECRET: secret,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
};
|
||||
|
||||
describe('local', () => {
|
||||
const enableLogin = true;
|
||||
const enableRegister = true;
|
||||
const minimalPasswordStrength = 1;
|
||||
const completeLocalConfig = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_AUTH_LOCAL_ENABLE_LOGIN: String(enableLogin),
|
||||
HD_AUTH_LOCAL_ENABLE_REGISTER: String(enableRegister),
|
||||
HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH: String(minimalPasswordStrength),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
};
|
||||
describe('is correctly parsed', () => {
|
||||
it('when given correct and complete environment variables', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLocalConfig,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.local.enableLogin).toEqual(enableLogin);
|
||||
expect(config.local.enableRegister).toEqual(enableRegister);
|
||||
expect(config.local.minimalPasswordStrength).toEqual(
|
||||
minimalPasswordStrength,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_AUTH_LOCAL_ENABLE_LOGIN is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLocalConfig,
|
||||
HD_AUTH_LOCAL_ENABLE_LOGIN: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.local.enableLogin).toEqual(false);
|
||||
expect(config.local.enableRegister).toEqual(enableRegister);
|
||||
expect(config.local.minimalPasswordStrength).toEqual(
|
||||
minimalPasswordStrength,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_AUTH_LOCAL_ENABLE_REGISTER is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLocalConfig,
|
||||
HD_AUTH_LOCAL_ENABLE_REGISTER: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.local.enableLogin).toEqual(enableLogin);
|
||||
expect(config.local.enableRegister).toEqual(false);
|
||||
expect(config.local.minimalPasswordStrength).toEqual(
|
||||
minimalPasswordStrength,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLocalConfig,
|
||||
HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.local.enableLogin).toEqual(enableLogin);
|
||||
expect(config.local.enableRegister).toEqual(enableRegister);
|
||||
expect(config.local.minimalPasswordStrength).toEqual(2);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fails to be parsed', () => {
|
||||
it('when HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH is 5', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLocalConfig,
|
||||
HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH: '5',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH" must be less than or equal to 4',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH is -1', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLocalConfig,
|
||||
HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH: '-1',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH" must be greater than or equal to 0',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ldap', () => {
|
||||
const ldapNames = ['futurama'];
|
||||
const providerName = 'Futurama LDAP';
|
||||
const url = 'ldap://localhost:389';
|
||||
const searchBase = 'ou=people,dc=planetexpress,dc=com';
|
||||
const searchFilter = '(mail={{username}})';
|
||||
const searchAttributes = ['mail', 'uid'];
|
||||
const userIdField = 'non_default_uid';
|
||||
const displayNameField = 'non_default_display_name';
|
||||
const profilePictureField = 'non_default_profile_picture';
|
||||
const bindDn = 'cn=admin,dc=planetexpress,dc=com';
|
||||
const bindCredentials = 'GoodNewsEveryone';
|
||||
const tlsCa = ['./test/private-api/fixtures/hedgedoc.pem'];
|
||||
const tlsCaContent = ['test-cert\n'];
|
||||
const completeLdapConfig = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_AUTH_LDAPS: ldapNames.join(','),
|
||||
HD_AUTH_LDAP_FUTURAMA_PROVIDER_NAME: providerName,
|
||||
HD_AUTH_LDAP_FUTURAMA_URL: url,
|
||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE: searchBase,
|
||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER: searchFilter,
|
||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_ATTRIBUTES: searchAttributes.join(','),
|
||||
HD_AUTH_LDAP_FUTURAMA_USER_ID_FIELD: userIdField,
|
||||
HD_AUTH_LDAP_FUTURAMA_DISPLAY_NAME_FIELD: displayNameField,
|
||||
HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD: profilePictureField,
|
||||
HD_AUTH_LDAP_FUTURAMA_BIND_DN: bindDn,
|
||||
HD_AUTH_LDAP_FUTURAMA_BIND_CREDENTIALS: bindCredentials,
|
||||
HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS: tlsCa.join(','),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
};
|
||||
describe('is correctly parsed', () => {
|
||||
it('when given correct and complete environment variables', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_PROVIDER_NAME is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_PROVIDER_NAME: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual('LDAP');
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_FILTER: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual('(uid={{username}})');
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_USER_ID_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_USER_ID_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toBe('uid');
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_DISPLAY_NAME_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_DISPLAY_NAME_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual('displayName');
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_PROFILE_PICTURE_FIELD: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual('jpegPhoto');
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_BIND_DN is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_BIND_DN: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toBe(undefined);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_BIND_CREDENTIALS is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_BIND_CREDENTIALS: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toBe(undefined);
|
||||
expect(firstLdap.tlsCaCerts).toEqual(tlsCaContent);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS is not set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = authConfig();
|
||||
expect(config.ldap).toHaveLength(1);
|
||||
const firstLdap = config.ldap[0];
|
||||
expect(firstLdap.identifier).toEqual(ldapNames[0].toUpperCase());
|
||||
expect(firstLdap.url).toEqual(url);
|
||||
expect(firstLdap.providerName).toEqual(providerName);
|
||||
expect(firstLdap.searchBase).toEqual(searchBase);
|
||||
expect(firstLdap.searchFilter).toEqual(searchFilter);
|
||||
expect(firstLdap.searchAttributes).toEqual(searchAttributes);
|
||||
expect(firstLdap.userIdField).toEqual(userIdField);
|
||||
expect(firstLdap.displayNameField).toEqual(displayNameField);
|
||||
expect(firstLdap.profilePictureField).toEqual(profilePictureField);
|
||||
expect(firstLdap.bindDn).toEqual(bindDn);
|
||||
expect(firstLdap.bindCredentials).toEqual(bindCredentials);
|
||||
expect(firstLdap.tlsCaCerts).toBe(undefined);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
describe('throws error', () => {
|
||||
it('when HD_AUTH_LDAP_FUTURAMA_URL is wrong', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_URL: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_LDAP_FUTURAMA_URL" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE is wrong', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE: undefined,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_LDAP_FUTURAMA_SEARCH_BASE" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS is wrong', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
...neededAuthConfig,
|
||||
...completeLdapConfig,
|
||||
HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS: 'not-a-file.pem',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => authConfig()).toThrow(
|
||||
'"HD_AUTH_LDAP_FUTURAMA_TLS_CERT_PATHS[0]" must not be a sparse array item',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
452
backend/src/config/auth.config.ts
Normal file
452
backend/src/config/auth.config.ts
Normal file
|
@ -0,0 +1,452 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { GitlabScope, GitlabVersion } from './gitlab.enum';
|
||||
import {
|
||||
buildErrorMessage,
|
||||
parseOptionalNumber,
|
||||
replaceAuthErrorsWithEnvironmentVariables,
|
||||
toArrayConfig,
|
||||
} from './utils';
|
||||
|
||||
export interface LDAPConfig {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
url: string;
|
||||
bindDn?: string;
|
||||
bindCredentials?: string;
|
||||
searchBase: string;
|
||||
searchFilter: string;
|
||||
searchAttributes: string[];
|
||||
userIdField: string;
|
||||
displayNameField: string;
|
||||
profilePictureField: string;
|
||||
tlsCaCerts?: string[];
|
||||
}
|
||||
|
||||
export interface AuthConfig {
|
||||
session: {
|
||||
secret: string;
|
||||
lifetime: number;
|
||||
};
|
||||
local: {
|
||||
enableLogin: boolean;
|
||||
enableRegister: boolean;
|
||||
minimalPasswordStrength: number;
|
||||
};
|
||||
facebook: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
twitter: {
|
||||
consumerKey: string;
|
||||
consumerSecret: string;
|
||||
};
|
||||
github: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
dropbox: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
appKey: string;
|
||||
};
|
||||
google: {
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
apiKey: string;
|
||||
};
|
||||
gitlab: {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
baseURL: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
scope: GitlabScope;
|
||||
version: GitlabVersion;
|
||||
}[];
|
||||
// ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback
|
||||
ldap: LDAPConfig[];
|
||||
saml: {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
idpSsoUrl: string;
|
||||
idpCert: string;
|
||||
clientCert: string;
|
||||
issuer: string;
|
||||
identifierFormat: string;
|
||||
disableRequestedAuthnContext: string;
|
||||
groupAttribute: string;
|
||||
requiredGroups?: string[];
|
||||
externalGroups?: string[];
|
||||
attribute: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
}[];
|
||||
oauth2: {
|
||||
identifier: string;
|
||||
providerName: string;
|
||||
baseURL: string;
|
||||
userProfileURL: string;
|
||||
userProfileIdAttr: string;
|
||||
userProfileUsernameAttr: string;
|
||||
userProfileDisplayNameAttr: string;
|
||||
userProfileEmailAttr: string;
|
||||
tokenURL: string;
|
||||
authorizationURL: string;
|
||||
clientID: string;
|
||||
clientSecret: string;
|
||||
scope: string;
|
||||
rolesClaim: string;
|
||||
accessRole: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const authSchema = Joi.object({
|
||||
session: {
|
||||
secret: Joi.string().label('HD_SESSION_SECRET'),
|
||||
lifetime: Joi.number()
|
||||
.default(1209600000) // 14 * 24 * 60 * 60 * 1000ms = 14 days
|
||||
.optional()
|
||||
.label('HD_SESSION_LIFETIME'),
|
||||
},
|
||||
local: {
|
||||
enableLogin: Joi.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.label('HD_AUTH_LOCAL_ENABLE_LOGIN'),
|
||||
enableRegister: Joi.boolean()
|
||||
.default(false)
|
||||
.optional()
|
||||
.label('HD_AUTH_LOCAL_ENABLE_REGISTER'),
|
||||
minimalPasswordStrength: Joi.number()
|
||||
.default(2)
|
||||
.min(0)
|
||||
.max(4)
|
||||
.optional()
|
||||
.label('HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH'),
|
||||
},
|
||||
facebook: {
|
||||
clientID: Joi.string().optional().label('HD_AUTH_FACEBOOK_CLIENT_ID'),
|
||||
clientSecret: Joi.string()
|
||||
.optional()
|
||||
.label('HD_AUTH_FACEBOOK_CLIENT_SECRET'),
|
||||
},
|
||||
twitter: {
|
||||
consumerKey: Joi.string().optional().label('HD_AUTH_TWITTER_CONSUMER_KEY'),
|
||||
consumerSecret: Joi.string()
|
||||
.optional()
|
||||
.label('HD_AUTH_TWITTER_CONSUMER_SECRET'),
|
||||
},
|
||||
github: {
|
||||
clientID: Joi.string().optional().label('HD_AUTH_GITHUB_CLIENT_ID'),
|
||||
clientSecret: Joi.string().optional().label('HD_AUTH_GITHUB_CLIENT_SECRET'),
|
||||
},
|
||||
dropbox: {
|
||||
clientID: Joi.string().optional().label('HD_AUTH_DROPBOX_CLIENT_ID'),
|
||||
clientSecret: Joi.string()
|
||||
.optional()
|
||||
.label('HD_AUTH_DROPBOX_CLIENT_SECRET'),
|
||||
appKey: Joi.string().optional().label('HD_AUTH_DROPBOX_APP_KEY'),
|
||||
},
|
||||
google: {
|
||||
clientID: Joi.string().optional().label('HD_AUTH_GOOGLE_CLIENT_ID'),
|
||||
clientSecret: Joi.string().optional().label('HD_AUTH_GOOGLE_CLIENT_SECRET'),
|
||||
apiKey: Joi.string().optional().label('HD_AUTH_GOOGLE_APP_KEY'),
|
||||
},
|
||||
gitlab: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('Gitlab').optional(),
|
||||
baseURL: Joi.string(),
|
||||
clientID: Joi.string(),
|
||||
clientSecret: Joi.string(),
|
||||
scope: Joi.string()
|
||||
.valid(...Object.values(GitlabScope))
|
||||
.default(GitlabScope.READ_USER)
|
||||
.optional(),
|
||||
version: Joi.string()
|
||||
.valid(...Object.values(GitlabVersion))
|
||||
.default(GitlabVersion.V4)
|
||||
.optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
// ToDo: should searchfilter have a default?
|
||||
ldap: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('LDAP').optional(),
|
||||
url: Joi.string(),
|
||||
bindDn: Joi.string().optional(),
|
||||
bindCredentials: Joi.string().optional(),
|
||||
searchBase: Joi.string(),
|
||||
searchFilter: Joi.string().default('(uid={{username}})').optional(),
|
||||
searchAttributes: Joi.array().items(Joi.string()).optional(),
|
||||
userIdField: Joi.string().default('uid').optional(),
|
||||
displayNameField: Joi.string().default('displayName').optional(),
|
||||
profilePictureField: Joi.string().default('jpegPhoto').optional(),
|
||||
tlsCaCerts: Joi.array().items(Joi.string()).optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
saml: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('SAML').optional(),
|
||||
idpSsoUrl: Joi.string(),
|
||||
idpCert: Joi.string(),
|
||||
clientCert: Joi.string().optional(),
|
||||
// ToDo: (default: config.serverURL) will be build on-the-fly in the config/index.js from domain, urlAddPort and urlPath.
|
||||
issuer: Joi.string().optional(),
|
||||
identifierFormat: Joi.string()
|
||||
.default('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress')
|
||||
.optional(),
|
||||
disableRequestedAuthnContext: Joi.boolean().default(false).optional(),
|
||||
groupAttribute: Joi.string().optional(),
|
||||
requiredGroups: Joi.array().items(Joi.string()).optional(),
|
||||
externalGroups: Joi.array().items(Joi.string()).optional(),
|
||||
attribute: {
|
||||
id: Joi.string().default('NameId').optional(),
|
||||
username: Joi.string().default('NameId').optional(),
|
||||
local: Joi.string().default('NameId').optional(),
|
||||
},
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
oauth2: Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
identifier: Joi.string(),
|
||||
providerName: Joi.string().default('OAuth2').optional(),
|
||||
baseURL: Joi.string(),
|
||||
userProfileURL: Joi.string(),
|
||||
userProfileIdAttr: Joi.string().optional(),
|
||||
userProfileUsernameAttr: Joi.string(),
|
||||
userProfileDisplayNameAttr: Joi.string(),
|
||||
userProfileEmailAttr: Joi.string(),
|
||||
tokenURL: Joi.string(),
|
||||
authorizationURL: Joi.string(),
|
||||
clientID: Joi.string(),
|
||||
clientSecret: Joi.string(),
|
||||
scope: Joi.string().optional(),
|
||||
rolesClaim: Joi.string().optional(),
|
||||
accessRole: Joi.string().optional(),
|
||||
}).optional(),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default registerAs('authConfig', () => {
|
||||
// ToDo: Validate these with Joi to prevent duplicate entries?
|
||||
const gitlabNames = (
|
||||
toArrayConfig(process.env.HD_AUTH_GITLABS, ',') ?? []
|
||||
).map((name) => name.toUpperCase());
|
||||
const ldapNames = (toArrayConfig(process.env.HD_AUTH_LDAPS, ',') ?? []).map(
|
||||
(name) => name.toUpperCase(),
|
||||
);
|
||||
const samlNames = (toArrayConfig(process.env.HD_AUTH_SAMLS, ',') ?? []).map(
|
||||
(name) => name.toUpperCase(),
|
||||
);
|
||||
const oauth2Names = (
|
||||
toArrayConfig(process.env.HD_AUTH_OAUTH2S, ',') ?? []
|
||||
).map((name) => name.toUpperCase());
|
||||
|
||||
const gitlabs = gitlabNames.map((gitlabName) => {
|
||||
return {
|
||||
identifier: gitlabName,
|
||||
providerName: process.env[`HD_AUTH_GITLAB_${gitlabName}_PROVIDER_NAME`],
|
||||
baseURL: process.env[`HD_AUTH_GITLAB_${gitlabName}_BASE_URL`],
|
||||
clientID: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_ID`],
|
||||
clientSecret: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_SECRET`],
|
||||
scope: process.env[`HD_AUTH_GITLAB_${gitlabName}_SCOPE`],
|
||||
version: process.env[`HD_AUTH_GITLAB_${gitlabName}_GITLAB_VERSION`],
|
||||
};
|
||||
});
|
||||
|
||||
const ldaps = ldapNames.map((ldapName) => {
|
||||
const caFiles = toArrayConfig(
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CERT_PATHS`],
|
||||
',',
|
||||
);
|
||||
let tlsCaCerts = undefined;
|
||||
if (caFiles) {
|
||||
tlsCaCerts = caFiles.map((fileName) => {
|
||||
if (fs.existsSync(fileName)) {
|
||||
return fs.readFileSync(fileName, 'utf8');
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
identifier: ldapName,
|
||||
providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`],
|
||||
url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`],
|
||||
bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`],
|
||||
bindCredentials: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_CREDENTIALS`],
|
||||
searchBase: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_BASE`],
|
||||
searchFilter: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_FILTER`],
|
||||
searchAttributes: toArrayConfig(
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_ATTRIBUTES`],
|
||||
',',
|
||||
),
|
||||
userIdField: process.env[`HD_AUTH_LDAP_${ldapName}_USER_ID_FIELD`],
|
||||
displayNameField:
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_DISPLAY_NAME_FIELD`],
|
||||
profilePictureField:
|
||||
process.env[`HD_AUTH_LDAP_${ldapName}_PROFILE_PICTURE_FIELD`],
|
||||
tlsCaCerts: tlsCaCerts,
|
||||
};
|
||||
});
|
||||
|
||||
const samls = samlNames.map((samlName) => {
|
||||
return {
|
||||
identifier: samlName,
|
||||
providerName: process.env[`HD_AUTH_SAML_${samlName}_PROVIDER_NAME`],
|
||||
idpSsoUrl: process.env[`HD_AUTH_SAML_${samlName}_IDP_SSO_URL`],
|
||||
idpCert: process.env[`HD_AUTH_SAML_${samlName}_IDP_CERT`],
|
||||
clientCert: process.env[`HD_AUTH_SAML_${samlName}_CLIENT_CERT`],
|
||||
issuer: process.env[`HD_AUTH_SAML_${samlName}_ISSUER`],
|
||||
identifierFormat:
|
||||
process.env[`HD_AUTH_SAML_${samlName}_IDENTIFIER_FORMAT`],
|
||||
disableRequestedAuthnContext:
|
||||
process.env[`HD_AUTH_SAML_${samlName}_DISABLE_REQUESTED_AUTHN_CONTEXT`],
|
||||
groupAttribute: process.env[`HD_AUTH_SAML_${samlName}_GROUP_ATTRIBUTE`],
|
||||
requiredGroups: toArrayConfig(
|
||||
process.env[`HD_AUTH_SAML_${samlName}_REQUIRED_GROUPS`],
|
||||
'|',
|
||||
),
|
||||
externalGroups: toArrayConfig(
|
||||
process.env[`HD_AUTH_SAML_${samlName}_EXTERNAL_GROUPS`],
|
||||
'|',
|
||||
),
|
||||
attribute: {
|
||||
id: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_ID`],
|
||||
username: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`],
|
||||
local: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_LOCAL`],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const oauth2s = oauth2Names.map((oauth2Name) => {
|
||||
return {
|
||||
identifier: oauth2Name,
|
||||
providerName: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_PROVIDER_NAME`],
|
||||
baseURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_BASE_URL`],
|
||||
userProfileURL:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_URL`],
|
||||
userProfileIdAttr:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_ID_ATTR`],
|
||||
userProfileUsernameAttr:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_USERNAME_ATTR`],
|
||||
userProfileDisplayNameAttr:
|
||||
process.env[
|
||||
`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_DISPLAY_NAME_ATTR`
|
||||
],
|
||||
userProfileEmailAttr:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_EMAIL_ATTR`],
|
||||
tokenURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_TOKEN_URL`],
|
||||
authorizationURL:
|
||||
process.env[`HD_AUTH_OAUTH2_${oauth2Name}_AUTHORIZATION_URL`],
|
||||
clientID: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_ID`],
|
||||
clientSecret: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_SECRET`],
|
||||
scope: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_SCOPE`],
|
||||
rolesClaim: process.env[`HD_AUTH_OAUTH2_${oauth2Name}`],
|
||||
accessRole: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_ACCESS_ROLE`],
|
||||
};
|
||||
});
|
||||
|
||||
const authConfig = authSchema.validate(
|
||||
{
|
||||
session: {
|
||||
secret: process.env.HD_SESSION_SECRET,
|
||||
lifetime: parseOptionalNumber(process.env.HD_SESSION_LIFETIME),
|
||||
},
|
||||
local: {
|
||||
enableLogin: process.env.HD_AUTH_LOCAL_ENABLE_LOGIN,
|
||||
enableRegister: process.env.HD_AUTH_LOCAL_ENABLE_REGISTER,
|
||||
minimalPasswordStrength: parseOptionalNumber(
|
||||
process.env.HD_AUTH_LOCAL_MINIMAL_PASSWORD_STRENGTH,
|
||||
),
|
||||
},
|
||||
facebook: {
|
||||
clientID: process.env.HD_AUTH_FACEBOOK_CLIENT_ID,
|
||||
clientSecret: process.env.HD_AUTH_FACEBOOK_CLIENT_SECRET,
|
||||
},
|
||||
twitter: {
|
||||
consumerKey: process.env.HD_AUTH_TWITTER_CONSUMER_KEY,
|
||||
consumerSecret: process.env.HD_AUTH_TWITTER_CONSUMER_SECRET,
|
||||
},
|
||||
github: {
|
||||
clientID: process.env.HD_AUTH_GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.HD_AUTH_GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
dropbox: {
|
||||
clientID: process.env.HD_AUTH_DROPBOX_CLIENT_ID,
|
||||
clientSecret: process.env.HD_AUTH_DROPBOX_CLIENT_SECRET,
|
||||
appKey: process.env.HD_AUTH_DROPBOX_APP_KEY,
|
||||
},
|
||||
google: {
|
||||
clientID: process.env.HD_AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.HD_AUTH_GOOGLE_CLIENT_SECRET,
|
||||
apiKey: process.env.HD_AUTH_GOOGLE_APP_KEY,
|
||||
},
|
||||
gitlab: gitlabs,
|
||||
ldap: ldaps,
|
||||
saml: samls,
|
||||
oauth2: oauth2s,
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (authConfig.error) {
|
||||
const errorMessages = authConfig.error.details
|
||||
.map((detail) => detail.message)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'gitlab',
|
||||
'HD_AUTH_GITLAB_',
|
||||
gitlabNames,
|
||||
),
|
||||
)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'ldap',
|
||||
'HD_AUTH_LDAP_',
|
||||
ldapNames,
|
||||
),
|
||||
)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'saml',
|
||||
'HD_AUTH_SAML_',
|
||||
samlNames,
|
||||
),
|
||||
)
|
||||
.map((error) =>
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
error,
|
||||
'oauth2',
|
||||
'HD_AUTH_OAUTH2_',
|
||||
oauth2Names,
|
||||
),
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return authConfig.value as AuthConfig;
|
||||
});
|
39
backend/src/config/csp.config.ts
Normal file
39
backend/src/config/csp.config.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { buildErrorMessage } from './utils';
|
||||
|
||||
export interface CspConfig {
|
||||
enable: boolean;
|
||||
reportURI: string;
|
||||
}
|
||||
|
||||
const cspSchema = Joi.object({
|
||||
enable: Joi.boolean().default(true).optional().label('HD_CSP_ENABLE'),
|
||||
reportURI: Joi.string().optional().label('HD_CSP_REPORT_URI'),
|
||||
});
|
||||
|
||||
export default registerAs('cspConfig', () => {
|
||||
const cspConfig = cspSchema.validate(
|
||||
{
|
||||
enable: process.env.HD_CSP_ENABLE || true,
|
||||
reportURI: process.env.HD_CSP_REPORT_URI,
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (cspConfig.error) {
|
||||
const errorMessages = cspConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return cspConfig.value as CspConfig;
|
||||
});
|
80
backend/src/config/customization.config.ts
Normal file
80
backend/src/config/customization.config.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { buildErrorMessage } from './utils';
|
||||
|
||||
export interface CustomizationConfig {
|
||||
branding: {
|
||||
customName: string;
|
||||
customLogo: string;
|
||||
};
|
||||
specialUrls: {
|
||||
privacy: string;
|
||||
termsOfUse: string;
|
||||
imprint: string;
|
||||
};
|
||||
}
|
||||
|
||||
const schema = Joi.object({
|
||||
branding: Joi.object({
|
||||
customName: Joi.string().optional().label('HD_CUSTOM_NAME'),
|
||||
customLogo: Joi.string()
|
||||
.uri({
|
||||
scheme: [/https?/],
|
||||
})
|
||||
.optional()
|
||||
.label('HD_CUSTOM_LOGO'),
|
||||
}),
|
||||
specialUrls: Joi.object({
|
||||
privacy: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.optional()
|
||||
.label('HD_PRIVACY_URL'),
|
||||
termsOfUse: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.optional()
|
||||
.label('HD_TERMS_OF_USE_URL'),
|
||||
imprint: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.optional()
|
||||
.label('HD_IMPRINT_URL'),
|
||||
}),
|
||||
});
|
||||
|
||||
export default registerAs('customizationConfig', () => {
|
||||
const customizationConfig = schema.validate(
|
||||
{
|
||||
branding: {
|
||||
customName: process.env.HD_CUSTOM_NAME,
|
||||
customLogo: process.env.HD_CUSTOM_LOGO,
|
||||
},
|
||||
specialUrls: {
|
||||
privacy: process.env.HD_PRIVACY_URL,
|
||||
termsOfUse: process.env.HD_TERMS_OF_USE_URL,
|
||||
imprint: process.env.HD_IMPRINT_URL,
|
||||
},
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (customizationConfig.error) {
|
||||
const errorMessages = customizationConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return customizationConfig.value as CustomizationConfig;
|
||||
});
|
12
backend/src/config/database-type.enum.ts
Normal file
12
backend/src/config/database-type.enum.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum DatabaseType {
|
||||
POSTGRES = 'postgres',
|
||||
MYSQL = 'mysql',
|
||||
MARIADB = 'mariadb',
|
||||
SQLITE = 'sqlite',
|
||||
}
|
73
backend/src/config/database.config.ts
Normal file
73
backend/src/config/database.config.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { DatabaseType } from './database-type.enum';
|
||||
import { buildErrorMessage, parseOptionalNumber } from './utils';
|
||||
|
||||
export interface DatabaseConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
host: string;
|
||||
port: number;
|
||||
type: DatabaseType;
|
||||
}
|
||||
|
||||
const databaseSchema = Joi.object({
|
||||
type: Joi.string()
|
||||
.valid(...Object.values(DatabaseType))
|
||||
.label('HD_DATABASE_TYPE'),
|
||||
|
||||
// This is the database name, except for SQLite,
|
||||
// where it is the path to the database file.
|
||||
database: Joi.string().label('HD_DATABASE_NAME'),
|
||||
username: Joi.when('type', {
|
||||
is: Joi.invalid(DatabaseType.SQLITE),
|
||||
then: Joi.string(),
|
||||
otherwise: Joi.optional(),
|
||||
}).label('HD_DATABASE_USER'),
|
||||
password: Joi.when('type', {
|
||||
is: Joi.invalid(DatabaseType.SQLITE),
|
||||
then: Joi.string(),
|
||||
otherwise: Joi.optional(),
|
||||
}).label('HD_DATABASE_PASS'),
|
||||
host: Joi.when('type', {
|
||||
is: Joi.invalid(DatabaseType.SQLITE),
|
||||
then: Joi.string(),
|
||||
otherwise: Joi.optional(),
|
||||
}).label('HD_DATABASE_HOST'),
|
||||
port: Joi.when('type', {
|
||||
is: Joi.invalid(DatabaseType.SQLITE),
|
||||
then: Joi.number(),
|
||||
otherwise: Joi.optional(),
|
||||
}).label('HD_DATABASE_PORT'),
|
||||
});
|
||||
|
||||
export default registerAs('databaseConfig', () => {
|
||||
const databaseConfig = databaseSchema.validate(
|
||||
{
|
||||
type: process.env.HD_DATABASE_TYPE,
|
||||
username: process.env.HD_DATABASE_USER,
|
||||
password: process.env.HD_DATABASE_PASS,
|
||||
database: process.env.HD_DATABASE_NAME,
|
||||
host: process.env.HD_DATABASE_HOST,
|
||||
port: parseOptionalNumber(process.env.HD_DATABASE_PORT),
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (databaseConfig.error) {
|
||||
const errorMessages = databaseConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return databaseConfig.value as DatabaseConfig;
|
||||
});
|
26
backend/src/config/default-access-permission.enum.ts
Normal file
26
backend/src/config/default-access-permission.enum.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum DefaultAccessPermission {
|
||||
NONE = 'none',
|
||||
READ = 'read',
|
||||
WRITE = 'write',
|
||||
}
|
||||
|
||||
export function getDefaultAccessPermissionOrdinal(
|
||||
permission: DefaultAccessPermission,
|
||||
): number {
|
||||
switch (permission) {
|
||||
case DefaultAccessPermission.NONE:
|
||||
return 0;
|
||||
case DefaultAccessPermission.READ:
|
||||
return 1;
|
||||
case DefaultAccessPermission.WRITE:
|
||||
return 2;
|
||||
default:
|
||||
throw Error('Unknown permission');
|
||||
}
|
||||
}
|
49
backend/src/config/external-services.config.ts
Normal file
49
backend/src/config/external-services.config.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { buildErrorMessage } from './utils';
|
||||
|
||||
export interface ExternalServicesConfig {
|
||||
plantUmlServer: string;
|
||||
imageProxy: string;
|
||||
}
|
||||
|
||||
const schema = Joi.object({
|
||||
plantUmlServer: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.optional()
|
||||
.label('HD_PLANTUML_SERVER'),
|
||||
imageProxy: Joi.string()
|
||||
.uri({
|
||||
scheme: /https?/,
|
||||
})
|
||||
.optional()
|
||||
.label('HD_IMAGE_PROXY'),
|
||||
});
|
||||
|
||||
export default registerAs('externalServicesConfig', () => {
|
||||
const externalConfig = schema.validate(
|
||||
{
|
||||
plantUmlServer: process.env.HD_PLANTUML_SERVER,
|
||||
imageProxy: process.env.HD_IMAGE_PROXY,
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (externalConfig.error) {
|
||||
const errorMessages = externalConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return externalConfig.value as ExternalServicesConfig;
|
||||
});
|
16
backend/src/config/gitlab.enum.ts
Normal file
16
backend/src/config/gitlab.enum.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum GitlabScope {
|
||||
READ_USER = 'read_user',
|
||||
API = 'api',
|
||||
}
|
||||
|
||||
// ToDo: Evaluate if V3 is really necessary anymore (it's deprecated since 2017)
|
||||
export enum GitlabVersion {
|
||||
V3 = 'v3',
|
||||
V4 = 'v4',
|
||||
}
|
27
backend/src/config/guest_access.enum.ts
Normal file
27
backend/src/config/guest_access.enum.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum GuestAccess {
|
||||
DENY = 'deny',
|
||||
READ = 'read',
|
||||
WRITE = 'write',
|
||||
CREATE = 'create',
|
||||
}
|
||||
|
||||
export function getGuestAccessOrdinal(guestAccess: GuestAccess): number {
|
||||
switch (guestAccess) {
|
||||
case GuestAccess.DENY:
|
||||
return 0;
|
||||
case GuestAccess.READ:
|
||||
return 1;
|
||||
case GuestAccess.WRITE:
|
||||
return 2;
|
||||
case GuestAccess.CREATE:
|
||||
return 3;
|
||||
default:
|
||||
throw Error('Unknown permission');
|
||||
}
|
||||
}
|
51
backend/src/config/hsts.config.ts
Normal file
51
backend/src/config/hsts.config.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { buildErrorMessage, parseOptionalNumber } from './utils';
|
||||
|
||||
export interface HstsConfig {
|
||||
enable: boolean;
|
||||
maxAgeSeconds: number;
|
||||
includeSubdomains: boolean;
|
||||
preload: boolean;
|
||||
}
|
||||
|
||||
const hstsSchema = Joi.object({
|
||||
enable: Joi.boolean().default(true).optional().label('HD_HSTS_ENABLE'),
|
||||
maxAgeSeconds: Joi.number()
|
||||
.default(60 * 60 * 24 * 365)
|
||||
.optional()
|
||||
.label('HD_HSTS_MAX_AGE'),
|
||||
includeSubdomains: Joi.boolean()
|
||||
.default(true)
|
||||
.optional()
|
||||
.label('HD_HSTS_INCLUDE_SUBDOMAINS'),
|
||||
preload: Joi.boolean().default(true).optional().label('HD_HSTS_PRELOAD'),
|
||||
});
|
||||
|
||||
export default registerAs('hstsConfig', () => {
|
||||
const hstsConfig = hstsSchema.validate(
|
||||
{
|
||||
enable: process.env.HD_HSTS_ENABLE,
|
||||
maxAgeSeconds: parseOptionalNumber(process.env.HD_HSTS_MAX_AGE),
|
||||
includeSubdomains: process.env.HD_HSTS_INCLUDE_SUBDOMAINS,
|
||||
preload: process.env.HD_HSTS_PRELOAD,
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (hstsConfig.error) {
|
||||
const errorMessages = hstsConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return hstsConfig.value as HstsConfig;
|
||||
});
|
13
backend/src/config/loglevel.enum.ts
Normal file
13
backend/src/config/loglevel.enum.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export enum Loglevel {
|
||||
TRACE = 'trace',
|
||||
DEBUG = 'debug',
|
||||
INFO = 'info',
|
||||
WARN = 'warn',
|
||||
ERROR = 'error',
|
||||
}
|
369
backend/src/config/media.config.spec.ts
Normal file
369
backend/src/config/media.config.spec.ts
Normal file
|
@ -0,0 +1,369 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import { BackendType } from '../media/backends/backend-type.enum';
|
||||
import mediaConfig from './media.config';
|
||||
|
||||
describe('mediaConfig', () => {
|
||||
// Filesystem
|
||||
const uploadPath = 'uploads';
|
||||
// S3
|
||||
const accessKeyId = 'accessKeyId';
|
||||
const secretAccessKey = 'secretAccessKey';
|
||||
const bucket = 'bucket';
|
||||
const endPoint = 'endPoint';
|
||||
// Azure
|
||||
const azureConnectionString = 'connectionString';
|
||||
const container = 'container';
|
||||
// Imgur
|
||||
const clientID = 'clientID';
|
||||
// Webdav
|
||||
const webdavConnectionString = 'https://example.com/webdav';
|
||||
const uploadDir = 'uploadDir';
|
||||
const publicUrl = 'https://example.com/images';
|
||||
|
||||
describe('correctly parses config', () => {
|
||||
it('for backend filesystem', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.FILESYSTEM,
|
||||
HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH: uploadPath,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = mediaConfig();
|
||||
expect(config.backend.use).toEqual(BackendType.FILESYSTEM);
|
||||
expect(config.backend.filesystem.uploadPath).toEqual(uploadPath);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('for backend s3', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.S3,
|
||||
HD_MEDIA_BACKEND_S3_ACCESS_KEY: accessKeyId,
|
||||
HD_MEDIA_BACKEND_S3_SECRET_KEY: secretAccessKey,
|
||||
HD_MEDIA_BACKEND_S3_BUCKET: bucket,
|
||||
HD_MEDIA_BACKEND_S3_ENDPOINT: endPoint,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = mediaConfig();
|
||||
expect(config.backend.use).toEqual(BackendType.S3);
|
||||
expect(config.backend.s3.accessKeyId).toEqual(accessKeyId);
|
||||
expect(config.backend.s3.secretAccessKey).toEqual(secretAccessKey);
|
||||
expect(config.backend.s3.bucket).toEqual(bucket);
|
||||
expect(config.backend.s3.endPoint).toEqual(endPoint);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('for backend azure', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.AZURE,
|
||||
HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING: azureConnectionString,
|
||||
HD_MEDIA_BACKEND_AZURE_CONTAINER: container,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = mediaConfig();
|
||||
expect(config.backend.use).toEqual(BackendType.AZURE);
|
||||
expect(config.backend.azure.connectionString).toEqual(
|
||||
azureConnectionString,
|
||||
);
|
||||
expect(config.backend.azure.container).toEqual(container);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('for backend imgur', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.IMGUR,
|
||||
HD_MEDIA_BACKEND_IMGUR_CLIENT_ID: clientID,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = mediaConfig();
|
||||
expect(config.backend.use).toEqual(BackendType.IMGUR);
|
||||
expect(config.backend.imgur.clientID).toEqual(clientID);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('for backend webdav', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.WEBDAV,
|
||||
HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: webdavConnectionString,
|
||||
HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR: uploadDir,
|
||||
HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: publicUrl,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = mediaConfig();
|
||||
expect(config.backend.use).toEqual(BackendType.WEBDAV);
|
||||
expect(config.backend.webdav.connectionString).toEqual(
|
||||
webdavConnectionString,
|
||||
);
|
||||
expect(config.backend.webdav.uploadDir).toEqual(uploadDir);
|
||||
expect(config.backend.webdav.publicUrl).toEqual(publicUrl);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('throws error', () => {
|
||||
describe('for backend filesystem', () => {
|
||||
it('when HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.FILESYSTEM,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for backend s3', () => {
|
||||
it('when HD_MEDIA_BACKEND_S3_ACCESS_KEY is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.S3,
|
||||
HD_MEDIA_BACKEND_S3_SECRET_KEY: secretAccessKey,
|
||||
HD_MEDIA_BACKEND_S3_BUCKET: bucket,
|
||||
HD_MEDIA_BACKEND_S3_ENDPOINT: endPoint,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_S3_ACCESS_KEY" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_S3_SECRET_KEY is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.S3,
|
||||
HD_MEDIA_BACKEND_S3_ACCESS_KEY: accessKeyId,
|
||||
HD_MEDIA_BACKEND_S3_BUCKET: bucket,
|
||||
HD_MEDIA_BACKEND_S3_ENDPOINT: endPoint,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_S3_SECRET_KEY" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_S3_BUCKET is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.S3,
|
||||
HD_MEDIA_BACKEND_S3_ACCESS_KEY: accessKeyId,
|
||||
HD_MEDIA_BACKEND_S3_SECRET_KEY: secretAccessKey,
|
||||
HD_MEDIA_BACKEND_S3_ENDPOINT: endPoint,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_S3_BUCKET" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_S3_ENDPOINT is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.S3,
|
||||
HD_MEDIA_BACKEND_S3_ACCESS_KEY: accessKeyId,
|
||||
HD_MEDIA_BACKEND_S3_SECRET_KEY: secretAccessKey,
|
||||
HD_MEDIA_BACKEND_S3_BUCKET: bucket,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_S3_ENDPOINT" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for backend azure', () => {
|
||||
it('when HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.AZURE,
|
||||
HD_MEDIA_BACKEND_AZURE_CONTAINER: container,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_AZURE_CONTAINER is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.AZURE,
|
||||
HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING: azureConnectionString,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_AZURE_CONTAINER" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for backend imgur', () => {
|
||||
it('when HD_MEDIA_BACKEND_IMGUR_CLIENT_ID is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.IMGUR,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_IMGUR_CLIENT_ID" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('for backend webdav', () => {
|
||||
it('when HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.WEBDAV,
|
||||
HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR: uploadDir,
|
||||
HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: publicUrl,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING is not set to an url', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.WEBDAV,
|
||||
HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: 'not-an-url',
|
||||
HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR: uploadDir,
|
||||
HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: publicUrl,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING" must be a valid uri',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL is not set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.WEBDAV,
|
||||
HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: webdavConnectionString,
|
||||
HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR: uploadDir,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL" is required',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
it('when HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL is not set to an url', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MEDIA_BACKEND: BackendType.WEBDAV,
|
||||
HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING: webdavConnectionString,
|
||||
HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR: uploadDir,
|
||||
HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL: 'not-an-url',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => mediaConfig()).toThrow(
|
||||
'"HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL" must be a valid uri',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
140
backend/src/config/media.config.ts
Normal file
140
backend/src/config/media.config.ts
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import { BackendType } from '../media/backends/backend-type.enum';
|
||||
import { buildErrorMessage } from './utils';
|
||||
|
||||
export interface MediaConfig {
|
||||
backend: MediaBackendConfig;
|
||||
}
|
||||
|
||||
export interface MediaBackendConfig {
|
||||
use: BackendType;
|
||||
filesystem: {
|
||||
uploadPath: string;
|
||||
};
|
||||
s3: {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
endPoint: string;
|
||||
};
|
||||
azure: {
|
||||
connectionString: string;
|
||||
container: string;
|
||||
};
|
||||
imgur: {
|
||||
clientID: string;
|
||||
};
|
||||
webdav: {
|
||||
connectionString: string;
|
||||
uploadDir: string;
|
||||
publicUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
const mediaSchema = Joi.object({
|
||||
backend: {
|
||||
use: Joi.string()
|
||||
.valid(...Object.values(BackendType))
|
||||
.label('HD_MEDIA_BACKEND'),
|
||||
filesystem: {
|
||||
uploadPath: Joi.when('...use', {
|
||||
is: Joi.valid(BackendType.FILESYSTEM),
|
||||
then: Joi.string(),
|
||||
otherwise: Joi.optional(),
|
||||
}).label('HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH'),
|
||||
},
|
||||
s3: Joi.when('use', {
|
||||
is: Joi.valid(BackendType.S3),
|
||||
then: Joi.object({
|
||||
accessKeyId: Joi.string().label('HD_MEDIA_BACKEND_S3_ACCESS_KEY'),
|
||||
secretAccessKey: Joi.string().label('HD_MEDIA_BACKEND_S3_SECRET_KEY'),
|
||||
bucket: Joi.string().label('HD_MEDIA_BACKEND_S3_BUCKET'),
|
||||
endPoint: Joi.string().label('HD_MEDIA_BACKEND_S3_ENDPOINT'),
|
||||
}),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
azure: Joi.when('use', {
|
||||
is: Joi.valid(BackendType.AZURE),
|
||||
then: Joi.object({
|
||||
connectionString: Joi.string().label(
|
||||
'HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING',
|
||||
),
|
||||
container: Joi.string().label('HD_MEDIA_BACKEND_AZURE_CONTAINER'),
|
||||
}),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
imgur: Joi.when('use', {
|
||||
is: Joi.valid(BackendType.IMGUR),
|
||||
then: Joi.object({
|
||||
clientID: Joi.string().label('HD_MEDIA_BACKEND_IMGUR_CLIENT_ID'),
|
||||
}),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
webdav: Joi.when('use', {
|
||||
is: Joi.valid(BackendType.WEBDAV),
|
||||
then: Joi.object({
|
||||
connectionString: Joi.string()
|
||||
.uri()
|
||||
.label('HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING'),
|
||||
uploadDir: Joi.string()
|
||||
.optional()
|
||||
.label('HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR'),
|
||||
publicUrl: Joi.string()
|
||||
.uri()
|
||||
.label('HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL'),
|
||||
}),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export default registerAs('mediaConfig', () => {
|
||||
const mediaConfig = mediaSchema.validate(
|
||||
{
|
||||
backend: {
|
||||
use: process.env.HD_MEDIA_BACKEND,
|
||||
filesystem: {
|
||||
uploadPath: process.env.HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH,
|
||||
},
|
||||
s3: {
|
||||
accessKeyId: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY,
|
||||
secretAccessKey: process.env.HD_MEDIA_BACKEND_S3_SECRET_KEY,
|
||||
bucket: process.env.HD_MEDIA_BACKEND_S3_BUCKET,
|
||||
endPoint: process.env.HD_MEDIA_BACKEND_S3_ENDPOINT,
|
||||
},
|
||||
azure: {
|
||||
connectionString:
|
||||
process.env.HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING,
|
||||
container: process.env.HD_MEDIA_BACKEND_AZURE_CONTAINER,
|
||||
},
|
||||
imgur: {
|
||||
clientID: process.env.HD_MEDIA_BACKEND_IMGUR_CLIENT_ID,
|
||||
},
|
||||
webdav: {
|
||||
connectionString:
|
||||
process.env.HD_MEDIA_BACKEND_WEBDAV_CONNECTION_STRING,
|
||||
uploadDir: process.env.HD_MEDIA_BACKEND_WEBDAV_UPLOAD_DIR,
|
||||
publicUrl: process.env.HD_MEDIA_BACKEND_WEBDAV_PUBLIC_URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (mediaConfig.error) {
|
||||
const errorMessages = mediaConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
return mediaConfig.value as MediaConfig;
|
||||
});
|
28
backend/src/config/mock/app.config.mock.ts
Normal file
28
backend/src/config/mock/app.config.mock.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { AppConfig } from '../app.config';
|
||||
import { Loglevel } from '../loglevel.enum';
|
||||
|
||||
export function createDefaultMockAppConfig(): AppConfig {
|
||||
return {
|
||||
domain: 'md.example.com',
|
||||
rendererBaseUrl: 'md-renderer.example.com',
|
||||
port: 3000,
|
||||
loglevel: Loglevel.ERROR,
|
||||
persistInterval: 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAppConfig(
|
||||
appConfig: AppConfig,
|
||||
): ConfigFactory<AppConfig> & ConfigFactoryKeyHost<AppConfig> {
|
||||
return registerAs('appConfig', (): AppConfig => appConfig);
|
||||
}
|
||||
|
||||
export default registerAppConfig(createDefaultMockAppConfig());
|
57
backend/src/config/mock/auth.config.mock.ts
Normal file
57
backend/src/config/mock/auth.config.mock.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { AuthConfig } from '../auth.config';
|
||||
|
||||
export function createDefaultMockAuthConfig(): AuthConfig {
|
||||
return {
|
||||
session: {
|
||||
secret: 'my_secret',
|
||||
lifetime: 1209600000,
|
||||
},
|
||||
local: {
|
||||
enableLogin: true,
|
||||
enableRegister: true,
|
||||
minimalPasswordStrength: 2,
|
||||
},
|
||||
facebook: {
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
twitter: {
|
||||
consumerKey: '',
|
||||
consumerSecret: '',
|
||||
},
|
||||
github: {
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
},
|
||||
dropbox: {
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
appKey: '',
|
||||
},
|
||||
google: {
|
||||
clientID: '',
|
||||
clientSecret: '',
|
||||
apiKey: '',
|
||||
},
|
||||
gitlab: [],
|
||||
ldap: [],
|
||||
saml: [],
|
||||
oauth2: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAuthConfig(
|
||||
authConfig: AuthConfig,
|
||||
): ConfigFactory<AuthConfig> & ConfigFactoryKeyHost<AuthConfig> {
|
||||
return registerAs('authConfig', (): AuthConfig => authConfig);
|
||||
}
|
||||
|
||||
export default registerAuthConfig(createDefaultMockAuthConfig());
|
37
backend/src/config/mock/customization.config.mock.ts
Normal file
37
backend/src/config/mock/customization.config.mock.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { CustomizationConfig } from '../customization.config';
|
||||
|
||||
export function createDefaultMockCustomizationConfig(): CustomizationConfig {
|
||||
return {
|
||||
branding: {
|
||||
customName: 'ACME Corp',
|
||||
customLogo: '',
|
||||
},
|
||||
specialUrls: {
|
||||
privacy: '/test/privacy',
|
||||
termsOfUse: '/test/termsOfUse',
|
||||
imprint: '/test/imprint',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerCustomizationConfig(
|
||||
customizationConfig: CustomizationConfig,
|
||||
): ConfigFactory<CustomizationConfig> &
|
||||
ConfigFactoryKeyHost<CustomizationConfig> {
|
||||
return registerAs(
|
||||
'customizationConfig',
|
||||
(): CustomizationConfig => customizationConfig,
|
||||
);
|
||||
}
|
||||
|
||||
export default registerCustomizationConfig(
|
||||
createDefaultMockCustomizationConfig(),
|
||||
);
|
30
backend/src/config/mock/database.config.mock.ts
Normal file
30
backend/src/config/mock/database.config.mock.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { DatabaseType } from '../database-type.enum';
|
||||
import { DatabaseConfig } from '../database.config';
|
||||
|
||||
export function createDefaultMockDatabaseConfig(): DatabaseConfig {
|
||||
return {
|
||||
type: (process.env.HEDGEDOC_TEST_DB_TYPE ||
|
||||
DatabaseType.SQLITE) as DatabaseType,
|
||||
database: 'hedgedoc',
|
||||
password: 'hedgedoc',
|
||||
host: 'localhost',
|
||||
port: 0,
|
||||
username: 'hedgedoc',
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDatabaseConfig(
|
||||
databaseConfig: DatabaseConfig,
|
||||
): ConfigFactory<DatabaseConfig> & ConfigFactoryKeyHost<DatabaseConfig> {
|
||||
return registerAs('databaseConfig', (): DatabaseConfig => databaseConfig);
|
||||
}
|
||||
|
||||
export default registerDatabaseConfig(createDefaultMockDatabaseConfig());
|
30
backend/src/config/mock/external-services.config.mock.ts
Normal file
30
backend/src/config/mock/external-services.config.mock.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { ExternalServicesConfig } from '../external-services.config';
|
||||
|
||||
export function createDefaultMockExternalServicesConfig(): ExternalServicesConfig {
|
||||
return {
|
||||
plantUmlServer: 'plantuml.example.com',
|
||||
imageProxy: 'imageProxy.example.com',
|
||||
};
|
||||
}
|
||||
|
||||
export function registerExternalServiceConfig(
|
||||
externalServicesConfig: ExternalServicesConfig,
|
||||
): ConfigFactory<ExternalServicesConfig> &
|
||||
ConfigFactoryKeyHost<ExternalServicesConfig> {
|
||||
return registerAs(
|
||||
'externalServicesConfig',
|
||||
(): ExternalServicesConfig => externalServicesConfig,
|
||||
);
|
||||
}
|
||||
|
||||
export default registerExternalServiceConfig(
|
||||
createDefaultMockExternalServicesConfig(),
|
||||
);
|
48
backend/src/config/mock/media.config.mock.ts
Normal file
48
backend/src/config/mock/media.config.mock.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { BackendType } from '../../media/backends/backend-type.enum';
|
||||
import { MediaConfig } from '../media.config';
|
||||
|
||||
export function createDefaultMockMediaConfig(): MediaConfig {
|
||||
return {
|
||||
backend: {
|
||||
use: BackendType.FILESYSTEM,
|
||||
filesystem: {
|
||||
uploadPath:
|
||||
'test_uploads' + Math.floor(Math.random() * 100000).toString(),
|
||||
},
|
||||
s3: {
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
bucket: '',
|
||||
endPoint: '',
|
||||
},
|
||||
azure: {
|
||||
connectionString: '',
|
||||
container: '',
|
||||
},
|
||||
imgur: {
|
||||
clientID: '',
|
||||
},
|
||||
webdav: {
|
||||
connectionString: '',
|
||||
uploadDir: '',
|
||||
publicUrl: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerMediaConfig(
|
||||
appConfig: MediaConfig,
|
||||
): ConfigFactory<MediaConfig> & ConfigFactoryKeyHost<MediaConfig> {
|
||||
return registerAs('mediaConfig', (): MediaConfig => appConfig);
|
||||
}
|
||||
|
||||
export default registerMediaConfig(createDefaultMockMediaConfig());
|
33
backend/src/config/mock/note.config.mock.ts
Normal file
33
backend/src/config/mock/note.config.mock.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
|
||||
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
|
||||
|
||||
import { DefaultAccessPermission } from '../default-access-permission.enum';
|
||||
import { GuestAccess } from '../guest_access.enum';
|
||||
import { NoteConfig } from '../note.config';
|
||||
|
||||
export function createDefaultMockNoteConfig(): NoteConfig {
|
||||
return {
|
||||
maxDocumentLength: 100000,
|
||||
forbiddenNoteIds: ['forbiddenNoteId'],
|
||||
permissions: {
|
||||
default: {
|
||||
everyone: DefaultAccessPermission.READ,
|
||||
loggedIn: DefaultAccessPermission.WRITE,
|
||||
},
|
||||
},
|
||||
guestAccess: GuestAccess.CREATE,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerNoteConfig(
|
||||
noteConfig: NoteConfig,
|
||||
): ConfigFactory<NoteConfig> & ConfigFactoryKeyHost<NoteConfig> {
|
||||
return registerAs('noteConfig', (): NoteConfig => noteConfig);
|
||||
}
|
||||
|
||||
export default registerNoteConfig(createDefaultMockNoteConfig());
|
458
backend/src/config/note.config.spec.ts
Normal file
458
backend/src/config/note.config.spec.ts
Normal file
|
@ -0,0 +1,458 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import mockedEnv from 'mocked-env';
|
||||
|
||||
import { DefaultAccessPermission } from './default-access-permission.enum';
|
||||
import { GuestAccess } from './guest_access.enum';
|
||||
import noteConfig from './note.config';
|
||||
|
||||
describe('noteConfig', () => {
|
||||
const forbiddenNoteIds = ['forbidden_1', 'forbidden_2'];
|
||||
const forbiddenNoteId = 'single_forbidden_id';
|
||||
const invalidforbiddenNoteIds = ['', ''];
|
||||
const maxDocumentLength = 1234;
|
||||
const negativeMaxDocumentLength = -123;
|
||||
const floatMaxDocumentLength = 2.71;
|
||||
const invalidMaxDocumentLength = 'not-a-max-document-length';
|
||||
const guestAccess = GuestAccess.CREATE;
|
||||
const wrongDefaultPermission = 'wrong';
|
||||
|
||||
describe('correctly parses config', () => {
|
||||
it('when given correct and complete environment variables', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length);
|
||||
expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.guestAccess).toEqual(guestAccess);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_FORBIDDEN_NOTE_IDS is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(0);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.guestAccess).toEqual(guestAccess);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_FORBIDDEN_NOTE_IDS is a single item', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteId,
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(1);
|
||||
expect(config.forbiddenNoteIds[0]).toEqual(forbiddenNoteId);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
|
||||
expect(config.guestAccess).toEqual(guestAccess);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_MAX_DOCUMENT_LENGTH is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length);
|
||||
expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds);
|
||||
expect(config.maxDocumentLength).toEqual(100000);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
|
||||
expect(config.guestAccess).toEqual(guestAccess);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_PERMISSION_DEFAULT_EVERYONE is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length);
|
||||
expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
|
||||
expect(config.guestAccess).toEqual(guestAccess);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_PERMISSION_DEFAULT_LOGGED_IN is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length);
|
||||
expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.WRITE,
|
||||
);
|
||||
|
||||
expect(config.guestAccess).toEqual(guestAccess);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when no HD_GUEST_ACCESS is set', () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
const config = noteConfig();
|
||||
expect(config.forbiddenNoteIds).toHaveLength(forbiddenNoteIds.length);
|
||||
expect(config.forbiddenNoteIds).toEqual(forbiddenNoteIds);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.permissions.default.everyone).toEqual(
|
||||
DefaultAccessPermission.READ,
|
||||
);
|
||||
expect(config.permissions.default.loggedIn).toEqual(
|
||||
DefaultAccessPermission.WRITE,
|
||||
);
|
||||
|
||||
expect(config.guestAccess).toEqual(GuestAccess.WRITE);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('throws error', () => {
|
||||
it('when given a non-valid HD_FORBIDDEN_NOTE_IDS', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: invalidforbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"forbiddenNoteIds[0]" is not allowed to be empty',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a negative HD_MAX_DOCUMENT_LENGTH', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: negativeMaxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"HD_MAX_DOCUMENT_LENGTH" must be a positive number',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-integer HD_MAX_DOCUMENT_LENGTH', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: floatMaxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"HD_MAX_DOCUMENT_LENGTH" must be an integer',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-number HD_MAX_DOCUMENT_LENGTH', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: invalidMaxDocumentLength,
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"HD_MAX_DOCUMENT_LENGTH" must be a number',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-valid HD_PERMISSION_DEFAULT_EVERYONE', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: wrongDefaultPermission,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"HD_PERMISSION_DEFAULT_EVERYONE" must be one of [none, read, write]',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-valid HD_PERMISSION_DEFAULT_LOGGED_IN', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: wrongDefaultPermission,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"HD_PERMISSION_DEFAULT_LOGGED_IN" must be one of [none, read, write]',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when given a non-valid HD_GUEST_ACCESS', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: wrongDefaultPermission,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
'"HD_GUEST_ACCESS" must be one of [deny, read, write, create]',
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_GUEST_ACCESS is set to deny and HD_PERMISSION_DEFAULT_EVERYONE is set', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: 'deny',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
`'HD_GUEST_ACCESS' is set to 'deny', but 'HD_PERMISSION_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSION_DEFAULT_EVERYONE'.`,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_PERMISSION_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to read', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.WRITE,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.READ,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessPermission.WRITE}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessPermission.READ}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_PERMISSION_DEFAULT_EVERYONE is set to write, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.WRITE,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.NONE,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessPermission.WRITE}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessPermission.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
|
||||
it('when HD_PERMISSION_DEFAULT_EVERYONE is set to read, but HD_PERMISSION_DEFAULT_LOGGED_IN is set to none', async () => {
|
||||
const restore = mockedEnv(
|
||||
{
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
HD_FORBIDDEN_NOTE_IDS: forbiddenNoteIds.join(' , '),
|
||||
HD_MAX_DOCUMENT_LENGTH: maxDocumentLength.toString(),
|
||||
HD_PERMISSION_DEFAULT_EVERYONE: DefaultAccessPermission.READ,
|
||||
HD_PERMISSION_DEFAULT_LOGGED_IN: DefaultAccessPermission.NONE,
|
||||
HD_GUEST_ACCESS: guestAccess,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
{
|
||||
clear: true,
|
||||
},
|
||||
);
|
||||
expect(() => noteConfig()).toThrow(
|
||||
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${DefaultAccessPermission.READ}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${DefaultAccessPermission.NONE}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
|
||||
);
|
||||
restore();
|
||||
});
|
||||
});
|
||||
});
|
116
backend/src/config/note.config.ts
Normal file
116
backend/src/config/note.config.ts
Normal file
|
@ -0,0 +1,116 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
import {
|
||||
DefaultAccessPermission,
|
||||
getDefaultAccessPermissionOrdinal,
|
||||
} from './default-access-permission.enum';
|
||||
import { GuestAccess } from './guest_access.enum';
|
||||
import { buildErrorMessage, parseOptionalNumber, toArrayConfig } from './utils';
|
||||
|
||||
export interface NoteConfig {
|
||||
forbiddenNoteIds: string[];
|
||||
maxDocumentLength: number;
|
||||
guestAccess: GuestAccess;
|
||||
permissions: {
|
||||
default: {
|
||||
everyone: DefaultAccessPermission;
|
||||
loggedIn: DefaultAccessPermission;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const schema = Joi.object<NoteConfig>({
|
||||
forbiddenNoteIds: Joi.array()
|
||||
.items(Joi.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.label('HD_FORBIDDEN_NOTE_IDS'),
|
||||
maxDocumentLength: Joi.number()
|
||||
.default(100000)
|
||||
.positive()
|
||||
.integer()
|
||||
.optional()
|
||||
.label('HD_MAX_DOCUMENT_LENGTH'),
|
||||
guestAccess: Joi.string()
|
||||
.valid(...Object.values(GuestAccess))
|
||||
.optional()
|
||||
.default(GuestAccess.WRITE)
|
||||
.label('HD_GUEST_ACCESS'),
|
||||
permissions: {
|
||||
default: {
|
||||
everyone: Joi.string()
|
||||
.valid(...Object.values(DefaultAccessPermission))
|
||||
.optional()
|
||||
.default(DefaultAccessPermission.READ)
|
||||
.label('HD_PERMISSION_DEFAULT_EVERYONE'),
|
||||
loggedIn: Joi.string()
|
||||
.valid(...Object.values(DefaultAccessPermission))
|
||||
.optional()
|
||||
.default(DefaultAccessPermission.WRITE)
|
||||
.label('HD_PERMISSION_DEFAULT_LOGGED_IN'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function checkEveryoneConfigIsConsistent(config: NoteConfig): void {
|
||||
const everyoneDefaultSet =
|
||||
process.env.HD_PERMISSION_DEFAULT_EVERYONE !== undefined;
|
||||
if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) {
|
||||
throw new Error(
|
||||
`'HD_GUEST_ACCESS' is set to '${config.guestAccess}', but 'HD_PERMISSION_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSION_DEFAULT_EVERYONE'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(
|
||||
config: NoteConfig,
|
||||
): void {
|
||||
const everyone = config.permissions.default.everyone;
|
||||
const loggedIn = config.permissions.default.loggedIn;
|
||||
if (
|
||||
getDefaultAccessPermissionOrdinal(everyone) >
|
||||
getDefaultAccessPermissionOrdinal(loggedIn)
|
||||
) {
|
||||
throw new Error(
|
||||
`'HD_PERMISSION_DEFAULT_EVERYONE' is set to '${everyone}', but 'HD_PERMISSION_DEFAULT_LOGGED_IN' is set to '${loggedIn}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default registerAs('noteConfig', () => {
|
||||
const noteConfig = schema.validate(
|
||||
{
|
||||
forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','),
|
||||
maxDocumentLength: parseOptionalNumber(
|
||||
process.env.HD_MAX_DOCUMENT_LENGTH,
|
||||
),
|
||||
guestAccess: process.env.HD_GUEST_ACCESS,
|
||||
permissions: {
|
||||
default: {
|
||||
everyone: process.env.HD_PERMISSION_DEFAULT_EVERYONE,
|
||||
loggedIn: process.env.HD_PERMISSION_DEFAULT_LOGGED_IN,
|
||||
},
|
||||
},
|
||||
} as NoteConfig,
|
||||
{
|
||||
abortEarly: false,
|
||||
presence: 'required',
|
||||
},
|
||||
);
|
||||
if (noteConfig.error) {
|
||||
const errorMessages = noteConfig.error.details.map(
|
||||
(detail) => detail.message,
|
||||
);
|
||||
throw new Error(buildErrorMessage(errorMessages));
|
||||
}
|
||||
const config = noteConfig.value;
|
||||
checkEveryoneConfigIsConsistent(config);
|
||||
checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(config);
|
||||
return config;
|
||||
});
|
119
backend/src/config/utils.spec.ts
Normal file
119
backend/src/config/utils.spec.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Loglevel } from './loglevel.enum';
|
||||
import {
|
||||
needToLog,
|
||||
parseOptionalNumber,
|
||||
replaceAuthErrorsWithEnvironmentVariables,
|
||||
toArrayConfig,
|
||||
} from './utils';
|
||||
|
||||
describe('config utils', () => {
|
||||
describe('toArrayConfig', () => {
|
||||
it('empty', () => {
|
||||
expect(toArrayConfig('')).toEqual(undefined);
|
||||
expect(toArrayConfig(undefined)).toEqual(undefined);
|
||||
});
|
||||
it('one element', () => {
|
||||
expect(toArrayConfig('one')).toEqual(['one']);
|
||||
});
|
||||
it('multiple elements', () => {
|
||||
expect(toArrayConfig('one, two, three')).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
it('non default seperator', () => {
|
||||
expect(toArrayConfig('one ; two ; three', ';')).toEqual([
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('replaceAuthErrorsWithEnvironmentVariables', () => {
|
||||
it('"gitlab[0].scope', () => {
|
||||
expect(
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
'"gitlab[0].scope',
|
||||
'gitlab',
|
||||
'HD_AUTH_GITLAB_',
|
||||
['test'],
|
||||
),
|
||||
).toEqual('"HD_AUTH_GITLAB_test_SCOPE');
|
||||
});
|
||||
it('"ldap[0].url', () => {
|
||||
expect(
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
'"ldap[0].url',
|
||||
'ldap',
|
||||
'HD_AUTH_LDAP_',
|
||||
['test'],
|
||||
),
|
||||
).toEqual('"HD_AUTH_LDAP_test_URL');
|
||||
});
|
||||
it('"ldap[0].url is not changed by gitlab call', () => {
|
||||
expect(
|
||||
replaceAuthErrorsWithEnvironmentVariables(
|
||||
'"ldap[0].url',
|
||||
'gitlab',
|
||||
'HD_AUTH_GITLAB_',
|
||||
['test'],
|
||||
),
|
||||
).toEqual('"ldap[0].url');
|
||||
});
|
||||
});
|
||||
describe('needToLog', () => {
|
||||
it('currentLevel ERROR', () => {
|
||||
const currentLevel = Loglevel.ERROR;
|
||||
expect(needToLog(currentLevel, Loglevel.ERROR)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.WARN)).toBeFalsy();
|
||||
expect(needToLog(currentLevel, Loglevel.INFO)).toBeFalsy();
|
||||
expect(needToLog(currentLevel, Loglevel.DEBUG)).toBeFalsy();
|
||||
expect(needToLog(currentLevel, Loglevel.TRACE)).toBeFalsy();
|
||||
});
|
||||
it('currentLevel WARN', () => {
|
||||
const currentLevel = Loglevel.WARN;
|
||||
expect(needToLog(currentLevel, Loglevel.ERROR)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.WARN)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.INFO)).toBeFalsy();
|
||||
expect(needToLog(currentLevel, Loglevel.DEBUG)).toBeFalsy();
|
||||
expect(needToLog(currentLevel, Loglevel.TRACE)).toBeFalsy();
|
||||
});
|
||||
it('currentLevel INFO', () => {
|
||||
const currentLevel = Loglevel.INFO;
|
||||
expect(needToLog(currentLevel, Loglevel.ERROR)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.WARN)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.INFO)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.DEBUG)).toBeFalsy();
|
||||
expect(needToLog(currentLevel, Loglevel.TRACE)).toBeFalsy();
|
||||
});
|
||||
it('currentLevel DEBUG', () => {
|
||||
const currentLevel = Loglevel.DEBUG;
|
||||
expect(needToLog(currentLevel, Loglevel.ERROR)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.WARN)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.INFO)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.DEBUG)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.TRACE)).toBeFalsy();
|
||||
});
|
||||
it('currentLevel TRACE', () => {
|
||||
const currentLevel = Loglevel.TRACE;
|
||||
expect(needToLog(currentLevel, Loglevel.ERROR)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.WARN)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.INFO)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.DEBUG)).toBeTruthy();
|
||||
expect(needToLog(currentLevel, Loglevel.TRACE)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
describe('parseOptionalNumber', () => {
|
||||
it('returns undefined on undefined parameter', () => {
|
||||
expect(parseOptionalNumber(undefined)).toEqual(undefined);
|
||||
});
|
||||
it('correctly parses a integer string', () => {
|
||||
expect(parseOptionalNumber('42')).toEqual(42);
|
||||
});
|
||||
it('correctly parses a float string', () => {
|
||||
expect(parseOptionalNumber('3.14')).toEqual(3.14);
|
||||
});
|
||||
});
|
||||
});
|
136
backend/src/config/utils.ts
Normal file
136
backend/src/config/utils.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Loglevel } from './loglevel.enum';
|
||||
|
||||
export function toArrayConfig(
|
||||
configValue?: string,
|
||||
separator = ',',
|
||||
): string[] | undefined {
|
||||
if (!configValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (!configValue.includes(separator)) {
|
||||
return [configValue.trim()];
|
||||
}
|
||||
return configValue.split(separator).map((arrayItem) => arrayItem.trim());
|
||||
}
|
||||
|
||||
export function buildErrorMessage(errorMessages: string[]): string {
|
||||
let totalErrorMessage = 'There were some errors with your configuration:';
|
||||
for (const message of errorMessages) {
|
||||
totalErrorMessage += '\n - ';
|
||||
totalErrorMessage += message;
|
||||
}
|
||||
totalErrorMessage +=
|
||||
'\nFor further information, have a look at our configuration docs at https://docs.hedgedoc.org/configuration';
|
||||
return totalErrorMessage;
|
||||
}
|
||||
|
||||
export function replaceAuthErrorsWithEnvironmentVariables(
|
||||
message: string,
|
||||
name: string,
|
||||
replacement: string,
|
||||
arrayOfNames: string[],
|
||||
): string {
|
||||
// this builds a regex like /"gitlab\[(\d+)]\./ to extract the position in the arrayOfNames
|
||||
const regex = new RegExp('"' + name + '\\[(\\d+)]\\.', 'g');
|
||||
let newMessage = message.replace(
|
||||
regex,
|
||||
(_, index: number) => `"${replacement}${arrayOfNames[index]}.`,
|
||||
);
|
||||
if (newMessage != message) {
|
||||
newMessage = newMessage.replace('.providerName', '_PROVIDER_NAME');
|
||||
newMessage = newMessage.replace('.baseURL', '_BASE_URL');
|
||||
newMessage = newMessage.replace('.clientID', '_CLIENT_ID');
|
||||
newMessage = newMessage.replace('.clientSecret', '_CLIENT_SECRET');
|
||||
newMessage = newMessage.replace('.scope', '_SCOPE');
|
||||
newMessage = newMessage.replace('.version', '_GITLAB_VERSION');
|
||||
newMessage = newMessage.replace('.url', '_URL');
|
||||
newMessage = newMessage.replace('.bindDn', '_BIND_DN');
|
||||
newMessage = newMessage.replace('.bindCredentials', '_BIND_CREDENTIALS');
|
||||
newMessage = newMessage.replace('.searchBase', '_SEARCH_BASE');
|
||||
newMessage = newMessage.replace('.searchFilter', '_SEARCH_FILTER');
|
||||
newMessage = newMessage.replace('.searchAttributes', '_SEARCH_ATTRIBUTES');
|
||||
newMessage = newMessage.replace('.userIdField', '_USER_ID_FIELD');
|
||||
newMessage = newMessage.replace('.displayNameField', '_DISPLAY_NAME_FIELD');
|
||||
newMessage = newMessage.replace(
|
||||
'.profilePictureField',
|
||||
'_PROFILE_PICTURE_FIELD',
|
||||
);
|
||||
newMessage = newMessage.replace('.tlsCaCerts', '_TLS_CERT_PATHS');
|
||||
newMessage = newMessage.replace('.idpSsoUrl', '_IDP_SSO_URL');
|
||||
newMessage = newMessage.replace('.idpCert', '_IDP_CERT');
|
||||
newMessage = newMessage.replace('.clientCert', '_CLIENT_CERT');
|
||||
newMessage = newMessage.replace('.issuer', '_ISSUER');
|
||||
newMessage = newMessage.replace('.identifierFormat', '_IDENTIFIER_FORMAT');
|
||||
newMessage = newMessage.replace(
|
||||
'.disableRequestedAuthnContext',
|
||||
'_DISABLE_REQUESTED_AUTHN_CONTEXT',
|
||||
);
|
||||
newMessage = newMessage.replace('.groupAttribute', '_GROUP_ATTRIBUTE');
|
||||
newMessage = newMessage.replace('.requiredGroups', '_REQUIRED_GROUPS');
|
||||
newMessage = newMessage.replace('.externalGroups', '_EXTERNAL_GROUPS');
|
||||
newMessage = newMessage.replace('.attribute.id', '_ATTRIBUTE_ID');
|
||||
newMessage = newMessage.replace(
|
||||
'.attribute.username',
|
||||
'_ATTRIBUTE_USERNAME',
|
||||
);
|
||||
newMessage = newMessage.replace('.attribute.local', '_ATTRIBUTE_LOCAL');
|
||||
newMessage = newMessage.replace('.userProfileURL', '_USER_PROFILE_URL');
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileIdAttr',
|
||||
'_USER_PROFILE_ID_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileUsernameAttr',
|
||||
'_USER_PROFILE_USERNAME_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileDisplayNameAttr',
|
||||
'_USER_PROFILE_DISPLAY_NAME_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace(
|
||||
'.userProfileEmailAttr',
|
||||
'_USER_PROFILE_EMAIL_ATTR',
|
||||
);
|
||||
newMessage = newMessage.replace('.tokenURL', '_TOKEN_URL');
|
||||
newMessage = newMessage.replace('.authorizationURL', '_AUTHORIZATION_URL');
|
||||
newMessage = newMessage.replace('.rolesClaim', '_ROLES_CLAIM');
|
||||
newMessage = newMessage.replace('.accessRole', '_ACCESS_ROLE');
|
||||
}
|
||||
return newMessage;
|
||||
}
|
||||
|
||||
export function needToLog(
|
||||
currentLoglevel: Loglevel,
|
||||
requestedLoglevel: Loglevel,
|
||||
): boolean {
|
||||
const current = transformLoglevelToInt(currentLoglevel);
|
||||
const requested = transformLoglevelToInt(requestedLoglevel);
|
||||
return current >= requested;
|
||||
}
|
||||
|
||||
function transformLoglevelToInt(loglevel: Loglevel): number {
|
||||
switch (loglevel) {
|
||||
case Loglevel.TRACE:
|
||||
return 5;
|
||||
case Loglevel.DEBUG:
|
||||
return 4;
|
||||
case Loglevel.INFO:
|
||||
return 3;
|
||||
case Loglevel.WARN:
|
||||
return 2;
|
||||
case Loglevel.ERROR:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOptionalNumber(value?: string): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
100
backend/src/errors/error-mapping.ts
Normal file
100
backend/src/errors/error-mapping.ts
Normal file
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
ArgumentsHost,
|
||||
BadRequestException,
|
||||
Catch,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpException } from '@nestjs/common/exceptions/http.exception';
|
||||
import { BaseExceptionFilter } from '@nestjs/core';
|
||||
|
||||
import {
|
||||
buildHttpExceptionObject,
|
||||
HttpExceptionObject,
|
||||
} from './http-exception-object';
|
||||
|
||||
type HttpExceptionConstructor = (object: HttpExceptionObject) => HttpException;
|
||||
|
||||
const mapOfHedgeDocErrorsToHttpErrors: Map<string, HttpExceptionConstructor> =
|
||||
new Map([
|
||||
['NotInDBError', (object): HttpException => new NotFoundException(object)],
|
||||
[
|
||||
'AlreadyInDBError',
|
||||
(object): HttpException => new ConflictException(object),
|
||||
],
|
||||
[
|
||||
'ForbiddenIdError',
|
||||
(object): HttpException => new BadRequestException(object),
|
||||
],
|
||||
['ClientError', (object): HttpException => new BadRequestException(object)],
|
||||
[
|
||||
'PermissionError',
|
||||
(object): HttpException => new UnauthorizedException(object),
|
||||
],
|
||||
[
|
||||
'TokenNotValidError',
|
||||
(object): HttpException => new UnauthorizedException(object),
|
||||
],
|
||||
[
|
||||
'TooManyTokensError',
|
||||
(object): HttpException => new BadRequestException(object),
|
||||
],
|
||||
[
|
||||
'PermissionsUpdateInconsistentError',
|
||||
(object): HttpException => new BadRequestException(object),
|
||||
],
|
||||
[
|
||||
'MediaBackendError',
|
||||
(object): HttpException => new InternalServerErrorException(object),
|
||||
],
|
||||
[
|
||||
'PrimaryAliasDeletionForbiddenError',
|
||||
(object): HttpException => new BadRequestException(object),
|
||||
],
|
||||
[
|
||||
'InvalidCredentialsError',
|
||||
(object): HttpException => new UnauthorizedException(object),
|
||||
],
|
||||
[
|
||||
'NoLocalIdentityError',
|
||||
(object): HttpException => new BadRequestException(object),
|
||||
],
|
||||
[
|
||||
'PasswordTooWeakError',
|
||||
(object): HttpException => new BadRequestException(object),
|
||||
],
|
||||
[
|
||||
'MaximumDocumentLengthExceededError',
|
||||
(object): HttpException => new PayloadTooLargeException(object),
|
||||
],
|
||||
]);
|
||||
|
||||
@Catch()
|
||||
export class ErrorExceptionMapping extends BaseExceptionFilter<Error> {
|
||||
catch(error: Error, host: ArgumentsHost): void {
|
||||
super.catch(ErrorExceptionMapping.transformError(error), host);
|
||||
}
|
||||
|
||||
private static transformError(error: Error): Error {
|
||||
const httpExceptionConstructor = mapOfHedgeDocErrorsToHttpErrors.get(
|
||||
error.name,
|
||||
);
|
||||
if (httpExceptionConstructor === undefined) {
|
||||
// We don't know how to map this error and just leave it be
|
||||
return error;
|
||||
}
|
||||
const httpExceptionObject = buildHttpExceptionObject(
|
||||
error.name,
|
||||
error.message,
|
||||
);
|
||||
return httpExceptionConstructor(httpExceptionObject);
|
||||
}
|
||||
}
|
61
backend/src/errors/errors.ts
Normal file
61
backend/src/errors/errors.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class NotInDBError extends Error {
|
||||
name = 'NotInDBError';
|
||||
}
|
||||
|
||||
export class AlreadyInDBError extends Error {
|
||||
name = 'AlreadyInDBError';
|
||||
}
|
||||
|
||||
export class ForbiddenIdError extends Error {
|
||||
name = 'ForbiddenIdError';
|
||||
}
|
||||
|
||||
export class ClientError extends Error {
|
||||
name = 'ClientError';
|
||||
}
|
||||
|
||||
export class PermissionError extends Error {
|
||||
name = 'PermissionError';
|
||||
}
|
||||
|
||||
export class TokenNotValidError extends Error {
|
||||
name = 'TokenNotValidError';
|
||||
}
|
||||
|
||||
export class TooManyTokensError extends Error {
|
||||
name = 'TooManyTokensError';
|
||||
}
|
||||
|
||||
export class PermissionsUpdateInconsistentError extends Error {
|
||||
name = 'PermissionsUpdateInconsistentError';
|
||||
}
|
||||
|
||||
export class MediaBackendError extends Error {
|
||||
name = 'MediaBackendError';
|
||||
}
|
||||
|
||||
export class PrimaryAliasDeletionForbiddenError extends Error {
|
||||
name = 'PrimaryAliasDeletionForbiddenError';
|
||||
}
|
||||
|
||||
export class InvalidCredentialsError extends Error {
|
||||
name = 'InvalidCredentialsError';
|
||||
}
|
||||
|
||||
export class NoLocalIdentityError extends Error {
|
||||
name = 'NoLocalIdentityError';
|
||||
}
|
||||
|
||||
export class PasswordTooWeakError extends Error {
|
||||
name = 'PasswordTooWeakError';
|
||||
}
|
||||
|
||||
export class MaximumDocumentLengthExceededError extends Error {
|
||||
name = 'MaximumDocumentLengthExceededError';
|
||||
}
|
20
backend/src/errors/http-exception-object.ts
Normal file
20
backend/src/errors/http-exception-object.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export interface HttpExceptionObject {
|
||||
name: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function buildHttpExceptionObject(
|
||||
name: string,
|
||||
message: string,
|
||||
): HttpExceptionObject {
|
||||
return {
|
||||
name: name,
|
||||
message: message,
|
||||
};
|
||||
}
|
20
backend/src/events.ts
Normal file
20
backend/src/events.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const eventModuleConfig = {
|
||||
wildcard: false,
|
||||
delimiter: '.',
|
||||
newListener: false,
|
||||
removeListener: false,
|
||||
maxListeners: 10,
|
||||
verboseMemoryLeak: true,
|
||||
ignoreErrors: false,
|
||||
};
|
||||
|
||||
export enum NoteEvent {
|
||||
PERMISSION_CHANGE = 'note.permission_change' /** noteId: The id of the [@link Note], which permissions are changed. **/,
|
||||
DELETION = 'note.deletion' /** noteId: The id of the [@link Note], which is being deleted. **/,
|
||||
}
|
182
backend/src/frontend-config/frontend-config.dto.ts
Normal file
182
backend/src/frontend-config/frontend-config.dto.ts
Normal file
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { GuestAccess } from '../config/guest_access.enum';
|
||||
import { ServerVersion } from '../monitoring/server-status.dto';
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export enum AuthProviderType {
|
||||
LOCAL = 'local',
|
||||
LDAP = 'ldap',
|
||||
SAML = 'saml',
|
||||
OAUTH2 = 'oauth2',
|
||||
GITLAB = 'gitlab',
|
||||
FACEBOOK = 'facebook',
|
||||
GITHUB = 'github',
|
||||
TWITTER = 'twitter',
|
||||
DROPBOX = 'dropbox',
|
||||
GOOGLE = 'google',
|
||||
}
|
||||
|
||||
export type AuthProviderTypeWithCustomName =
|
||||
| AuthProviderType.LDAP
|
||||
| AuthProviderType.OAUTH2
|
||||
| AuthProviderType.SAML
|
||||
| AuthProviderType.GITLAB;
|
||||
|
||||
export type AuthProviderTypeWithoutCustomName =
|
||||
| AuthProviderType.LOCAL
|
||||
| AuthProviderType.FACEBOOK
|
||||
| AuthProviderType.GITHUB
|
||||
| AuthProviderType.TWITTER
|
||||
| AuthProviderType.DROPBOX
|
||||
| AuthProviderType.GOOGLE;
|
||||
|
||||
export class AuthProviderWithoutCustomNameDto extends BaseDto {
|
||||
/**
|
||||
* The type of the auth provider.
|
||||
*/
|
||||
@IsString()
|
||||
type: AuthProviderTypeWithoutCustomName;
|
||||
}
|
||||
|
||||
export class AuthProviderWithCustomNameDto extends BaseDto {
|
||||
/**
|
||||
* The type of the auth provider.
|
||||
*/
|
||||
@IsString()
|
||||
type: AuthProviderTypeWithCustomName;
|
||||
|
||||
/**
|
||||
* The identifier with which the auth provider can be called
|
||||
* @example gitlab-fsorg
|
||||
*/
|
||||
@IsString()
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* The name given to the auth provider
|
||||
* @example GitLab fachschaften.org
|
||||
*/
|
||||
@IsString()
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export type AuthProviderDto =
|
||||
| AuthProviderWithCustomNameDto
|
||||
| AuthProviderWithoutCustomNameDto;
|
||||
|
||||
export class BrandingDto extends BaseDto {
|
||||
/**
|
||||
* The name to be displayed next to the HedgeDoc logo
|
||||
* @example ACME Corp
|
||||
*/
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* The logo to be displayed next to the HedgeDoc logo
|
||||
* @example https://md.example.com/logo.png
|
||||
*/
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
logo?: URL;
|
||||
}
|
||||
|
||||
export class SpecialUrlsDto extends BaseDto {
|
||||
/**
|
||||
* A link to the privacy notice
|
||||
* @example https://md.example.com/n/privacy
|
||||
*/
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
privacy?: URL;
|
||||
|
||||
/**
|
||||
* A link to the terms of use
|
||||
* @example https://md.example.com/n/termsOfUse
|
||||
*/
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
termsOfUse?: URL;
|
||||
|
||||
/**
|
||||
* A link to the imprint
|
||||
* @example https://md.example.com/n/imprint
|
||||
*/
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
imprint?: URL;
|
||||
}
|
||||
|
||||
export class FrontendConfigDto extends BaseDto {
|
||||
/**
|
||||
* Maximum access level for guest users
|
||||
*/
|
||||
@IsString()
|
||||
guestAccess: GuestAccess;
|
||||
|
||||
/**
|
||||
* Are users allowed to register on this instance?
|
||||
*/
|
||||
@IsBoolean()
|
||||
allowRegister: boolean;
|
||||
|
||||
/**
|
||||
* Which auth providers are enabled and how are they configured?
|
||||
*/
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
authProviders: AuthProviderDto[];
|
||||
|
||||
/**
|
||||
* Individual branding information
|
||||
*/
|
||||
@ValidateNested()
|
||||
branding: BrandingDto;
|
||||
|
||||
/**
|
||||
* Is an image proxy enabled?
|
||||
*/
|
||||
@IsBoolean()
|
||||
useImageProxy: boolean;
|
||||
|
||||
/**
|
||||
* Links to some special pages
|
||||
*/
|
||||
@ValidateNested()
|
||||
specialUrls: SpecialUrlsDto;
|
||||
|
||||
/**
|
||||
* The version of HedgeDoc
|
||||
*/
|
||||
@ValidateNested()
|
||||
version: ServerVersion;
|
||||
|
||||
/**
|
||||
* The plantUML server that should be used to render.
|
||||
*/
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
plantUmlServer?: URL;
|
||||
|
||||
/**
|
||||
* The maximal length of each document
|
||||
*/
|
||||
@IsNumber()
|
||||
maxDocumentLength: number;
|
||||
}
|
17
backend/src/frontend-config/frontend-config.module.ts
Normal file
17
backend/src/frontend-config/frontend-config.module.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { FrontendConfigService } from './frontend-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoggerModule, ConfigModule],
|
||||
providers: [FrontendConfigService],
|
||||
exports: [FrontendConfigService],
|
||||
})
|
||||
export class FrontendConfigModule {}
|
423
backend/src/frontend-config/frontend-config.service.spec.ts
Normal file
423
backend/src/frontend-config/frontend-config.service.spec.ts
Normal file
|
@ -0,0 +1,423 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule, registerAs } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { AppConfig } from '../config/app.config';
|
||||
import { AuthConfig } from '../config/auth.config';
|
||||
import { CustomizationConfig } from '../config/customization.config';
|
||||
import { DefaultAccessPermission } from '../config/default-access-permission.enum';
|
||||
import { ExternalServicesConfig } from '../config/external-services.config';
|
||||
import { GitlabScope, GitlabVersion } from '../config/gitlab.enum';
|
||||
import { GuestAccess } from '../config/guest_access.enum';
|
||||
import { Loglevel } from '../config/loglevel.enum';
|
||||
import { NoteConfig } from '../config/note.config';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
||||
import { AuthProviderType } from './frontend-config.dto';
|
||||
import { FrontendConfigService } from './frontend-config.service';
|
||||
|
||||
/* eslint-disable
|
||||
jest/no-conditional-expect
|
||||
*/
|
||||
|
||||
describe('FrontendConfigService', () => {
|
||||
const domain = 'http://md.example.com';
|
||||
const emptyAuthConfig: AuthConfig = {
|
||||
session: {
|
||||
secret: 'my-secret',
|
||||
lifetime: 1209600000,
|
||||
},
|
||||
local: {
|
||||
enableLogin: false,
|
||||
enableRegister: false,
|
||||
minimalPasswordStrength: 2,
|
||||
},
|
||||
facebook: {
|
||||
clientID: undefined,
|
||||
clientSecret: undefined,
|
||||
},
|
||||
twitter: {
|
||||
consumerKey: undefined,
|
||||
consumerSecret: undefined,
|
||||
},
|
||||
github: {
|
||||
clientID: undefined,
|
||||
clientSecret: undefined,
|
||||
},
|
||||
dropbox: {
|
||||
clientID: undefined,
|
||||
clientSecret: undefined,
|
||||
appKey: undefined,
|
||||
},
|
||||
google: {
|
||||
clientID: undefined,
|
||||
clientSecret: undefined,
|
||||
apiKey: undefined,
|
||||
},
|
||||
gitlab: [],
|
||||
ldap: [],
|
||||
saml: [],
|
||||
oauth2: [],
|
||||
};
|
||||
|
||||
describe('getAuthProviders', () => {
|
||||
const facebook: AuthConfig['facebook'] = {
|
||||
clientID: 'facebookTestId',
|
||||
clientSecret: 'facebookTestSecret',
|
||||
};
|
||||
const twitter: AuthConfig['twitter'] = {
|
||||
consumerKey: 'twitterTestId',
|
||||
consumerSecret: 'twitterTestSecret',
|
||||
};
|
||||
const github: AuthConfig['github'] = {
|
||||
clientID: 'githubTestId',
|
||||
clientSecret: 'githubTestSecret',
|
||||
};
|
||||
const dropbox: AuthConfig['dropbox'] = {
|
||||
clientID: 'dropboxTestId',
|
||||
clientSecret: 'dropboxTestSecret',
|
||||
appKey: 'dropboxTestKey',
|
||||
};
|
||||
const google: AuthConfig['google'] = {
|
||||
clientID: 'googleTestId',
|
||||
clientSecret: 'googleTestSecret',
|
||||
apiKey: 'googleTestKey',
|
||||
};
|
||||
const gitlab: AuthConfig['gitlab'] = [
|
||||
{
|
||||
identifier: 'gitlabTestIdentifier',
|
||||
providerName: 'gitlabTestName',
|
||||
baseURL: 'gitlabTestUrl',
|
||||
clientID: 'gitlabTestId',
|
||||
clientSecret: 'gitlabTestSecret',
|
||||
scope: GitlabScope.API,
|
||||
version: GitlabVersion.V4,
|
||||
},
|
||||
];
|
||||
const ldap: AuthConfig['ldap'] = [
|
||||
{
|
||||
identifier: 'ldapTestIdentifier',
|
||||
providerName: 'ldapTestName',
|
||||
url: 'ldapTestUrl',
|
||||
bindDn: 'ldapTestBindDn',
|
||||
bindCredentials: 'ldapTestBindCredentials',
|
||||
searchBase: 'ldapTestSearchBase',
|
||||
searchFilter: 'ldapTestSearchFilter',
|
||||
searchAttributes: ['ldapTestSearchAttribute'],
|
||||
userIdField: 'ldapTestUserId',
|
||||
displayNameField: 'ldapTestDisplayName',
|
||||
profilePictureField: 'ldapTestProfilePicture',
|
||||
tlsCaCerts: ['ldapTestTlsCa'],
|
||||
},
|
||||
];
|
||||
const saml: AuthConfig['saml'] = [
|
||||
{
|
||||
identifier: 'samlTestIdentifier',
|
||||
providerName: 'samlTestName',
|
||||
idpSsoUrl: 'samlTestUrl',
|
||||
idpCert: 'samlTestCert',
|
||||
clientCert: 'samlTestClientCert',
|
||||
issuer: 'samlTestIssuer',
|
||||
identifierFormat: 'samlTestUrl',
|
||||
disableRequestedAuthnContext: 'samlTestUrl',
|
||||
groupAttribute: 'samlTestUrl',
|
||||
requiredGroups: ['samlTestUrl'],
|
||||
externalGroups: ['samlTestUrl'],
|
||||
attribute: {
|
||||
id: 'samlTestUrl',
|
||||
username: 'samlTestUrl',
|
||||
email: 'samlTestUrl',
|
||||
},
|
||||
},
|
||||
];
|
||||
const oauth2: AuthConfig['oauth2'] = [
|
||||
{
|
||||
identifier: 'oauth2Testidentifier',
|
||||
providerName: 'oauth2TestName',
|
||||
baseURL: 'oauth2TestUrl',
|
||||
userProfileURL: 'oauth2TestProfileUrl',
|
||||
userProfileIdAttr: 'oauth2TestProfileId',
|
||||
userProfileUsernameAttr: 'oauth2TestProfileUsername',
|
||||
userProfileDisplayNameAttr: 'oauth2TestProfileDisplay',
|
||||
userProfileEmailAttr: 'oauth2TestProfileEmail',
|
||||
tokenURL: 'oauth2TestTokenUrl',
|
||||
authorizationURL: 'oauth2TestAuthUrl',
|
||||
clientID: 'oauth2TestId',
|
||||
clientSecret: 'oauth2TestSecret',
|
||||
scope: 'oauth2TestScope',
|
||||
rolesClaim: 'oauth2TestRoles',
|
||||
accessRole: 'oauth2TestAccess',
|
||||
},
|
||||
];
|
||||
for (const authConfigConfigured of [
|
||||
facebook,
|
||||
twitter,
|
||||
github,
|
||||
dropbox,
|
||||
google,
|
||||
gitlab,
|
||||
ldap,
|
||||
saml,
|
||||
oauth2,
|
||||
]) {
|
||||
it(`works with ${JSON.stringify(authConfigConfigured)}`, async () => {
|
||||
const appConfig: AppConfig = {
|
||||
domain: domain,
|
||||
rendererBaseUrl: 'https://renderer.example.org',
|
||||
port: 3000,
|
||||
loglevel: Loglevel.ERROR,
|
||||
persistInterval: 10,
|
||||
};
|
||||
const authConfig: AuthConfig = {
|
||||
...emptyAuthConfig,
|
||||
...authConfigConfigured,
|
||||
};
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
registerAs('appConfig', () => appConfig),
|
||||
registerAs('authConfig', () => authConfig),
|
||||
registerAs('customizationConfig', () => {
|
||||
return { branding: {}, specialUrls: {} };
|
||||
}),
|
||||
registerAs('externalServicesConfig', () => {
|
||||
return {};
|
||||
}),
|
||||
registerAs('noteConfig', () => {
|
||||
return {
|
||||
forbiddenNoteIds: [],
|
||||
maxDocumentLength: 200,
|
||||
guestAccess: GuestAccess.CREATE,
|
||||
permissions: {
|
||||
default: {
|
||||
everyone: DefaultAccessPermission.READ,
|
||||
loggedIn: DefaultAccessPermission.WRITE,
|
||||
},
|
||||
},
|
||||
} as NoteConfig;
|
||||
}),
|
||||
],
|
||||
}),
|
||||
LoggerModule,
|
||||
],
|
||||
providers: [FrontendConfigService],
|
||||
}).compile();
|
||||
const service = module.get(FrontendConfigService);
|
||||
const config = await service.getFrontendConfig();
|
||||
if (authConfig.dropbox.clientID) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.DROPBOX,
|
||||
});
|
||||
}
|
||||
if (authConfig.facebook.clientID) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.FACEBOOK,
|
||||
});
|
||||
}
|
||||
if (authConfig.google.clientID) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.GOOGLE,
|
||||
});
|
||||
}
|
||||
if (authConfig.github.clientID) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.GITHUB,
|
||||
});
|
||||
}
|
||||
if (authConfig.local.enableLogin) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.LOCAL,
|
||||
});
|
||||
}
|
||||
if (authConfig.twitter.consumerKey) {
|
||||
expect(config.authProviders).toContainEqual({
|
||||
type: AuthProviderType.TWITTER,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.GITLAB,
|
||||
).length,
|
||||
).toEqual(authConfig.gitlab.length);
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.LDAP,
|
||||
).length,
|
||||
).toEqual(authConfig.ldap.length);
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.SAML,
|
||||
).length,
|
||||
).toEqual(authConfig.saml.length);
|
||||
expect(
|
||||
config.authProviders.filter(
|
||||
(provider) => provider.type === AuthProviderType.OAUTH2,
|
||||
).length,
|
||||
).toEqual(authConfig.oauth2.length);
|
||||
if (authConfig.gitlab.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.GITLAB,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.GITLAB,
|
||||
providerName: authConfig.gitlab[0].providerName,
|
||||
identifier: authConfig.gitlab[0].identifier,
|
||||
});
|
||||
}
|
||||
if (authConfig.ldap.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.LDAP,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.LDAP,
|
||||
providerName: authConfig.ldap[0].providerName,
|
||||
identifier: authConfig.ldap[0].identifier,
|
||||
});
|
||||
}
|
||||
if (authConfig.saml.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.SAML,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.SAML,
|
||||
providerName: authConfig.saml[0].providerName,
|
||||
identifier: authConfig.saml[0].identifier,
|
||||
});
|
||||
}
|
||||
if (authConfig.oauth2.length > 0) {
|
||||
expect(
|
||||
config.authProviders.find(
|
||||
(provider) => provider.type === AuthProviderType.OAUTH2,
|
||||
),
|
||||
).toEqual({
|
||||
type: AuthProviderType.OAUTH2,
|
||||
providerName: authConfig.oauth2[0].providerName,
|
||||
identifier: authConfig.oauth2[0].identifier,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const maxDocumentLength = 100000;
|
||||
const enableRegister = true;
|
||||
const imageProxy = 'https://imageProxy.example.com';
|
||||
const customName = 'Test Branding Name';
|
||||
|
||||
let index = 1;
|
||||
for (const customLogo of [undefined, 'https://example.com/logo.png']) {
|
||||
for (const privacyLink of [undefined, 'https://example.com/privacy']) {
|
||||
for (const termsOfUseLink of [undefined, 'https://example.com/terms']) {
|
||||
for (const imprintLink of [undefined, 'https://example.com/imprint']) {
|
||||
for (const plantUmlServer of [
|
||||
undefined,
|
||||
'https://plantuml.example.com',
|
||||
]) {
|
||||
it(`combination #${index} works`, async () => {
|
||||
const appConfig: AppConfig = {
|
||||
domain: domain,
|
||||
rendererBaseUrl: 'https://renderer.example.org',
|
||||
port: 3000,
|
||||
loglevel: Loglevel.ERROR,
|
||||
persistInterval: 10,
|
||||
};
|
||||
const authConfig: AuthConfig = {
|
||||
...emptyAuthConfig,
|
||||
local: {
|
||||
enableLogin: true,
|
||||
enableRegister,
|
||||
minimalPasswordStrength: 3,
|
||||
},
|
||||
};
|
||||
const customizationConfig: CustomizationConfig = {
|
||||
branding: {
|
||||
customName: customName,
|
||||
customLogo: customLogo,
|
||||
},
|
||||
specialUrls: {
|
||||
privacy: privacyLink,
|
||||
termsOfUse: termsOfUseLink,
|
||||
imprint: imprintLink,
|
||||
},
|
||||
};
|
||||
const externalServicesConfig: ExternalServicesConfig = {
|
||||
plantUmlServer: plantUmlServer,
|
||||
imageProxy: imageProxy,
|
||||
};
|
||||
const noteConfig: NoteConfig = {
|
||||
forbiddenNoteIds: [],
|
||||
maxDocumentLength: maxDocumentLength,
|
||||
guestAccess: GuestAccess.CREATE,
|
||||
permissions: {
|
||||
default: {
|
||||
everyone: DefaultAccessPermission.READ,
|
||||
loggedIn: DefaultAccessPermission.WRITE,
|
||||
},
|
||||
},
|
||||
};
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
registerAs('appConfig', () => appConfig),
|
||||
registerAs('authConfig', () => authConfig),
|
||||
registerAs(
|
||||
'customizationConfig',
|
||||
() => customizationConfig,
|
||||
),
|
||||
registerAs(
|
||||
'externalServicesConfig',
|
||||
() => externalServicesConfig,
|
||||
),
|
||||
registerAs('noteConfig', () => noteConfig),
|
||||
],
|
||||
}),
|
||||
LoggerModule,
|
||||
],
|
||||
providers: [FrontendConfigService],
|
||||
}).compile();
|
||||
|
||||
const service = module.get(FrontendConfigService);
|
||||
const config = await service.getFrontendConfig();
|
||||
expect(config.allowRegister).toEqual(enableRegister);
|
||||
expect(config.guestAccess).toEqual(noteConfig.guestAccess);
|
||||
expect(config.branding.name).toEqual(customName);
|
||||
expect(config.branding.logo).toEqual(
|
||||
customLogo ? new URL(customLogo) : undefined,
|
||||
);
|
||||
expect(config.maxDocumentLength).toEqual(maxDocumentLength);
|
||||
expect(config.plantUmlServer).toEqual(
|
||||
plantUmlServer ? new URL(plantUmlServer) : undefined,
|
||||
);
|
||||
expect(config.specialUrls.imprint).toEqual(
|
||||
imprintLink ? new URL(imprintLink) : undefined,
|
||||
);
|
||||
expect(config.specialUrls.privacy).toEqual(
|
||||
privacyLink ? new URL(privacyLink) : undefined,
|
||||
);
|
||||
expect(config.specialUrls.termsOfUse).toEqual(
|
||||
termsOfUseLink ? new URL(termsOfUseLink) : undefined,
|
||||
);
|
||||
expect(config.useImageProxy).toEqual(!!imageProxy);
|
||||
expect(config.version).toEqual(
|
||||
await getServerVersionFromPackageJson(),
|
||||
);
|
||||
});
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
147
backend/src/frontend-config/frontend-config.service.ts
Normal file
147
backend/src/frontend-config/frontend-config.service.ts
Normal file
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { URL } from 'url';
|
||||
|
||||
import appConfiguration, { AppConfig } from '../config/app.config';
|
||||
import authConfiguration, { AuthConfig } from '../config/auth.config';
|
||||
import customizationConfiguration, {
|
||||
CustomizationConfig,
|
||||
} from '../config/customization.config';
|
||||
import externalServicesConfiguration, {
|
||||
ExternalServicesConfig,
|
||||
} from '../config/external-services.config';
|
||||
import noteConfiguration, { NoteConfig } from '../config/note.config';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { getServerVersionFromPackageJson } from '../utils/serverVersion';
|
||||
import {
|
||||
AuthProviderDto,
|
||||
AuthProviderType,
|
||||
BrandingDto,
|
||||
FrontendConfigDto,
|
||||
SpecialUrlsDto,
|
||||
} from './frontend-config.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendConfigService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@Inject(appConfiguration.KEY)
|
||||
private appConfig: AppConfig,
|
||||
@Inject(noteConfiguration.KEY)
|
||||
private noteConfig: NoteConfig,
|
||||
@Inject(authConfiguration.KEY)
|
||||
private authConfig: AuthConfig,
|
||||
@Inject(customizationConfiguration.KEY)
|
||||
private customizationConfig: CustomizationConfig,
|
||||
@Inject(externalServicesConfiguration.KEY)
|
||||
private externalServicesConfig: ExternalServicesConfig,
|
||||
) {
|
||||
this.logger.setContext(FrontendConfigService.name);
|
||||
}
|
||||
|
||||
async getFrontendConfig(): Promise<FrontendConfigDto> {
|
||||
return {
|
||||
guestAccess: this.noteConfig.guestAccess,
|
||||
allowRegister: this.authConfig.local.enableRegister,
|
||||
authProviders: this.getAuthProviders(),
|
||||
branding: this.getBranding(),
|
||||
maxDocumentLength: this.noteConfig.maxDocumentLength,
|
||||
plantUmlServer: this.externalServicesConfig.plantUmlServer
|
||||
? new URL(this.externalServicesConfig.plantUmlServer)
|
||||
: undefined,
|
||||
specialUrls: this.getSpecialUrls(),
|
||||
useImageProxy: !!this.externalServicesConfig.imageProxy,
|
||||
version: await getServerVersionFromPackageJson(),
|
||||
};
|
||||
}
|
||||
|
||||
private getAuthProviders(): AuthProviderDto[] {
|
||||
const providers: AuthProviderDto[] = [];
|
||||
if (this.authConfig.local.enableLogin) {
|
||||
providers.push({
|
||||
type: AuthProviderType.LOCAL,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.dropbox.clientID) {
|
||||
providers.push({
|
||||
type: AuthProviderType.DROPBOX,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.facebook.clientID) {
|
||||
providers.push({
|
||||
type: AuthProviderType.FACEBOOK,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.github.clientID) {
|
||||
providers.push({
|
||||
type: AuthProviderType.GITHUB,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.google.clientID) {
|
||||
providers.push({
|
||||
type: AuthProviderType.GOOGLE,
|
||||
});
|
||||
}
|
||||
if (this.authConfig.twitter.consumerKey) {
|
||||
providers.push({
|
||||
type: AuthProviderType.TWITTER,
|
||||
});
|
||||
}
|
||||
this.authConfig.gitlab.forEach((gitLabEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.GITLAB,
|
||||
providerName: gitLabEntry.providerName,
|
||||
identifier: gitLabEntry.identifier,
|
||||
});
|
||||
});
|
||||
this.authConfig.ldap.forEach((ldapEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.LDAP,
|
||||
providerName: ldapEntry.providerName,
|
||||
identifier: ldapEntry.identifier,
|
||||
});
|
||||
});
|
||||
this.authConfig.oauth2.forEach((oauth2Entry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.OAUTH2,
|
||||
providerName: oauth2Entry.providerName,
|
||||
identifier: oauth2Entry.identifier,
|
||||
});
|
||||
});
|
||||
this.authConfig.saml.forEach((samlEntry) => {
|
||||
providers.push({
|
||||
type: AuthProviderType.SAML,
|
||||
providerName: samlEntry.providerName,
|
||||
identifier: samlEntry.identifier,
|
||||
});
|
||||
});
|
||||
return providers;
|
||||
}
|
||||
|
||||
private getBranding(): BrandingDto {
|
||||
return {
|
||||
logo: this.customizationConfig.branding.customLogo
|
||||
? new URL(this.customizationConfig.branding.customLogo)
|
||||
: undefined,
|
||||
name: this.customizationConfig.branding.customName,
|
||||
};
|
||||
}
|
||||
|
||||
private getSpecialUrls(): SpecialUrlsDto {
|
||||
return {
|
||||
imprint: this.customizationConfig.specialUrls.imprint
|
||||
? new URL(this.customizationConfig.specialUrls.imprint)
|
||||
: undefined,
|
||||
privacy: this.customizationConfig.specialUrls.privacy
|
||||
? new URL(this.customizationConfig.specialUrls.privacy)
|
||||
: undefined,
|
||||
termsOfUse: this.customizationConfig.specialUrls.termsOfUse
|
||||
? new URL(this.customizationConfig.specialUrls.termsOfUse)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
}
|
36
backend/src/groups/group-info.dto.ts
Normal file
36
backend/src/groups/group-info.dto.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class GroupInfoDto extends BaseDto {
|
||||
/**
|
||||
* Name of the group
|
||||
* @example "superheroes"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Display name of this group
|
||||
* @example "Superheroes"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
displayName: string;
|
||||
|
||||
/**
|
||||
* True if this group must be specially handled
|
||||
* Used for e.g. "everybody", "all logged in users"
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
special: boolean;
|
||||
}
|
58
backend/src/groups/group.entity.ts
Normal file
58
backend/src/groups/group.entity.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Entity()
|
||||
export class Group {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({
|
||||
unique: true,
|
||||
})
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
displayName: string;
|
||||
|
||||
/**
|
||||
* Is set to denote a special group
|
||||
* Special groups are used to map the old share settings like "everyone can edit"
|
||||
* or "logged in users can view" to the group permission system
|
||||
*/
|
||||
@Column()
|
||||
special: boolean;
|
||||
|
||||
@ManyToMany((_) => User, (user) => user.groups, {
|
||||
eager: true,
|
||||
})
|
||||
@JoinTable()
|
||||
members: Promise<User[]>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
public static create(
|
||||
name: string,
|
||||
displayName: string,
|
||||
special: boolean,
|
||||
): Omit<Group, 'id'> {
|
||||
const newGroup = new Group();
|
||||
newGroup.name = name;
|
||||
newGroup.displayName = displayName;
|
||||
newGroup.special = special; // this attribute should only be true for the two special groups
|
||||
newGroup.members = Promise.resolve([]);
|
||||
return newGroup;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue