upload user avatar, front end avatar crop modal

This commit is contained in:
Evert Prants 2022-03-20 14:09:36 +02:00
parent d7fc152c8a
commit 88f19163c6
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
22 changed files with 825 additions and 23 deletions

145
package-lock.json generated
View File

@ -13,13 +13,18 @@
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/serve-static": "^2.2.2",
"@nestjs/throttler": "^2.0.1",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"cropperjs": "^1.5.12",
"dotenv": "^16.0.0",
"express-session": "^1.17.2",
"image-size": "^1.0.1",
"jsonwebtoken": "^8.5.1",
"mime-types": "^2.1.35",
"multer": "^1.4.4",
"mysql2": "^2.3.3",
"nodemailer": "^6.7.2",
"otplib": "^12.0.1",
@ -43,6 +48,8 @@
"@types/express-session": "^1.17.4",
"@types/jest": "27.4.1",
"@types/jsonwebtoken": "^8.5.8",
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.2",
@ -2830,6 +2837,23 @@
"typescript": "^3.4.5 || ^4.3.5"
}
},
"node_modules/@nestjs/serve-static": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-2.2.2.tgz",
"integrity": "sha512-3Mr+Q/npS3N7iGoF3Wd6Lj9QcjMGxbNrSqupi5cviM0IKrZ1BHl5qekW95rWYNATAVqoTmjGROAq+nKKpuUagQ==",
"dependencies": {
"path-to-regexp": "0.1.7"
},
"peerDependencies": {
"@nestjs/common": "^6.0.0 || ^7.0.0 || ^8.0.0",
"@nestjs/core": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@nestjs/serve-static/node_modules/path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
},
"node_modules/@nestjs/testing": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
@ -3239,6 +3263,21 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"node_modules/@types/mime-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
"dev": true
},
"node_modules/@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
"integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "16.11.26",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
@ -5081,6 +5120,11 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"node_modules/cropperjs": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -6831,6 +6875,20 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.1.tgz",
"integrity": "sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/immutable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
@ -8666,19 +8724,19 @@
}
},
"node_modules/mime-db": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==",
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.34",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.51.0"
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
@ -9816,6 +9874,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -14296,6 +14362,21 @@
"pluralize": "8.0.0"
}
},
"@nestjs/serve-static": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-2.2.2.tgz",
"integrity": "sha512-3Mr+Q/npS3N7iGoF3Wd6Lj9QcjMGxbNrSqupi5cviM0IKrZ1BHl5qekW95rWYNATAVqoTmjGROAq+nKKpuUagQ==",
"requires": {
"path-to-regexp": "0.1.7"
},
"dependencies": {
"path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
}
}
},
"@nestjs/testing": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
@ -14659,6 +14740,21 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"@types/mime-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==",
"dev": true
},
"@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
"integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/node": {
"version": "16.11.26",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
@ -16097,6 +16193,11 @@
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true
},
"cropperjs": {
"version": "1.5.12",
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
},
"cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -17401,6 +17502,14 @@
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
"dev": true
},
"image-size": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.1.tgz",
"integrity": "sha512-VAwkvNSNGClRw9mDHhc5Efax8PLlsOGcUTh0T/LIriC8vPA3U5PdqXWqkz406MoYHMKW8Uf9gWr05T/rYB44kQ==",
"requires": {
"queue": "6.0.2"
}
},
"immutable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
@ -18829,16 +18938,16 @@
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
},
"mime-db": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types": {
"version": "2.1.34",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"requires": {
"mime-db": "1.51.0"
"mime-db": "1.52.0"
}
},
"mimic-fn": {
@ -19715,6 +19824,14 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
"integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw=="
},
"queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"requires": {
"inherits": "~2.0.3"
}
},
"queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -27,13 +27,18 @@
"@nestjs/common": "^8.0.0",
"@nestjs/core": "^8.0.0",
"@nestjs/platform-express": "^8.0.0",
"@nestjs/serve-static": "^2.2.2",
"@nestjs/throttler": "^2.0.1",
"bcrypt": "^5.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"cropperjs": "^1.5.12",
"dotenv": "^16.0.0",
"express-session": "^1.17.2",
"image-size": "^1.0.1",
"jsonwebtoken": "^8.5.1",
"mime-types": "^2.1.35",
"multer": "^1.4.4",
"mysql2": "^2.3.3",
"nodemailer": "^6.7.2",
"otplib": "^12.0.1",
@ -57,6 +62,8 @@
"@types/express-session": "^1.17.4",
"@types/jest": "27.4.1",
"@types/jsonwebtoken": "^8.5.8",
"@types/mime-types": "^2.1.1",
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/nodemailer": "^6.4.4",
"@types/qrcode": "^1.4.2",

View File

@ -1,5 +1,12 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import {
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common';
import { ServeStaticModule } from '@nestjs/serve-static';
import { ThrottlerModule } from '@nestjs/throttler';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { CSRFMiddleware } from './middleware/csrf.middleware';
@ -20,6 +27,10 @@ import { UtilityModule } from './modules/utility/utility.module';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'uploads'),
serveRoot: '/uploads',
}),
ThrottlerModule.forRoot({
ttl: 10,
limit: 10,
@ -43,7 +54,12 @@ import { UtilityModule } from './modules/utility/utility.module';
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(CSRFMiddleware).forRoutes('*');
consumer.apply(UserMiddleware).forRoutes('*');
consumer
.apply(CSRFMiddleware, UserMiddleware)
.exclude(
{ path: 'uploads*', method: RequestMethod.ALL },
{ path: 'public*', method: RequestMethod.ALL },
)
.forRoutes('*');
}
}

View File

@ -19,3 +19,17 @@
.text-center {
text-align: center;
}
.row {
display: flex;
flex-direction: row;
gap: 2rem;
@include break-on(xs, down) {
flex-direction: column;
}
}
.col {
display: flex;
flex-direction: column;
flex: 1 1 50%;
}

58
src/fe/scss/_modal.scss Normal file
View File

@ -0,0 +1,58 @@
.modal {
display: block;
position: fixed;
overflow: auto;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
&__content {
max-width: 800px;
background-color: #2e6b81;
box-shadow: 0px 6px 62px -14px rgba(0, 0, 0, 0.45);
margin: 4rem auto 0 auto;
}
&__body {
padding: 1rem;
h1,
h2,
h3,
h4,
h5 {
&:first-of-type {
margin-top: 0;
}
}
}
&__footer {
padding: 1rem;
display: flex;
flex-direction: row;
justify-content: flex-end;
border-top: 1px solid #005b74;
gap: 1rem;
}
&__title {
display: flex;
flex-direction: row;
padding: 1rem;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #005b74;
&-button .btn {
min-width: initial;
font-weight: bold;
font-size: 2rem;
padding: 0 0.65rem;
}
}
}

View File

@ -1,3 +1,5 @@
@import 'cropperjs/dist/cropper.css';
.settings {
max-width: 1000px;
display: flex;
@ -7,6 +9,7 @@
&__nav {
padding: 2rem 0rem;
background-color: #005b74;
ul {
display: flex;
flex-direction: column;
@ -43,5 +46,39 @@
&__content {
padding: 2.75rem;
flex-grow: 1;
}
.user-avatar {
gap: 1rem;
&__picture {
height: 120px;
width: 120px;
flex: 0 0 120px;
margin: auto;
}
}
#avatar-modal {
#cropper {
max-height: 50vh;
}
[data-upload-step='3'] {
display: flex;
flex-direction: column;
align-items: center;
p {
margin-top: 0;
}
}
#crop-result {
max-width: 100%;
max-height: 50vh;
background-color: #005b74;
}
}
}

View File

@ -6,6 +6,7 @@
@import 'alert';
@import 'authorize';
@import 'settings';
@import 'modal';
*,
*::before,
@ -43,3 +44,7 @@ a {
vertical-align: top;
}
}
[data-script] {
display: none;
}

View File

@ -0,0 +1,21 @@
import { AvatarModal } from './modal/avatar';
import { ModalManager } from './modal/modals';
(function () {
// This site supports disabling javascript. Some interactive elements can be hidden.
const noscriptElements = document.querySelectorAll('[data-noscript]');
const scriptElements = document.querySelectorAll('[data-script]');
noscriptElements.forEach(
(element: HTMLElement) => (element.style.display = 'none'),
);
scriptElements.forEach(
(element: HTMLElement) =>
(element.style.display = element.getAttribute('data-script') || 'block'),
);
const modals = new ModalManager();
const avatar = new AvatarModal();
modals.register(avatar);
})();

175
src/fe/ts/modal/avatar.ts Normal file
View File

@ -0,0 +1,175 @@
import { Modal } from './modal';
import Cropper from 'cropperjs';
export class AvatarModal extends Modal {
private cropper?: Cropper;
private csrf!: string;
private stages!: NodeListOf<HTMLElement>;
private fileInput!: HTMLInputElement;
private continueBtn!: HTMLElement;
private resetBtn!: HTMLElement;
private uploadBtn!: HTMLElement;
private cropRoot!: HTMLImageElement;
private previewRoot!: HTMLImageElement;
private currentAvatar!: HTMLImageElement;
private cropResultUrl?: string;
private cropResultBlob?: Blob;
private currentStep = 1;
constructor() {
super('avatar');
}
public reset(): void {
super.reset();
if (!this.modal) {
return;
}
if (this.cropper) {
this.cropper.destroy();
}
this.cropResultUrl = null;
this.cropResultBlob = null;
this.fileInput.value = null;
this.previewRoot.removeAttribute('src');
this.cropRoot.removeAttribute('src');
this.setStep(1);
}
public initialize(): void {
super.initialize();
this.csrf = (document.querySelector('#csrf') as HTMLInputElement).value;
this.stages = this.modal?.querySelectorAll(
'[data-upload-step]',
) as NodeListOf<HTMLElement>;
this.fileInput = this.modal?.querySelector(
'#image-file',
) as HTMLInputElement;
this.cropRoot = this.modal?.querySelector('#cropper') as HTMLImageElement;
this.previewRoot = this.modal?.querySelector(
'#crop-result',
) as HTMLImageElement;
this.currentAvatar = document.querySelector(
'#current-avatar',
) as HTMLImageElement;
this.continueBtn = this.modal?.querySelector('#continue') as HTMLElement;
this.resetBtn = this.modal?.querySelector('#reset') as HTMLElement;
this.uploadBtn = this.modal?.querySelector('#upload') as HTMLElement;
this.setSteps();
this.registerEvents();
}
public setSteps(): void {
this.stages?.forEach((item) => {
const itemState = parseInt(item.getAttribute('data-upload-step'), 10);
item.style.display = itemState === this.currentStep ? null : 'none';
});
}
public setStep(index: number): void {
this.currentStep = index;
this.setSteps();
}
private startCrop(): void {
const imgf = this.fileInput.files[0];
if (!imgf.type.includes('image/') || imgf.type.includes('svg')) {
// TODO: error
return;
}
const reader = new FileReader();
reader.onerror = () => {
// TODO: error
this.reset();
};
reader.onload = () => {
this.cropRoot.src = reader.result as string;
this.createCropper();
this.setStep(2);
};
reader.readAsDataURL(imgf);
}
private registerEvents(): void {
if (!this.modal) {
return;
}
this.fileInput.addEventListener('change', () => {
this.startCrop();
});
this.continueBtn.addEventListener('click', () => {
this.createCropResult();
this.setStep(3);
});
this.resetBtn.addEventListener('click', () => {
if (this.cropper) {
this.cropper.destroy();
}
this.fileInput.value = null;
this.cropResultUrl = null;
this.cropResultBlob = null;
this.previewRoot.removeAttribute('src');
this.setStep(1);
});
this.uploadBtn.addEventListener('click', () => {
const formData = new FormData();
formData.append('file', this.cropResultBlob);
formData.append('csrf', this.csrf);
// TODO: error
fetch('/account/avatar', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.then((data) => {
this.reset();
if (data.file) {
this.currentAvatar.src = `/uploads/${data.file}`;
}
});
});
}
private createCropper(): void {
this.cropper = new Cropper(this.cropRoot, {
aspectRatio: 1,
viewMode: 1,
modal: false,
});
}
private createCropResult(): void {
const cropCanvas = this.cropper.getCroppedCanvas({
maxHeight: 1024,
maxWidth: 1024,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
this.cropResultUrl = cropCanvas.toDataURL();
this.previewRoot.src = this.cropResultUrl;
cropCanvas.toBlob((blob) => {
this.cropResultBlob = blob;
this.setStep(3);
});
}
}

98
src/fe/ts/modal/modal.ts Normal file
View File

@ -0,0 +1,98 @@
export class Modal {
public triggers?: NodeListOf<HTMLElement>;
public modal?: HTMLElement;
protected focusLock: HTMLElement[] = [];
protected trigger?: HTMLElement;
constructor(public name: string) {}
public reset(): void {
if (!this.modal) {
return;
}
this.modal.style.display = 'none';
this.removeFocusLock();
}
public open(): void {
if (!this.modal) {
return;
}
this.modal.style.display = 'block';
this.createFocusLock();
}
public initialize(): void {
this.triggers = document.querySelectorAll(
`[data-modal-trigger="${this.name}"]`,
) as NodeListOf<HTMLElement>;
this.modal = document.querySelector(
`[data-modal="${this.name}"]`,
) as HTMLElement;
this.triggers.forEach((item) =>
item.addEventListener('click', (evt) => {
evt.preventDefault();
this.trigger = item;
this.open();
}),
);
if (this.modal) {
const attrLabel = `modal_${this.name}_label`;
const label = this.modal.querySelector('.modal__title');
this.modal.setAttribute('aria-modal', 'true');
this.modal.setAttribute('aria-labelledby', attrLabel);
label.setAttribute('id', attrLabel);
}
}
private getFocusable(): HTMLElement[] {
const focusable = Array.from(
this.modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as NodeListOf<HTMLElement>,
).filter(
(item) => item.offsetParent !== null && !this.focusLock.includes(item),
);
const firstFocusable = focusable[0];
const lastFocusable = focusable[focusable.length - 1];
return [firstFocusable, lastFocusable];
}
private createFocusLock(): void {
const startFocus = document.createElement('div');
startFocus.setAttribute('tabindex', '0');
const stopFocus = document.createElement('div');
stopFocus.setAttribute('tabindex', '0');
this.modal.prepend(startFocus);
this.modal.appendChild(stopFocus);
this.focusLock = [startFocus, stopFocus];
stopFocus.addEventListener('focus', (event) => {
event.preventDefault();
this.getFocusable()[0].focus();
});
startFocus.addEventListener('focus', (event) => {
event.preventDefault();
this.getFocusable()[1].focus();
});
this.getFocusable()[0].focus();
}
private removeFocusLock(): void {
this.focusLock.forEach((item) => {
item.parentElement.removeChild(item);
});
this.trigger?.focus();
}
}

34
src/fe/ts/modal/modals.ts Normal file
View File

@ -0,0 +1,34 @@
import { Modal } from './modal';
export class ModalManager {
public modals: Record<string, Modal> = {};
public closers = document.querySelectorAll(
'[data-modal-closer]',
) as NodeListOf<HTMLElement>;
constructor() {
this.closers.forEach((item) => {
item.setAttribute('aria-label', 'Close modal');
item.addEventListener('click', (evt) => {
evt.preventDefault();
this.close();
});
});
}
public register<T extends Modal>(item: T): T {
this.modals[item.name] = item;
item.initialize();
item.reset();
return item;
}
public close(name?: string): void {
if (name) {
this.modals[name].reset();
return;
}
Object.values(this.modals).forEach((item) => item.reset());
}
}

View File

@ -4,9 +4,15 @@ import { NextFunction, Request, Response } from 'express';
@Injectable()
export class ValidateCSRFMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Multipart is handeled elsewhere
if (req.header('content-type')?.startsWith('multipart/form-data')) {
return next();
}
if (req.body.csrf !== req.session.csrf) {
return next(new Error('Invalid session'));
}
next();
}
}

View File

@ -9,6 +9,7 @@ import {
Res,
Session,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Request, Response } from 'express';
import { SessionData } from 'express-session';
import {
@ -244,6 +245,7 @@ export class LoginController {
}
@Post('password')
@Throttle(3, 60)
public async setNewPassword(
@Req() req: Request,
@Res() res: Response,

View File

@ -1,13 +1,21 @@
import {
BadRequestException,
Controller,
Get,
Post,
Redirect,
Render,
Req,
Session,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
import { SessionData } from 'express-session';
import { unlink } from 'fs/promises';
import { UploadService } from 'src/modules/objects/upload/upload.service';
import { UserService } from 'src/modules/objects/user/user.service';
import { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
import { SettingsService } from './settings.service';
@ -16,6 +24,8 @@ export class SettingsController {
constructor(
private readonly _service: SettingsService,
private readonly _form: FormUtilityService,
private readonly _upload: UploadService,
private readonly _user: UserService,
) {}
@Get()
@ -27,6 +37,39 @@ export class SettingsController {
@Get('general')
@Render('settings/general')
public general(@Req() req: Request, @Session() sess: SessionData) {
return this._form.populateTemplate(req, sess);
return this._form.populateTemplate(req, sess, { user: req.user });
}
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
async uploadAvatarFile(
@Req() req: Request,
@UploadedFile() file: Express.Multer.File,
) {
if (req.body.csrf !== req.session.csrf) {
throw new BadRequestException('Invalid session. Please try again.');
}
if (!file) {
throw new BadRequestException('Avatar upload failed');
}
try {
const matches = await this._upload.checkImageAspect(file);
if (!matches) {
throw new BadRequestException(
'Avatar should be with a 1:1 aspect ratio.',
);
}
} catch (e) {
await unlink(file.path);
throw e;
}
const upload = await this._upload.registerUploadedFile(file, req.user);
await this._user.updateAvatar(req.user, upload);
return {
file: upload.file,
};
}
}

View File

@ -4,10 +4,16 @@ import {
NestModule,
RequestMethod,
} from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import * as multer from 'multer';
import * as mime from 'mime-types';
import { join } from 'path';
import { AuthMiddleware } from 'src/middleware/auth.middleware';
import { FlashMiddleware } from 'src/middleware/flash.middleware';
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
import { ConfigurationModule } from 'src/modules/config/config.module';
import { ConfigurationService } from 'src/modules/config/config.service';
import { UploadModule } from 'src/modules/objects/upload/upload.module';
import { UserModule } from 'src/modules/objects/user/user.module';
import { OAuth2Module } from '../oauth2/oauth2.module';
import { SettingsController } from './settings.controller';
@ -15,7 +21,44 @@ import { SettingsService } from './settings.service';
@Module({
controllers: [SettingsController],
imports: [ConfigurationModule, UserModule, OAuth2Module],
imports: [
ConfigurationModule,
UploadModule,
UserModule,
OAuth2Module,
MulterModule.registerAsync({
imports: [ConfigurationModule],
useFactory: async (config: ConfigurationService) => {
return {
storage: multer.diskStorage({
destination: (req, file, cb) => {
cb(null, join(__dirname, '..', '..', '..', '..', 'uploads'));
},
filename: (req, file, cb) => {
const hashTruncate = req.user.uuid.split('-')[0];
const timestamp = Math.floor(Date.now() / 1000);
const ext = mime.extension(file.mimetype);
cb(null, `user-${hashTruncate}-${timestamp}.${ext}`);
},
}),
limits: {
fileSize: 1.049e7, // 10 MiB
},
fileFilter: (req, file, cb) => {
if (
!file.mimetype.startsWith('image/') ||
file.mimetype.includes('svg')
) {
return cb(new Error('Invalid file type.'), false);
}
cb(null, true);
},
};
},
inject: [ConfigurationService],
}),
],
providers: [SettingsService],
})
export class SettingsModule implements NestModule {

View File

@ -16,6 +16,9 @@ export class Upload {
@Column({ nullable: false })
original_name: string;
@Column({ nullable: false })
mimetype: string;
@Column({ nullable: false })
file: string;

View File

@ -1,11 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { readFile, unlink } from 'fs/promises';
import { imageSize } from 'image-size';
import { join } from 'path';
import { Repository } from 'typeorm';
import { User } from '../user/user.entity';
import { Upload } from './upload.entity';
@Injectable()
export class UploadService {
public uploadPath = join(__dirname, '..', '..', '..', '..', 'uploads');
constructor(
@Inject('UPLOAD_REPOSITORY')
private uploadRepository: Repository<Upload>,
) {}
public async registerUploadedFile(
file: Express.Multer.File,
user: User,
): Promise<Upload> {
const upload = new Upload();
upload.file = file.filename;
upload.original_name = file.originalname;
upload.mimetype = file.mimetype;
upload.uploader = user;
await this.uploadRepository.insert(upload);
return upload;
}
public async checkImageAspect(file: Express.Multer.File): Promise<boolean> {
const opened = await readFile(file.path);
return new Promise((resolve) => {
const result = imageSize(opened);
if (result.height / result.width !== 1) {
return resolve(false);
}
resolve(true);
});
}
public async delete(upload: Upload): Promise<void> {
const path = join(this.uploadPath, upload.file);
try {
await unlink(path);
} catch (e: any) {
console.error('Failed to unlink avatar file:', e.stack);
}
await this.uploadRepository.remove(upload);
}
}

View File

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '../database/database.module';
import { EmailModule } from '../email/email.module';
import { UploadModule } from '../upload/upload.module';
import { UserTokenModule } from '../user-token/user-token.module';
import { userProviders } from './user.providers';
import { UserService } from './user.service';
@Module({
imports: [DatabaseModule, EmailModule, UserTokenModule],
imports: [DatabaseModule, EmailModule, UserTokenModule, UploadModule],
providers: [...userProviders, UserService],
exports: [UserService],
})

View File

@ -9,6 +9,8 @@ import { RegistrationEmail } from './email/registration.email';
import { ForgotPasswordEmail } from './email/forgot-password.email';
import { UserTokenService } from '../user-token/user-token.service';
import { ConfigurationService } from 'src/modules/config/config.service';
import { Upload } from '../upload/upload.entity';
import { UploadService } from '../upload/upload.service';
@Injectable()
export class UserService {
@ -19,6 +21,7 @@ export class UserService {
private token: TokenService,
private email: EmailService,
private config: ConfigurationService,
private upload: UploadService,
) {}
public async getById(id: number, relations?: string[]): Promise<User> {
@ -64,6 +67,16 @@ export class UserService {
return user;
}
public async updateAvatar(user: User, upload: Upload): Promise<User> {
if (user.picture) {
await this.upload.delete(user.picture);
}
user.picture = upload;
await this.updateUser(user);
return user;
}
public async comparePasswords(
hash: string,
password: string,

View File

@ -21,7 +21,8 @@ block body
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="password") New password
input.form-control#password(type="password", name="password", placeholder="Password")
input.form-control#password(type="password", name="password", autofocus, placeholder="Password")
small.form-hint Must be at least 8 characters long, contain a capital and lowercase letter and a number.
label.form-label(for="password_repeat") Repeat new password
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Password")
button.btn.btn-primary(type="submit") Set password
@ -32,6 +33,6 @@ block body
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="email") Email address
input.form-control#email(type="email", name="email", placeholder="Email addres")
input.form-control#email(type="email", name="email", autofocus, placeholder="Email addres")
button.btn.btn-primary(type="submit") Send recovery email
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead

View File

@ -14,7 +14,7 @@ html(lang="en")
meta(name="twitter:description", content="Icy Network is a Single-Sign-On (SSO) provider")
block links
link(rel="stylesheet", type="text/css", href="/public/css/index.css")
script(href="/public/js/app.bundle.js")
script(src="/public/js/app.bundle.js", defer)
title
block title
body

View File

@ -5,3 +5,65 @@ block title
block settings
h1 General settings
if message.text
if message.error
.alert.alert-danger
span #{message.text}
else
.alert.alert-success
span #{message.text}
.row
.col
form(method="post")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="username") Username
input.form-control#username(type="text", name="username", placeholder="Username", disabled, value=user.username)
label.form-label(for="display_name") Display Name
input.form-control#display_name(type="text", name="display_name", placeholder="Display name", value=user.display_name)
button.btn.btn-primary(type="submit") Change
.col
.user-avatar.row
.col.user-avatar__picture
if user.picture
img#current-avatar(src='/uploads/' + user.picture.file,alt=user.username)
else
img#current-avatar(src='/public/image/avatar.png',alt='No avatar')
.col.user-avatar__options
.flex-column(data-script="flex")
button.btn.btn-primary(data-modal-trigger="avatar") Change avatar
if user.picture
button.btn.btn-link#remove-avatar Remove avatar
form(method="post", data-noscript, action="/account/avatar", enctype="multipart/form-data")
div.form-container
input#csrf(type="hidden", name="csrf", value=csrf)
label.form-label(for="image") Image
input.form-control#image(type="file", name="file")
small.form-hint Must be less than 10 MB and 1:1 aspect ratio. Enable JavaScript to custom crop your image.
button.btn.btn-primary(type="submit") Change
.modal#avatar-modal(data-modal="avatar", aria-live="polite", role="modal", style="display: none")
.modal__content
.modal__title
|Change avatar
.modal__title-button
button.btn.btn-link(data-modal-closer) x
.modal__body
div(data-upload-step="1")
h2 Upload file
div.form-container
input#image-file(type="file", name="file")
small.form-hint Must be less than 10 MB in size.
div(data-upload-step="2")
h2 Crop image
img#cropper
div(data-upload-step="3")
h2 Crop result
p This is how your profile picture will look like:
img#crop-result
.modal__footer
button.btn.btn-link(data-modal-closer) Cancel
button.btn.btn-primary#continue(data-upload-step="2") Continue
button.btn.btn-primary#reset(data-upload-step="3") Reset
button.btn.btn-primary#upload(data-upload-step="3") Upload