diff --git a/package-lock.json b/package-lock.json index 1c50ceb..bd5ef15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5a1e2e3..1870ef5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.module.ts b/src/app.module.ts index 1e352bc..86c022a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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('*'); } } diff --git a/src/fe/scss/_flex.scss b/src/fe/scss/_flex.scss index 30f9f6b..b7856bd 100644 --- a/src/fe/scss/_flex.scss +++ b/src/fe/scss/_flex.scss @@ -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%; +} diff --git a/src/fe/scss/_modal.scss b/src/fe/scss/_modal.scss new file mode 100644 index 0000000..b014b72 --- /dev/null +++ b/src/fe/scss/_modal.scss @@ -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; + } + } +} diff --git a/src/fe/scss/_settings.scss b/src/fe/scss/_settings.scss index c09baca..59c0a68 100644 --- a/src/fe/scss/_settings.scss +++ b/src/fe/scss/_settings.scss @@ -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; + } } } diff --git a/src/fe/scss/index.scss b/src/fe/scss/index.scss index 63efe36..97bed28 100644 --- a/src/fe/scss/index.scss +++ b/src/fe/scss/index.scss @@ -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; +} diff --git a/src/fe/ts/index.ts b/src/fe/ts/index.ts index e69de29..029fa46 100644 --- a/src/fe/ts/index.ts +++ b/src/fe/ts/index.ts @@ -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); +})(); diff --git a/src/fe/ts/modal/avatar.ts b/src/fe/ts/modal/avatar.ts new file mode 100644 index 0000000..17b9eb7 --- /dev/null +++ b/src/fe/ts/modal/avatar.ts @@ -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; + 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; + + 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); + }); + } +} diff --git a/src/fe/ts/modal/modal.ts b/src/fe/ts/modal/modal.ts new file mode 100644 index 0000000..67f926f --- /dev/null +++ b/src/fe/ts/modal/modal.ts @@ -0,0 +1,98 @@ +export class Modal { + public triggers?: NodeListOf; + 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; + + 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, + ).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(); + } +} diff --git a/src/fe/ts/modal/modals.ts b/src/fe/ts/modal/modals.ts new file mode 100644 index 0000000..2ae49ad --- /dev/null +++ b/src/fe/ts/modal/modals.ts @@ -0,0 +1,34 @@ +import { Modal } from './modal'; + +export class ModalManager { + public modals: Record = {}; + public closers = document.querySelectorAll( + '[data-modal-closer]', + ) as NodeListOf; + + constructor() { + this.closers.forEach((item) => { + item.setAttribute('aria-label', 'Close modal'); + item.addEventListener('click', (evt) => { + evt.preventDefault(); + this.close(); + }); + }); + } + + public register(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()); + } +} diff --git a/src/middleware/validate-csrf.middleware.ts b/src/middleware/validate-csrf.middleware.ts index 47e8ea4..3067b70 100644 --- a/src/middleware/validate-csrf.middleware.ts +++ b/src/middleware/validate-csrf.middleware.ts @@ -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(); } } diff --git a/src/modules/features/login/login.controller.ts b/src/modules/features/login/login.controller.ts index 4465b20..8d6297c 100644 --- a/src/modules/features/login/login.controller.ts +++ b/src/modules/features/login/login.controller.ts @@ -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, diff --git a/src/modules/features/settings/settings.controller.ts b/src/modules/features/settings/settings.controller.ts index 97b829b..67e24ab 100644 --- a/src/modules/features/settings/settings.controller.ts +++ b/src/modules/features/settings/settings.controller.ts @@ -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, + }; } } diff --git a/src/modules/features/settings/settings.module.ts b/src/modules/features/settings/settings.module.ts index 6992ba5..4a9dda8 100644 --- a/src/modules/features/settings/settings.module.ts +++ b/src/modules/features/settings/settings.module.ts @@ -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 { diff --git a/src/modules/objects/upload/upload.entity.ts b/src/modules/objects/upload/upload.entity.ts index 981bf52..ec6e26c 100644 --- a/src/modules/objects/upload/upload.entity.ts +++ b/src/modules/objects/upload/upload.entity.ts @@ -16,6 +16,9 @@ export class Upload { @Column({ nullable: false }) original_name: string; + @Column({ nullable: false }) + mimetype: string; + @Column({ nullable: false }) file: string; diff --git a/src/modules/objects/upload/upload.service.ts b/src/modules/objects/upload/upload.service.ts index 6d05acf..2f9eec4 100644 --- a/src/modules/objects/upload/upload.service.ts +++ b/src/modules/objects/upload/upload.service.ts @@ -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, ) {} + + public async registerUploadedFile( + file: Express.Multer.File, + user: User, + ): Promise { + 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 { + 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 { + 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); + } } diff --git a/src/modules/objects/user/user.module.ts b/src/modules/objects/user/user.module.ts index 7807363..297f0ff 100644 --- a/src/modules/objects/user/user.module.ts +++ b/src/modules/objects/user/user.module.ts @@ -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], }) diff --git a/src/modules/objects/user/user.service.ts b/src/modules/objects/user/user.service.ts index 4d811c6..c5b64c1 100644 --- a/src/modules/objects/user/user.service.ts +++ b/src/modules/objects/user/user.service.ts @@ -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 { @@ -64,6 +67,16 @@ export class UserService { return user; } + public async updateAvatar(user: User, upload: Upload): Promise { + 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, diff --git a/views/login/password.pug b/views/login/password.pug index d32551e..493db27 100644 --- a/views/login/password.pug +++ b/views/login/password.pug @@ -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 diff --git a/views/partials/layout.pug b/views/partials/layout.pug index 5b7c69a..b46c447 100644 --- a/views/partials/layout.pug +++ b/views/partials/layout.pug @@ -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 diff --git a/views/settings/general.pug b/views/settings/general.pug index eb32efa..89a7ada 100644 --- a/views/settings/general.pug +++ b/views/settings/general.pug @@ -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 +