/* * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ import { AuthProviderType, LoginUserInfoDto, REGEX_USERNAME, UserInfoDto, } from '@hedgedoc/commons'; import { FieldNameUser, TableUser, TypeUpdateUser, User, } from '@hedgedoc/database'; import { BadRequestException, Injectable } from '@nestjs/common'; import { createHash } from 'crypto'; import { Knex } from 'knex'; import { InjectConnection } from 'nest-knexjs'; import { v4 as uuidv4 } from 'uuid'; import { GenericDBError, NotInDBError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { generateRandomName } from '../realtime/realtime-note/random-word-lists/name-randomizer'; @Injectable() export class UsersService { constructor( private readonly logger: ConsoleLoggerService, @InjectConnection() private readonly knex: Knex, ) { this.logger.setContext(UsersService.name); } /** * 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, transaction?: Knex, ): Promise { if (!REGEX_USERNAME.test(username)) { throw new BadRequestException( `The username '${username}' is not a valid username.`, ); } const dbActor = transaction ? transaction : this.knex; try { const newUsers = await dbActor(TableUser).insert( { [FieldNameUser.username]: username, [FieldNameUser.displayName]: displayName, [FieldNameUser.email]: email ?? null, [FieldNameUser.photoUrl]: this.generatePhotoUrl( username, email, photoUrl, ), // TODO Use generatePhotoUrl method to generate a random avatar image [FieldNameUser.guestUuid]: null, [FieldNameUser.authorStyle]: this.generateAuthorStyleIndex(username), }, [FieldNameUser.id], ); if (newUsers.length !== 1) { throw new Error('User was not added to the database'); } return newUsers[0][FieldNameUser.id]; } catch { throw new GenericDBError( `Failed to create user '${username}', no user was created.`, this.logger.getContext(), 'createUser', ); } } /** * 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 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]: this.generateAuthorStyleIndex(uuid), }, [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]; } /** * 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 { 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 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( userId: number, displayName?: string, email?: string | null, profilePicture?: string | null, ): Promise { const updateData = {} as TypeUpdateUser; if (displayName !== undefined) { updateData[FieldNameUser.displayName] = displayName; } if (email !== undefined) { updateData[FieldNameUser.email] = email; } if (profilePicture !== undefined) { updateData[FieldNameUser.photoUrl] = profilePicture; } 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 { 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 { const dbActor = transaction ? transaction : this.knex; const usernameResponse = await dbActor(TableUser) .select(FieldNameUser.username) .where(FieldNameUser.id, userId) .first(); const username = usernameResponse?.[FieldNameUser.username] ?? null; return username !== null; } /** * 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 { 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 { 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 { 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 { 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; } /** * Generates the photo url for a user. * When a valid photoUrl is provided, it will be used. * When an email address is provided and libravatar is enabled, this will be used. * Otherwise, a random image will be generated based on the username. * * @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. */ private generatePhotoUrl( username: string, email: string | null, photoUrl: string | null, ): string { if (photoUrl && URL.canParse(photoUrl)) { return photoUrl; } if (email && email.length > 0) { // TODO Add config option to enable or disable libravatar const hash = createHash('sha256') .update(email.toLowerCase()) .digest('hex'); return `https://seccdn.libravatar.org/avatar/${hash}`; } // TODO Generate random fallback image (data URL) with the username as seed return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+L+E8T8ABu4CpDyuE+YAAAAASUVORK5CYII='; } /** * 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) */ private generateAuthorStyleIndex(username: string): number { let hash = 0; for (let i = 0; i < username.length; i++) { hash = username.charCodeAt(i) + ((hash << 5) - hash); } return Math.abs(hash % 9); } /** * 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 */ toLoginUserInfoDto( user: User, authProvider: AuthProviderType, ): LoginUserInfoDto { return { username: user[FieldNameUser.username], displayName: user[FieldNameUser.displayName] ?? user[FieldNameUser.username], photoUrl: user[FieldNameUser.photoUrl], email: user[FieldNameUser.email] ?? null, authProvider, }; } }