upload user avatar, front end avatar crop modal
This commit is contained in:
parent
d7fc152c8a
commit
88f19163c6
145
package-lock.json
generated
145
package-lock.json
generated
@ -13,13 +13,18 @@
|
|||||||
"@nestjs/common": "^8.0.0",
|
"@nestjs/common": "^8.0.0",
|
||||||
"@nestjs/core": "^8.0.0",
|
"@nestjs/core": "^8.0.0",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^8.0.0",
|
||||||
|
"@nestjs/serve-static": "^2.2.2",
|
||||||
"@nestjs/throttler": "^2.0.1",
|
"@nestjs/throttler": "^2.0.1",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
|
"cropperjs": "^1.5.12",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express-session": "^1.17.2",
|
"express-session": "^1.17.2",
|
||||||
|
"image-size": "^1.0.1",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"multer": "^1.4.4",
|
||||||
"mysql2": "^2.3.3",
|
"mysql2": "^2.3.3",
|
||||||
"nodemailer": "^6.7.2",
|
"nodemailer": "^6.7.2",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
@ -43,6 +48,8 @@
|
|||||||
"@types/express-session": "^1.17.4",
|
"@types/express-session": "^1.17.4",
|
||||||
"@types/jest": "27.4.1",
|
"@types/jest": "27.4.1",
|
||||||
"@types/jsonwebtoken": "^8.5.8",
|
"@types/jsonwebtoken": "^8.5.8",
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
"@types/qrcode": "^1.4.2",
|
"@types/qrcode": "^1.4.2",
|
||||||
@ -2830,6 +2837,23 @@
|
|||||||
"typescript": "^3.4.5 || ^4.3.5"
|
"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": {
|
"node_modules/@nestjs/testing": {
|
||||||
"version": "8.4.0",
|
"version": "8.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
|
||||||
@ -3239,6 +3263,21 @@
|
|||||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "16.11.26",
|
"version": "16.11.26",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
|
||||||
@ -5081,6 +5120,11 @@
|
|||||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
@ -6831,6 +6875,20 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/immutable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
||||||
@ -8666,19 +8724,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.51.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.34",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.51.0"
|
"mime-db": "1.52.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@ -9816,6 +9874,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@ -14296,6 +14362,21 @@
|
|||||||
"pluralize": "8.0.0"
|
"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": {
|
"@nestjs/testing": {
|
||||||
"version": "8.4.0",
|
"version": "8.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.4.0.tgz",
|
||||||
@ -14659,6 +14740,21 @@
|
|||||||
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||||
"dev": true
|
"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": {
|
"@types/node": {
|
||||||
"version": "16.11.26",
|
"version": "16.11.26",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.26.tgz",
|
||||||
@ -16097,6 +16193,11 @@
|
|||||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"cropperjs": {
|
||||||
|
"version": "1.5.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.5.12.tgz",
|
||||||
|
"integrity": "sha512-re7UdjE5UnwdrovyhNzZ6gathI4Rs3KGCBSc8HCIjUo5hO42CtzyblmWLj6QWVw7huHyDMfpKxhiO2II77nhDw=="
|
||||||
|
},
|
||||||
"cross-spawn": {
|
"cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||||
@ -17401,6 +17502,14 @@
|
|||||||
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
|
"integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
|
||||||
"dev": true
|
"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": {
|
"immutable": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
|
||||||
@ -18829,16 +18938,16 @@
|
|||||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
|
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
|
||||||
},
|
},
|
||||||
"mime-db": {
|
"mime-db": {
|
||||||
"version": "1.51.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
|
||||||
},
|
},
|
||||||
"mime-types": {
|
"mime-types": {
|
||||||
"version": "2.1.34",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"mime-db": "1.51.0"
|
"mime-db": "1.52.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"mimic-fn": {
|
"mimic-fn": {
|
||||||
@ -19715,6 +19824,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz",
|
||||||
"integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw=="
|
"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": {
|
"queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
@ -27,13 +27,18 @@
|
|||||||
"@nestjs/common": "^8.0.0",
|
"@nestjs/common": "^8.0.0",
|
||||||
"@nestjs/core": "^8.0.0",
|
"@nestjs/core": "^8.0.0",
|
||||||
"@nestjs/platform-express": "^8.0.0",
|
"@nestjs/platform-express": "^8.0.0",
|
||||||
|
"@nestjs/serve-static": "^2.2.2",
|
||||||
"@nestjs/throttler": "^2.0.1",
|
"@nestjs/throttler": "^2.0.1",
|
||||||
"bcrypt": "^5.0.1",
|
"bcrypt": "^5.0.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.13.2",
|
"class-validator": "^0.13.2",
|
||||||
|
"cropperjs": "^1.5.12",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"express-session": "^1.17.2",
|
"express-session": "^1.17.2",
|
||||||
|
"image-size": "^1.0.1",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"multer": "^1.4.4",
|
||||||
"mysql2": "^2.3.3",
|
"mysql2": "^2.3.3",
|
||||||
"nodemailer": "^6.7.2",
|
"nodemailer": "^6.7.2",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
@ -57,6 +62,8 @@
|
|||||||
"@types/express-session": "^1.17.4",
|
"@types/express-session": "^1.17.4",
|
||||||
"@types/jest": "27.4.1",
|
"@types/jest": "27.4.1",
|
||||||
"@types/jsonwebtoken": "^8.5.8",
|
"@types/jsonwebtoken": "^8.5.8",
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
|
"@types/multer": "^1.4.7",
|
||||||
"@types/node": "^16.0.0",
|
"@types/node": "^16.0.0",
|
||||||
"@types/nodemailer": "^6.4.4",
|
"@types/nodemailer": "^6.4.4",
|
||||||
"@types/qrcode": "^1.4.2",
|
"@types/qrcode": "^1.4.2",
|
||||||
|
@ -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 { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { join } from 'path';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { CSRFMiddleware } from './middleware/csrf.middleware';
|
import { CSRFMiddleware } from './middleware/csrf.middleware';
|
||||||
@ -20,6 +27,10 @@ import { UtilityModule } from './modules/utility/utility.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '..', 'uploads'),
|
||||||
|
serveRoot: '/uploads',
|
||||||
|
}),
|
||||||
ThrottlerModule.forRoot({
|
ThrottlerModule.forRoot({
|
||||||
ttl: 10,
|
ttl: 10,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@ -43,7 +54,12 @@ import { UtilityModule } from './modules/utility/utility.module';
|
|||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(CSRFMiddleware).forRoutes('*');
|
consumer
|
||||||
consumer.apply(UserMiddleware).forRoutes('*');
|
.apply(CSRFMiddleware, UserMiddleware)
|
||||||
|
.exclude(
|
||||||
|
{ path: 'uploads*', method: RequestMethod.ALL },
|
||||||
|
{ path: 'public*', method: RequestMethod.ALL },
|
||||||
|
)
|
||||||
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,3 +19,17 @@
|
|||||||
.text-center {
|
.text-center {
|
||||||
text-align: 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
58
src/fe/scss/_modal.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
@import 'cropperjs/dist/cropper.css';
|
||||||
|
|
||||||
.settings {
|
.settings {
|
||||||
max-width: 1000px;
|
max-width: 1000px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -7,6 +9,7 @@
|
|||||||
&__nav {
|
&__nav {
|
||||||
padding: 2rem 0rem;
|
padding: 2rem 0rem;
|
||||||
background-color: #005b74;
|
background-color: #005b74;
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -43,5 +46,39 @@
|
|||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
padding: 2.75rem;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
@import 'alert';
|
@import 'alert';
|
||||||
@import 'authorize';
|
@import 'authorize';
|
||||||
@import 'settings';
|
@import 'settings';
|
||||||
|
@import 'modal';
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@ -43,3 +44,7 @@ a {
|
|||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-script] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@ -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
175
src/fe/ts/modal/avatar.ts
Normal 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
98
src/fe/ts/modal/modal.ts
Normal 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
34
src/fe/ts/modal/modals.ts
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
@ -4,9 +4,15 @@ import { NextFunction, Request, Response } from 'express';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ValidateCSRFMiddleware implements NestMiddleware {
|
export class ValidateCSRFMiddleware implements NestMiddleware {
|
||||||
use(req: Request, res: Response, next: NextFunction) {
|
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) {
|
if (req.body.csrf !== req.session.csrf) {
|
||||||
return next(new Error('Invalid session'));
|
return next(new Error('Invalid session'));
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
Res,
|
Res,
|
||||||
Session,
|
Session,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { SessionData } from 'express-session';
|
import { SessionData } from 'express-session';
|
||||||
import {
|
import {
|
||||||
@ -244,6 +245,7 @@ export class LoginController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('password')
|
@Post('password')
|
||||||
|
@Throttle(3, 60)
|
||||||
public async setNewPassword(
|
public async setNewPassword(
|
||||||
@Req() req: Request,
|
@Req() req: Request,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
Redirect,
|
Redirect,
|
||||||
Render,
|
Render,
|
||||||
Req,
|
Req,
|
||||||
Session,
|
Session,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { SessionData } from 'express-session';
|
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 { FormUtilityService } from 'src/modules/utility/services/form-utility.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
@ -16,6 +24,8 @@ export class SettingsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly _service: SettingsService,
|
private readonly _service: SettingsService,
|
||||||
private readonly _form: FormUtilityService,
|
private readonly _form: FormUtilityService,
|
||||||
|
private readonly _upload: UploadService,
|
||||||
|
private readonly _user: UserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ -27,6 +37,39 @@ export class SettingsController {
|
|||||||
@Get('general')
|
@Get('general')
|
||||||
@Render('settings/general')
|
@Render('settings/general')
|
||||||
public general(@Req() req: Request, @Session() sess: SessionData) {
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,10 +4,16 @@ import {
|
|||||||
NestModule,
|
NestModule,
|
||||||
RequestMethod,
|
RequestMethod,
|
||||||
} from '@nestjs/common';
|
} 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 { AuthMiddleware } from 'src/middleware/auth.middleware';
|
||||||
import { FlashMiddleware } from 'src/middleware/flash.middleware';
|
import { FlashMiddleware } from 'src/middleware/flash.middleware';
|
||||||
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
|
import { ValidateCSRFMiddleware } from 'src/middleware/validate-csrf.middleware';
|
||||||
import { ConfigurationModule } from 'src/modules/config/config.module';
|
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 { UserModule } from 'src/modules/objects/user/user.module';
|
||||||
import { OAuth2Module } from '../oauth2/oauth2.module';
|
import { OAuth2Module } from '../oauth2/oauth2.module';
|
||||||
import { SettingsController } from './settings.controller';
|
import { SettingsController } from './settings.controller';
|
||||||
@ -15,7 +21,44 @@ import { SettingsService } from './settings.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [SettingsController],
|
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],
|
providers: [SettingsService],
|
||||||
})
|
})
|
||||||
export class SettingsModule implements NestModule {
|
export class SettingsModule implements NestModule {
|
||||||
|
@ -16,6 +16,9 @@ export class Upload {
|
|||||||
@Column({ nullable: false })
|
@Column({ nullable: false })
|
||||||
original_name: string;
|
original_name: string;
|
||||||
|
|
||||||
|
@Column({ nullable: false })
|
||||||
|
mimetype: string;
|
||||||
|
|
||||||
@Column({ nullable: false })
|
@Column({ nullable: false })
|
||||||
file: string;
|
file: string;
|
||||||
|
|
||||||
|
@ -1,11 +1,57 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
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 { Repository } from 'typeorm';
|
||||||
|
import { User } from '../user/user.entity';
|
||||||
import { Upload } from './upload.entity';
|
import { Upload } from './upload.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploadService {
|
export class UploadService {
|
||||||
|
public uploadPath = join(__dirname, '..', '..', '..', '..', 'uploads');
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('UPLOAD_REPOSITORY')
|
@Inject('UPLOAD_REPOSITORY')
|
||||||
private uploadRepository: Repository<Upload>,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DatabaseModule } from '../database/database.module';
|
import { DatabaseModule } from '../database/database.module';
|
||||||
import { EmailModule } from '../email/email.module';
|
import { EmailModule } from '../email/email.module';
|
||||||
|
import { UploadModule } from '../upload/upload.module';
|
||||||
import { UserTokenModule } from '../user-token/user-token.module';
|
import { UserTokenModule } from '../user-token/user-token.module';
|
||||||
import { userProviders } from './user.providers';
|
import { userProviders } from './user.providers';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, EmailModule, UserTokenModule],
|
imports: [DatabaseModule, EmailModule, UserTokenModule, UploadModule],
|
||||||
providers: [...userProviders, UserService],
|
providers: [...userProviders, UserService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
})
|
})
|
||||||
|
@ -9,6 +9,8 @@ import { RegistrationEmail } from './email/registration.email';
|
|||||||
import { ForgotPasswordEmail } from './email/forgot-password.email';
|
import { ForgotPasswordEmail } from './email/forgot-password.email';
|
||||||
import { UserTokenService } from '../user-token/user-token.service';
|
import { UserTokenService } from '../user-token/user-token.service';
|
||||||
import { ConfigurationService } from 'src/modules/config/config.service';
|
import { ConfigurationService } from 'src/modules/config/config.service';
|
||||||
|
import { Upload } from '../upload/upload.entity';
|
||||||
|
import { UploadService } from '../upload/upload.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@ -19,6 +21,7 @@ export class UserService {
|
|||||||
private token: TokenService,
|
private token: TokenService,
|
||||||
private email: EmailService,
|
private email: EmailService,
|
||||||
private config: ConfigurationService,
|
private config: ConfigurationService,
|
||||||
|
private upload: UploadService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getById(id: number, relations?: string[]): Promise<User> {
|
public async getById(id: number, relations?: string[]): Promise<User> {
|
||||||
@ -64,6 +67,16 @@ export class UserService {
|
|||||||
return user;
|
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(
|
public async comparePasswords(
|
||||||
hash: string,
|
hash: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
@ -21,7 +21,8 @@ block body
|
|||||||
div.form-container
|
div.form-container
|
||||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||||
label.form-label(for="password") New password
|
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
|
label.form-label(for="password_repeat") Repeat new password
|
||||||
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Password")
|
input.form-control#password_repeat(type="password", name="password_repeat", placeholder="Password")
|
||||||
button.btn.btn-primary(type="submit") Set password
|
button.btn.btn-primary(type="submit") Set password
|
||||||
@ -32,6 +33,6 @@ block body
|
|||||||
div.form-container
|
div.form-container
|
||||||
input#csrf(type="hidden", name="csrf", value=csrf)
|
input#csrf(type="hidden", name="csrf", value=csrf)
|
||||||
label.form-label(for="email") Email address
|
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
|
button.btn.btn-primary(type="submit") Send recovery email
|
||||||
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead
|
a.btn.btn-link.align-self-end(type="button" href="/login") Log in instead
|
||||||
|
@ -14,7 +14,7 @@ html(lang="en")
|
|||||||
meta(name="twitter:description", content="Icy Network is a Single-Sign-On (SSO) provider")
|
meta(name="twitter:description", content="Icy Network is a Single-Sign-On (SSO) provider")
|
||||||
block links
|
block links
|
||||||
link(rel="stylesheet", type="text/css", href="/public/css/index.css")
|
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
|
title
|
||||||
block title
|
block title
|
||||||
body
|
body
|
||||||
|
@ -5,3 +5,65 @@ block title
|
|||||||
|
|
||||||
block settings
|
block settings
|
||||||
h1 General 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
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user