349 lines
9.5 KiB
TypeScript
349 lines
9.5 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
Redirect,
|
|
Render,
|
|
Req,
|
|
Res,
|
|
UnauthorizedException,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import { Throttle, ThrottlerGuard } 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 settingsService: SettingsService,
|
|
private readonly formService: FormUtilityService,
|
|
private readonly uploadService: UploadService,
|
|
private readonly tokenService: TokenService,
|
|
private readonly userService: UserService,
|
|
private readonly totpService: UserTOTPService,
|
|
private readonly clientService: OAuth2ClientService,
|
|
private readonly oaTokenService: OAuth2TokenService,
|
|
private readonly auditService: AuditService,
|
|
) {}
|
|
|
|
@Get()
|
|
@Redirect('/account/general')
|
|
public redirectGeneral() {
|
|
return;
|
|
}
|
|
|
|
@Get('general')
|
|
@Render('settings/general')
|
|
public general(@Req() req: Request) {
|
|
return this.formService.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.formService.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.userService.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({ default: { limit: 3, ttl: 60000 } })
|
|
@UseGuards(ThrottlerGuard)
|
|
@Post('avatar')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
async uploadAvatarFile(
|
|
@Req() req: Request,
|
|
@UploadedFile() file: Express.Multer.File,
|
|
) {
|
|
try {
|
|
if (!this.tokenService.verifyCSRF(req)) {
|
|
throw new BadRequestException('Invalid session. Please try again.');
|
|
}
|
|
|
|
if (!file) {
|
|
throw new BadRequestException('Avatar upload failed');
|
|
}
|
|
|
|
const matches = await this.uploadService.checkImageAspect(file);
|
|
if (!matches) {
|
|
throw new BadRequestException(
|
|
'Avatar should be with a 1:1 aspect ratio.',
|
|
);
|
|
}
|
|
|
|
const upload = await this.uploadService.registerUploadedFile(
|
|
file,
|
|
req.user,
|
|
);
|
|
await this.userService.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.userService.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.clientService.getAuthorizations(req.user);
|
|
return this.formService.populateTemplate(req, { authorizations });
|
|
}
|
|
|
|
@Post('oauth2/revoke/:id')
|
|
public async revokeAuthorization(
|
|
@Req() req: Request,
|
|
@Res() res: Response,
|
|
@Param('id') id: number,
|
|
) {
|
|
const getAuth = await this.clientService.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.oaTokenService.wipeClientTokens(getAuth.client, req.user);
|
|
await this.clientService.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.totpService.userHasTOTP(req.user);
|
|
return this.formService.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.tokenService.comparePasswords(req.user.password, password))
|
|
) {
|
|
throw new Error('Current password is invalid.');
|
|
}
|
|
|
|
if (!new_password.match(this.formService.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.tokenService.hashPassword(new_password);
|
|
req.user.password = newPassword;
|
|
await this.userService.updateUser(req.user);
|
|
await this.auditService.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.formService.emailRegex)) {
|
|
throw new Error('The new email address is invalid.');
|
|
}
|
|
|
|
if (
|
|
!current_password ||
|
|
!(await this.tokenService.comparePasswords(
|
|
req.user.password,
|
|
current_password,
|
|
))
|
|
) {
|
|
throw new Error('Current password is invalid.');
|
|
}
|
|
|
|
const existing = await this.userService.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.userService.updateUser(req.user);
|
|
await this.auditService.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.tokenService.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.auditService.getUserLogins(
|
|
req.user,
|
|
req.session.id,
|
|
);
|
|
const creation = await this.auditService.getUserAccountCreation(req.user);
|
|
return this.formService.populateTemplate(req, {
|
|
logins,
|
|
creation,
|
|
});
|
|
}
|
|
}
|