feat(server,web): Delete and restore user from the admin portal (#935)

* delete and restore user from admin UI

* addressed review comments and fix e2e test

* added cron job to delete user, and some formatting changes

* addressed review comments

* adding missing queue registration
This commit is contained in:
Zeeshan Khan
2022-11-07 16:53:47 -05:00
committed by GitHub
parent 948ff5530c
commit fe4b307fe6
30 changed files with 804 additions and 59 deletions

View File

@@ -9,6 +9,7 @@ export class UserResponseDto {
profileImagePath!: string;
shouldChangePassword!: boolean;
isAdmin!: boolean;
deletedAt!: Date | null;
}
export function mapUser(entity: UserEntity): UserResponseDto {
@@ -21,5 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt || null,
};
}

View File

@@ -7,12 +7,14 @@ import * as bcrypt from 'bcrypt';
import { UpdateUserDto } from './dto/update-user.dto';
export interface IUserRepository {
get(userId: string): Promise<UserEntity | null>;
get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>;
getByEmail(email: string): Promise<UserEntity | null>;
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
create(createUserDto: CreateUserDto): Promise<UserEntity>;
update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>;
createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
delete(user: UserEntity): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>;
}
export const USER_REPOSITORY = 'USER_REPOSITORY';
@@ -27,8 +29,8 @@ export class UserRepository implements IUserRepository {
return bcrypt.hash(password, salt);
}
async get(userId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId } });
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
}
async getByEmail(email: string): Promise<UserEntity | null> {
@@ -40,9 +42,10 @@ export class UserRepository implements IUserRepository {
if (!excludeId) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below
}
return this.userRepository.find({
return this.userRepository
.find({
where: { id: Not(excludeId) },
withDeleted: true,
order: {
createdAt: 'DESC',
},
@@ -88,6 +91,17 @@ export class UserRepository implements IUserRepository {
return this.userRepository.save(user);
}
async delete(user: UserEntity): Promise<UserEntity> {
if (user.isAdmin) {
throw new BadRequestException('Cannot delete admin user! stay sane!');
}
return this.userRepository.softRemove(user);
}
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> {
user.profileImagePath = fileInfo.path;
return this.userRepository.save(user);

View File

@@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
Delete,
Body,
Param,
ValidationPipe,
@@ -67,6 +68,20 @@ export class UserController {
return await this.userService.getUserCount();
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Delete('/:userId')
async deleteUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
return await this.userService.deleteUser(authUser, userId);
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Post('/:userId/restore')
async restoreUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
return await this.userService.restoreUser(authUser, userId);
}
@Authenticated()
@ApiBearerAuth()
@Put()

View File

@@ -65,6 +65,8 @@ describe('UserService', () => {
getByEmail: jest.fn(),
getList: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
sui = new UserService(userRepositoryMock);

View File

@@ -1,11 +1,13 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
StreamableFile,
UnauthorizedException,
} from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
@@ -38,8 +40,8 @@ export class UserService {
return allUserExceptRequestedUser.map(mapUser);
}
async getUserById(userId: string): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId);
async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId, withDeleted);
if (!user) {
throw new NotFoundException('User not found');
}
@@ -105,6 +107,48 @@ export class UserService {
}
}
async deleteUser(authUser: AuthUserDto, userId: string): Promise<UserResponseDto> {
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
throw new ForbiddenException('Unauthorized');
}
const user = await this.userRepository.get(userId);
if (!user) {
throw new BadRequestException('User not found');
}
try {
const deletedUser = await this.userRepository.delete(user);
return mapUser(deletedUser);
} catch (e) {
Logger.error(e, 'Failed to delete user');
throw new InternalServerErrorException('Failed to delete user');
}
}
async restoreUser(authUser: AuthUserDto, userId: string): Promise<UserResponseDto> {
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
throw new ForbiddenException('Unauthorized');
}
const user = await this.userRepository.get(userId, true);
if (!user) {
throw new BadRequestException('User not found');
}
try {
const restoredUser = await this.userRepository.restore(user);
return mapUser(restoredUser);
} catch (e) {
Logger.error(e, 'Failed to restore deleted user');
throw new InternalServerErrorException('Failed to restore deleted user');
}
}
async createProfileImage(
authUser: AuthUserDto,
fileInfo: Express.Multer.File,

View File

@@ -2,10 +2,10 @@ import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity';
import fs from 'fs';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { Job } from 'bull';
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
import { assetUtils } from '@app/common/utils';
@Processor('background-task')
export class BackgroundTaskProcessor {
@@ -23,37 +23,7 @@ export class BackgroundTaskProcessor {
const { assets } = job.data;
for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
// TODO: what if there is no asset.resizePath. Should fail the Job?
// => panoti report: Job not fail
if (asset.resizePath) {
fs.unlink(asset.resizePath, (err) => {
if (err) {
console.log('error deleting ', asset.resizePath);
}
});
}
if (asset.webpPath) {
fs.unlink(asset.webpPath, (err) => {
if (err) {
console.log('error deleting ', asset.webpPath);
}
});
}
if (asset.encodedVideoPath) {
fs.unlink(asset.encodedVideoPath, (err) => {
if (err) {
console.log('error deleting ', asset.encodedVideoPath);
}
});
}
assetUtils.deleteFiles(asset);
}
}
}

View File

@@ -5,10 +5,19 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service';
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { UserEntity } from '@app/database/entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
TypeOrmModule.forFeature([AssetEntity, ExifEntity, UserEntity]),
BullModule.registerQueue({
name: QueueNameEnum.USER_DELETION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: {

View File

@@ -8,6 +8,7 @@ import { Queue } from 'bull';
import { randomUUID } from 'crypto';
import { ExifEntity } from '@app/database/entities/exif.entity';
import {
userDeletionProcessorName,
exifExtractionProcessorName,
generateWEBPThumbnailProcessorName,
IMetadataExtractionJob,
@@ -18,10 +19,16 @@ import {
videoMetadataExtractionProcessorName,
} from '@app/job';
import { ConfigService } from '@nestjs/config';
import { UserEntity } from '@app/database/entities/user.entity';
import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
import { userUtils } from '@app/common';
@Injectable()
export class ScheduleTasksService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@@ -37,6 +44,9 @@ export class ScheduleTasksService {
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue(QueueNameEnum.USER_DELETION)
private userDeletionQueue: Queue<IUserDeletionJob>,
private configService: ConfigService,
) {}
@@ -128,4 +138,14 @@ export class ScheduleTasksService {
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_11PM)
async deleteUserAndRelatedAssets() {
const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
for (const user of usersToDelete) {
if (userUtils.isReadyForDeletion(user)) {
await this.userDeletionQueue.add(userDeletionProcessorName, { user: user }, { jobId: randomUUID() });
}
}
}
}

View File

@@ -104,6 +104,7 @@ describe('User', () => {
isAdmin: false,
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
},
{
email: userTwoEmail,
@@ -114,6 +115,7 @@ describe('User', () => {
isAdmin: false,
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
},
]),
);

View File

@@ -0,0 +1,35 @@
import { APP_UPLOAD_LOCATION, userUtils } from '@app/common';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { QueueNameEnum, userDeletionProcessorName } from '@app/job';
import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { join } from 'path';
import fs from 'fs';
import { Repository } from 'typeorm';
@Processor(QueueNameEnum.USER_DELETION)
export class UserDeletionProcessor {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
@Process(userDeletionProcessorName)
async processUserDeletion(job: Job<IUserDeletionJob>) {
const { user } = job.data;
// just for extra protection here
if (userUtils.isReadyForDeletion(user)) {
const basePath = APP_UPLOAD_LOCATION;
const userAssetDir = join(basePath, user.id)
fs.rmSync(userAssetDir, { recursive: true, force: true })
await this.assetRepository.delete({ userId: user.id })
await this.userRepository.remove(user);
}
}
}