Skip to content
This repository has been archived by the owner on Apr 19, 2023. It is now read-only.

Commit

Permalink
✨ Add profile picture, deleting to user
Browse files Browse the repository at this point in the history
  • Loading branch information
AnandChowdhary committed Jan 9, 2021
1 parent 4f70e60 commit a96f824
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 39 deletions.
38 changes: 34 additions & 4 deletions src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Expand All @@ -8,13 +9,19 @@ import {
Patch,
Post,
Query,
Req,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { User } from '@prisma/client';
import { Files } from '../../helpers/interfaces';
import { CursorPipe } from '../../pipes/cursor.pipe';
import { OptionalIntPipe } from '../../pipes/optional-int.pipe';
import { OrderByPipe } from '../../pipes/order-by.pipe';
import { WherePipe } from '../../pipes/where.pipe';
import { Expose } from '../../providers/prisma/prisma.interface';
import { UserRequest } from '../auth/auth.interface';
import { RateLimit } from '../auth/rate-limit.decorator';
import { Scopes } from '../auth/scope.decorator';
import { UpdateUserDto } from './users.dto';
Expand All @@ -24,6 +31,7 @@ import { UsersService } from './users.service';
export class UserController {
constructor(private usersService: UsersService) {}

/** Get users */
@Get()
@Scopes('user-*:read-info')
async getAll(
Expand All @@ -36,36 +44,58 @@ export class UserController {
return this.usersService.getUsers({ skip, take, orderBy, cursor, where });
}

/** Get a user */
@Get(':userId')
@Scopes('user-{userId}:read-info')
async get(@Param('userId', ParseIntPipe) id: number): Promise<Expose<User>> {
return this.usersService.getUser(Number(id));
return this.usersService.getUser(id);
}

/** Update a user */
@Patch(':userId')
@Scopes('user-{userId}:write-info')
async update(
@Req() request: UserRequest,
@Param('userId', ParseIntPipe) id: number,
@Body() data: UpdateUserDto,
): Promise<Expose<User>> {
return this.usersService.updateUser(Number(id), data);
return this.usersService.updateUser(id, data, request.user.role);
}

/** Delete a user */
@Delete(':userId')
@Scopes('user-{userId}:deactivate')
async remove(
@Param('userId', ParseIntPipe) id: number,
@Req() request: UserRequest,
): Promise<Expose<User>> {
return this.usersService.deactivateUser(Number(id));
return this.usersService.deactivateUser(
id,
request.user.type === 'user' && request.user?.id,
);
}

/** Upload profile picture */
@Post(':userId/profile-picture')
@Scopes('user-{userId}:write-info')
@UseInterceptors(FilesInterceptor('files'))
async profilePicture(
@Param('userId', ParseIntPipe) id: number,
@UploadedFiles() files: Files,
) {
if (files.length && files[0])
return this.usersService.uploadProfilePicture(id, files[0]);
else throw new BadRequestException();
}

/** Send a link to merge two users */
@Post(':userId/merge-request')
@Scopes('user-{userId}:merge')
@RateLimit(10)
async mergeRequest(
@Param('userId', ParseIntPipe) id: number,
@Body('email') email: string,
): Promise<{ queued: true }> {
return this.usersService.requestMerge(Number(id), email);
return this.usersService.requestMerge(id, email);
}
}
1 change: 0 additions & 1 deletion src/modules/users/users.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
IsEnum,
IsIn,
IsLocale,
IsObject,
IsOptional,
IsString,
IsUrl,
Expand Down
12 changes: 11 additions & 1 deletion src/modules/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ import { PrismaModule } from '../../providers/prisma/prisma.module';
import { TokensModule } from '../../providers/tokens/tokens.module';
import { UserController } from './users.controller';
import { UsersService } from './users.service';
import { S3Module } from '../../providers/s3/s3.module';
import { ApiKeysModule } from '../api-keys/api-keys.module';

@Module({
imports: [PrismaModule, AuthModule, MailModule, ConfigModule, TokensModule],
imports: [
PrismaModule,
AuthModule,
MailModule,
ConfigModule,
TokensModule,
S3Module,
ApiKeysModule,
],
controllers: [UserController],
providers: [UsersService],
exports: [UsersService],
Expand Down
136 changes: 103 additions & 33 deletions src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,38 @@ import {
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Prisma } from '@prisma/client';
import type { Prisma, UserRole } from '@prisma/client';
import { User } from '@prisma/client';
import { compare } from 'bcrypt';
import { Configuration } from '../../config/configuration.interface';
import { extname } from 'path';
import {
CURRENT_PASSWORD_REQUIRED,
FILE_TOO_LARGE,
INVALID_CREDENTIALS,
USER_NOT_FOUND,
} from '../../errors/errors.constants';
import { Files } from '../../helpers/interfaces';
import { safeEmail } from '../../helpers/safe-email';
import { MailService } from '../../providers/mail/mail.service';
import { Expose } from '../../providers/prisma/prisma.interface';
import { PrismaService } from '../../providers/prisma/prisma.service';
import { S3Service } from '../../providers/s3/s3.service';
import { MERGE_ACCOUNTS_TOKEN } from '../../providers/tokens/tokens.constants';
import { TokensService } from '../../providers/tokens/tokens.service';
import { ApiKeysService } from '../api-keys/api-keys.service';
import { AuthService } from '../auth/auth.service';
import { PasswordUpdateInput } from './users.interface';

@Injectable()
export class UsersService {
private metaConfig = this.configService.get<Configuration['meta']>('meta');
private securityConfig = this.configService.get<Configuration['security']>(
'security',
);

constructor(
private prisma: PrismaService,
private auth: AuthService,
private email: MailService,
private configService: ConfigService,
private tokensService: TokensService,
private s3Service: S3Service,
private apiKeysService: ApiKeysService,
) {}

async getUser(id: number): Promise<Expose<User>> {
Expand All @@ -53,61 +54,104 @@ export class UsersService {
orderBy?: Prisma.UserOrderByInput;
}): Promise<Expose<User>[]> {
const { skip, take, cursor, where, orderBy } = params;
const users = await this.prisma.user.findMany({
skip,
take,
cursor,
where,
orderBy,
});
return users.map((user) => this.prisma.expose<User>(user));
}

async createUser(data: Prisma.UserCreateInput): Promise<User> {
return this.prisma.user.create({
data,
});
try {
const users = await this.prisma.user.findMany({
skip,
take,
cursor,
where,
orderBy,
});
return users.map((user) => this.prisma.expose<User>(user));
} catch (error) {
return [];
}
}

async updateUser(
id: number,
data: Omit<Prisma.UserUpdateInput, 'password'> & PasswordUpdateInput,
role?: UserRole,
): Promise<Expose<User>> {
const testUser = await this.prisma.user.findUnique({ where: { id } });
if (!testUser) throw new NotFoundException(USER_NOT_FOUND);
const transformed: Prisma.UserUpdateInput & PasswordUpdateInput = data;
// If the user is updating their password
if (data.newPassword) {
if (!data.currentPassword)
throw new BadRequestException(CURRENT_PASSWORD_REQUIRED);
const previousPassword = (
await this.prisma.user.findUnique({
where: { id },
select: { password: true },
})
)?.password;
const user = await this.prisma.user.findUnique({
where: { id },
include: { prefersEmail: true },
});
const previousPassword = user?.password;
if (previousPassword)
if (!(await compare(data.currentPassword, previousPassword)))
throw new BadRequestException(INVALID_CREDENTIALS);
transformed.password = await this.auth.hashAndValidatePassword(
data.newPassword,
!!data.ignorePwnedPassword,
);
this.email.send({
to: `"${user.name}" <${user.prefersEmail.email}>`,
template: 'users/password-changed',
data: {
name: user.name,
},
});
}
delete transformed.currentPassword;
delete transformed.newPassword;
delete transformed.ignorePwnedPassword;
if (role !== 'SUDO') delete transformed.role;
const updateData: Prisma.UserUpdateInput = transformed;
const user = await this.prisma.user.update({
data: updateData,
where: { id },
});
// If the role of this user has changed
if (transformed.role && testUser.role !== transformed.role) {
// Log out from all sessions since their scopes have changed
await this.prisma.session.deleteMany({ where: { user: { id } } });
// Remove all scopes now allowed anymore from API keys
await this.apiKeysService.cleanAllApiKeysForUser(id);
}
return this.prisma.expose<User>(user);
}

async deactivateUser(id: number): Promise<Expose<User>> {
async deactivateUser(
id: number,
deactivatedBy?: number,
): Promise<Expose<User>> {
const user = await this.prisma.user.update({
where: { id },
data: { active: false },
include: { prefersEmail: true },
});
await this.prisma.session.deleteMany({ where: { user: { id } } });
if (deactivatedBy === id)
this.email.send({
to: `"${user.name}" <${user.prefersEmail.email}>`,
template: 'users/deactivated',
data: {
name: user.name,
},
});
return this.prisma.expose<User>(user);
}

async deleteUser(id: number): Promise<Expose<User>> {
const testUser = await this.prisma.user.findUnique({ where: { id } });
if (!testUser) throw new NotFoundException(USER_NOT_FOUND);
await this.prisma.membership.deleteMany({ where: { user: { id } } });
await this.prisma.email.deleteMany({ where: { user: { id } } });
await this.prisma.session.deleteMany({ where: { user: { id } } });
await this.prisma.approvedSubnet.deleteMany({ where: { user: { id } } });
await this.prisma.backupCode.deleteMany({ where: { user: { id } } });
await this.prisma.identity.deleteMany({ where: { user: { id } } });
await this.prisma.auditLog.deleteMany({ where: { user: { id } } });
await this.prisma.apiKey.deleteMany({ where: { user: { id } } });
const user = await this.prisma.user.delete({ where: { id } });
return this.prisma.expose<User>(user);
}

Expand All @@ -119,16 +163,18 @@ export class UsersService {
});
if (!user) throw new NotFoundException(USER_NOT_FOUND);
if (user.id === userId) throw new NotFoundException(USER_NOT_FOUND);
const minutes = this.securityConfig.mergeUsersTokenExpiry;
const minutes = parseInt(
this.configService.get<string>('security.mergeUsersTokenExpiry') ?? '',
);
this.email.send({
to: `"${user.name}" <${user.prefersEmail.email}>`,
template: 'auth/mfa-code',
template: 'users/merge-request',
data: {
name: user.name,
minutes,
link: `${
this.metaConfig.frontendUrl
}/auth/link/merge-accounts?token=${this.tokensService.signJwt(
link: `${this.configService.get<string>(
'frontendUrl',
)}/auth/link/merge-accounts?token=${this.tokensService.signJwt(
MERGE_ACCOUNTS_TOKEN,
{ baseUserId: userId, mergeUserId: user.id },
`${minutes}m`,
Expand All @@ -137,4 +183,28 @@ export class UsersService {
});
return { queued: true };
}

async uploadProfilePicture(
id: number,
file: Files[0],
): Promise<Expose<User>> {
if (file.size > 25000000) throw new Error(FILE_TOO_LARGE);
const { Location } = await this.s3Service.upload(
`picture-${id}-${this.tokensService.generateUuid()}${extname(
file.originalname,
)}`,
file.buffer,
'koj-user-uploads',
true,
);
return this.prisma.user.update({
where: { id },
data: {
profilePictureUrl: Location.replace(
'koj-user-uploads.s3.eu-central-1.amazonaws.com',
'd1ykbyudrr6gx9.cloudfront.net',
),
},
});
}
}

0 comments on commit a96f824

Please sign in to comment.