From: Chocobozzz Date: Thu, 4 Jan 2018 10:19:16 +0000 (+0100) Subject: Add ability to delete comments X-Git-Tag: v0.0.1-alpha~54 X-Git-Url: https://git.immae.eu/?a=commitdiff_plain;h=4cb6d4578893db310297d7e118ce2fb7ecb952a3;p=github%2FChocobozzz%2FPeerTube.git Add ability to delete comments --- diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index b1818c298..ef8597203 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core' import { Router } from '@angular/router' -import { AuthService, ServerService } from './core' +import { AuthService, ServerService } from '@app/core' @Component({ selector: 'my-app', @@ -50,10 +50,6 @@ export class AppComponent implements OnInit { } } - isInAdmin () { - return this.router.url.indexOf('/admin/') !== -1 - } - toggleMenu () { window.scrollTo(0, 0) this.isMenuDisplayed = !this.isMenuDisplayed diff --git a/client/src/app/shared/account/account.model.ts b/client/src/app/shared/account/account.model.ts index cc46dad77..1dce0003c 100644 --- a/client/src/app/shared/account/account.model.ts +++ b/client/src/app/shared/account/account.model.ts @@ -1,11 +1,11 @@ import { Account as ServerAccount } from '../../../../../shared/models/actors/account.model' import { Avatar } from '../../../../../shared/models/avatars/avatar.model' -import { environment } from '../../../environments/environment' import { getAbsoluteAPIUrl } from '../misc/utils' export class Account implements ServerAccount { id: number uuid: string + url: string name: string displayName: string host: string diff --git a/client/src/app/videos/+video-edit/shared/video-edit.component.scss b/client/src/app/videos/+video-edit/shared/video-edit.component.scss index 0fefcee28..1df9d4006 100644 --- a/client/src/app/videos/+video-edit/shared/video-edit.component.scss +++ b/client/src/app/videos/+video-edit/shared/video-edit.component.scss @@ -51,8 +51,6 @@ .submit-container { text-align: right; - position: relative; - bottom: $button-height; .message-submit { display: inline-block; diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.html b/client/src/app/videos/+video-watch/comment/video-comment.component.html index e9c23929c..4f9597607 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.html @@ -3,13 +3,14 @@
{{ comment.text }}
Reply
+
Delete
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.scss b/client/src/app/videos/+video-watch/comment/video-comment.component.scss index aae03ab6d..a22c5a9fd 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.scss +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.scss @@ -20,6 +20,9 @@ margin-bottom: 4px; .comment-account { + @include disable-default-a-behaviour; + + color: #000; font-weight: $font-bold; } @@ -31,10 +34,16 @@ .comment-actions { margin: 10px 0; + display: flex; - .comment-action-reply { + .comment-action-reply, .comment-action-delete { color: #585858; cursor: pointer; + margin-right: 10px; + + &:hover { + color: #000; + } } } } diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts index b305c639a..9bc9c8844 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts @@ -1,5 +1,6 @@ import { Component, EventEmitter, Input, Output } from '@angular/core' import { Account as AccountInterface } from '../../../../../../shared/models/actors' +import { UserRight } from '../../../../../../shared/models/users' import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' import { AuthService } from '../../../core/auth' import { Account } from '../../../shared/account/account.model' @@ -17,7 +18,9 @@ export class VideoCommentComponent { @Input() commentTree: VideoCommentThreadTree @Input() inReplyToCommentId: number + @Output() wantedToDelete = new EventEmitter() @Output() wantedToReply = new EventEmitter() + @Output() threadCreated = new EventEmitter() @Output() resetReply = new EventEmitter() constructor (private authService: AuthService) {} @@ -32,6 +35,8 @@ export class VideoCommentComponent { comment: this.comment, children: [] } + + this.threadCreated.emit(this.commentTree) } this.commentTree.children.push({ @@ -41,17 +46,16 @@ export class VideoCommentComponent { this.resetReply.emit() } - onWantToReply () { - this.wantedToReply.emit(this.comment) + onWantToReply (comment?: VideoComment) { + this.wantedToReply.emit(comment || this.comment) } - isUserLoggedIn () { - return this.authService.isLoggedIn() + onWantToDelete (comment?: VideoComment) { + this.wantedToDelete.emit(comment || this.comment) } - // Event from child comment - onWantedToReply (comment: VideoComment) { - this.wantedToReply.emit(comment) + isUserLoggedIn () { + return this.authService.isLoggedIn() } onResetReply () { @@ -61,4 +65,12 @@ export class VideoCommentComponent { getAvatarUrl (account: AccountInterface) { return Account.GET_ACCOUNT_AVATAR_URL(account) } + + isRemovableByUser () { + return this.isUserLoggedIn() && + ( + this.user.account.id === this.comment.account.id || + this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) + ) + } } diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts index 2fe6cc3e9..c42f55496 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.service.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.service.ts @@ -66,6 +66,15 @@ export class VideoCommentService { .catch((res) => this.restExtractor.handleError(res)) } + deleteVideoComment (videoId: number | string, commentId: number) { + const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}` + + return this.authHttp + .delete(url) + .map(this.restExtractor.extractDataBool) + .catch((res) => this.restExtractor.handleError(res)) + } + private extractVideoComment (videoComment: VideoCommentServerModel) { return new VideoComment(videoComment) } diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.html b/client/src/app/videos/+video-watch/comment/video-comments.component.html index 4a4248073..80b200931 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.html @@ -27,6 +27,8 @@ [inReplyToCommentId]="inReplyToCommentId" [commentTree]="threadComments[comment.id]" (wantedToReply)="onWantedToReply($event)" + (wantedToDelete)="onWantedToDelete($event)" + (threadCreated)="onThreadCreated($event)" (resetReply)="onResetReply()" > diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts index 1230725c1..030dee9af 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts @@ -1,6 +1,7 @@ import { Component, Input, OnInit } from '@angular/core' +import { ConfirmService } from '@app/core' import { NotificationsService } from 'angular2-notifications' -import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' +import { VideoComment as VideoCommentInterface, VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model' import { AuthService } from '../../../core/auth' import { ComponentPagination } from '../../../shared/rest/component-pagination.model' import { User } from '../../../shared/users' @@ -32,6 +33,7 @@ export class VideoCommentsComponent implements OnInit { constructor ( private authService: AuthService, private notificationsService: NotificationsService, + private confirmService: ConfirmService, private videoCommentService: VideoCommentService ) {} @@ -41,7 +43,7 @@ export class VideoCommentsComponent implements OnInit { } } - viewReplies (comment: VideoComment) { + viewReplies (comment: VideoCommentInterface) { this.threadLoading[comment.id] = true this.videoCommentService.getVideoThreadComments(this.video.id, comment.id) @@ -79,6 +81,44 @@ export class VideoCommentsComponent implements OnInit { this.inReplyToCommentId = undefined } + onThreadCreated (commentTree: VideoCommentThreadTree) { + this.viewReplies(commentTree.comment) + } + + onWantedToDelete (commentToDelete: VideoComment) { + let message = 'Do you really want to delete this comment?' + if (commentToDelete.totalReplies !== 0) message += `${commentToDelete.totalReplies} would be deleted too.` + + this.confirmService.confirm(message, 'Delete').subscribe( + res => { + if (res === false) return + + this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) + .subscribe( + () => { + // Delete the comment in the tree + if (commentToDelete.inReplyToCommentId) { + const thread = this.threadComments[commentToDelete.threadId] + if (!thread) { + console.error(`Cannot find thread ${commentToDelete.threadId} of the comment to delete ${commentToDelete.id}`) + return + } + + this.deleteLocalCommentThread(thread, commentToDelete) + return + } + + // Delete the thread + this.comments = this.comments.filter(c => c.id !== commentToDelete.id) + this.componentPagination.totalItems-- + }, + + err => this.notificationsService.error('Error', err.message) + ) + } + ) + } + isUserLoggedIn () { return this.authService.isLoggedIn() } @@ -91,7 +131,7 @@ export class VideoCommentsComponent implements OnInit { } } - protected hasMoreComments () { + private hasMoreComments () { // No results if (this.componentPagination.totalItems === 0) return false @@ -101,4 +141,15 @@ export class VideoCommentsComponent implements OnInit { const maxPage = this.componentPagination.totalItems / this.componentPagination.itemsPerPage return maxPage > this.componentPagination.currentPage } + + private deleteLocalCommentThread (parentComment: VideoCommentThreadTree, commentToDelete: VideoComment) { + for (const commentChild of parentComment.children) { + if (commentChild.comment.id === commentToDelete.id) { + parentComment.children = parentComment.children.filter(c => c.comment.id !== commentToDelete.id) + return + } + + this.deleteLocalCommentThread(commentChild, commentToDelete) + } + } } diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts index 0f44d3dd7..f1f194764 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.ts +++ b/client/src/app/videos/+video-watch/video-watch.component.ts @@ -137,7 +137,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy { blacklistVideo (event: Event) { event.preventDefault() - this.confirmService.confirm('Do you really want to blacklist this video ?', 'Blacklist').subscribe( + this.confirmService.confirm('Do you really want to blacklist this video?', 'Blacklist').subscribe( res => { if (res === false) return diff --git a/client/tsconfig.json b/client/tsconfig.json index a6c016bf3..43b27ce8e 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -14,6 +14,10 @@ "lib": [ "es2017", "dom" - ] + ], + "baseUrl": "src", + "paths": { + "@app/*": [ "app/*" ] + } } } diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts index e09b242ed..65fcf6b35 100644 --- a/server/controllers/api/videos/comment.ts +++ b/server/controllers/api/videos/comment.ts @@ -2,14 +2,15 @@ import * as express from 'express' import { ResultList } from '../../../../shared/models' import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' import { retryTransactionWrapper } from '../../../helpers/database-utils' +import { logger } from '../../../helpers/logger' import { getFormattedObjects } from '../../../helpers/utils' import { sequelizeTypescript } from '../../../initializers' import { buildFormattedCommentTree, createVideoComment } from '../../../lib/video-comment' import { asyncMiddleware, authenticate, paginationValidator, setPagination, setVideoCommentThreadsSort } from '../../../middlewares' import { videoCommentThreadsSortValidator } from '../../../middlewares/validators' import { - addVideoCommentReplyValidator, addVideoCommentThreadValidator, listVideoCommentThreadsValidator, - listVideoThreadCommentsValidator + addVideoCommentReplyValidator, addVideoCommentThreadValidator, listVideoCommentThreadsValidator, listVideoThreadCommentsValidator, + removeVideoCommentValidator } from '../../../middlewares/validators/video-comments' import { VideoModel } from '../../../models/video/video' import { VideoCommentModel } from '../../../models/video/video-comment' @@ -39,6 +40,11 @@ videoCommentRouter.post('/:videoId/comments/:commentId', asyncMiddleware(addVideoCommentReplyValidator), asyncMiddleware(addVideoCommentReplyRetryWrapper) ) +videoCommentRouter.delete('/:videoId/comments/:commentId', + authenticate, + asyncMiddleware(removeVideoCommentValidator), + asyncMiddleware(removeVideoCommentRetryWrapper) +) // --------------------------------------------------------------------------- @@ -131,3 +137,24 @@ function addVideoCommentReply (req: express.Request, res: express.Response, next }, t) }) } + +async function removeVideoCommentRetryWrapper (req: express.Request, res: express.Response, next: express.NextFunction) { + const options = { + arguments: [ req, res ], + errorMessage: 'Cannot remove the video comment with many retries.' + } + + await retryTransactionWrapper(removeVideoComment, options) + + return res.type('json').status(204).end() +} + +async function removeVideoComment (req: express.Request, res: express.Response) { + const videoCommentInstance: VideoCommentModel = res.locals.videoComment + + await sequelizeTypescript.transaction(async t => { + await videoCommentInstance.destroy({ transaction: t }) + }) + + logger.info('Video comment %d deleted.', videoCommentInstance.id) +} diff --git a/server/helpers/custom-validators/activitypub/activity.ts b/server/helpers/custom-validators/activitypub/activity.ts index 856c87f2c..577cf4b52 100644 --- a/server/helpers/custom-validators/activitypub/activity.ts +++ b/server/helpers/custom-validators/activitypub/activity.ts @@ -5,7 +5,7 @@ import { isAnnounceActivityValid } from './announce' import { isActivityPubUrlValid } from './misc' import { isDislikeActivityValid, isLikeActivityValid } from './rate' import { isUndoActivityValid } from './undo' -import { isVideoCommentCreateActivityValid } from './video-comments' +import { isVideoCommentCreateActivityValid, isVideoCommentDeleteActivityValid } from './video-comments' import { isVideoFlagValid, isVideoTorrentCreateActivityValid, @@ -70,7 +70,8 @@ function checkUpdateActivity (activity: any) { function checkDeleteActivity (activity: any) { return isVideoTorrentDeleteActivityValid(activity) || - isActorDeleteActivityValid(activity) + isActorDeleteActivityValid(activity) || + isVideoCommentDeleteActivityValid(activity) } function checkFollowActivity (activity: any) { diff --git a/server/helpers/custom-validators/activitypub/video-comments.ts b/server/helpers/custom-validators/activitypub/video-comments.ts index 489ff27de..6928aced3 100644 --- a/server/helpers/custom-validators/activitypub/video-comments.ts +++ b/server/helpers/custom-validators/activitypub/video-comments.ts @@ -18,10 +18,15 @@ function isVideoCommentObjectValid (comment: any) { isActivityPubUrlValid(comment.url) } +function isVideoCommentDeleteActivityValid (activity: any) { + return isBaseActivityValid(activity, 'Delete') +} + // --------------------------------------------------------------------------- export { - isVideoCommentCreateActivityValid + isVideoCommentCreateActivityValid, + isVideoCommentDeleteActivityValid } // --------------------------------------------------------------------------- diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts index 523a31822..604570e74 100644 --- a/server/lib/activitypub/process/process-delete.ts +++ b/server/lib/activitypub/process/process-delete.ts @@ -6,6 +6,7 @@ import { AccountModel } from '../../../models/account/account' import { ActorModel } from '../../../models/activitypub/actor' import { VideoModel } from '../../../models/video/video' import { VideoChannelModel } from '../../../models/video/video-channel' +import { VideoCommentModel } from '../../../models/video/video-comment' import { getOrCreateActorAndServerAndModel } from '../actor' async function processDeleteActivity (activity: ActivityDelete) { @@ -24,9 +25,16 @@ async function processDeleteActivity (activity: ActivityDelete) { } { - let videoObject = await VideoModel.loadByUrlAndPopulateAccount(activity.id) - if (videoObject !== undefined) { - return processDeleteVideo(actor, videoObject) + const videoCommentInstance = await VideoCommentModel.loadByUrlAndPopulateAccount(activity.id) + if (videoCommentInstance) { + return processDeleteVideoComment(actor, videoCommentInstance) + } + } + + { + const videoInstance = await VideoModel.loadByUrlAndPopulateAccount(activity.id) + if (videoInstance) { + return processDeleteVideo(actor, videoInstance) } } @@ -101,3 +109,22 @@ async function deleteRemoteVideoChannel (videoChannelToRemove: VideoChannelModel logger.info('Remote video channel with uuid %s removed.', videoChannelToRemove.Actor.uuid) } + +async function processDeleteVideoComment (actor: ActorModel, videoComment: VideoCommentModel) { + const options = { + arguments: [ actor, videoComment ], + errorMessage: 'Cannot remove the remote video comment with many retries.' + } + + await retryTransactionWrapper(deleteRemoteVideoComment, options) +} + +function deleteRemoteVideoComment (actor: ActorModel, videoComment: VideoCommentModel) { + logger.debug('Removing remote video comment "%s".', videoComment.url) + + return sequelizeTypescript.transaction(async t => { + await videoComment.destroy({ transaction: t }) + + logger.info('Remote video comment %s removed.', videoComment.url) + }) +} diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts index 4bc5db77e..1ca031898 100644 --- a/server/lib/activitypub/send/send-delete.ts +++ b/server/lib/activitypub/send/send-delete.ts @@ -2,6 +2,7 @@ import { Transaction } from 'sequelize' import { ActivityDelete } from '../../../../shared/models/activitypub' import { ActorModel } from '../../../models/activitypub/actor' import { VideoModel } from '../../../models/video/video' +import { VideoCommentModel } from '../../../models/video/video-comment' import { VideoShareModel } from '../../../models/video/video-share' import { broadcastToFollowers } from './misc' @@ -22,11 +23,24 @@ async function sendDeleteActor (byActor: ActorModel, t: Transaction) { return broadcastToFollowers(data, byActor, [ byActor ], t) } +async function sendDeleteVideoComment (videoComment: VideoCommentModel, t: Transaction) { + const byActor = videoComment.Account.Actor + + const data = deleteActivityData(videoComment.url, byActor) + + const actorsInvolved = await VideoShareModel.loadActorsByShare(videoComment.Video.id, t) + actorsInvolved.push(videoComment.Video.VideoChannel.Account.Actor) + actorsInvolved.push(byActor) + + return broadcastToFollowers(data, byActor, actorsInvolved, t) +} + // --------------------------------------------------------------------------- export { sendDeleteVideo, - sendDeleteActor + sendDeleteActor, + sendDeleteVideoComment } // --------------------------------------------------------------------------- diff --git a/server/middlewares/validators/video-comments.ts b/server/middlewares/validators/video-comments.ts index ade0b7b9f..63804da30 100644 --- a/server/middlewares/validators/video-comments.ts +++ b/server/middlewares/validators/video-comments.ts @@ -1,9 +1,11 @@ import * as express from 'express' import { body, param } from 'express-validator/check' +import { UserRight } from '../../../shared' import { isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' import { isValidVideoCommentText } from '../../helpers/custom-validators/video-comments' import { isVideoExist } from '../../helpers/custom-validators/videos' import { logger } from '../../helpers/logger' +import { UserModel } from '../../models/account/user' import { VideoModel } from '../../models/video/video' import { VideoCommentModel } from '../../models/video/video-comment' import { areValidationErrors } from './utils' @@ -83,6 +85,24 @@ const videoCommentGetValidator = [ } ] +const removeVideoCommentValidator = [ + param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), + param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.debug('Checking removeVideoCommentValidator parameters.', { parameters: req.params }) + + if (areValidationErrors(req, res)) return + if (!await isVideoExist(req.params.videoId, res)) return + if (!await isVideoCommentExist(req.params.commentId, res.locals.video, res)) return + + // Check if the user who did the request is able to delete the video + if (!checkUserCanDeleteVideoComment(res.locals.oauth.token.User, res.locals.videoComment, res)) return + + return next() + } +] + // --------------------------------------------------------------------------- export { @@ -90,7 +110,8 @@ export { listVideoThreadCommentsValidator, addVideoCommentThreadValidator, addVideoCommentReplyValidator, - videoCommentGetValidator + videoCommentGetValidator, + removeVideoCommentValidator } // --------------------------------------------------------------------------- @@ -160,3 +181,15 @@ function isVideoCommentsEnabled (video: VideoModel, res: express.Response) { return true } + +function checkUserCanDeleteVideoComment (user: UserModel, videoComment: VideoCommentModel, res: express.Response) { + const account = videoComment.Account + if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) === false && account.userId !== user.id) { + res.status(403) + .json({ error: 'Cannot remove video comment of another user' }) + .end() + return false + } + + return true +} diff --git a/server/middlewares/validators/videos.ts b/server/middlewares/validators/videos.ts index e8cb2ae03..1acb306c0 100644 --- a/server/middlewares/validators/videos.ts +++ b/server/middlewares/validators/videos.ts @@ -253,7 +253,7 @@ function checkUserCanDeleteVideo (user: UserModel, video: VideoModel, res: expre } // Check if the user can delete the video - // The user can delete it if s/he is an admin + // The user can delete it if he has the right // Or if s/he is the video's account const account = video.VideoChannel.Account if (user.hasRight(UserRight.REMOVE_ANY_VIDEO) === false && account.userId !== user.id) { diff --git a/server/models/activitypub/actor.ts b/server/models/activitypub/actor.ts index 2ef7c77a2..ed7fcfe27 100644 --- a/server/models/activitypub/actor.ts +++ b/server/models/activitypub/actor.ts @@ -271,6 +271,7 @@ export class ActorModel extends Model { return { id: this.id, + url: this.url, uuid: this.uuid, host: this.getHost(), score, diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index d2d8945c3..66fca2484 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -7,12 +7,14 @@ import { VideoCommentObject } from '../../../shared/models/activitypub/objects/v import { VideoComment } from '../../../shared/models/videos/video-comment.model' import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' import { CONSTRAINTS_FIELDS } from '../../initializers' +import { sendDeleteVideoComment } from '../../lib/activitypub/send' import { AccountModel } from '../account/account' import { ActorModel } from '../activitypub/actor' import { AvatarModel } from '../avatar/avatar' import { ServerModel } from '../server/server' import { getSort, throwIfNotValid } from '../utils' import { VideoModel } from './video' +import { VideoChannelModel } from './video-channel' enum ScopeNames { WITH_ACCOUNT = 'WITH_ACCOUNT', @@ -70,7 +72,25 @@ enum ScopeNames { include: [ { model: () => VideoModel, - required: false + required: true, + include: [ + { + model: () => VideoChannelModel.unscoped(), + required: true, + include: [ + { + model: () => AccountModel, + required: true, + include: [ + { + model: () => ActorModel, + required: true + } + ] + } + ] + } + ] } ] } @@ -155,9 +175,10 @@ export class VideoCommentModel extends Model { Account: AccountModel @AfterDestroy - static sendDeleteIfOwned (instance: VideoCommentModel) { - // TODO - return undefined + static async sendDeleteIfOwned (instance: VideoCommentModel) { + if (instance.isOwned()) { + await sendDeleteVideoComment(instance, undefined) + } } static loadById (id: number, t?: Sequelize.Transaction) { @@ -198,6 +219,18 @@ export class VideoCommentModel extends Model { return VideoCommentModel.findOne(query) } + static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) { + const query: IFindOptions = { + where: { + url + } + } + + if (t !== undefined) query.transaction = t + + return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query) + } + static listThreadsForApi (videoId: number, start: number, count: number, sort: string) { const query = { offset: start, @@ -237,6 +270,10 @@ export class VideoCommentModel extends Model { }) } + isOwned () { + return this.Account.isOwned() + } + toFormattedJSON () { return { id: this.id, diff --git a/server/models/video/video.ts b/server/models/video/video.ts index c4b716cd2..4d15c2a50 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -43,7 +43,8 @@ import { VideoTagModel } from './video-tag' enum ScopeNames { AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', - WITH_ACCOUNT = 'WITH_ACCOUNT', + WITH_ACCOUNT_API = 'WITH_ACCOUNT_API', + WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', WITH_TAGS = 'WITH_TAGS', WITH_FILES = 'WITH_FILES', WITH_SHARES = 'WITH_SHARES', @@ -62,7 +63,35 @@ enum ScopeNames { privacy: VideoPrivacy.PUBLIC } }, - [ScopeNames.WITH_ACCOUNT]: { + [ScopeNames.WITH_ACCOUNT_API]: { + include: [ + { + model: () => VideoChannelModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'name' ], + model: () => AccountModel.unscoped(), + required: true, + include: [ + { + attributes: [ 'serverId' ], + model: () => ActorModel.unscoped(), + required: true, + include: [ + { + model: () => ServerModel.unscoped(), + required: false + } + ] + } + ] + } + ] + } + ] + }, + [ScopeNames.WITH_ACCOUNT_DETAILS]: { include: [ { model: () => VideoChannelModel, @@ -146,6 +175,9 @@ enum ScopeNames { }, { fields: [ 'channelId' ] + }, + { + fields: [ 'id', 'privacy' ] } ] }) @@ -461,7 +493,7 @@ export class VideoModel extends Model { order: [ getSort(sort) ] } - return VideoModel.scope([ ScopeNames.AVAILABLE_FOR_LIST, ScopeNames.WITH_ACCOUNT ]) + return VideoModel.scope([ ScopeNames.AVAILABLE_FOR_LIST, ScopeNames.WITH_ACCOUNT_API ]) .findAndCountAll(query) .then(({ rows, count }) => { return { @@ -496,7 +528,7 @@ export class VideoModel extends Model { if (t !== undefined) query.transaction = t - return VideoModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_FILES ]).findOne(query) + return VideoModel.scope([ ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_FILES ]).findOne(query) } static loadByUUIDOrURL (uuid: string, url: string, t?: Sequelize.Transaction) { @@ -520,7 +552,7 @@ export class VideoModel extends Model { } return VideoModel - .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ]) + .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ]) .findById(id, options) } @@ -545,7 +577,7 @@ export class VideoModel extends Model { } return VideoModel - .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT ]) + .scope([ ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, ScopeNames.WITH_ACCOUNT_DETAILS ]) .findOne(options) } @@ -563,7 +595,7 @@ export class VideoModel extends Model { ScopeNames.WITH_SHARES, ScopeNames.WITH_TAGS, ScopeNames.WITH_FILES, - ScopeNames.WITH_ACCOUNT, + ScopeNames.WITH_ACCOUNT_DETAILS, ScopeNames.WITH_COMMENTS ]) .findOne(options) diff --git a/server/tests/api/check-params/video-comments.ts b/server/tests/api/check-params/video-comments.ts index c11660d07..9190054da 100644 --- a/server/tests/api/check-params/video-comments.ts +++ b/server/tests/api/check-params/video-comments.ts @@ -3,8 +3,9 @@ import * as chai from 'chai' import 'mocha' import { - flushTests, killallServers, makeGetRequest, makePostBodyRequest, runServer, ServerInfo, setAccessTokensToServers, - uploadVideo + createUser, + flushTests, killallServers, makeDeleteRequest, makeGetRequest, makePostBodyRequest, runServer, ServerInfo, setAccessTokensToServers, + uploadVideo, userLogin } from '../../utils' import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '../../utils/requests/check-api-params' import { addVideoCommentThread } from '../../utils/videos/video-comments' @@ -16,6 +17,7 @@ describe('Test video comments API validator', function () { let pathComment: string let server: ServerInfo let videoUUID: string + let userAccessToken: string let commentId: number // --------------------------------------------------------------- @@ -40,6 +42,15 @@ describe('Test video comments API validator', function () { commentId = res.body.comment.id pathComment = '/api/v1/videos/' + videoUUID + '/comments/' + commentId } + + { + const user = { + username: 'user1', + password: 'my super password' + } + await createUser(server.url, server.accessToken, user.username, user.password) + userAccessToken = await userLogin(server, user) + } }) describe('When listing video comment threads', function () { @@ -185,6 +196,30 @@ describe('Test video comments API validator', function () { }) }) + describe('When removing video comments', function () { + it('Should fail with a non authenticated user', async function () { + await makeDeleteRequest({ url: server.url, path: pathComment, token: 'none', statusCodeExpected: 401 }) + }) + + it('Should fail with another user', async function () { + await makeDeleteRequest({ url: server.url, path: pathComment, token: userAccessToken, statusCodeExpected: 403 }) + }) + + it('Should fail with an incorrect video', async function () { + const path = '/api/v1/videos/ba708d62-e3d7-45d9-9d73-41b9097cc02d/comments/' + commentId + await makeDeleteRequest({ url: server.url, path, token: server.accessToken, statusCodeExpected: 404 }) + }) + + it('Should fail with an incorrect comment', async function () { + const path = '/api/v1/videos/' + videoUUID + '/comments/124' + await makeDeleteRequest({ url: server.url, path, token: server.accessToken, statusCodeExpected: 404 }) + }) + + it('Should succeed with the correct parameters', async function () { + await makeDeleteRequest({ url: server.url, path: pathComment, token: server.accessToken, statusCodeExpected: 204 }) + }) + }) + describe('When a video has comments disabled', function () { before(async function () { const res = await uploadVideo(server.url, server.accessToken, { commentsEnabled: false }) diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts index b6dfe0d1b..6712829d4 100644 --- a/server/tests/api/videos/multiple-servers.ts +++ b/server/tests/api/videos/multiple-servers.ts @@ -13,7 +13,7 @@ import { updateVideo, uploadVideo, userLogin, viewVideo, wait, webtorrentAdd } from '../../utils' import { - addVideoCommentReply, addVideoCommentThread, getVideoCommentThreads, + addVideoCommentReply, addVideoCommentThread, deleteVideoComment, getVideoCommentThreads, getVideoThreadComments } from '../../utils/videos/video-comments' @@ -738,6 +738,37 @@ describe('Test multiple servers', function () { } }) + it('Should delete the thread comments', async function () { + this.timeout(10000) + + const res1 = await getVideoCommentThreads(servers[0].url, videoUUID, 0, 5) + const threadId = res1.body.data.find(c => c.text === 'my super first comment').id + await deleteVideoComment(servers[0].url, servers[0].accessToken, videoUUID, threadId) + + await wait(5000) + }) + + it('Should have the thread comments deleted on other servers too', async function () { + for (const server of servers) { + const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5) + + expect(res.body.total).to.equal(1) + expect(res.body.data).to.be.an('array') + expect(res.body.data).to.have.lengthOf(1) + + { + const comment: VideoComment = res.body.data[0] + expect(comment).to.not.be.undefined + expect(comment.inReplyToCommentId).to.be.null + expect(comment.account.name).to.equal('root') + expect(comment.account.host).to.equal('localhost:9003') + expect(comment.totalReplies).to.equal(0) + expect(dateIsValid(comment.createdAt as string)).to.be.true + expect(dateIsValid(comment.updatedAt as string)).to.be.true + } + } + }) + it('Should disable comments', async function () { this.timeout(20000) diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts index 604a3027d..18d484ccf 100644 --- a/server/tests/api/videos/video-comments.ts +++ b/server/tests/api/videos/video-comments.ts @@ -9,7 +9,7 @@ import { uploadVideo } from '../../utils/index' import { - addVideoCommentReply, addVideoCommentThread, getVideoCommentThreads, + addVideoCommentReply, addVideoCommentThread, deleteVideoComment, getVideoCommentThreads, getVideoThreadComments } from '../../utils/videos/video-comments' @@ -20,6 +20,7 @@ describe('Test video comments', function () { let videoId let videoUUID let threadId + let replyToDeleteId: number before(async function () { this.timeout(10000) @@ -61,6 +62,7 @@ describe('Test video comments', function () { expect(comment.id).to.equal(comment.threadId) expect(comment.account.name).to.equal('root') expect(comment.account.host).to.equal('localhost:9001') + expect(comment.account.url).to.equal('http://localhost:9001/accounts/root') expect(comment.totalReplies).to.equal(0) expect(dateIsValid(comment.createdAt as string)).to.be.true expect(dateIsValid(comment.updatedAt as string)).to.be.true @@ -132,6 +134,8 @@ describe('Test video comments', function () { const secondChild = tree.children[1] expect(secondChild.comment.text).to.equal('my second answer to thread 1') expect(secondChild.children).to.have.lengthOf(0) + + replyToDeleteId = secondChild.comment.id }) it('Should create other threads', async function () { @@ -157,6 +161,38 @@ describe('Test video comments', function () { expect(res.body.data[2].totalReplies).to.equal(0) }) + it('Should delete a reply', async function () { + await deleteVideoComment(server.url, server.accessToken, videoId, replyToDeleteId) + + const res = await getVideoThreadComments(server.url, videoUUID, threadId) + + const tree: VideoCommentThreadTree = res.body + expect(tree.comment.text).equal('my super first comment') + expect(tree.children).to.have.lengthOf(1) + + const firstChild = tree.children[0] + expect(firstChild.comment.text).to.equal('my super answer to thread 1') + expect(firstChild.children).to.have.lengthOf(1) + + const childOfFirstChild = firstChild.children[0] + expect(childOfFirstChild.comment.text).to.equal('my super answer to answer of thread 1') + expect(childOfFirstChild.children).to.have.lengthOf(0) + }) + + it('Should delete a complete thread', async function () { + await deleteVideoComment(server.url, server.accessToken, videoId, threadId) + + const res = await getVideoCommentThreads(server.url, videoUUID, 0, 5, 'createdAt') + expect(res.body.total).to.equal(2) + expect(res.body.data).to.be.an('array') + expect(res.body.data).to.have.lengthOf(2) + + expect(res.body.data[0].text).to.equal('super thread 2') + expect(res.body.data[0].totalReplies).to.equal(0) + expect(res.body.data[1].text).to.equal('super thread 3') + expect(res.body.data[1].totalReplies).to.equal(0) + }) + after(async function () { killallServers([ server ]) diff --git a/server/tests/utils/videos/video-comments.ts b/server/tests/utils/videos/video-comments.ts index 878147049..1b9ee452e 100644 --- a/server/tests/utils/videos/video-comments.ts +++ b/server/tests/utils/videos/video-comments.ts @@ -1,4 +1,5 @@ import * as request from 'supertest' +import { makeDeleteRequest } from '../' function getVideoCommentThreads (url: string, videoId: number | string, start: number, count: number, sort?: string) { const path = '/api/v1/videos/' + videoId + '/comment-threads' @@ -54,11 +55,29 @@ function addVideoCommentReply ( .expect(expectedStatus) } +function deleteVideoComment ( + url: string, + token: string, + videoId: number | string, + commentId: number, + statusCodeExpected = 204 +) { + const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + + return makeDeleteRequest({ + url, + path, + token, + statusCodeExpected + }) +} + // --------------------------------------------------------------------------- export { getVideoCommentThreads, getVideoThreadComments, addVideoCommentThread, - addVideoCommentReply + addVideoCommentReply, + deleteVideoComment } diff --git a/shared/models/actors/account.model.ts b/shared/models/actors/account.model.ts index ef6fca539..e4dbc81e5 100644 --- a/shared/models/actors/account.model.ts +++ b/shared/models/actors/account.model.ts @@ -3,6 +3,7 @@ import { Avatar } from '../avatars/avatar.model' export interface Account { id: number uuid: string + url: string name: string displayName: string host: string diff --git a/shared/models/users/user-right.enum.ts b/shared/models/users/user-right.enum.ts index 238e38a36..2e7fa1bcf 100644 --- a/shared/models/users/user-right.enum.ts +++ b/shared/models/users/user-right.enum.ts @@ -6,5 +6,6 @@ export enum UserRight { MANAGE_VIDEO_BLACKLIST, MANAGE_JOBS, REMOVE_ANY_VIDEO, - REMOVE_ANY_VIDEO_CHANNEL + REMOVE_ANY_VIDEO_CHANNEL, + REMOVE_ANY_VIDEO_COMMENT } diff --git a/shared/models/users/user-role.ts b/shared/models/users/user-role.ts index cc32c768d..0e75444f8 100644 --- a/shared/models/users/user-role.ts +++ b/shared/models/users/user-role.ts @@ -23,7 +23,8 @@ const userRoleRights: { [ id: number ]: UserRight[] } = { UserRight.MANAGE_VIDEO_BLACKLIST, UserRight.MANAGE_VIDEO_ABUSES, UserRight.REMOVE_ANY_VIDEO, - UserRight.REMOVE_ANY_VIDEO_CHANNEL + UserRight.REMOVE_ANY_VIDEO_CHANNEL, + UserRight.REMOVE_ANY_VIDEO_COMMENT ], [UserRole.USER]: [] diff --git a/shared/models/videos/video-channel.model.ts b/shared/models/videos/video-channel.model.ts index ee56c54b6..d1a952826 100644 --- a/shared/models/videos/video-channel.model.ts +++ b/shared/models/videos/video-channel.model.ts @@ -3,6 +3,7 @@ import { Video } from './video.model' export interface VideoChannel { id: number name: string + url: string description: string isLocal: boolean createdAt: Date | string