From 1ea7da819e5bfae7b443ed722c18c4165d101439 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Wed, 13 Jan 2021 09:12:55 +0100 Subject: [PATCH] add ability to remove one's avatar for account and channels (#3467) * add ability to remove one's avatar for account and channels * add ability to remove one's avatar for account and channels * only display avatar edition options after input change --- .../my-account-settings.component.html | 2 +- .../my-account-settings.component.ts | 13 ++++++++ .../my-video-channel-edit.component.html | 2 +- .../my-video-channel-edit.ts | 1 + .../my-video-channel-update.component.ts | 21 ++++++++++++- client/src/app/core/users/user.model.ts | 5 +-- client/src/app/core/users/user.service.ts | 10 ++++++ .../shared-main/account/account.model.ts | 5 +++ .../account/actor-avatar-info.component.html | 28 ++++++++++++++--- .../account/actor-avatar-info.component.scss | 14 +++++++++ .../account/actor-avatar-info.component.ts | 31 ++++++++++++++++--- .../video-channel/video-channel.model.ts | 5 +++ .../video-channel/video-channel.service.ts | 10 ++++++ client/src/sass/include/_mixins.scss | 12 ++++--- server/controllers/api/users/me.ts | 18 +++++++++-- server/controllers/api/video-channel.ts | 19 ++++++++++-- server/lib/activitypub/actor.ts | 14 +++++++++ server/lib/avatar.ts | 22 +++++++++++-- 18 files changed, 206 insertions(+), 26 deletions(-) diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html index 40505b92f..b0d2ec58d 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html @@ -3,7 +3,7 @@
- +
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts index d5d019b35..c16368952 100644 --- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts +++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.ts @@ -53,4 +53,17 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked { }) ) } + + onAvatarDelete () { + this.userService.deleteAvatar() + .subscribe( + data => { + this.notifier.success($localize`Avatar deleted.`) + + this.user.updateAccountAvatar() + }, + + (err: HttpErrorResponse) => this.notifier.error(err.message) + ) + } } diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html index 5ea000400..735f9e3ba 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html +++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.component.html @@ -46,7 +46,7 @@
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts index 09db0df9d..3e20a27ee 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts +++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-edit.ts @@ -14,6 +14,7 @@ export abstract class MyVideoChannelEdit extends FormReactive { // We need this method so angular does not complain in child template that doesn't need this onAvatarChange (formData: FormData) { /* empty */ } + onAvatarDelete () { /* empty */ } // Should be implemented by the child isBulkUpdateVideosDisplayed () { diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts index c6cb5ade6..6cd1ff503 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts +++ b/client/src/app/+my-library/+my-video-channels/my-video-channel-update.component.ts @@ -11,6 +11,8 @@ import { FormValidatorService } from '@app/shared/shared-forms' import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' import { ServerConfig, VideoChannelUpdate } from '@shared/models' import { MyVideoChannelEdit } from './my-video-channel-edit' +import { HttpErrorResponse } from '@angular/common/http' +import { uploadErrorHandler } from '@app/helpers' @Component({ selector: 'my-video-channel-update', @@ -107,10 +109,27 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements this.videoChannelToUpdate.updateAvatar(data.avatar) }, - err => this.notifier.error(err.message) + (err: HttpErrorResponse) => uploadErrorHandler({ + err, + name: $localize`avatar`, + notifier: this.notifier + }) ) } + onAvatarDelete () { + this.videoChannelService.deleteVideoChannelAvatar(this.videoChannelToUpdate.name) + .subscribe( + data => { + this.notifier.success($localize`Avatar deleted.`) + + this.videoChannelToUpdate.resetAvatar() + }, + + err => this.notifier.error(err.message) + ) + } + get maxAvatarSize () { return this.serverConfig.avatar.file.size.max } diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts index 7c9569ed4..15a4f7f82 100644 --- a/client/src/app/core/users/user.model.ts +++ b/client/src/app/core/users/user.model.ts @@ -131,8 +131,9 @@ export class User implements UserServerModel { } } - updateAccountAvatar (newAccountAvatar: Avatar) { - this.account.updateAvatar(newAccountAvatar) + updateAccountAvatar (newAccountAvatar?: Avatar) { + if (newAccountAvatar) this.account.updateAvatar(newAccountAvatar) + else this.account.resetAvatar() } isUploadDisabled () { diff --git a/client/src/app/core/users/user.service.ts b/client/src/app/core/users/user.service.ts index 2f3945169..99ed3b407 100644 --- a/client/src/app/core/users/user.service.ts +++ b/client/src/app/core/users/user.service.ts @@ -123,6 +123,16 @@ export class UserService { .pipe(catchError(err => this.restExtractor.handleError(err))) } + deleteAvatar () { + const url = UserService.BASE_USERS_URL + 'me/avatar' + + return this.authHttp.delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + signup (userCreate: UserRegister) { return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) .pipe( diff --git a/client/src/app/shared/shared-main/account/account.model.ts b/client/src/app/shared/shared-main/account/account.model.ts index b3dc6cfe5..b71a893d1 100644 --- a/client/src/app/shared/shared-main/account/account.model.ts +++ b/client/src/app/shared/shared-main/account/account.model.ts @@ -44,6 +44,11 @@ export class Account extends Actor implements ServerAccount { this.updateComputedAttributes() } + resetAvatar () { + this.avatar = null + this.avatarUrl = Account.GET_DEFAULT_AVATAR_URL() + } + private updateComputedAttributes () { this.avatarUrl = Account.GET_ACTOR_AVATAR_URL(this) } diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.html b/client/src/app/shared/shared-main/account/actor-avatar-info.component.html index e63d8de2d..a34d27b26 100644 --- a/client/src/app/shared/shared-main/account/actor-avatar-info.component.html +++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.html @@ -4,12 +4,18 @@ Avatar
-
+ +
+ + + +
+ +
- - +
+
@@ -22,4 +28,16 @@
{{ actor.followersCount }} subscribers
- \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss b/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss index 7118e9471..57c298508 100644 --- a/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss +++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.scss @@ -70,3 +70,17 @@ } } } + +.actor-img-edit-container ::ng-deep .popover-avatar-info .popover-body { + padding: 0; + + .dropdown-item { + padding: 6px 10px; + border-radius: 4px; + + &:first-child { + @include peertube-file; + display: block; + } + } +} diff --git a/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts b/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts index de78a390e..451bbbba3 100644 --- a/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts +++ b/client/src/app/shared/shared-main/account/actor-avatar-info.component.ts @@ -1,22 +1,27 @@ -import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' +import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' import { Notifier, ServerService } from '@app/core' import { getBytes } from '@root-helpers/bytes' import { ServerConfig } from '@shared/models' import { VideoChannel } from '../video-channel/video-channel.model' import { Account } from '../account/account.model' +import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' +import { Actor } from './actor.model' @Component({ selector: 'my-actor-avatar-info', templateUrl: './actor-avatar-info.component.html', styleUrls: [ './actor-avatar-info.component.scss' ] }) -export class ActorAvatarInfoComponent implements OnInit { +export class ActorAvatarInfoComponent implements OnInit, OnChanges { @ViewChild('avatarfileInput') avatarfileInput: ElementRef + @ViewChild('avatarPopover') avatarPopover: NgbPopover @Input() actor: VideoChannel | Account @Output() avatarChange = new EventEmitter() + @Output() avatarDelete = new EventEmitter() + private avatarUrl: string private serverConfig: ServerConfig constructor ( @@ -30,19 +35,31 @@ export class ActorAvatarInfoComponent implements OnInit { .subscribe(config => this.serverConfig = config) } - onAvatarChange () { + ngOnChanges (changes: SimpleChanges) { + if (changes['actor']) { + this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.actor) + } + } + + onAvatarChange (input: HTMLInputElement) { + this.avatarfileInput = new ElementRef(input) + const avatarfile = this.avatarfileInput.nativeElement.files[ 0 ] if (avatarfile.size > this.maxAvatarSize) { - this.notifier.error('Error', 'This image is too large.') + this.notifier.error('Error', $localize`This image is too large.`) return } const formData = new FormData() formData.append('avatarfile', avatarfile) - + this.avatarPopover?.close() this.avatarChange.emit(formData) } + deleteAvatar () { + this.avatarDelete.emit() + } + get maxAvatarSize () { return this.serverConfig.avatar.file.size.max } @@ -58,4 +75,8 @@ export class ActorAvatarInfoComponent implements OnInit { get avatarFormat () { return `${$localize`max size`}: 192*192px, ${this.maxAvatarSizeInBytes} ${$localize`extensions`}: ${this.avatarExtensions}` } + + get hasAvatar () { + return !!this.avatarUrl + } } diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts index 4f1f5b65d..c6a63fe6c 100644 --- a/client/src/app/shared/shared-main/video-channel/video-channel.model.ts +++ b/client/src/app/shared/shared-main/video-channel/video-channel.model.ts @@ -56,6 +56,11 @@ export class VideoChannel extends Actor implements ServerVideoChannel { this.updateComputedAttributes() } + resetAvatar () { + this.avatar = null + this.avatarUrl = VideoChannel.GET_DEFAULT_AVATAR_URL() + } + private updateComputedAttributes () { this.avatarUrl = VideoChannel.GET_ACTOR_AVATAR_URL(this) } diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts index 64dcf638a..eff3fad4d 100644 --- a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts +++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts @@ -89,6 +89,16 @@ export class VideoChannelService { .pipe(catchError(err => this.restExtractor.handleError(err))) } + deleteVideoChannelAvatar (videoChannelName: string) { + const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannelName + '/avatar' + + return this.authHttp.delete(url) + .pipe( + map(this.restExtractor.extractDataBool), + catchError(err => this.restExtractor.handleError(err)) + ) + } + removeVideoChannel (videoChannel: VideoChannel) { return this.authHttp.delete(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost) .pipe( diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss index 0ce22354e..51cf4c3ed 100644 --- a/client/src/sass/include/_mixins.scss +++ b/client/src/sass/include/_mixins.scss @@ -260,15 +260,12 @@ } } -@mixin peertube-button-file ($width) { +@mixin peertube-file { position: relative; overflow: hidden; display: inline-block; - width: $width; min-height: 30px; - @include peertube-button; - input[type=file] { position: absolute; top: 0; @@ -286,6 +283,13 @@ } } +@mixin peertube-button-file ($width) { + width: $width; + + @include peertube-file; + @include peertube-button; +} + @mixin icon ($size) { display: inline-block; background-repeat: no-repeat; diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts index 7ab089713..009cf42b7 100644 --- a/server/controllers/api/users/me.ts +++ b/server/controllers/api/users/me.ts @@ -10,7 +10,7 @@ import { CONFIG } from '../../../initializers/config' import { MIMETYPES } from '../../../initializers/constants' import { sequelizeTypescript } from '../../../initializers/database' import { sendUpdateActor } from '../../../lib/activitypub/send' -import { updateActorAvatarFile } from '../../../lib/avatar' +import { deleteActorAvatarFile, updateActorAvatarFile } from '../../../lib/avatar' import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' import { asyncMiddleware, @@ -89,6 +89,11 @@ meRouter.post('/me/avatar/pick', asyncRetryTransactionMiddleware(updateMyAvatar) ) +meRouter.delete('/me/avatar', + authenticate, + asyncRetryTransactionMiddleware(deleteMyAvatar) +) + // --------------------------------------------------------------------------- export { @@ -225,7 +230,16 @@ async function updateMyAvatar (req: express.Request, res: express.Response) { const userAccount = await AccountModel.load(user.Account.id) - const avatar = await updateActorAvatarFile(avatarPhysicalFile, userAccount) + const avatar = await updateActorAvatarFile(userAccount, avatarPhysicalFile) return res.json({ avatar: avatar.toFormattedJSON() }) } + +async function deleteMyAvatar (req: express.Request, res: express.Response) { + const user = res.locals.oauth.token.user + + const userAccount = await AccountModel.load(user.Account.id) + await deleteActorAvatarFile(userAccount) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts index c48e00232..7ac01b0ef 100644 --- a/server/controllers/api/video-channel.ts +++ b/server/controllers/api/video-channel.ts @@ -13,7 +13,7 @@ import { MIMETYPES } from '../../initializers/constants' import { sequelizeTypescript } from '../../initializers/database' import { setAsyncActorKeys } from '../../lib/activitypub/actor' import { sendUpdateActor } from '../../lib/activitypub/send' -import { updateActorAvatarFile } from '../../lib/avatar' +import { deleteActorAvatarFile, updateActorAvatarFile } from '../../lib/avatar' import { JobQueue } from '../../lib/job-queue' import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' import { @@ -70,6 +70,13 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick', asyncMiddleware(updateVideoChannelAvatar) ) +videoChannelRouter.delete('/:nameWithHost/avatar', + authenticate, + // Check the rights + asyncMiddleware(videoChannelsUpdateValidator), + asyncMiddleware(deleteVideoChannelAvatar) +) + videoChannelRouter.put('/:nameWithHost', authenticate, asyncMiddleware(videoChannelsUpdateValidator), @@ -133,7 +140,7 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp const videoChannel = res.locals.videoChannel const oldVideoChannelAuditKeys = new VideoChannelAuditView(videoChannel.toFormattedJSON()) - const avatar = await updateActorAvatarFile(avatarPhysicalFile, videoChannel) + const avatar = await updateActorAvatarFile(videoChannel, avatarPhysicalFile) auditLogger.update(getAuditIdFromRes(res), new VideoChannelAuditView(videoChannel.toFormattedJSON()), oldVideoChannelAuditKeys) @@ -144,6 +151,14 @@ async function updateVideoChannelAvatar (req: express.Request, res: express.Resp .end() } +async function deleteVideoChannelAvatar (req: express.Request, res: express.Response) { + const videoChannel = res.locals.videoChannel + + await deleteActorAvatarFile(videoChannel) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + async function addVideoChannel (req: express.Request, res: express.Response) { const videoChannelInfo: VideoChannelCreate = req.body diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts index 52547536c..086d656f9 100644 --- a/server/lib/activitypub/actor.ts +++ b/server/lib/activitypub/actor.ts @@ -199,6 +199,19 @@ async function updateActorAvatarInstance (actor: MActorDefault, info: AvatarInfo return actor } +async function deleteActorAvatarInstance (actor: MActorDefault, t: Transaction) { + try { + await actor.Avatar.destroy({ transaction: t }) + } catch (err) { + logger.error('Cannot remove old avatar of actor %s.', actor.url, { err }) + } + + actor.avatarId = null + actor.Avatar = null + + return actor +} + async function fetchActorTotalItems (url: string) { const options = { uri: url, @@ -337,6 +350,7 @@ export { fetchActorTotalItems, getAvatarInfoIfExists, updateActorInstance, + deleteActorAvatarInstance, refreshActorIfNeeded, updateActorAvatarInstance, addFetchOutboxJob diff --git a/server/lib/avatar.ts b/server/lib/avatar.ts index be6657b6f..9d59a4966 100644 --- a/server/lib/avatar.ts +++ b/server/lib/avatar.ts @@ -1,7 +1,7 @@ import 'multer' import { sendUpdateActor } from './activitypub/send' import { AVATARS_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants' -import { updateActorAvatarInstance } from './activitypub/actor' +import { updateActorAvatarInstance, deleteActorAvatarInstance } from './activitypub/actor' import { processImage } from '../helpers/image-utils' import { extname, join } from 'path' import { retryTransactionWrapper } from '../helpers/database-utils' @@ -14,8 +14,8 @@ import { downloadImage } from '../helpers/requests' import { MAccountDefault, MChannelDefault } from '../types/models' async function updateActorAvatarFile ( - avatarPhysicalFile: Express.Multer.File, - accountOrChannel: MAccountDefault | MChannelDefault + accountOrChannel: MAccountDefault | MChannelDefault, + avatarPhysicalFile: Express.Multer.File ) { const extension = extname(avatarPhysicalFile.filename) const avatarName = uuidv4() + extension @@ -40,6 +40,21 @@ async function updateActorAvatarFile ( }) } +async function deleteActorAvatarFile ( + accountOrChannel: MAccountDefault | MChannelDefault +) { + return retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async t => { + const updatedActor = await deleteActorAvatarInstance(accountOrChannel.Actor, t) + await updatedActor.save({ transaction: t }) + + await sendUpdateActor(accountOrChannel, t) + + return updatedActor.Avatar + }) + }) +} + type DownloadImageQueueTask = { fileUrl: string, filename: string } const downloadImageQueue = queue((task, cb) => { @@ -64,5 +79,6 @@ const avatarPathUnsafeCache = new LRUCache({ max: LRU_CACHE.AVAT export { avatarPathUnsafeCache, updateActorAvatarFile, + deleteActorAvatarFile, pushAvatarProcessInQueue } -- 2.41.0