From 1baddaad0f1a2ce6a02ef392945c6d591351e651 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 20 Dec 2021 17:51:17 -0500 Subject: [PATCH] user profile photo (large and small) with remove --- app/controllers/user.js | 60 ++++++++++++++++++++++ app/services/user.js | 58 +++++++++++++++++++++ app/views/components/file-upload-image.pug | 4 +- app/views/components/navbar.pug | 4 +- app/views/layouts/public-profile.pug | 5 +- app/views/user/settings.pug | 1 + client/js/site-app.js | 42 +++++++++++++++ config/limiter.js | 10 ++++ 8 files changed, 179 insertions(+), 5 deletions(-) diff --git a/app/controllers/user.js b/app/controllers/user.js index 2f2b4d0..3f1a9ba 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -58,6 +58,12 @@ class UserController extends SiteController { router.param('userId', this.populateUser.bind(this)); + router.post('/:userId/profile-photo', + limiterService.create(limiterService.config.user.postProfilePhoto), + upload.single('imageFile'), + this.postProfilePhoto.bind(this), + ); + router.post('/:userId/settings', limiterService.create(limiterService.config.user.postUpdateSettings), upload.none(), @@ -83,6 +89,13 @@ class UserController extends SiteController { checkProfileOwner, this.getUserView.bind(this), ); + + router.delete('/:userId/profile-photo', + limiterService.create(limiterService.config.user.deleteProfilePhoto), + authRequired, + checkProfileOwner, + this.deleteProfilePhoto.bind(this), + ); } async populateUser (req, res, next, userId) { @@ -136,6 +149,30 @@ class UserController extends SiteController { } } + async postProfilePhoto (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('profile-photo'); + + this.log.debug('received profile photo', { file: req.file }); + await userService.updatePhoto(req.user, req.file); + + displayList.showNotification( + 'Profile photo updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to update profile photo', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postUpdateSettings (req, res) { const { user: userService } = this.dtp.services; try { @@ -177,6 +214,29 @@ class UserController extends SiteController { return next(error); } } + + async deleteProfilePhoto (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('app-settings'); + + await userService.removePhoto(req.user); + + displayList.showNotification( + 'Profile photo removed successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove profile photo', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } } module.exports = { diff --git a/app/services/user.js b/app/services/user.js index bf92c65..401965d 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -263,6 +263,14 @@ class UserService { const user = await User .findById(userId) .select('+email +flags +permissions') + .populate([ + { + path: 'picture.large', + }, + { + path: 'picture.small', + }, + ]) .lean(); if (!user) { throw new SiteError(404, 'Member account not found'); @@ -439,6 +447,56 @@ class UserService { async getTotalCount ( ) { return await User.estimatedDocumentCount(); } + + async updatePhoto (user, file) { + const { image: imageService } = this.dtp.services; + + const images = [ + { + width: 512, + height: 512, + format: 'jpeg', + formatParameters: { + quality: 80, + }, + }, + { + width: 64, + height: 64, + format: 'jpeg', + formatParameters: { + conpressionLevel: 9, + }, + }, + ]; + await imageService.processImageFile(user, file, images); + await User.updateOne( + { _id: user._id }, + { + $set: { + 'picture.large': images[0].image._id, + 'picture.small': images[1].image._id, + }, + }, + ); + } + + async removePhoto (user) { + const { image: imageService } = this.dtp.services; + + this.log.info('remove profile photo', { user }); + user = await this.getUserAccount(user._id); + await imageService.deleteImage(user.picture.large); + await imageService.deleteImage(user.picture.small); + await User.updateOne( + { _id: user._id }, + { + $unset: { + 'picture': '', + }, + }, + ); + } } module.exports = { diff --git a/app/views/components/file-upload-image.pug b/app/views/components/file-upload-image.pug index 762992b..9e20473 100644 --- a/app/views/components/file-upload-image.pug +++ b/app/views/components/file-upload-image.pug @@ -1,6 +1,6 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaultImage, currentImage, cropperOptions) div(id= containerId).dtp-file-upload - form(method="POST", action= actionUrl, enctype="multipart/form-data", onsubmit= "return dtp.app.submitForm(event);").uk-form + form(method="POST", action= actionUrl, enctype="multipart/form-data", onsubmit= "return dtp.app.submitImageForm(event);").uk-form .uk-margin .uk-card.uk-card-default.uk-card-small .uk-card-body @@ -21,7 +21,6 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul div(uk-form-custom).uk-margin-small-left input( type="file", - name="imageFile", formenctype="multipart/form-data", accept=".jpg,.png,image/jpeg,image/png", data-file-select-container= containerId, @@ -51,6 +50,7 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul #remove-btn(hidden= !currentImage).uk-width-auto button( type= "button", + data-image-type= imageId, onclick= "return dtp.app.removeImageFile(event);", ).uk-button.uk-button-danger Remove diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index 40dd011..f06faa3 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -16,9 +16,9 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top .uk-navbar-item if user div.no-select - if user.picture_url + if user.picture && user.picture.small img( - src= user.picture_url, + src= `/image/${user.picture.small._id}`, title="Member Menu", ).site-profile-picture.sb-navbar else diff --git a/app/views/layouts/public-profile.pug b/app/views/layouts/public-profile.pug index 77f12cc..068f0f5 100644 --- a/app/views/layouts/public-profile.pug +++ b/app/views/layouts/public-profile.pug @@ -9,7 +9,10 @@ block content-container a(href=`/admin/user/${userProfile._id}`).uk-button.dtp-button-danger User Admin .uk-margin.uk-margin-auto.uk-width-small - img(src="/img/icon/icon-512x512.png").responsive + if userProfile.picture && userProfile.picture.large + img(src= `/image/${user.picture.large._id}`).responsive + else + img(src= "/img/icon/icon-512x512.png").responsive .uk-margin.uk-text-center if userProfile.displayName diff --git a/app/views/user/settings.pug b/app/views/user/settings.pug index db82294..cea99fd 100644 --- a/app/views/user/settings.pug +++ b/app/views/user/settings.pug @@ -19,6 +19,7 @@ block content currentImage = user.picture.large; } +renderFileUploadImage(`/user/${user._id}/profile-photo`, 'test-image-upload', 'profile-picture-file', 'site-profile-picture', `/img/default-member.png`, currentImage) + div(class="uk-width-1-1 uk-width-expand@m") form(method="POST", action=`/user/${user._id}/settings`, onsubmit="return dtp.app.submitForm(event, 'user account update');").uk-form .uk-margin diff --git a/client/js/site-app.js b/client/js/site-app.js index 58b8457..41c7a6e 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -194,6 +194,43 @@ export default class DtpSiteApp extends DtpApp { return; } + async submitImageForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const formElement = event.currentTarget || event.target; + const form = new FormData(formElement); + + this.cropper.getCroppedCanvas().toBlob(async (imageData) => { + try { + form.append('imageFile', imageData, 'profile.png'); + + this.log.info('submitImageForm', 'updating user settings', { event, action: formElement.action }); + const response = await fetch(formElement.action, { + method: formElement.method, + body: form, + }); + + if (!response.ok) { + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error('Server error'); + } + throw new Error(json.message || 'Server error'); + } + + await this.processResponse(response); + window.location.reload(); + } catch (error) { + UIkit.modal.alert(`Failed to update profile photo: ${error.message}`); + } + }); + + return; + } + async copyHtmlToText (event, textContentId) { const content = this.editor.getContent({ format: 'text' }); const text = document.getElementById(textContentId); @@ -468,6 +505,10 @@ export default class DtpSiteApp extends DtpApp { let response; switch (imageType) { + case 'profile-picture-file': + response = await fetch(`/user/${this.user._id}/profile-photo`, { method: 'DELETE' }); + break; + case 'channel-app-icon': const channelId = (event.target || event.currentTarget).getAttribute('data-channel-id'); response = await fetch(`/channel/${channelId}/app-icon`, { @@ -484,6 +525,7 @@ export default class DtpSiteApp extends DtpApp { } await this.processResponse(response); + window.location.reload(); } catch (error) { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } diff --git a/config/limiter.js b/config/limiter.js index 47e64d1..a5c08f1 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -156,6 +156,11 @@ module.exports = { expire: ONE_MINUTE, message: 'You are creating accounts too quickly', }, + postProfilePhoto: { + total: 5, + expire: ONE_MINUTE * 5, + message: 'You are updating your profile photo too quickly', + }, postUpdateSettings: { total: 4, expire: ONE_MINUTE, @@ -171,6 +176,11 @@ module.exports = { expire: ONE_MINUTE, message: 'You are requesting user profiles too quickly', }, + deleteProfilePhoto: { + total: 5, + expire: ONE_MINUTE * 5, + message: 'You are deleting your profile photo too quickly', + }, }, welcome: {