fix(repository): Move backend code into subdirectory

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-10-02 20:10:32 +02:00 committed by David Mehren
parent 86584e705f
commit bf30cbcf48
272 changed files with 87 additions and 67 deletions

6
backend/.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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"]
}

View 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
View 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

View 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
View 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
View 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
View 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
View 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`

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

View 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
View file

@ -0,0 +1,14 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true
}
}
]
}
}

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

View 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
View file

3
backend/public/intro.md Normal file
View file

@ -0,0 +1,3 @@
:::success
You're connected to a real backend! :party:
:::

2
backend/public/motd.md Normal file
View file

@ -0,0 +1,2 @@
This is the test motd text
:smile:

View 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;
}
}

View 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();
}
});
});
}
}

View 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();
}
}

View 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),
);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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),
);
}
}

View 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 {}

View 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',
);
}
}

View 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),
);
}
}

View 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);
}
}

View 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)),
);
}
}

View 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);
}
}

View 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 '';
}
}

View 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)),
);
}
}

View 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 {}

View 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.';

View 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);
}

View 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;
}
}

View 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);
},
],
);

View 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();
}
}

View 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);
};

View 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);

View 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;
}
}

View 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;
}
}

View 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;
},
);

View 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;
},
);

View 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
View 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
View 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 {}

View 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;
}

View 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;
}
}

View 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 {}

View 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(),
);
});
});
});

View 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',
);
}
}

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View 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 {}

View 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();
});
});
});

View 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;
});

View 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();
});
});
});
});

View 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;
});

View 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;
});

View 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;
});

View 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',
}

View 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;
});

View 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');
}
}

View 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;
});

View 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',
}

View 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');
}
}

View 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;
});

View 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',
}

View 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();
});
});
});
});

View 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;
});

View 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());

View 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());

View 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(),
);

View 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());

View 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(),
);

View 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());

View 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());

View 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();
});
});
});

View 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;
});

View 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
View 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);
}

View 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);
}
}

View 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';
}

View 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
View 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. **/,
}

View 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;
}

View 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 {}

View 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;
}
}
}
}
}
});

View 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,
};
}
}

View 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;
}

View 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