Picsur/backend/src/collections/user-db/user-db.service.ts

290 lines
7.9 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcrypt';
import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum';
import {
AsyncFailable,
Fail,
FT,
HasFailed,
HasSuccess,
} from 'picsur-shared/dist/types';
import { FindResult } from 'picsur-shared/dist/types/find-result';
import { makeUnique } from 'picsur-shared/dist/util/unique';
import { Repository } from 'typeorm';
import { EUserBackend } from '../../database/entities/user.entity';
import { Permissions } from '../../models/constants/permissions.const';
import {
DefaultRolesList,
SoulBoundRolesList,
} from '../../models/constants/roles.const';
import {
ImmutableUsersList,
LockedLoginUsersList,
UndeletableUsersList,
} from '../../models/constants/special-users.const';
import { GetCols } from '../../util/collection';
import { SysPreferenceDbService } from '../preference-db/sys-preference-db.service';
import { RoleDbService } from '../role-db/role-db.service';
@Injectable()
export class UserDbService {
private readonly logger = new Logger(UserDbService.name);
constructor(
@InjectRepository(EUserBackend)
private readonly usersRepository: Repository<EUserBackend>,
private readonly rolesService: RoleDbService,
private readonly prefService: SysPreferenceDbService,
) {}
// Creation and deletion
public async create(
username: string,
password: string,
roles?: string[],
// Add option to create "invalid" users, should only be used by system
byPassRoleCheck?: boolean,
): AsyncFailable<EUserBackend> {
if (await this.exists(username))
return Fail(FT.Conflict, 'User already exists');
const strength = await this.getBCryptStrength();
const hashedPassword = await bcrypt.hash(password, strength);
let user = new EUserBackend();
user.username = username;
user.hashed_password = hashedPassword;
if (byPassRoleCheck) {
const rolesToAdd = roles ?? [];
user.roles = makeUnique(rolesToAdd);
} else {
// Strip soulbound roles and add default roles
const rolesToAdd = this.filterAddedRoles(roles ?? []);
user.roles = makeUnique([...DefaultRolesList, ...rolesToAdd]);
}
try {
return await this.usersRepository.save(user);
} catch (e) {
return Fail(FT.Database, e);
}
}
public async delete(uuid: string): AsyncFailable<EUserBackend> {
const userToDelete = await this.findOne(uuid);
if (HasFailed(userToDelete)) return userToDelete;
if (UndeletableUsersList.includes(userToDelete.username)) {
return Fail(FT.Permission, 'Cannot delete system user');
}
try {
return await this.usersRepository.remove(userToDelete);
} catch (e) {
return Fail(FT.Database, e);
}
}
// Updating
public async setRoles(
uuid: string,
roles: string[],
): AsyncFailable<EUserBackend> {
const userToModify = await this.findOne(uuid);
if (HasFailed(userToModify)) return userToModify;
if (ImmutableUsersList.includes(userToModify.username)) {
// Just fail silently
this.logger.verbose('User tried to modify system user, failed silently');
return userToModify;
}
const rolesToKeep = userToModify.roles.filter((role) =>
SoulBoundRolesList.includes(role),
);
const rolesToAdd = this.filterAddedRoles(roles);
const newRoles = makeUnique([...rolesToKeep, ...rolesToAdd]);
userToModify.roles = newRoles;
try {
return await this.usersRepository.save(userToModify);
} catch (e) {
return Fail(FT.Database, e);
}
}
public async removeRoleEveryone(role: string): AsyncFailable<true> {
try {
await this.usersRepository
.createQueryBuilder('user')
.update()
.set({
roles: () => 'ARRAY_REMOVE(roles, :role)',
})
.where('roles @> ARRAY[:role]', { role })
.execute();
} catch (e) {
return Fail(FT.Database, e);
}
return true;
}
public async getPermissions(uuid: string): AsyncFailable<Permissions> {
const userToModify = await this.findOne(uuid);
if (HasFailed(userToModify)) return userToModify;
return await this.rolesService.getPermissions(userToModify.roles);
}
public async updatePassword(
uuid: string,
password: string,
): AsyncFailable<EUserBackend> {
let userToModify = await this.findOne(uuid);
if (HasFailed(userToModify)) return userToModify;
const strength = await this.getBCryptStrength();
userToModify.hashed_password = await bcrypt.hash(password, strength);
try {
userToModify = await this.usersRepository.save(userToModify);
} catch (e) {
return Fail(FT.Database, e);
}
return userToModify;
}
// Authentication
async authenticate(
username: string,
password: string,
): AsyncFailable<EUserBackend> {
const user = await this.findByUsername(username, true);
if (HasFailed(user)) {
if (user.getType() === FT.NotFound)
return Fail(
FT.Authentication,
'Wrong username or password',
user.getDebugMessage(),
);
else return user;
}
if (LockedLoginUsersList.includes(user.username)) {
// Error should be kept in backend
return Fail(FT.Authentication, 'Wrong username or password');
}
if (!(await bcrypt.compare(password, user.hashed_password ?? '')))
return Fail(FT.Authentication, 'Wrong username or password');
return await this.findOne(user.id ?? '');
}
// Listing
public async checkUsername(username: string): AsyncFailable<{
available: boolean;
}> {
try {
const found = await this.usersRepository.findOne({
where: { username },
select: ['id'],
});
return { available: !found };
} catch (e) {
return Fail(FT.Database, e);
}
}
public async findByUsername(
username: string,
// Also fetch fields that aren't normally sent to the client
// (e.g. hashed password)
getPrivate: boolean = false,
): AsyncFailable<EUserBackend> {
try {
const found = await this.usersRepository.findOne({
where: { username },
select: getPrivate ? GetCols(this.usersRepository) : undefined,
});
if (!found) return Fail(FT.NotFound, 'User not found');
return found;
} catch (e) {
return Fail(FT.Database, e);
}
}
public async findOne(uuid: string): AsyncFailable<EUserBackend> {
try {
const found = await this.usersRepository.findOne({
where: { id: uuid },
});
if (!found) return Fail(FT.NotFound, 'User not found');
return found as EUserBackend;
} catch (e) {
return Fail(FT.Database, e);
}
}
public async findMany(
count: number,
page: number,
): AsyncFailable<FindResult<EUserBackend>> {
if (count < 1 || page < 0) return Fail(FT.UsrValidation, 'Invalid page');
if (count > 100) return Fail(FT.UsrValidation, 'Too many results');
try {
const [users, amount] = await this.usersRepository.findAndCount({
take: count,
skip: count * page,
order: { username: 'ASC' },
});
if (users === undefined) return Fail(FT.NotFound, 'Users not found');
return {
results: users,
total: amount,
page,
pages: Math.ceil(amount / count),
};
} catch (e) {
return Fail(FT.Database, e);
}
}
public async exists(username: string): Promise<boolean> {
return HasSuccess(await this.findByUsername(username));
}
// Internal
private filterAddedRoles(roles: string[]): string[] {
const filteredRoles = roles.filter(
(role) => !SoulBoundRolesList.includes(role),
);
return filteredRoles;
}
private async getBCryptStrength(): Promise<number> {
const result = await this.prefService.getNumberPreference(
SysPreference.BCryptStrength,
);
if (HasFailed(result)) {
return 12;
}
return result;
}
}