wip: chore(esdoc): update and unify ESDoc and parameter names

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-05-20 13:16:02 +00:00
parent 3a90c9ca96
commit 3cb09d247c
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
38 changed files with 474 additions and 365 deletions

View file

@ -7,8 +7,6 @@ import { AliasDto } from '@hedgedoc/commons';
import {
Alias,
FieldNameAlias,
FieldNameNote,
Note,
TableAlias,
TypeInsertAlias,
} from '@hedgedoc/database';
@ -60,12 +58,12 @@ export class AliasService {
* @param noteId The id of the note to add the aliases to
* @param alias The alias to add to the note
* @param transaction The optional transaction to access the db
* @throws {AlreadyInDBError} The alias is already in use.
* @throws {ForbiddenIdError} The requested alias is forbidden
* @throws AlreadyInDBError The alias is already in use.
* @throws ForbiddenIdError The requested alias is forbidden
*/
async addAlias(
noteId: Note[FieldNameNote.id],
alias: Alias[FieldNameAlias.alias],
noteId: number,
alias: string,
transaction?: Knex,
): Promise<void> {
const dbActor: Knex = transaction ? transaction : this.knex;
@ -89,14 +87,11 @@ export class AliasService {
*
* @param noteId The id of the note to change the primary alias
* @param alias The alias to be the new primary alias of the note
* @throws {ForbiddenIdError} when the requested alias is forbidden
* @throws {NotInDBError} when the alias is not assigned to this note
* @throws {GenericDBError} when the database has an inconsistent state
* @throws ForbiddenIdError when the requested alias is forbidden
* @throws NotInDBError when the alias is not assigned to this note
* @throws GenericDBError when the database has an inconsistent state
*/
async makeAliasPrimary(
noteId: Note[FieldNameNote.id],
alias: Alias[FieldNameAlias.alias],
): Promise<void> {
async makeAliasPrimary(noteId: number, alias: string): Promise<void> {
await this.knex.transaction(async (transaction) => {
// First, set all existing aliases to not primary
const numberOfUpdatedEntries = await transaction(TableAlias)
@ -106,7 +101,7 @@ export class AliasService {
.where(FieldNameAlias.noteId, noteId);
if (numberOfUpdatedEntries === 0) {
throw new GenericDBError(
`The note does not exist or has no primary alias. This should never happen`,
'The note does not exist or has no primary alias. This should never happen',
this.logger.getContext(),
'makeAliasPrimary',
);
@ -130,12 +125,12 @@ export class AliasService {
/**
* Removes the specified alias from the note
* This method only does not require the noteId since it can be obtained from the alias prior to deletion
* This method only requires the alias since it can obtain the noteId from the alias prior to deletion
*
* @param alias The alias to remove from the note
* @throws {ForbiddenIdError} The requested alias is forbidden
* @throws {NotInDBError} The alias is not assigned to this note
* @throws {PrimaryAliasDeletionForbiddenError} The primary alias cannot be deleted
* @throws ForbiddenIdError The requested alias is forbidden
* @throws NotInDBError The alias is not assigned to this note
* @throws PrimaryAliasDeletionForbiddenError The primary alias cannot be deleted
*/
async removeAlias(alias: string): Promise<void> {
await this.knex.transaction(async (transaction) => {
@ -172,13 +167,14 @@ export class AliasService {
* Gets the primary alias of the note specified by the noteId
*
* @param noteId The id of the note to get the primary alias of
* @param transaction The optional transaction to access the db
* @returns The primary alias of the note
* @throws {NotInDBError} The note has no primary alias which should mean that the note does not exist
* @throws NotInDBError The note has no primary alias which should mean that the note does not exist
*/
async getPrimaryAliasByNoteId(
noteId: number,
transaction?: Knex,
): Promise<Alias[FieldNameAlias.alias]> {
): Promise<string> {
const dbActor = transaction ?? this.knex;
const primaryAlias = await dbActor(TableAlias)
.select(FieldNameAlias.alias)
@ -187,7 +183,7 @@ export class AliasService {
.first();
if (primaryAlias === undefined) {
throw new NotInDBError(
`The noteId '${noteId}' has no primary alias.`,
'The note does not exist or has no primary alias. This should never happen',
this.logger.getContext(),
'getPrimaryAliasByNoteId',
);
@ -198,9 +194,10 @@ export class AliasService {
/**
* Gets all aliases of the note specified by the noteId
*
* @param noteId The id of the note to get the primary alias of
* @returns The primary alias of the note
* @throws {NotInDBError} The note has no primary alias which should mean that the note does not exist
* @param noteId The id of the note to get the list of aliases for
* @param transaction The optional transaction to access the db
* @returns The list of aliases for the note
* @throws NotInDBError The note with the specified id does not exist
*/
async getAllAliases(
noteId: number,
@ -212,7 +209,7 @@ export class AliasService {
.where(FieldNameAlias.noteId, noteId);
if (aliases.length === 0) {
throw new NotInDBError(
`The noteId '${noteId}' has no aliases. This should never happen.`,
'The note does not exist or has no aliases. This should never happen',
this.logger.getContext(),
'getAllAliases',
);
@ -226,11 +223,11 @@ export class AliasService {
*
* @param alias The alias to check
* @param transaction The optional transaction to access the db
* @throws {ForbiddenIdError} The requested alias is not available
* @throws {AlreadyInDBError} The requested alias already exists
* @throws ForbiddenIdError The requested alias is not available
* @throws AlreadyInDBError The requested alias already exists
*/
async ensureAliasIsAvailable(
alias: Alias[FieldNameAlias.alias],
alias: string,
transaction?: Knex,
): Promise<void> {
if (this.isAliasForbidden(alias)) {
@ -243,7 +240,7 @@ export class AliasService {
const isUsed = await this.isAliasUsed(alias, transaction);
if (isUsed) {
throw new AlreadyInDBError(
`A note with the id or alias '${alias}' already exists.`,
`A note with the alias '${alias}' already exists.`,
this.logger.getContext(),
'ensureAliasIsAvailable',
);
@ -254,17 +251,10 @@ export class AliasService {
* Checks if the provided alias is forbidden by configuration
*
* @param alias The alias to check
* @return {boolean} true if the alias is forbidden, false otherwise
* @returns true if the alias is forbidden, false otherwise
*/
isAliasForbidden(alias: Alias[FieldNameAlias.alias]): boolean {
const forbidden = this.noteConfig.forbiddenNoteIds.includes(alias);
if (forbidden) {
this.logger.warn(
`A note with the alias '${alias}' is forbidden by the administrator.`,
'isAliasForbidden',
);
}
return forbidden;
isAliasForbidden(alias: string): boolean {
return this.noteConfig.forbiddenNoteIds.includes(alias);
}
/**
@ -272,19 +262,16 @@ export class AliasService {
*
* @param alias The alias to check
* @param transaction The optional transaction to access the db
* @return {boolean} true if the id or alias is already used, false otherwise
* @returns true if the alias is already used, false otherwise
*/
async isAliasUsed(
alias: Alias[FieldNameAlias.alias],
transaction?: Knex,
): Promise<boolean> {
async isAliasUsed(alias: string, transaction?: Knex): Promise<boolean> {
const dbActor = transaction ? transaction : this.knex;
const result = await dbActor(TableAlias)
.select(FieldNameAlias.alias)
.where(FieldNameAlias.alias, alias);
if (result.length === 1) {
this.logger.log(
`A note with the id or alias '${alias}' already exists.`,
`A note with the alias '${alias}' already exists.`,
'isAliasUsed',
);
return true;
@ -293,11 +280,11 @@ export class AliasService {
}
/**
* Build the AliasDto from a note.
* Returns alias information in the AliasDto format
*
* @param alias The alias to use
* @param isPrimaryAlias If the alias is the primary alias.
* @throws {NotInDBError} The specified alias does not exist
* @return {AliasDto} The built AliasDto
* @param isPrimaryAlias Whether the alias is the primary alias.
* @returns The built AliasDto
*/
toAliasDto(alias: string, isPrimaryAlias: boolean): AliasDto {
return {

View file

@ -4,12 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons';
import {
ApiToken,
FieldNameApiToken,
TableApiToken,
TypeInsertApiToken,
} from '@hedgedoc/database';
import { ApiToken, FieldNameApiToken, TableApiToken } from '@hedgedoc/database';
import { Injectable } from '@nestjs/common';
import { Cron, Timeout } from '@nestjs/schedule';
import { randomBytes } from 'crypto';
@ -28,7 +23,8 @@ import {
hashApiToken,
} from '../utils/password';
export const AUTH_TOKEN_PREFIX = 'hd2';
const AUTH_TOKEN_PREFIX = 'hd2';
const MESSAGE_TOKEN_INVALID = 'API token is invalid, expired or not found';
@Injectable()
export class ApiTokenService {
@ -46,21 +42,22 @@ export class ApiTokenService {
* The usage of this token is tracked in the database
*
* @param tokenString The token string to validate and parse
* @return The userId associated with the token
* @returns The userId associated with the token
* @throws TokenNotValidError if the token is not valid
*/
async getUserIdForToken(tokenString: string): Promise<number> {
const [prefix, keyId, secret, ...rest] = tokenString.split('.');
if (!keyId || !secret || prefix !== AUTH_TOKEN_PREFIX || rest.length > 0) {
// We always expect 86 characters for the secret and 11 characters for the keyId
// as they are generated with 64 bytes and 8 bytes respectively and then converted to a base64url string
if (
keyId.length !== 11 ||
!secret ||
secret.length !== 86 ||
prefix !== AUTH_TOKEN_PREFIX ||
rest.length > 0
) {
throw new TokenNotValidError('Invalid API token 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(
`API token '${tokenString}' has incorrect length`,
);
}
return await this.knex.transaction(async (transaction) => {
const token = await transaction(TableApiToken)
.select(
@ -71,7 +68,7 @@ export class ApiTokenService {
.where(FieldNameApiToken.id, keyId)
.first();
if (token === undefined) {
throw new TokenNotValidError('Token not found');
throw new TokenNotValidError(MESSAGE_TOKEN_INVALID);
}
const tokenHash = token[FieldNameApiToken.secretHash];
@ -88,16 +85,20 @@ export class ApiTokenService {
/**
* Creates a new API token for the given user
* We limit the number of tokens to 200 per user to avoid users losing track over their tokens.
* There is no technical limit to this.
*
* The returned secret is stored hashed in the database and therefore cannot be retrieved again.
*
* @param userId The id of the user to create the token for
* @param tokenLabel The label of the token
* @param label The label of the token
* @param userDefinedValidUntil Maximum date until the token is valid, will be truncated to 2 years
* @throws TooManyTokensError if the user already has 200 tokens
* @returns The created token together with the secret
* @throws TooManyTokensError if the user already has 200 tokens
*/
async createToken(
userId: number,
tokenLabel: string,
label: string,
userDefinedValidUntil?: Date,
): Promise<ApiTokenWithSecretDto> {
return await this.knex.transaction(async (transaction) => {
@ -105,16 +106,15 @@ export class ApiTokenService {
.select(FieldNameApiToken.id)
.where(FieldNameApiToken.userId, userId);
if (existingTokensForUser.length >= 200) {
// This is a very high ceiling unlikely to hinder legitimate usage,
// but should prevent possible attack vectors
throw new TooManyTokensError(
`User '${userId}' has already 200 API tokens and can't have more`,
'There is a maximum of 200 API tokens per user',
);
}
const secret = bufferToBase64Url(randomBytes(64));
const keyId = bufferToBase64Url(randomBytes(8));
const accessTokenHash = hashApiToken(secret);
const secretHash = hashApiToken(secret);
const fullToken = `${AUTH_TOKEN_PREFIX}.${keyId}.${secret}`;
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity = new Date();
maximumTokenValidity.setTime(
@ -125,31 +125,28 @@ export class ApiTokenService {
const validUntil = isTokenLimitedToMaximumValidity
? maximumTokenValidity
: userDefinedValidUntil;
const token: TypeInsertApiToken = {
const createdAt = new Date();
await this.knex(TableApiToken).insert({
[FieldNameApiToken.id]: keyId,
[FieldNameApiToken.label]: tokenLabel,
[FieldNameApiToken.label]: label,
[FieldNameApiToken.userId]: userId,
[FieldNameApiToken.secretHash]: accessTokenHash,
[FieldNameApiToken.secretHash]: secretHash,
[FieldNameApiToken.validUntil]: validUntil,
[FieldNameApiToken.createdAt]: new Date(),
[FieldNameApiToken.createdAt]: createdAt,
});
return {
label,
keyId,
createdAt: createdAt.toISOString(),
validUntil: validUntil.toISOString(),
lastUsedAt: null,
secret: fullToken,
};
await this.knex(TableApiToken).insert(token);
return this.toAuthTokenWithSecretDto(
{
...token,
[FieldNameApiToken.validUntil]:
token[FieldNameApiToken.validUntil].toISOString(),
[FieldNameApiToken.createdAt]:
token[FieldNameApiToken.createdAt].toISOString(),
[FieldNameApiToken.lastUsedAt]: null,
},
secret,
);
});
}
/**
* Ensures that the given token secret is valid for the given token
* Ensures that a token is valid by evaluating the expiry date as well as comparing secret and stored hash
* This method does not return any value but throws an error if the token is not valid
*
* @param secret The secret to compare against the hash from the database
@ -164,14 +161,12 @@ export class ApiTokenService {
): void {
// First, verify token expiry is not in the past (cheap operation)
if (validUntil.getTime() < new Date().getTime()) {
throw new TokenNotValidError(
`Auth token is not valid since ${validUntil.toISOString()}`,
);
throw new TokenNotValidError(MESSAGE_TOKEN_INVALID);
}
// Second, verify the secret (costly operation)
if (!checkTokenEquality(secret, tokenHash)) {
throw new TokenNotValidError(`Secret does not match token hash`);
throw new TokenNotValidError(MESSAGE_TOKEN_INVALID);
}
}
@ -179,7 +174,7 @@ export class ApiTokenService {
* Returns all tokens of a user
*
* @param userId The id of the user to get the tokens for
* @return The tokens of the user
* @returns A list of the user's tokens as ApiToken objects
*/
getTokensOfUserById(userId: number): Promise<ApiToken[]> {
return this.knex(TableApiToken)
@ -205,10 +200,10 @@ export class ApiTokenService {
}
/**
* Converts an ApiToken to an ApiTokenDto
* Formats an ApiToken object from the database to an ApiTokenDto
*
* @param apiToken The token to convert
* @return The converted token
* @param apiToken The token object to convert
* @returns The built ApiTokenDto
*/
toAuthTokenDto(apiToken: ApiToken): ApiTokenDto {
return {
@ -224,25 +219,6 @@ export class ApiTokenService {
};
}
/**
* Converts an ApiToken to an ApiTokenWithSecretDto
*
* @param apiToken The token to convert
* @param secret The secret of the token
* @return The converted token
*/
toAuthTokenWithSecretDto(
apiToken: ApiToken,
secret: string,
): ApiTokenWithSecretDto {
const tokenDto = this.toAuthTokenDto(apiToken);
const fullToken = `${AUTH_TOKEN_PREFIX}.${tokenDto.keyId}.${secret}`;
return {
...tokenDto,
secret: fullToken,
};
}
// Deletes all invalid tokens every sunday on 3:00 AM
@Cron('0 0 3 * * 0')
async handleCron(): Promise<void> {
@ -264,7 +240,7 @@ export class ApiTokenService {
.where(FieldNameApiToken.validUntil, '<', new Date())
.delete();
this.logger.log(
`${numberOfDeletedTokens} invalid AuthTokens were purged from the DB.`,
`${numberOfDeletedTokens} expired API tokens were purged from the DB`,
'removeInvalidTokens',
);
}

View file

@ -51,7 +51,7 @@ export class LocalController {
@Body() registerDto: RegisterDto,
): Promise<void> {
await this.localIdentityService.checkPasswordStrength(registerDto.password);
const userId = await this.localIdentityService.createLocalIdentity(
const userId = await this.localIdentityService.createUserWithLocalIdentity(
registerDto.username,
registerDto.password,
registerDto.displayName,

View file

@ -8,14 +8,7 @@ import {
PendingUserConfirmationDto,
PendingUserInfoDto,
} from '@hedgedoc/commons';
import {
FieldNameIdentity,
FieldNameUser,
Identity,
TableIdentity,
TypeInsertIdentity,
User,
} from '@hedgedoc/database';
import { FieldNameIdentity, Identity, TableIdentity } from '@hedgedoc/database';
import { Inject, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
@ -44,7 +37,7 @@ export class IdentityService {
* Determines if the identity should be updated
*
* @param authProviderIdentifier The identifier of the auth source
* @return true if the authProviderIdentifier is the sync source, false otherwise
* @returns true if the authProviderIdentifier is the sync source, false otherwise
*/
mayUpdateIdentity(authProviderIdentifier: string): boolean {
return this.authConfig.common.syncSource === authProviderIdentifier;
@ -53,10 +46,11 @@ export class IdentityService {
/**
* Retrieve an identity from the information received from an auth provider.
*
* @param authProviderUserId - the userId of the wanted identity
* @param authProviderType - the providerType of the wanted identity
* @param authProviderIdentifier - optional name of the provider if multiple exist
* @return
* @param authProviderUserId the userId of the wanted identity
* @param authProviderType the providerType of the wanted identity
* @param authProviderIdentifier optional name of the provider if multiple exist
* @returns The found identity
* @throws NotInDBError if the identity is not found
*/
async getIdentityFromUserIdAndProviderType(
authProviderUserId: string,
@ -78,15 +72,14 @@ export class IdentityService {
}
/**
* Creates a new generic identity.
* Creates a new generic identity
*
* @param userId - the user the identity should be added to
* @param authProviderType - the providerType of the identity
* @param authProviderIdentifier - the providerIdentifier of the identity
* @param authProviderUserId - the userId the identity should have
* @param passwordHash - the password hash if the identiy uses that.
* @param transaction - the database transaction to use if any
* @return the new local identity
* @param userId the user the identity should be added to
* @param authProviderType the providerType of the identity
* @param authProviderIdentifier the providerIdentifier of the identity
* @param authProviderUserId the userId the identity should have
* @param passwordHash the password hash if the identity uses that
* @param transaction the database transaction to use if any
*/
async createIdentity(
userId: number,
@ -97,28 +90,27 @@ export class IdentityService {
transaction?: Knex,
): Promise<void> {
const dbActor = transaction ?? this.knex;
const identity: TypeInsertIdentity = {
await dbActor(TableIdentity).insert({
[FieldNameIdentity.userId]: userId,
[FieldNameIdentity.providerType]: authProviderType,
[FieldNameIdentity.providerIdentifier]: authProviderIdentifier,
[FieldNameIdentity.providerUserId]: authProviderUserId,
[FieldNameIdentity.passwordHash]: passwordHash ?? null,
};
await dbActor(TableIdentity).insert(identity);
});
}
/**
* Creates a new user with the given user data.
* Creates a new user with the given user data
*
* @param authProviderType The type of the auth provider
* @param authProviderIdentifier The identifier of the auth provider
* @param authProviderUserId The id of the user in the auth system
* @param username The new username
* @param displayName The dispay name of the new user
* @param displayName The display name of the new user
* @param email The email address of the new user
* @param photoUrl The URL to the new user's profile picture
* @param passwordHash The optional password hash, only required for local identities
* @return The id of the newly created user
* @returns The id of the newly created user
*/
async createUserWithIdentity(
authProviderType: AuthProviderType,
@ -129,7 +121,7 @@ export class IdentityService {
email: string | null,
photoUrl: string | null,
passwordHash?: string,
): Promise<User[FieldNameUser.id]> {
): Promise<number> {
return await this.knex.transaction(async (transaction) => {
const userId = await this.usersService.createUser(
username,
@ -151,14 +143,14 @@ export class IdentityService {
}
/**
* Create a user with identity from pending user confirmation data.
* Create a user with identity from pending user confirmation data
*
* @param sessionUserData The data we got from the authProvider itself
* @param pendingUserConfirmationData The data the user entered while confirming their account
* @param authProviderType The type of the auth provider
* @param authProviderIdentifier The identifier of the auth provider
* @param authProviderUserId The id of the user in the auth system
* @return The id of the newly created user
* @returns The id of the newly created user
*/
async createUserWithIdentityFromPendingUserConfirmation(
sessionUserData: PendingUserInfoDto,
@ -166,7 +158,7 @@ export class IdentityService {
authProviderType: AuthProviderType,
authProviderIdentifier: string,
authProviderUserId: string,
): Promise<User[FieldNameUser.id]> {
): Promise<number> {
const profileEditsAllowed = this.authConfig.common.allowProfileEdits;
const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername;

View file

@ -32,7 +32,7 @@ const LDAP_ERROR_MAP: Record<string, string> = {
'775': 'User account locked',
default: 'Invalid username/password',
/* eslint-enable @typescript-eslint/naming-convention */
};
} as const;
@Injectable()
export class LdapService {
@ -45,15 +45,14 @@ export class LdapService {
}
/**
* Try to log in the user with the given credentials.
* Tries to log in the user with the given credentials and returns the user info on success
*
* @param ldapConfig {LdapConfig} - the ldap config to use
* @param username {string} - the username to log in with
* @param password {string} - the password to log in with
* @param ldapConfig The ldap config to use
* @param username The user-provided username
* @param password The user-provided password
* @returns The user info of the user that logged in
* @throws {UnauthorizedException} - the user has given us incorrect credentials
* @throws {InternalServerErrorException} - if there are errors that we can't assign to wrong credentials
* @private
* @throws UnauthorizedException if the user has given us incorrect credentials
* @throws InternalServerErrorException if there are errors that we can't assign to wrong credentials
*/
getUserInfoFromLdap(
ldapConfig: LdapConfig,
@ -119,11 +118,11 @@ export class LdapService {
}
/**
* Get and return the correct ldap config from the list of available configs.
* @param {string} ldapIdentifier the identifier for the ldap config to be used
* @returns {LdapConfig} - the ldap config with the given identifier
* @throws {NotFoundException} - there is no ldap config with the given identifier
* @private
* Fetches the correct LDAP config from the list of available configs
*
* @param ldapIdentifier The identifier for the LDAP config to be used
* @returns The LDAP config with the given identifier
* @throws NotFoundException if there is no LDAP config with the given identifier
*/
getLdapConfig(ldapIdentifier: string): LdapConfig {
const ldapConfig = this.authConfig.ldap.find(
@ -131,19 +130,22 @@ export class LdapService {
);
if (!ldapConfig) {
this.logger.warn(
`The LDAP Config '${ldapIdentifier}' was requested, but doesn't exist`,
`The LDAP config '${ldapIdentifier}' was requested, but doesn't exist`,
);
throw new NotFoundException(
`There is no LDAP config '${ldapIdentifier}'`,
);
throw new NotFoundException(`There is no ldapConfig '${ldapIdentifier}'`);
}
return ldapConfig;
}
/**
* This method transforms the ldap error codes we receive into correct errors.
* This method transforms the LDAP error codes we receive into correct errors.
* It's very much inspired by https://github.com/vesse/passport-ldapauth/blob/b58c60000a7cc62165b112274b80c654adf59fff/lib/passport-ldapauth/strategy.js#L261
* @returns {HttpException} - the matching HTTP exception to throw to the client
* @throws {UnauthorizedException} if error indicates that the user is not allowed to log in
* @throws {InternalServerErrorException} in every other case
*
* @returns The matching HTTP exception to throw to the client
* @throws UnauthorizedException if the error indicates that the user is not allowed to log in
* @throws InternalServerErrorException in every other case
*/
private getLdapException(
username: string,

View file

@ -4,13 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AuthProviderType } from '@hedgedoc/commons';
import {
FieldNameIdentity,
FieldNameUser,
Identity,
TableIdentity,
User,
} from '@hedgedoc/database';
import { FieldNameIdentity, Identity, TableIdentity } from '@hedgedoc/database';
import { Inject, Injectable } from '@nestjs/common';
import {
OptionsGraph,
@ -63,18 +57,18 @@ export class LocalService {
}
/**
* Create a new identity for internal auth
* Creates a new user with an identity for internal auth and returns the id of the newly created user
*
* @param username The username of the new identity
* @param password The password the identity should have
* @param displayName The display name of the new identity
* @returns {Identity} the new local identity
* @returns The id of the newly created user
*/
async createLocalIdentity(
async createUserWithLocalIdentity(
username: string,
password: string,
displayName: string,
): Promise<User[FieldNameUser.id]> {
): Promise<number> {
const passwordHash = await hashPassword(password);
return await this.identityService.createUserWithIdentity(
AuthProviderType.LOCAL,
@ -89,12 +83,12 @@ export class LocalService {
}
/**
* @async
* Update the internal password of the specified the user
* @param {User} userId - the user, which identity should be updated
* @param {string} newPassword - the new password
* @throws {NoLocalIdentityError} the specified user has no internal identity
* @return {Identity} the changed identity
* Updates the password hash for the local identity of the specified the user
*
* @param userId The user, whose local identity should be updated
* @param newPassword The new password
* @throws NoLocalIdentityError if the specified user has no local identity
* @throws PasswordTooWeakError if the password is too weak
*/
async updateLocalPassword(
userId: number,
@ -112,12 +106,12 @@ export class LocalService {
}
/**
* @async
* Checks if the user and password combination matches
* @param {string} username - the user to use
* @param {string} password - the password to use
* @throws {InvalidCredentialsError} the password and user do not match
* @throws {NoLocalIdentityError} the specified user has no internal identity
* Checks if the user and password combination matches for the local identity and returns the local identity on success
*
* @param username The user to use
* @param password The password to use
* @returns The identity of the user if the credentials are valid
* @throws InvalidCredentialsError if the credentials are invalid
*/
async checkLocalPassword(
username: string,
@ -129,12 +123,11 @@ export class LocalService {
AuthProviderType.LOCAL,
null,
);
if (
!(await checkPassword(
password,
identity[FieldNameIdentity.passwordHash] ?? '',
))
) {
const passwordValid = await checkPassword(
password,
identity[FieldNameIdentity.passwordHash] ?? '',
);
if (!passwordValid) {
throw new InvalidCredentialsError(
'Username or password is not correct',
this.logger.getContext(),
@ -145,11 +138,12 @@ export class LocalService {
}
/**
* @async
* Check if the password is strong and long enough.
* Checks if the password is strong and long enough
* This check is performed against the minimalPasswordStrength of the {@link AuthConfig}.
* @param {string} password - the password to check
* @throws {PasswordTooWeakError} the password is too weak
* The method acts as a guard and therefore throws an error on failure instead of returning a boolean.
*
* @param password The password to check
* @throws PasswordTooWeakError if the password is too weak
*/
async checkPasswordStrength(password: string): Promise<void> {
if (password.length < 6) {

View file

@ -265,6 +265,7 @@ export class OidcService {
oidcIdentifier,
);
} catch (e) {
// Catch not-found errors when registration via OIDC is enabled and return null instead
if (e instanceof NotInDBError) {
if (!clientConfig.config.enableRegistration) {
throw new ForbiddenException(

View file

@ -16,10 +16,10 @@ import { ConsoleLoggerService } from '../logger/console-logger.service';
/**
* This guard checks if a session is present.
*
* If there is a username in `request.session.username` it will try to get this user from the database and put it into `request.user`. See {@link RequestUser}.
* If there is no `request.session.username`, but any PermissionLevel is configured, `request.session.authProvider` is set to `guest` to indicate a guest user.
* It checks if the session contains a `userId` and an `authProviderType`. If both are present, they are added to the request object.
* Otherwise, an `UnauthorizedException` is thrown.
*
* @throws UnauthorizedException
* @throws UnauthorizedException if the session is not present or does not contain a `userId` or `authProviderType`.
*/
@Injectable()
export class SessionGuard implements CanActivate {
@ -27,13 +27,20 @@ export class SessionGuard implements CanActivate {
this.logger.setContext(SessionGuard.name);
}
/**
* Checks if the request has a valid session.
*
* @param context The execution context containing the request.
* @returns true if the session is valid
* @throws UnauthorizedException when the session is invalid, and therefore stops further execution
*/
canActivate(context: ExecutionContext): boolean {
const request: CompleteRequest = context.switchToHttp().getRequest();
const userId = request.session?.userId;
const authProviderType = request.session?.authProviderType;
if (!userId || !authProviderType) {
this.logger.debug('The user has no session.');
throw new UnauthorizedException("You're not logged in");
throw new UnauthorizedException('You have no active session');
}
request.userId = userId;
request.authProviderType = authProviderType;

View file

@ -18,6 +18,12 @@ import {
extractDescriptionFromZodIssue,
} from './zod-error-message';
/**
* Validates that a given URL is valid, uses the HTTP or HTTPS protocol, and does not end with a slash
*
* @param value The URL string to validate.
* @param ctx The Zod refinement context.
*/
function validateUrl(value: string | undefined, ctx: RefinementCtx): void {
if (!value) {
return z.NEVER;

View file

@ -1,10 +0,0 @@
/*
* 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',
}

View file

@ -60,6 +60,16 @@ const schema = z.object({
export type NoteConfig = z.infer<typeof schema>;
/**
* Checks if the configuration for guest access is consistent with the environment variable
* HD_PERMISSIONS_DEFAULT_EVERYONE.
*
* If HD_PERMISSIONS_DEFAULT_EVERYONE is set, it should not conflict with the guestAccess setting.
* If guestAccess is DENY, then HD_PERMISSIONS_DEFAULT_EVERYONE should not be set.
*
* @param config The NoteConfig to check.
* @throws Error if the configuration is inconsistent.
*/
function checkEveryoneConfigIsConsistent(config: NoteConfig): void {
const everyoneDefaultSet =
process.env.HD_PERMISSIONS_DEFAULT_EVERYONE !== undefined;
@ -70,6 +80,15 @@ function checkEveryoneConfigIsConsistent(config: NoteConfig): void {
}
}
/**
* Checks if the default permissions for logged-in users are higher than those for guests.
*
* If the default permissions for 'everyone' are set to a level that is higher than
* the default permissions for 'loggedIn', it throws an error.
*
* @param config The NoteConfig to check.
* @throws Error if the default permissions for 'everyone' are higher than those for 'loggedIn'.
*/
function checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(
config: NoteConfig,
): void {
@ -80,7 +99,7 @@ function checkLoggedInUsersHaveHigherDefaultPermissionsThanGuests(
getDefaultAccessLevelOrdinal(loggedIn)
) {
throw new Error(
`'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${everyone}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${loggedIn}'. This gives everyone greater permissions than logged-in users which is not allowed.`,
`'HD_PERMISSIONS_DEFAULT_EVERYONE' is set to '${everyone}', but 'HD_PERMISSIONS_DEFAULT_LOGGED_IN' is set to '${loggedIn}'. This would give everyone greater permissions than logged-in users, and is not allowed since it doesn't make sense.`,
);
}
}

View file

@ -5,14 +5,27 @@
*/
import { Loglevel } from './loglevel.enum';
/**
* Finds duplicates in an array
* This function uses the conversion of the array to a Set to find duplicates even if an item is present three or more times
*
* @param array The array to search for duplicates
* @returns An array containing the duplicate items
*/
export function findDuplicatesInArray<T>(array: T[]): T[] {
// This uses the Array-Set conversion to remove duplicates in the finding.
// This can happen if an entry is present three or more times
return Array.from(
new Set(array.filter((item, index) => array.indexOf(item) !== index)),
);
}
/**
* Ensures that no duplicates exist in the provided array of names
* If duplicates are found, an error is thrown with a message containing the duplicate names
*
* @param authName The name of the authentication method
* @param names The array of names to check for duplicates
* @throws Error if duplicates are found in the names array
*/
export function ensureNoDuplicatesExist(
authName: string,
names: string[],
@ -26,6 +39,14 @@ export function ensureNoDuplicatesExist(
);
}
}
/**
* Converts a (comma-)separated configuration value to an array of strings or undefined if it is undefined
*
* @param configValue The configuration value to convert
* @param separator The separator to use for splitting the value (default is ',')
* @returns An array of strings or undefined if configValue is undefined
*/
export function toArrayConfig(
configValue?: string,
separator = ',',
@ -39,6 +60,13 @@ export function toArrayConfig(
return configValue.split(separator).map((arrayItem) => arrayItem.trim());
}
/**
* Checks if the current log level is sufficient to log a message at the requested log level
*
* @param currentLoglevel The current log level
* @param requestedLoglevel The requested log level
* @returns true if the current log level is sufficient to log the requested log level, false otherwise
*/
export function needToLog(
currentLoglevel: Loglevel,
requestedLoglevel: Loglevel,
@ -48,6 +76,12 @@ export function needToLog(
return current >= requested;
}
/**
* Transforms a Loglevel value to an integer representation where a higher number means more log output
*
* @param loglevel The Loglevel to transform
* @returns The integer representation of the log level
*/
function transformLoglevelToInt(loglevel: Loglevel): number {
switch (loglevel) {
case Loglevel.TRACE:
@ -63,6 +97,12 @@ function transformLoglevelToInt(loglevel: Loglevel): number {
}
}
/**
* Parses a string to a number. If the value is undefined, it returns undefined.
*
* @param value The value to parse
* @returns The parsed number or undefined if the value is undefined
*/
export function parseOptionalNumber(value?: string): number | undefined {
if (value === undefined) {
return undefined;
@ -81,5 +121,10 @@ export function parseOptionalBoolean(value?: string): boolean | undefined {
if (value === undefined) {
return undefined;
}
return value === 'true' || value === '1' || value === 'y';
return (
value === '1' ||
value.toLowerCase() === 'y' ||
value.toLowerCase() === 'yes' ||
value.toLowerCase() === 'true'
);
}

View file

@ -5,10 +5,25 @@
*/
import { ZodIssue } from 'zod';
/**
* Converts a camelCase string to snake_case.
*
* @param str The camelCase string to convert.
* @returns The converted snake_case string.
*/
function camelToSnakeCase(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
/**
* Extracts a descriptive error message from a Zod issue by traversing its path to rebuild the identifier
* This is required because Zod does not provide a way to extract the identifier from the issue directly
*
* @param issue The Zod issue to extract the description from.
* @param prefix A prefix to prepend to the identifier.
* @param allArrays An optional record mapping array names to their string representations
* @returns A formatted error message string
*/
export function extractDescriptionFromZodIssue(
issue: ZodIssue,
prefix: string,
@ -31,6 +46,12 @@ export function extractDescriptionFromZodIssue(
return `${identifier}: ${issue.message}`;
}
/**
* Builds a formatted error message from an array of zod error messages
*
* @param errorMessages An array of error messages to include in the formatted message
* @returns A string containing the formatted error message
*/
export function buildErrorMessage(errorMessages: string[]): string {
let totalErrorMessage = 'There were some errors with your configuration:';
for (const message of errorMessages) {

View file

@ -0,0 +1,6 @@
# Migrations
The migrations are loaded and executed by Knex itself. It seems Knex expects CommonJS modules and does not support
TypeScript. Additionally, there were problems when importing types from TypeScript from the backend, or from the
commons package due to other (ESM-only) dependencies. Therefore, the migrations and database types use their own
CommonJS package.

View file

@ -24,7 +24,7 @@ export async function seed(knex: Knex): Promise<void> {
{
[FieldNameUser.username]: null,
[FieldNameUser.guestUuid]: '55b4618a-d5f3-4320-93d3-f3501c73d72b',
[FieldNameUser.displayName]: 'Gast 1',
[FieldNameUser.displayName]: 'Guest 1',
[FieldNameUser.photoUrl]: null,
[FieldNameUser.email]: null,
[FieldNameUser.authorStyle]: 1,

View file

@ -86,6 +86,9 @@ const mapOfHedgeDocErrorsToHttpErrors: Map<string, HttpExceptionConstructor> =
]);
@Catch()
/**
* Filters all errors that are not instances of HttpException and maps them to the appropriate HTTP error
*/
export class ErrorExceptionMapping extends BaseExceptionFilter<Error> {
private readonly loggerService: ConsoleLoggerService;
constructor(logger: ConsoleLoggerService, applicationRef?: HttpServer) {
@ -97,6 +100,14 @@ export class ErrorExceptionMapping extends BaseExceptionFilter<Error> {
super.catch(this.transformError(error), host);
}
/**
* Transforms an error into an HttpException if it is a HedgeDoc error.
* Logs the error message to the console if it is an ErrorWithContextDetails.
* If the error is not a HedgeDoc error, it returns the original error.
*
* @param error The error to transform
* @returns An HttpException if the error is a HedgeDoc error, otherwise the original error
*/
private transformError(error: Error): Error {
const httpExceptionConstructor = mapOfHedgeDocErrorsToHttpErrors.get(
error.name,

View file

@ -40,6 +40,11 @@ export class FrontendConfigService {
this.logger.setContext(FrontendConfigService.name);
}
/**
* Returns the config options for the frontend
*
* @returns A frontend config DTO
*/
async getFrontendConfig(): Promise<FrontendConfigDto> {
return {
guestAccess: this.noteConfig.guestAccess,
@ -58,6 +63,11 @@ export class FrontendConfigService {
};
}
/**
* Reads the auth providers from the config and returns them
*
* @returns An array of auth provider DTOs
*/
private getAuthProviders(): AuthProviderDto[] {
const providers: AuthProviderDto[] = [];
if (this.authConfig.local.enableLogin) {
@ -84,6 +94,11 @@ export class FrontendConfigService {
return providers;
}
/**
* Reads the branding from the config and returns it
*
* @returns A branding DTO
*/
private getBranding(): BrandingDto {
return {
logo: this.customizationConfig.branding.customLogo
@ -93,6 +108,11 @@ export class FrontendConfigService {
};
}
/**
* Reads the special URLs like imprint or privacy policy from the config and returns them
*
* @returns A special URL DTO
*/
private getSpecialUrls(): SpecialUrlDto {
return {
imprint: this.customizationConfig.specialUrls.imprint

View file

@ -4,11 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { GroupInfoDto } from '@hedgedoc/commons';
import {
FieldNameGroup,
TableGroup,
TypeInsertGroup,
} from '@hedgedoc/database';
import { FieldNameGroup, TableGroup } from '@hedgedoc/database';
import { Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
@ -32,16 +28,15 @@ export class GroupsService {
*
* @param name The group name as identifier the new group shall have
* @param displayName The display name the new group shall have
* @throws {AlreadyInDBError} The group name is already taken
* @throws AlreadyInDBError if the group name is already taken
*/
async createGroup(name: string, displayName: string): Promise<void> {
const group: TypeInsertGroup = {
[FieldNameGroup.name]: name,
[FieldNameGroup.displayName]: displayName,
[FieldNameGroup.isSpecial]: false,
};
try {
await this.knex(TableGroup).insert(group);
await this.knex(TableGroup).insert({
[FieldNameGroup.name]: name,
[FieldNameGroup.displayName]: displayName,
[FieldNameGroup.isSpecial]: false,
});
} catch {
const message = `A group with the name '${name}' already exists.`;
this.logger.debug(message, 'createGroup');
@ -53,8 +48,8 @@ export class GroupsService {
* Fetches a group by its identifier name
*
* @param name Name of the group to query
* @return The group
* @throws {NotInDBError} if there is no group with this name
* @returns The group's metadata
* @throws NotInDBError if there is no group with this name
*/
async getGroupInfoDtoByName(name: string): Promise<GroupInfoDto> {
const group = await this.knex(TableGroup)
@ -76,8 +71,8 @@ export class GroupsService {
*
* @param name Name of the group to query
* @param transaction The optional database transaction to use
* @return The groupId
* @throws {NotInDBError} if there is no group with this name
* @returns The groupId
* @throws NotInDBError if there is no group with this name
*/
async getGroupIdByName(name: string, transaction?: Knex): Promise<number> {
const dbActor = transaction ?? this.knex;

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -7,12 +7,13 @@ import { FileTypeResult } from 'file-type';
export interface MediaBackend {
/**
* Saves a file according to backend internals.
* Saves a file according to backend internals
*
* @param uuid Unique identifier of the uploaded file
* @param buffer File data
* @param fileType File type result
* @throws {MediaBackendError} - there was an error saving the file
* @return The internal backend data, which should be saved
* @returns The internal backend data, which should be saved
* @throws MediaBackendError - there was an error saving the file
*/
saveFile(
uuid: string,
@ -21,19 +22,21 @@ export interface MediaBackend {
): Promise<string | null>;
/**
* Delete a file from the backend
* Deletes a file from the backend
*
* @param uuid Unique identifier of the uploaded file
* @param backendData Internal backend data
* @throws {MediaBackendError} - there was an error deleting the file
* @throws MediaBackendError if there was an error deleting the file
*/
deleteFile(uuid: string, backendData: string | null): Promise<void>;
/**
* Get a publicly accessible URL of a file from the backend
* Gets a publicly accessible URL of a file from the backend
*
* @param uuid Unique identifier of the uploaded file
* @param backendData Internal backend data
* @throws {MediaBackendError} - there was an error getting the file
* @return Public accessible URL of the file
* @returns Public accessible URL of the file
* @throws MediaBackendError if there was an error getting the file
*/
getFileUrl(uuid: string, backendData: string | null): Promise<string>;
}

View file

@ -55,6 +55,12 @@ export class MediaService {
this.mediaBackend = this.getBackendFromType(this.mediaBackendType);
}
/**
* Checks if the given MIME type is allowed for media uploads
*
* @param mimeType The MIME type to check
* @returns true if the MIME type is allowed, false otherwise
*/
private static isAllowedMimeType(mimeType: string): boolean {
const allowedTypes = [
'image/apng',
@ -81,10 +87,10 @@ export class MediaService {
* @param fileBuffer The buffer with the file contents to save
* @param userId Id of the user who uploaded this file
* @param noteId Id of the note which will be associated with the new file
* @return The created MediaUpload entity
* @throws {ClientError} if the MIME type of the file is not supported
* @throws {NotInDBError} if the note or user is not in the database
* @throws {MediaBackendError} if there was an error saving the file
* @returns The created MediaUpload entity
* @throws ClientError if the MIME type of the file is not supported
* @throws NotInDBError if the note or user is not in the database
* @throws MediaBackendError if there was an error saving the file
*/
async saveFile(
fileName: string,
@ -123,10 +129,11 @@ export class MediaService {
}
/**
* @async
* Try to delete the specified file.
* @param {uuid} uuid - the name of the file to delete.
* @throws {MediaBackendError} - there was an error deleting the file
* Tries to delete the specified file
*
* @param uuid the uuid of the file to delete
* @throws NotInDBError if the file with the given uuid is not found in the database
* @throws MediaBackendError if there was an error deleting the file at the backend
*/
async deleteFile(uuid: string): Promise<void> {
const backendData = await this.knex(TableMediaUpload)
@ -150,11 +157,11 @@ export class MediaService {
}
/**
* @async
* Get the URL of the file.
* @param {string} uuid - the uuid of the file to get the URL for.
* @return {string} the URL of the file.
* @throws {MediaBackendError} - there was an error retrieving the url
* Retrieves the URL to a media upload file
*
* @param uuid the uuid of the file to get the URL for
* @returns the URL of the file
* @throws MediaBackendError if there was an error retrieving the url
*/
async getFileUrl(uuid: string): Promise<string> {
const mediaUpload = await this.knex(TableMediaUpload)
@ -178,11 +185,11 @@ export class MediaService {
}
/**
* @async
* Find a file entry by its UUID.
* @param {string} uuid - The UUID of the MediaUpload entity to find.
* @returns {MediaUpload} - the MediaUpload entity if found.
* @throws {NotInDBError} - the MediaUpload entity with the provided UUID is not found in the database.
* Finds a file entry by its UUID
*
* @param uuid The UUID of the MediaUpload entity to find
* @returns The MediaUpload entity if found
* @throws NotInDBError if the MediaUpload entity with the provided UUID is not found in the database
*/
async findUploadByUuid(uuid: string): Promise<MediaUpload> {
const mediaUpload = await this.knex(TableMediaUpload)
@ -196,10 +203,10 @@ export class MediaService {
}
/**
* @async
* List all uploads by a specific user
* @param {number} userId - the specific user
* @return {MediaUpload[]} arary of media uploads owned by the user
* Lists all uploads by a specific user
*
* @param userId the id of the user
* @returns An array of media uploads owned by the user
*/
async getMediaUploadUuidsByUserId(
userId: number,
@ -211,10 +218,10 @@ export class MediaService {
}
/**
* @async
* List all uploads to a specific note
* @param {number} noteId - the specific user
* @return {MediaUpload[]} array of media uploads owned by the user
* Lists all uploads to a specific note
*
* @param noteId the specific user
* @returns An array of media uploads owned by the user
*/
async getMediaUploadUuidsByNoteId(
noteId: number,
@ -228,9 +235,9 @@ export class MediaService {
}
/**
* @async
* Set the note of a mediaUpload to null
* @param {string} uuid - the media upload to be changed
* Sets the note of a mediaUpload to null
*
* @param uuid the media upload to be changed
*/
async removeNoteFromMediaUpload(uuid: string): Promise<void> {
this.logger.debug(
@ -244,6 +251,9 @@ export class MediaService {
.where(FieldNameMediaUpload.uuid, uuid);
}
/**
* Returns the backend type that is configured in the media configuration
*/
private chooseBackendType(): MediaBackendType {
switch (this.mediaConfig.backend.use as string) {
case 'filesystem':
@ -263,6 +273,12 @@ export class MediaService {
}
}
/**
* Returns the MediaBackend instance for the given MediaBackendType
*
* @param type The MediaBackendType to get the backend for
* @returns The MediaBackend instance
*/
private getBackendFromType(type: MediaBackendType): MediaBackend {
switch (type) {
case MediaBackendType.FILESYSTEM:
@ -278,6 +294,12 @@ export class MediaService {
}
}
/**
* Retrieves media upload DTOs by a list of their UUIDs
*
* @param uuids The UUIDs of the media uploads to retrieve
* @returns An array of MediaUploadDto objects containing the details of the media uploads
*/
async getMediaUploadDtosByUuids(uuids: string[]): Promise<MediaUploadDto[]> {
const mediaUploads = await this.knex(TableMediaUpload)
.select<

View file

@ -25,7 +25,6 @@ import { NoteService } from './note.service';
LoggerModule,
forwardRef(() => PermissionsModule),
ConfigModule,
RealtimeNoteModule,
KnexModule,
],
controllers: [],

View file

@ -48,7 +48,6 @@ import { GroupsService } from '../groups/groups.service';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { PermissionService } from '../permissions/permission.service';
import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store';
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
import { RevisionsService } from '../revisions/revisions.service';
import { UsersService } from '../users/users.service';
@ -68,7 +67,6 @@ export class NoteService {
private aliasService: AliasService,
@Inject(forwardRef(() => PermissionService))
private permissionService: PermissionService,
private realtimeNoteService: RealtimeNoteService,
private realtimeNoteStore: RealtimeNoteStore,
private eventEmitter: EventEmitter2<NoteEventMap>,
) {
@ -79,7 +77,7 @@ export class NoteService {
* Get all notes owned by a user
*
* @param userId The id of the user who owns the notes
* @return Array of notes owned by the user
* @returns Array of notes owned by the user
*/
async getUserNoteIds(userId: number): Promise<number[]> {
const result = await this.knex(TableNote)
@ -94,18 +92,18 @@ export class NoteService {
* @param noteContent The content of the new note, in most cases an empty string
* @param givenAlias An optional alias the note should have
* @param ownerUserId The owner of the note
* @return The newly created note
* @throws {AlreadyInDBError} a note with the requested id or aliases already exists
* @throws {ForbiddenIdError} the requested id or aliases is forbidden
* @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength
* @thorws {GenericDBError} the database returned a non-expected value
* @returns The newly created note
* @throws AlreadyInDBError if a note with the requested id or aliases already exists
* @throws ForbiddenIdError if the requested id or aliases is forbidden
* @throws MaximumDocumentLengthExceededError if the noteContent is longer than the maxDocumentLength
* @throws GenericDBError if the database returned a non-expected value
*/
async createNote(
noteContent: string,
ownerUserId: number,
givenAlias?: string,
): Promise<number> {
// Check if new note doesn't violate application constraints
// Ensures that a new note doesn't violate application constraints
if (noteContent.length > this.noteConfig.maxDocumentLength) {
throw new MaximumDocumentLengthExceededError();
}
@ -192,12 +190,12 @@ export class NoteService {
}
/**
* Get the current content of the note
* Gets the current content of the note
*
* @param noteId the note to use
* @param transaction The optional database transaction to use
* @throws {NotInDBError} the note is not in the DB
* @return {string} the content of the note
* @returns the content of the note
* @throws NotInDBError the note is not found in the database
*/
async getNoteContent(noteId: number, transaction?: Knex): Promise<string> {
const realtimeContent = this.realtimeNoteStore
@ -216,13 +214,13 @@ export class NoteService {
}
/**
* Get a note by either their id or aliases
* Gets a note's id by their aliases
*
* @param alias the notes id or aliases
* @param alias the alias
* @param transaction The optional database transaction to use
* @return the note id
* @throws {NotInDBError} there is no note with this id or aliases
* @throws {ForbiddenIdError} the requested id or aliases is forbidden
* @returns the note id
* @throws NotInDBError if there is no note with this alias
* @throws ForbiddenIdError if the requested note with the alias is forbidden
*/
async getNoteIdByAlias(alias: string, transaction?: Knex): Promise<number> {
const dbActor = transaction ?? this.knex;
@ -233,12 +231,6 @@ export class NoteService {
);
}
this.logger.debug(`Trying to find note '${alias}'`, 'getNoteIdByAlias');
/*
* This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and
* then only selects the note, that has a alias with this name.
*/
const note = await dbActor(TableAlias)
.select<Pick<Note, FieldNameNote.id>>(`${TableNote}.${FieldNameNote.id}`)
.where(FieldNameAlias.alias, alias)
@ -261,8 +253,8 @@ export class NoteService {
/**
* Deletes a note
*
* @param noteId If of the note to delete
* @throws {NotInDBError} if there is no note with this id
* @param noteId Id of the note to delete
* @throws NotInDBError if there is no note with this id
*/
async deleteNote(noteId: Note[FieldNameNote.id]): Promise<void> {
this.eventEmitter.emit(NoteEvent.DELETION, noteId);
@ -275,13 +267,12 @@ export class NoteService {
}
/**
* Updates the content of a note
* The realtime connection is closed in beforehand to ensure that realtime editing does not interfere with the update
*
* Update the content of a note
*
* @param noteId - the note
* @param noteContent - the new content
* @return the note with a new revision and new content
* @throws {NotInDBError} there is no note with this id or aliases
* @param noteId the note id
* @param noteContent the new content
* @throws NotInDBError if there is no note with this id or aliases
*/
async updateNote(noteId: number, noteContent: string): Promise<void> {
this.eventEmitter.emit(NoteEvent.CLOSE_REALTIME, noteId);
@ -289,10 +280,12 @@ export class NoteService {
}
/**
* Build NotePermissionsDto from a note.
* @param noteId The id of the ntoe to get the permissions for
* Builds a NotePermissionsDto for a note
* This method is a wrapper around the innerToNotePermissionsDto method to ensure a single transaction is used
*
* @param noteId The id of the note to get the permissions for
* @param transaction The optional database transaction to use
* @return The built NotePermissionDto
* @returns The built NotePermissionDto
*/
async toNotePermissionsDto(
noteId: number,
@ -306,7 +299,15 @@ export class NoteService {
return await this.innerToNotePermissionsDto(noteId, transaction);
}
async innerToNotePermissionsDto(
/**
* Builds a NotePermissionsDto for a note
*
* @param noteId The id of the note to get the permissions for
* @param transaction The database transaction to use
* @returns The built NotePermissionDto
* @throws NotInDBError if the note does not exist
*/
private async innerToNotePermissionsDto(
noteId: number,
transaction: Knex,
): Promise<NotePermissionsDto> {
@ -374,11 +375,12 @@ export class NoteService {
}
/**
* @async
* Build NoteMetadataDto from a note.
* @param noteId The if of the note to get the metadata for
* Builds a NoteMetadataDto for a note
* This method is a wrapper around the innerToNoteMetadataDto method to ensure a single transaction is used
*
* @param noteId The id of the note to get the metadata for
* @param transaction The optional database transaction to use
* @return The built NoteMetadataDto
* @returns The built NoteMetadataDto
*/
async toNoteMetadataDto(
noteId: number,
@ -392,6 +394,14 @@ export class NoteService {
return await this.innerToNoteMetadataDto(noteId, transaction);
}
/**
* Builds a NoteMetadataDto for a note
*
* @param noteId The id of the note to get the metadata for
* @param transaction The database transaction to use
* @returns The built NoteMetadataDto
* @throws NotInDBError if the note does not exist or has no primary alias
*/
private async innerToNoteMetadataDto(
noteId: number,
transaction: Knex,
@ -473,7 +483,7 @@ export class NoteService {
* Gets the note data for the note DTO
*
* @param noteId The id of the note to transform
* @return {NoteDto} the built NoteDto
* @returns The built NoteDto
*/
async toNoteDto(noteId: number): Promise<NoteDto> {
return await this.knex.transaction(async (transaction) => {

View file

@ -18,7 +18,7 @@ export enum NotePermissionLevel {
* Returns the display name for the given {@link NotePermissionLevel}.
*
* @param {NotePermissionLevel} value the note permission to display
* @return {string} The display name
* @returns The display name
*/
export function getNotePermissionLevelDisplayName(
value: NotePermissionLevel,

View file

@ -90,7 +90,7 @@ export class PermissionService {
* Checks if the given {@link User} is allowed to create notes.
*
* @param username - The user whose permission should be checked. Value is null if guest access should be checked
* @return if the user is allowed to create notes
* @returns if the user is allowed to create notes
*/
public mayCreate(username: string | null): boolean {
return (
@ -105,7 +105,7 @@ export class PermissionService {
* @param userId The id of the user
* @param noteId The id of the note
* @param transaction Optional transaction to use
* @return true if the user is the owner of the note
* @returns true if the user is the owner of the note
*/
async isOwner(
userId: number | null,
@ -135,7 +135,7 @@ export class PermissionService {
*
* @param {number | null} userId The user whose permission should be checked
* @param {number} noteId The note that is accessed by the given user
* @return {Promise<NotePermissionLevel>} The determined permission
* @returns {Promise<NotePermissionLevel>} The determined permission
*/
public async determinePermission(
userId: number,
@ -284,7 +284,7 @@ export class PermissionService {
* @param noteId the note
* @param userId the user for which the permission should be set
* @param canEdit specifies if the user can edit the note
* @return the note with the new permission
* @returns the note with the new permission
*/
async setUserPermission(
noteId: number,
@ -361,7 +361,7 @@ export class PermissionService {
* Remove permission for a specific group on a note.
* @param noteId - the note
* @param groupId - the group for which the permission should be set
* @return the note with the new permission
* @returns the note with the new permission
*/
async removeGroupPermission(noteId: number, groupId: number): Promise<void> {
const result = await this.knex(TableNoteGroupPermission)
@ -382,7 +382,7 @@ export class PermissionService {
* Updates the owner of a note.
* @param noteId - the note to use
* @param newOwnerId - the new owner
* @return the updated note
* @returns the updated note
*/
async changeOwner(noteId: number, newOwnerId: number): Promise<void> {
const result = await this.knex(TableNote)

View file

@ -11,7 +11,7 @@ import { NotePermissionLevel } from '../note-permission.enum';
* Converts the given guest access level to the highest possible {@link NotePermissionLevel}.
*
* @param guestAccess the guest access level to should be converted
* @return the {@link NotePermissionLevel} representation
* @returns the {@link NotePermissionLevel} representation
*/
export function convertPermissionLevelToNotePermissionLevel(
guestAccess: PermissionLevel,

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -8,7 +8,7 @@ import { adjectives, items } from './random-words';
/**
* Generates a random names based on an adjective and a noun.
*
* @return the generated name
* @returns the generated name
*/
export function generateRandomName(): string {
const adjective = generateRandomWord(adjectives);

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -18,7 +18,7 @@ export class RealtimeNoteStore {
* @param initialTextContent the initial text content of realtime doc
* @param initialYjsState the initial yjs state. If provided this will be used instead of the text content
* @throws Error if there is already an realtime note for the given note.
* @return The created realtime note
* @returns The created realtime note
*/
public create(
noteId: number,
@ -43,7 +43,7 @@ export class RealtimeNoteStore {
/**
* Retrieves a {@link RealtimeNote} that is linked to the given {@link Note} id.
* @param noteId The id of the {@link Note}
* @return A {@link RealtimeNote} or {@code undefined} if no instance is existing.
* @returns A {@link RealtimeNote} or {@code undefined} if no instance is existing.
*/
public find(noteId: number): RealtimeNote | undefined {
return this.noteIdToRealtimeNote.get(noteId);

View file

@ -61,7 +61,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
* Creates or reuses a {@link RealtimeNote} that is handling the real time editing of the {@link Note} which is identified by the given note id.
* @param noteId The {@link Note} for which a {@link RealtimeNote realtime note} should be retrieved.
* @throws NotInDBError if note doesn't exist or has no revisions.
* @return A {@link RealtimeNote} that is linked to the given note.
* @returns A {@link RealtimeNote} that is linked to the given note.
*/
public async getOrCreateRealtimeNote(noteId: number): Promise<RealtimeNote> {
return (
@ -75,7 +75,7 @@ export class RealtimeNoteService implements BeforeApplicationShutdown {
*
* @param noteId The note for which the realtime note should be created
* @throws NotInDBError if note doesn't exist or has no revisions.
* @return The created realtime note
* @returns The created realtime note
*/
private async createNewRealtimeNote(noteId: number): Promise<RealtimeNote> {
const lastRevision = await this.revisionsService.getLatestRevision(noteId);

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -114,7 +114,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
/**
* Checks if there's still clients connected to this note.
*
* @return {@code true} if there a still clinets connected, otherwise {@code false}
* @returns {@code true} if there a still clinets connected, otherwise {@code false}
*/
public hasConnections(): boolean {
return this.clients.size !== 0;
@ -123,7 +123,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
/**
* Returns all {@link RealtimeConnection WebsocketConnections} currently hold by this note.
*
* @return an array of {@link RealtimeConnection WebsocketConnections}
* @returns an array of {@link RealtimeConnection WebsocketConnections}
*/
public getConnections(): RealtimeConnection[] {
return [...this.clients];
@ -132,7 +132,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
/**
* Get the {@link RealtimeDoc realtime note} of the note.
*
* @return the {@link RealtimeDoc realtime note} of the note
* @returns the {@link RealtimeDoc realtime note} of the note
*/
public getRealtimeDoc(): RealtimeDoc {
return this.doc;
@ -141,7 +141,7 @@ export class RealtimeNote extends EventEmitter2<RealtimeNoteEventMap> {
/**
* Get the {@link Note note} that is edited.
*
* @return the {@link Note note}
* @returns the {@link Note note}
*/
public getNoteId(): number {
return this.noteId;

View file

@ -79,7 +79,7 @@ export class MockConnectionBuilder {
/**
* Creates a new connection based on the given configuration.
*
* @return {RealtimeConnection} The constructed mocked connection
* @returns {RealtimeConnection} The constructed mocked connection
* @throws Error if neither withGuestUser nor withLoggedInUser has been called.
*/
public build(): RealtimeConnection {

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -9,7 +9,7 @@ import { IncomingMessage } from 'http';
* Extracts the note id from the url of the given request.
*
* @param request The request whose URL should be extracted
* @return The extracted note id
* @returns The extracted note id
* @throws Error if the given string isn't a valid realtime URL path
*/
export function extractNoteAliasFromRequestUrl(

View file

@ -118,7 +118,7 @@ export class WebsocketGateway implements OnGatewayConnection {
* Finds the user id whose session cookie is saved in the given {@link IncomingMessage}.
*
* @param request The request that contains the session cookie
* @return The found user id
* @returns The found user id
*/
private async findUserIdByRequestSession(
request: IncomingMessage,

View file

@ -59,7 +59,7 @@ export class RevisionsService {
* Returns all revisions of a note
*
* @param noteId The id of the note
* @return The list of revisions
* @returns The list of revisions
*/
async getAllRevisionMetadataDto(
noteId: number,
@ -294,14 +294,14 @@ export class RevisionsService {
* Creates (but does not persist(!)) a new {@link Revision} for the given {@link Note}.
* Useful if the revision is saved together with the note in one action.
*
* @async
*
* @param noteId The note for which the revision should be created
* @param newContent The new note content
* @param firstRevision Whether this is called for the first revision of a note
* @param transaction The optional pre-existing database transaction to use
* @param yjsStateVector The yjs state vector that describes the new content
* @return {Revision} the created revision
* @return {undefined} if the revision couldn't be created because e.g. the content hasn't changed
* @returns {Revision} the created revision
* @returns {undefined} if the revision couldn't be created because e.g. the content hasn't changed
*/
async createRevision(
noteId: number,

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -31,7 +31,7 @@ interface FrontmatterParserResult {
/**
* Parses the frontmatter of the given content and extracts the metadata that are necessary to create a new revision..
*
* @param {string} content the revision content that contains the frontmatter.
* @param content the revision content that contains the frontmatter.
*/
export function extractRevisionMetadataFromContent(
content: string,

View file

@ -40,7 +40,7 @@ export class SessionService {
* Returns the currently used session store for usage outside of the HTTP session context
* Note that this method is also used for connecting the session store with NestJS initially
*
* @return The used session store
* @returns The used session store
*/
getSessionStore(): KeyvSessionStore<SessionState> {
return this.sessionStore;
@ -50,7 +50,7 @@ export class SessionService {
* Finds the username of the user that has the given session id
*
* @param sessionId The session id for which the owning user should be found
* @return A Promise that either resolves with the username or rejects with an error
* @returns A Promise that either resolves with the username or rejects with an error
*/
async getUserIdForSessionId(
sessionId: string,
@ -63,7 +63,7 @@ export class SessionService {
* Extracts the hedgedoc session cookie from the given {@link IncomingMessage request} and checks if the signature is correct.
*
* @param request The http request that contains a session cookie
* @return An {@link Optional optional} that either contains the extracted session id or is empty if no session cookie has been found
* @returns An {@link Optional optional} that either contains the extracted session id or is empty if no session cookie has been found
* @throws Error if the cookie has been found but the content is malformed
* @throws Error if the cookie has been found but the content isn't signed
*/
@ -79,7 +79,7 @@ export class SessionService {
* Parses the given session cookie content and extracts the session id
*
* @param rawCookie The cookie to parse
* @return The extracted session id
* @returns The extracted session id
* @throws Error if the cookie has been found but the content is malformed
* @throws Error if the cookie has been found but the content isn't signed
*/

View file

@ -44,7 +44,7 @@ export class UsersService {
* @param [email] New user's email address if exists
* @param [photoUrl] URL of the user's profile picture if exists
* @param transaction The optional transaction to access the db
* @return The id of newly created user
* @returns The id of newly created user
* @throws {BadRequestException} if the username contains invalid characters or is too short
* @throws {AlreadyInDBError} the username is already taken.
* @thorws {GenericDBError} the database returned a non-expected value
@ -96,7 +96,7 @@ export class UsersService {
/**
* Creates a new guest user with a random displayName
*
* @return The guest uuid and the id of the newly created user
* @returns The guest uuid and the id of the newly created user
* @throws {GenericDBError} the database returned a non-expected value
*/
async createGuestUser(): Promise<[string, number]> {
@ -195,7 +195,7 @@ export class UsersService {
* Checks if a given username is already taken
*
* @param username The username to check
* @return true if the user exists, false otherwise
* @returns true if the user exists, false otherwise
*/
async isUsernameTaken(username: string): Promise<boolean> {
const result = await this.knex(TableUser)
@ -209,7 +209,7 @@ export class UsersService {
*
* @param userId The id of the user to check
* @param transaction the optional transaction to access the db
* @return true if the user is registered, false otherwise
* @returns true if the user is registered, false otherwise
*/
async isRegisteredUser(
userId: User[FieldNameUser.id],
@ -228,7 +228,7 @@ export class UsersService {
* Fetches the userId for a given username from the database
*
* @param username The username to fetch
* @return The found user object
* @returns The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserIdByUsername(username: string): Promise<number> {
@ -250,7 +250,7 @@ export class UsersService {
* Fetches the userId for a given username from the database
*
* @param uuid The uuid to fetch
* @return The found user object
* @returns The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserIdByGuestUuid(uuid: string): Promise<User[FieldNameUser.id]> {
@ -272,7 +272,7 @@ export class UsersService {
* Fetches the user object for a given username from the database
*
* @param username The username to fetch
* @return The found user object
* @returns The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserDtoByUsername(username: string): Promise<UserInfoDto> {
@ -295,7 +295,7 @@ export class UsersService {
* Fetches the user object for a given username from the database
*
* @param userId The username to fetch
* @return The found user object
* @returns The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserById(userId: number): Promise<User> {
@ -318,7 +318,7 @@ export class UsersService {
* @param username The username to use as a seed when generating a random avatar
* @param email The email address of the user for using livbravatar if configured
* @param photoUrl The user-provided photo URL
* @return A URL to the user's profile picture.
* @returns A URL to the user's profile picture.
*/
private generatePhotoUrl(
username: string,
@ -343,7 +343,7 @@ export class UsersService {
* Creates a random author style index based on a hashing of the username
*
* @param username The username is used as input for the hash
* @return An index between 0 and 8 (including 0 and 8)
* @returns An index between 0 and 8 (including 0 and 8)
*/
private generateAuthorStyleIndex(username: string): number {
let hash = 0;
@ -358,7 +358,7 @@ export class UsersService {
*
* @param user The user to fetch their data for
* @param authProvider The auth provider used for the current login session
* @return The built OwnUserInfoDto
* @returns The built OwnUserInfoDto
*/
toLoginUserInfoDto(
user: User,

View file

@ -13,7 +13,7 @@ let versionCache: ServerVersionDto | undefined = undefined;
/**
* Reads the HedgeDoc version from the root package.json. This is done only once per run.
*
* @return {Promise<ServerVersionDto>} A Promise that contains the parsed server version.
* @returns {Promise<ServerVersionDto>} A Promise that contains the parsed server version.
* @throws {Error} if the package.json couldn't be found or doesn't contain a correct version.
*/
export async function getServerVersionFromPackageJson(): Promise<ServerVersionDto> {

View file

@ -414,17 +414,20 @@ export class TestSetupBuilder {
);
// Create identities for login
await this.testSetup.localIdentityService.createLocalIdentity(
await this.testSetup.localIdentityService.createUserWithLocalIdentity(
this.testSetup.users[0],
password1,
'',
);
await this.testSetup.localIdentityService.createLocalIdentity(
await this.testSetup.localIdentityService.createUserWithLocalIdentity(
this.testSetup.users[1],
password2,
'',
);
await this.testSetup.localIdentityService.createLocalIdentity(
await this.testSetup.localIdentityService.createUserWithLocalIdentity(
this.testSetup.users[2],
password3,
'',
);
// create auth tokens