Add ability to delete comments
authorChocobozzz <me@florianbigard.com>
Thu, 4 Jan 2018 10:19:16 +0000 (11:19 +0100)
committerChocobozzz <me@florianbigard.com>
Thu, 4 Jan 2018 10:19:16 +0000 (11:19 +0100)
29 files changed:
client/src/app/app.component.ts
client/src/app/shared/account/account.model.ts
client/src/app/videos/+video-edit/shared/video-edit.component.scss
client/src/app/videos/+video-watch/comment/video-comment.component.html
client/src/app/videos/+video-watch/comment/video-comment.component.scss
client/src/app/videos/+video-watch/comment/video-comment.component.ts
client/src/app/videos/+video-watch/comment/video-comment.service.ts
client/src/app/videos/+video-watch/comment/video-comments.component.html
client/src/app/videos/+video-watch/comment/video-comments.component.ts
client/src/app/videos/+video-watch/video-watch.component.ts
client/tsconfig.json
server/controllers/api/videos/comment.ts
server/helpers/custom-validators/activitypub/activity.ts
server/helpers/custom-validators/activitypub/video-comments.ts
server/lib/activitypub/process/process-delete.ts
server/lib/activitypub/send/send-delete.ts
server/middlewares/validators/video-comments.ts
server/middlewares/validators/videos.ts
server/models/activitypub/actor.ts
server/models/video/video-comment.ts
server/models/video/video.ts
server/tests/api/check-params/video-comments.ts
server/tests/api/videos/multiple-servers.ts
server/tests/api/videos/video-comments.ts
server/tests/utils/videos/video-comments.ts
shared/models/actors/account.model.ts
shared/models/users/user-right.enum.ts
shared/models/users/user-role.ts
shared/models/videos/video-channel.model.ts

index b1818c298eb0545b31ec433bde20c9a60516e190..ef85972033e8afa0d0b4c759111ea6f4d5509f35 100644 (file)
@@ -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
index cc46dad77d1cca24e6d371ab183787f3d60edf54..1dce0003ca0dd9dd02d57a4024242bc800ac13f9 100644 (file)
@@ -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
index 0fefcee28d94c1f1af142911380ed11afddcfaf5..1df9d400610286ef8eac4ffb146748361272d8fd 100644 (file)
@@ -51,8 +51,6 @@
 
 .submit-container {
   text-align: right;
-  position: relative;
-  bottom: $button-height;
 
   .message-submit {
     display: inline-block;
index e9c23929caeac0da37a622bfce15c63dbc82c6ad..4f9597607cd93e49b4a83ceda0b0aaab62ab3f33 100644 (file)
@@ -3,13 +3,14 @@
 
   <div class="comment">
     <div class="comment-account-date">
-      <div class="comment-account">{{ comment.by }}</div>
+      <a target="_blank" [href]="comment.account.url" class="comment-account">{{ comment.by }}</a>
       <div class="comment-date">{{ comment.createdAt | myFromNow }}</div>
     </div>
     <div>{{ comment.text }}</div>
 
     <div class="comment-actions">
       <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply">Reply</div>
+      <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete">Delete</div>
     </div>
 
     <my-video-comment-add
@@ -28,7 +29,8 @@
           [video]="video"
           [inReplyToCommentId]="inReplyToCommentId"
           [commentTree]="commentChild"
-          (wantedToReply)="onWantedToReply($event)"
+          (wantedToReply)="onWantToReply($event)"
+          (wantedToDelete)="onWantToDelete($event)"
           (resetReply)="onResetReply()"
         ></my-video-comment>
       </div>
index aae03ab6de1538b68f9210011760efacc74ae7de..a22c5a9fd175ee79e56a4ff88ea355617e57a059 100644 (file)
@@ -20,6 +20,9 @@
       margin-bottom: 4px;
 
       .comment-account {
+        @include disable-default-a-behaviour;
+
+        color: #000;
         font-weight: $font-bold;
       }
 
 
     .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;
+        }
       }
     }
   }
index b305c639a7ee25f1d5df7677cf18bbaa227a6832..9bc9c8844c68261c3c6cd1e1d92ca694d0e8ad8a 100644 (file)
@@ -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<VideoComment>()
   @Output() wantedToReply = new EventEmitter<VideoComment>()
+  @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
   @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)
+      )
+  }
 }
index 2fe6cc3e9692ed54a6b78c767534ef084b391571..c42f554966bd5780f925207f6eafe73c8015fd92 100644 (file)
@@ -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)
   }
index 4a424807302564646db1e7d812cfe013045323a8..80b2009312d20ea95fc1aa71579224550a009b42 100644 (file)
@@ -27,6 +27,8 @@
           [inReplyToCommentId]="inReplyToCommentId"
           [commentTree]="threadComments[comment.id]"
           (wantedToReply)="onWantedToReply($event)"
+          (wantedToDelete)="onWantedToDelete($event)"
+          (threadCreated)="onThreadCreated($event)"
           (resetReply)="onResetReply()"
         ></my-video-comment>
 
index 1230725c1e3fc60d79fd7775a87a28aef4d7dc7e..030dee9af05fc5e9afa503916fb3de22ef9a588b 100644 (file)
@@ -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)
+    }
+  }
 }
index 0f44d3dd7dbd50ae75e9cc04bdb89856fef0d699..f1f19476483f29455b5f571e388cb21b289c6357 100644 (file)
@@ -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
 
index a6c016bf38ad7b4c02385b2e5797752c227bb2e0..43b27ce8eea04e40a719a11042744fbd60354ee2 100644 (file)
     "lib": [
       "es2017",
       "dom"
-    ]
+    ],
+    "baseUrl": "src",
+    "paths": {
+      "@app/*": [ "app/*" ]
+    }
   }
 }
index e09b242ed9d688faba8912fe915fd996529fa38f..65fcf6b3569e339c1cf4500c1b4565e21e608b17 100644 (file)
@@ -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)
+}
index 856c87f2c3eb8a8fa50b25892de1096f606b14a1..577cf4b52f07bba4dbac2bfb4d8743114ae81c4a 100644 (file)
@@ -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) {
index 489ff27de325d22f47e064cc222622272ef8bb59..6928aced3ecc0f7d83c42657405971dc5606302c 100644 (file)
@@ -18,10 +18,15 @@ function isVideoCommentObjectValid (comment: any) {
     isActivityPubUrlValid(comment.url)
 }
 
+function isVideoCommentDeleteActivityValid (activity: any) {
+  return isBaseActivityValid(activity, 'Delete')
+}
+
 // ---------------------------------------------------------------------------
 
 export {
-  isVideoCommentCreateActivityValid
+  isVideoCommentCreateActivityValid,
+  isVideoCommentDeleteActivityValid
 }
 
 // ---------------------------------------------------------------------------
index 523a318227407dc3fcef4910442054ab3c249c1e..604570e74808e56dc8948377a3cd13e94273028d 100644 (file)
@@ -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)
+  })
+}
index 4bc5db77ebe2fc82888ad4902072ba214ab02318..1ca03189802ea138f91f358d338a47bc55514e96 100644 (file)
@@ -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
 }
 
 // ---------------------------------------------------------------------------
index ade0b7b9fb440596e603ce63ea6167eeece07c0d..63804da30ac49746a43f943976dffb4a187a3d07 100644 (file)
@@ -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
+}
index e8cb2ae03202be93c34555075c7209538901f46b..1acb306c0c144f1f576f4e90647dabcf1b870529 100644 (file)
@@ -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) {
index 2ef7c77a2a447d374d485f94ffa600d7ebdd7ade..ed7fcfe275065bb84c2ef2172ed82cfc6c59c5ea 100644 (file)
@@ -271,6 +271,7 @@ export class ActorModel extends Model<ActorModel> {
 
     return {
       id: this.id,
+      url: this.url,
       uuid: this.uuid,
       host: this.getHost(),
       score,
index d2d8945c3cd8eec776735dbe2ed7af364b02626a..66fca2484484084b4ad3107dbdf9f7a4e84fb83b 100644 (file)
@@ -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<VideoCommentModel> {
   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<VideoCommentModel> {
     return VideoCommentModel.findOne(query)
   }
 
+  static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
+    const query: IFindOptions<VideoCommentModel> = {
+      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<VideoCommentModel> {
       })
   }
 
+  isOwned () {
+    return this.Account.isOwned()
+  }
+
   toFormattedJSON () {
     return {
       id: this.id,
index c4b716cd21e68013adbc4f1d0b167f9012d53036..4d15c2a5038c5ebf81a7d6c5f706434f7388e266 100644 (file)
@@ -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<VideoModel> {
       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<VideoModel> {
 
     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<VideoModel> {
     }
 
     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<VideoModel> {
     }
 
     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<VideoModel> {
         ScopeNames.WITH_SHARES,
         ScopeNames.WITH_TAGS,
         ScopeNames.WITH_FILES,
-        ScopeNames.WITH_ACCOUNT,
+        ScopeNames.WITH_ACCOUNT_DETAILS,
         ScopeNames.WITH_COMMENTS
       ])
       .findOne(options)
index c11660d072f0e15910a28a5e3bb68bbad3734fa7..9190054da9fa70d6fa57139f7ce8bda23f509790 100644 (file)
@@ -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 })
index b6dfe0d1b569d51075687682ef453d0273c31192..6712829d4a65977705eaff33e2715b8c550e0248 100644 (file)
@@ -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)
 
index 604a3027de73daebe7a7ccccdec4e2cd45826f61..18d484ccf0b811d18b25c85f5034aad938346bcd 100644 (file)
@@ -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 ])
 
index 8781470496d6d091267c900cd5ca952c0dc85673..1b9ee452eeb1d71a12dd41de25b4a657096c3fda 100644 (file)
@@ -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
 }
index ef6fca53919c847a67cf334dd511669a348097ba..e4dbc81e5a8d9385d3a91f4b8531ca67c24c389c 100644 (file)
@@ -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
index 238e38a366f00f2b34043e503e8da30a8b493471..2e7fa1bcf939df4eff5f45b8f04b239c3d632807 100644 (file)
@@ -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
 }
index cc32c768d9dace81751877154afdb7fbd0d65535..0e75444f8f35f5667ae9f361aeaf3ffe6fef5ab7 100644 (file)
@@ -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]: []
index ee56c54b619b10f47d9e8016ec7d825706b54e2f..d1a952826f34a3b18833be875000f493c96daa24 100644 (file)
@@ -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