hedgedoc/backend/src/users/users.service.ts
Erik Michelson 327cc1f925
fix(backend): correct user permissions, photo urls, events, etc.
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
2025-05-20 11:45:35 +00:00

376 lines
12 KiB
TypeScript

/*
* 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<User[FieldNameUser.id]> {
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<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 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<void> {
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<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 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<number> {
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;
}
/**
* 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,
};
}
}