import { BadRequestException, Body, Controller, Get, Param, Post, Query, Redirect, Render, Req, Res, UnauthorizedException, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { unlink } from 'fs/promises'; import { AuditAction } from 'src/modules/objects/audit/audit.enum'; import { AuditService } from 'src/modules/objects/audit/audit.service'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { UploadService } from 'src/modules/objects/upload/upload.service'; import { UserTOTPService } from 'src/modules/objects/user-token/user-totp-token.service'; import { UserService } from 'src/modules/objects/user/user.service'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; import { TokenService } from 'src/modules/utility/services/token.service'; import { SettingsService } from './settings.service'; @Controller('/account') export class SettingsController { constructor( private readonly _service: SettingsService, private readonly _form: FormUtilityService, private readonly _upload: UploadService, private readonly _token: TokenService, private readonly _user: UserService, private readonly _totp: UserTOTPService, private readonly _client: OAuth2ClientService, private readonly _oaToken: OAuth2TokenService, private readonly _audit: AuditService, ) {} @Get() @Redirect('/account/general') public redirectGeneral() { return; } @Get('general') @Render('settings/general') public general(@Req() req: Request) { return this._form.populateTemplate(req, { user: req.user }); } @Post('general') public async updateDisplayName( @Req() req: Request, @Res() res: Response, @Body() body: { display_name?: string }, ) { try { const { display_name } = this._form.trimmed(body, ['display_name']); if (!display_name) { throw new Error('Display name is required.'); } if (display_name.length < 3 || display_name.length > 32) { throw new Error( 'Display name must be between 3 and 32 characters long.', ); } req.user.display_name = display_name; await this._user.updateUser(req.user); req.flash('message', { error: false, text: 'Display name has been changed!', }); } catch (e: unknown) { req.flash('message', { error: true, text: (e as Error).message, }); } res.redirect('/account/general'); } @Throttle(3, 60) @Post('avatar') @UseInterceptors(FileInterceptor('file')) async uploadAvatarFile( @Req() req: Request, @UploadedFile() file: Express.Multer.File, ) { try { if (!this._token.verifyCSRF(req)) { throw new BadRequestException('Invalid session. Please try again.'); } if (!file) { throw new BadRequestException('Avatar upload failed'); } const matches = await this._upload.checkImageAspect(file); if (!matches) { throw new BadRequestException( 'Avatar should be with a 1:1 aspect ratio.', ); } const upload = await this._upload.registerUploadedFile(file, req.user); await this._user.updateAvatar(req.user, upload); return { file: upload.file, }; } catch (e) { await unlink(file.path); throw e; } } @Post('avatar/delete') public async deleteUserAvatar(@Req() req: Request, @Res() res: Response) { this._user.deleteAvatar(req.user); req.flash('message', { error: false, text: 'Avatar removed successfully.', }); res.redirect('/account/general'); } @Get('oauth2') @Render('settings/oauth2') public async authorizations(@Req() req: Request) { const authorizations = await this._client.getAuthorizations(req.user); return this._form.populateTemplate(req, { authorizations }); } @Post('oauth2/revoke/:id') public async revokeAuthorization( @Req() req: Request, @Res() res: Response, @Param('id') id: number, ) { const getAuth = await this._client.getAuthorization(req.user, id); const jsreq = req.header('content-type').startsWith('application/json') || req.header('accept').startsWith('application/json'); if (!getAuth) { if (jsreq) { throw new UnauthorizedException( 'Unauthorized or invalid revokation request', ); } req.flash('message', { error: true, text: 'Unauthorized revokation.', }); res.redirect('/account/oauth2'); return; } await this._oaToken.wipeClientTokens(getAuth.client, req.user); await this._client.revokeAuthorization(getAuth); if (jsreq) { return res.json({ success: true }); } res.redirect('/account/oauth2'); } @Get('security') @Render('settings/security') public async security(@Req() req: Request) { const mailSplit = req.user.email.split('@'); const asterisks = ''.padStart(mailSplit[0].substring(1).length, '*'); const emailHint = `${mailSplit[0].substring(0, 1)}${asterisks}@${ mailSplit[1] }`; const twofactor = await this._totp.userHasTOTP(req.user); return this._form.populateTemplate(req, { user: req.user, emailHint, twofactor, }); } @Post('security/password') public async setPassword( @Req() req: Request, @Res() res: Response, @Body() body: { password: string; new_password: string; password_repeat: string; }, ) { const { password, new_password, password_repeat } = body; try { if (!password || !new_password || !password_repeat) { throw new Error('Please fill out all of the fields.'); } if (!(await this._user.comparePasswords(req.user.password, password))) { throw new Error('Current password is invalid.'); } if (!new_password.match(this._form.passwordRegex)) { throw new Error( 'Password must be at least 8 characters long, contain a capital and lowercase letter and a number', ); } if (new_password !== password_repeat) { throw new Error('The passwords do not match.'); } } catch (e: unknown) { req.flash('message', { error: true, text: (e as Error).message, }); res.redirect('/account/security'); return; } const newPassword = await this._user.hashPassword(new_password); req.user.password = newPassword; await this._user.updateUser(req.user); await this._audit.auditRequest( req, AuditAction.PASSWORD_CHANGE, 'settings', ); req.flash('message', { error: false, text: 'Password changed successfully.', }); res.redirect('/account/security'); } @Post('security/email') public async setEmail( @Req() req: Request, @Res() res: Response, @Body() body: { current_email: string; current_password: string; email: string; }, ) { const { current_email, current_password, email } = body; try { if (!current_email || !email) { throw new Error('Please fill out all of the fields.'); } if (current_email !== req.user.email) { throw new Error('The current email address is invalid.'); } if (!email.match(this._form.emailRegex)) { throw new Error('The new email address is invalid.'); } if ( !current_password || !(await this._user.comparePasswords( req.user.password, current_password, )) ) { throw new Error('Current password is invalid.'); } const existing = await this._user.getByEmail(email); if (existing) { throw new Error( 'There is already an existing user with this email address.', ); } } catch (e: unknown) { req.flash('message', { error: true, text: (e as Error).message, }); res.redirect('/account/security'); return; } req.user.email = email; await this._user.updateUser(req.user); await this._audit.auditRequest(req, AuditAction.EMAIL_CHANGE, 'settings'); req.flash('message', { error: false, text: 'Email address changed successfully.', }); res.redirect('/account/security'); } @Get('logout') public logOut( @Req() req: Request, @Res() res: Response, @Query('csrf') csrf: string, ) { if (!this._token.verifyCSRF(req, csrf)) { throw new BadRequestException('Invalid csrf token'); } req.session.destroy(() => res.redirect('/login')); } @Get('logins') @Render('login-list') public async userLogins(@Req() req: Request) { const logins = await this._audit.getUserLogins(req.user, req.session.id); const creation = await this._audit.getUserAccountCreation(req.user); return this._form.populateTemplate(req, { logins, creation, }); } }