refactor: replace TypeORM with knex.js

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-03-14 23:33:29 +01:00
parent 6e151c8a1b
commit c0ce00b3f9
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
242 changed files with 4601 additions and 6871 deletions

View file

@ -4,199 +4,339 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
FullUserInfoDto,
LoginUserInfoDto,
ProviderType,
AuthProviderType,
REGEX_USERNAME,
UserInfoDto,
} from '@hedgedoc/commons';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { LoginUserInfoDto } from '@hedgedoc/commons';
import { BadRequestException, Injectable } from '@nestjs/common';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import { v4 as uuidv4 } from 'uuid';
import AuthConfiguration, { AuthConfig } from '../config/auth.config';
import { User } from '../database/user.entity';
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
import { FieldNameUser, TableUser, User } from '../database/types';
import { TypeUpdateUser } from '../database/types/user';
import { GenericDBError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { UserRelationEnum } from './user-relation.enum';
import { generateRandomName } from '../realtime/realtime-note/random-word-lists/name-randomizer';
@Injectable()
export class UsersService {
constructor(
private readonly logger: ConsoleLoggerService,
@Inject(AuthConfiguration.KEY)
private authConfig: AuthConfig,
@InjectRepository(User) private userRepository: Repository<User>,
@InjectConnection()
private readonly knex: Knex,
) {
this.logger.setContext(UsersService.name);
}
/**
* @async
* Create a new user with a given username and displayName
* @param {string} username - the username the new user shall have
* @param {string} displayName - the display name the new user shall have
* @param {string} [email] - the email the new user shall have
* @param {string} [photoUrl] - the photoUrl the new user shall have
* @return {User} the user
* Creates a new user with a given username and displayName
*
* @param username New user's username
* @param displayName New user's displayName
* @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
* @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
*/
async createUser(
username: string,
displayName: string,
email: string | null,
photoUrl: string | null,
): Promise<User> {
transaction?: Knex,
): Promise<User[FieldNameUser.id]> {
if (!REGEX_USERNAME.test(username)) {
throw new BadRequestException(
`The username '${username}' is not a valid username.`,
);
}
const user = User.create(
username,
displayName,
email || undefined,
photoUrl || undefined,
);
const dbActor = transaction ? transaction : this.knex;
try {
return await this.userRepository.save(user);
} catch {
this.logger.debug(
`A user with the username '${username}' already exists.`,
'createUser',
const newUsers = await dbActor(TableUser).insert(
{
[FieldNameUser.username]: username,
[FieldNameUser.displayName]: displayName,
[FieldNameUser.email]: email ?? null,
[FieldNameUser.photoUrl]: photoUrl ?? null,
// TODO Use generatePhotoUrl method to generate a random avatar image
[FieldNameUser.guestUuid]: null,
[FieldNameUser.authorStyle]: 0,
// FIXME Set unique authorStyle per user
},
[FieldNameUser.id],
);
throw new AlreadyInDBError(
`A user with the username '${username}' already exists.`,
if (newUsers.length !== 1) {
throw new Error();
}
return newUsers[0][FieldNameUser.id];
} catch {
throw new GenericDBError(
`Failed to create user '${username}', no user was created.`,
this.logger.getContext(),
'createUser',
);
}
}
/**
* @async
* Delete the user with the specified username
* @param {User} user - the username of the user to be delete
* @throws {NotInDBError} the username has no user associated with it.
* Creates a new guest user with a random displayName
*
* @return The guest uuid and the id of the newly created user
* @throws {GenericDBError} the database returned a non-expected value
*/
async deleteUser(user: User): Promise<void> {
await this.userRepository.remove(user);
this.logger.debug(
`Successfully deleted user with username ${user.username}`,
'deleteUser',
async createGuestUser(): Promise<[string, number]> {
const randomName = generateRandomName();
const uuid = uuidv4();
const createdUserIds = await this.knex(TableUser).insert(
{
[FieldNameUser.username]: null,
[FieldNameUser.displayName]: `Guest ${randomName}`,
[FieldNameUser.email]: null,
[FieldNameUser.photoUrl]: null,
[FieldNameUser.guestUuid]: uuid,
[FieldNameUser.authorStyle]: 0,
// FIXME Set unique authorStyle per user
},
[FieldNameUser.id],
);
if (createdUserIds.length !== 1) {
throw new GenericDBError(
'Failed to create guest user',
this.logger.getContext(),
'createGuestUser',
);
}
const newUserId = createdUserIds[0][FieldNameUser.id];
return [uuid, newUserId];
}
/**
* @async
* Update the given User with the given information.
* Deletes a user by its id
*
* @param userId id of the user to be deleted
* @throws {NotInDBError} the username has no user associated with it
*/
async deleteUser(userId: number): Promise<void> {
const usersDeleted = await this.knex(TableUser)
.where(FieldNameUser.id, userId)
.delete();
if (usersDeleted === 0) {
throw new NotInDBError(
`User with id '${userId}' not found`,
this.logger.getContext(),
'deletUser',
);
}
if (usersDeleted > 1) {
this.logger.error(
`Deleted multiple (${usersDeleted}) users with the same userId '${userId}'. This should never happen!`,
'deleteUser',
);
}
}
/**
* Updates the given User with new information
* Use {@code null} to clear the stored value (email or profilePicture).
* Use {@code undefined} to keep the stored value.
* @param {User} user - the User to update
* @param {string | undefined} displayName - the displayName to update the user with
* @param {string | null | undefined} email - the email to update the user with
* @param {string | null | undefined} profilePicture - the profilePicture to update the user with
*
* @param userId The username of the user to update
* @param displayName The new display name
* @param email The new email address
* @param profilePicture The new profile picture URL
*/
async updateUser(
user: User,
userId: number,
displayName?: string,
email?: string | null,
profilePicture?: string | null,
): Promise<User> {
let shouldSave = false;
): Promise<void> {
const updateData = {} as TypeUpdateUser;
if (displayName !== undefined) {
user.displayName = displayName;
shouldSave = true;
updateData[FieldNameUser.displayName] = displayName;
}
if (email !== undefined) {
user.email = email;
shouldSave = true;
updateData[FieldNameUser.email] = email;
}
if (profilePicture !== undefined) {
user.photo = profilePicture;
shouldSave = true;
// ToDo: handle LDAP images (https://github.com/hedgedoc/hedgedoc/issues/5032)
updateData[FieldNameUser.photoUrl] = profilePicture;
}
if (shouldSave) {
return await this.userRepository.save(user);
if (Object.keys(updateData).length === 0) {
this.logger.debug('No update data provided.', 'updateUser');
return;
}
const result = await this.knex(TableUser)
.where(FieldNameUser.id, userId)
.update(updateData);
if (result !== 1) {
throw new NotInDBError(
`Failed to update user '${userId}'.`,
this.logger.getContext(),
'updateUser',
);
}
}
/**
* Checks if a given username is already taken
*
* @param username The username to check
* @return true if the user exists, false otherwise
*/
async isUsernameTaken(username: string): Promise<boolean> {
const result = await this.knex(TableUser)
.select(FieldNameUser.username)
.where(FieldNameUser.username, username);
return result.length === 1;
}
/**
* Checks if a given user is a registered user in contrast to a guest user
*
* @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
*/
async isRegisteredUser(
userId: User[FieldNameUser.id],
transaction?: Knex,
): Promise<boolean> {
const dbActor = transaction ? transaction : this.knex;
const username = await dbActor(TableUser)
.select(FieldNameUser.username)
.where(FieldNameUser.id, userId)
.first();
return username !== null && username !== undefined;
}
/**
* Fetches the userId for a given username from the database
*
* @param username The username to fetch
* @return The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserIdByUsername(username: string): Promise<User[FieldNameUser.id]> {
const userId = await this.knex(TableUser)
.select(FieldNameUser.id)
.where(FieldNameUser.username, username)
.first();
if (userId === undefined) {
throw new NotInDBError(
`User with username "${username}" does not exist`,
this.logger.getContext(),
'getUserIdByUsername',
);
}
return userId[FieldNameUser.id];
}
/**
* Fetches the userId for a given username from the database
*
* @param uuid The uuid to fetch
* @return The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserIdByGuestUuid(uuid: string): Promise<User[FieldNameUser.id]> {
const userId = await this.knex(TableUser)
.select(FieldNameUser.id)
.where(FieldNameUser.guestUuid, uuid)
.first();
if (userId === undefined) {
throw new NotInDBError(
`User with uuid "${uuid}" does not exist`,
this.logger.getContext(),
'getUserIdByGuestUuid',
);
}
return userId[FieldNameUser.id];
}
/**
* Fetches the user object for a given username from the database
*
* @param username The username to fetch
* @return The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserDtoByUsername(username: string): Promise<UserInfoDto> {
const user = await this.knex(TableUser)
.select()
.where(FieldNameUser.username, username)
.first();
if (!user) {
throw new NotInDBError(`User with username "${username}" does not exist`);
}
return {
username: user[FieldNameUser.username],
displayName:
user[FieldNameUser.displayName] ?? user[FieldNameUser.username],
photoUrl: user[FieldNameUser.photoUrl],
};
}
/**
* Fetches the user object for a given username from the database
*
* @param userId The username to fetch
* @return The found user object
* @throws {NotInDBError} if the user could not be found
*/
async getUserById(userId: number): Promise<User> {
const user = await this.knex(TableUser)
.select()
.where(FieldNameUser.id, userId)
.first();
if (!user) {
throw new NotInDBError(`User with id "${userId}" does not exist`);
}
return user;
}
/**
* @async
* Checks if the user with the specified username exists
* @param username - the username to check
* @return {boolean} true if the user exists, false otherwise
* Extract the photoUrl of the user or falls back to libravatar if enabled
*
* @param user The user of which to get the photo url
* @return A URL to the user's profile picture. If the user has no photo and libravatar support is enabled,
* a URL to that is returned. Otherwise, undefined is returned to indicate that the frontend needs to generate
* a random avatar image based on the username.
*/
async checkIfUserExists(username: string): Promise<boolean> {
const user = await this.userRepository.findOne({
where: { username: username },
});
return user !== null;
}
/**
* @async
* Get the user specified by the username
* @param {string} username the username by which the user is specified
* @param {UserRelationEnum[]} [withRelations=[]] if the returned user object should contain certain relations
* @return {User} the specified user
*/
async getUserByUsername(
username: string,
withRelations: UserRelationEnum[] = [],
): Promise<User> {
const user = await this.userRepository.findOne({
where: { username: username },
relations: withRelations,
});
if (user === null) {
throw new NotInDBError(`User with username '${username}' not found`);
}
return user;
}
/**
* Extract the photoUrl of the user or in case no photo url is present generate a deterministic user photo
* @param {User} user - the specified User
* @return the url of the photo
*/
getPhotoUrl(user: User): string {
if (user.photo) {
return user.photo;
getPhotoUrl(user: User): string | undefined {
if (user[FieldNameUser.photoUrl]) {
return user[FieldNameUser.photoUrl];
} else {
return '';
// TODO If libravatar is enabled and the user has an email address, use it to fetch the profile picture from there
// Otherwise return undefined to let the frontend generate a random avatar image (#5010)
return undefined;
}
}
/**
* Build UserInfoDto from a user.
* @param {User=} user - the user to use
* @return {(UserInfoDto)} the built UserInfoDto
* Builds a DTO for the user used when the user requests their own data
*
* @param user The user to fetch their data for
* @param authProvider The auth provider used for the current login session
* @return The built OwnUserInfoDto
*/
toUserDto(user: User): UserInfoDto {
toLoginUserInfoDto(
user: User,
authProvider: AuthProviderType,
): LoginUserInfoDto {
return {
username: user.username,
displayName: user.displayName,
photoUrl: this.getPhotoUrl(user),
username: user[FieldNameUser.username],
displayName:
user[FieldNameUser.displayName] ?? user[FieldNameUser.username],
photoUrl: user[FieldNameUser.photoUrl],
email: user[FieldNameUser.email] ?? null,
authProvider,
};
}
/**
* Build FullUserInfoDto from a user.
* @param {User=} user - the user to use
* @return {(UserInfoDto)} the built FullUserInfoDto
*/
toFullUserDto(user: User): FullUserInfoDto {
return {
username: user.username,
displayName: user.displayName,
photoUrl: this.getPhotoUrl(user),
email: user.email ?? '',
};
}
toLoginUserInfoDto(user: User, authProvider: ProviderType): LoginUserInfoDto {
return { ...this.toFullUserDto(user), authProvider };
}
}