Add ability to bulk delete comments
authorChocobozzz <me@florianbigard.com>
Thu, 14 May 2020 14:56:15 +0000 (16:56 +0200)
committerChocobozzz <chocobozzz@cpy.re>
Fri, 29 May 2020 07:21:26 +0000 (09:21 +0200)
15 files changed:
server/controllers/api/bulk.ts [new file with mode: 0644]
server/controllers/api/index.ts
server/controllers/api/videos/comment.ts
server/helpers/custom-validators/bulk.ts [new file with mode: 0644]
server/lib/activitypub/send/send-delete.ts
server/lib/video-comment.ts
server/middlewares/validators/blocklist.ts
server/middlewares/validators/bulk.ts [new file with mode: 0644]
server/models/video/video-comment.ts
server/tests/api/check-params/bulk.ts [new file with mode: 0644]
server/tests/api/check-params/index.ts
server/tests/api/server/bulk.ts [new file with mode: 0644]
shared/extra-utils/bulk/bulk.ts [new file with mode: 0644]
shared/extra-utils/index.ts
shared/models/bulk/bulk-remove-comments-of-body.model.ts [new file with mode: 0644]

diff --git a/server/controllers/api/bulk.ts b/server/controllers/api/bulk.ts
new file mode 100644 (file)
index 0000000..1fe139c
--- /dev/null
@@ -0,0 +1,41 @@
+import * as express from 'express'
+import { asyncMiddleware, authenticate } from '../../middlewares'
+import { bulkRemoveCommentsOfValidator } from '@server/middlewares/validators/bulk'
+import { VideoCommentModel } from '@server/models/video/video-comment'
+import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model'
+import { removeComment } from '@server/lib/video-comment'
+
+const bulkRouter = express.Router()
+
+bulkRouter.post('/remove-comments-of',
+  authenticate,
+  asyncMiddleware(bulkRemoveCommentsOfValidator),
+  asyncMiddleware(bulkRemoveCommentsOf)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  bulkRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function bulkRemoveCommentsOf (req: express.Request, res: express.Response) {
+  const account = res.locals.account
+  const body = req.body as BulkRemoveCommentsOfBody
+  const user = res.locals.oauth.token.User
+
+  const filter = body.scope === 'my-videos'
+    ? { onVideosOfAccount: user.Account }
+    : {}
+
+  const comments = await VideoCommentModel.listForBulkDelete(account, filter)
+
+  // Don't wait result
+  res.sendStatus(204)
+
+  for (const comment of comments) {
+    await removeComment(comment)
+  }
+}
index 7bec6c527c2e368e06772755e2bddce7a12f557d..c334a26b48c3807ce7f66c2aa0e7c13ee4b7ba8e 100644 (file)
@@ -1,20 +1,21 @@
+import * as cors from 'cors'
 import * as express from 'express'
+import * as RateLimit from 'express-rate-limit'
+import { badRequest } from '../../helpers/express-utils'
+import { CONFIG } from '../../initializers/config'
+import { accountsRouter } from './accounts'
+import { bulkRouter } from './bulk'
 import { configRouter } from './config'
 import { jobsRouter } from './jobs'
 import { oauthClientsRouter } from './oauth-clients'
+import { overviewsRouter } from './overviews'
+import { pluginRouter } from './plugins'
+import { searchRouter } from './search'
 import { serverRouter } from './server'
 import { usersRouter } from './users'
-import { accountsRouter } from './accounts'
-import { videosRouter } from './videos'
-import { badRequest } from '../../helpers/express-utils'
 import { videoChannelRouter } from './video-channel'
-import * as cors from 'cors'
-import { searchRouter } from './search'
-import { overviewsRouter } from './overviews'
 import { videoPlaylistRouter } from './video-playlist'
-import { CONFIG } from '../../initializers/config'
-import { pluginRouter } from './plugins'
-import * as RateLimit from 'express-rate-limit'
+import { videosRouter } from './videos'
 
 const apiRouter = express.Router()
 
@@ -31,6 +32,7 @@ const apiRateLimiter = RateLimit({
 apiRouter.use(apiRateLimiter)
 
 apiRouter.use('/server', serverRouter)
+apiRouter.use('/bulk', bulkRouter)
 apiRouter.use('/oauth-clients', oauthClientsRouter)
 apiRouter.use('/config', configRouter)
 apiRouter.use('/users', usersRouter)
index 5070bb3c03bd4bf05e517d44dc28e3b71fc1cf06..bdd3cf9e27334a1689d2b4ea543375b613e4bcb0 100644 (file)
@@ -1,11 +1,12 @@
 import * as express from 'express'
-import { cloneDeep } from 'lodash'
 import { ResultList } from '../../../../shared/models'
 import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model'
-import { logger } from '../../../helpers/logger'
+import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
 import { getFormattedObjects } from '../../../helpers/utils'
 import { sequelizeTypescript } from '../../../initializers/database'
-import { buildFormattedCommentTree, createVideoComment, markCommentAsDeleted } from '../../../lib/video-comment'
+import { Notifier } from '../../../lib/notifier'
+import { Hooks } from '../../../lib/plugins/hooks'
+import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment'
 import {
   asyncMiddleware,
   asyncRetryTransactionMiddleware,
@@ -23,12 +24,8 @@ import {
   removeVideoCommentValidator,
   videoCommentThreadsSortValidator
 } from '../../../middlewares/validators'
-import { VideoCommentModel } from '../../../models/video/video-comment'
-import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
 import { AccountModel } from '../../../models/account/account'
-import { Notifier } from '../../../lib/notifier'
-import { Hooks } from '../../../lib/plugins/hooks'
-import { sendDeleteVideoComment } from '../../../lib/activitypub/send'
+import { VideoCommentModel } from '../../../models/video/video-comment'
 
 const auditLogger = auditLoggerFactory('comments')
 const videoCommentRouter = express.Router()
@@ -149,9 +146,7 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
 
   Hooks.runAction('action:api.video-thread.created', { comment })
 
-  return res.json({
-    comment: comment.toFormattedJSON()
-  }).end()
+  return res.json({ comment: comment.toFormattedJSON() })
 }
 
 async function addVideoCommentReply (req: express.Request, res: express.Response) {
@@ -173,27 +168,15 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
 
   Hooks.runAction('action:api.video-comment-reply.created', { comment })
 
-  return res.json({ comment: comment.toFormattedJSON() }).end()
+  return res.json({ comment: comment.toFormattedJSON() })
 }
 
 async function removeVideoComment (req: express.Request, res: express.Response) {
   const videoCommentInstance = res.locals.videoCommentFull
-  const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
-
-  await sequelizeTypescript.transaction(async t => {
-    if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
-      await sendDeleteVideoComment(videoCommentInstance, t)
-    }
 
-    markCommentAsDeleted(videoCommentInstance)
-
-    await videoCommentInstance.save()
-  })
+  await removeComment(videoCommentInstance)
 
   auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
-  logger.info('Video comment %d deleted.', videoCommentInstance.id)
-
-  Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore })
 
-  return res.type('json').status(204).end()
+  return res.type('json').status(204)
 }
diff --git a/server/helpers/custom-validators/bulk.ts b/server/helpers/custom-validators/bulk.ts
new file mode 100644 (file)
index 0000000..9e0ce0b
--- /dev/null
@@ -0,0 +1,9 @@
+function isBulkRemoveCommentsOfScopeValid (value: string) {
+  return value === 'my-videos' || value === 'instance'
+}
+
+// ---------------------------------------------------------------------------
+
+export {
+  isBulkRemoveCommentsOfScopeValid
+}
index fd3f06dec36383033cfc4d0d14a662441ac47119..2afd2c05dec453229675328813be766d9c9d6813 100644 (file)
@@ -1,15 +1,15 @@
 import { Transaction } from 'sequelize'
+import { getServerActor } from '@server/models/application/application'
 import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub'
+import { logger } from '../../../helpers/logger'
 import { ActorModel } from '../../../models/activitypub/actor'
 import { VideoCommentModel } from '../../../models/video/video-comment'
 import { VideoShareModel } from '../../../models/video/video-share'
+import { MActorUrl } from '../../../typings/models'
+import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../typings/models/video'
+import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
 import { getDeleteActivityPubUrl } from '../url'
 import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
-import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
-import { logger } from '../../../helpers/logger'
-import { MCommentOwnerVideoReply, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../typings/models/video'
-import { MActorUrl } from '../../../typings/models'
-import { getServerActor } from '@server/models/application/application'
 
 async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
   logger.info('Creating job to broadcast delete of video %s.', video.url)
@@ -42,7 +42,7 @@ async function sendDeleteActor (byActor: ActorModel, t: Transaction) {
   return broadcastToFollowers(activity, byActor, actorsInvolved, t)
 }
 
-async function sendDeleteVideoComment (videoComment: MCommentOwnerVideoReply, t: Transaction) {
+async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, t: Transaction) {
   logger.info('Creating job to send delete of comment %s.', videoComment.url)
 
   const isVideoOrigin = videoComment.Video.isOwned()
index 516c912a90a30f0f3e679e65e37384ee6a3c3542..97aa639fbdc278475cd6c738cc7b5df2b9f5c85e 100644 (file)
@@ -1,10 +1,32 @@
+import { cloneDeep } from 'lodash'
 import * as Sequelize from 'sequelize'
+import { logger } from '@server/helpers/logger'
+import { sequelizeTypescript } from '@server/initializers/database'
 import { ResultList } from '../../shared/models'
 import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model'
 import { VideoCommentModel } from '../models/video/video-comment'
+import { MAccountDefault, MComment, MCommentOwnerVideoReply, MVideoFullLight, MCommentOwnerVideo } from '../typings/models'
+import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
 import { getVideoCommentActivityPubUrl } from './activitypub/url'
-import { sendCreateVideoComment } from './activitypub/send'
-import { MAccountDefault, MComment, MCommentOwnerVideoReply, MVideoFullLight } from '../typings/models'
+import { Hooks } from './plugins/hooks'
+
+async function removeComment (videoCommentInstance: MCommentOwnerVideo) {
+  const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
+
+  await sequelizeTypescript.transaction(async t => {
+    if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
+      await sendDeleteVideoComment(videoCommentInstance, t)
+    }
+
+    markCommentAsDeleted(videoCommentInstance)
+
+    await videoCommentInstance.save()
+  })
+
+  logger.info('Video comment %d deleted.', videoCommentInstance.id)
+
+  Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore })
+}
 
 async function createVideoComment (obj: {
   text: string
@@ -73,7 +95,7 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
   return thread
 }
 
-function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void {
+function markCommentAsDeleted (comment: MComment): void {
   comment.text = ''
   comment.deletedAt = new Date()
   comment.accountId = null
@@ -82,6 +104,7 @@ function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void {
 // ---------------------------------------------------------------------------
 
 export {
+  removeComment,
   createVideoComment,
   buildFormattedCommentTree,
   markCommentAsDeleted
index 27224ff9b5c5218a45e20d52817dc9fd528034ac..c24fa9609c9d438d48d9f66376c08dd093e157ed 100644 (file)
@@ -24,8 +24,7 @@ const blockAccountValidator = [
 
     if (user.Account.id === accountToBlock.id) {
       res.status(409)
-         .send({ error: 'You cannot block yourself.' })
-         .end()
+         .json({ error: 'You cannot block yourself.' })
 
       return
     }
@@ -80,8 +79,7 @@ const blockServerValidator = [
 
     if (host === WEBSERVER.HOST) {
       return res.status(409)
-        .send({ error: 'You cannot block your own server.' })
-        .end()
+        .json({ error: 'You cannot block your own server.' })
     }
 
     const server = await ServerModel.loadOrCreateByHost(host)
@@ -139,8 +137,7 @@ async function doesUnblockAccountExist (accountId: number, targetAccountId: numb
   const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId)
   if (!accountBlock) {
     res.status(404)
-       .send({ error: 'Account block entry not found.' })
-       .end()
+       .json({ error: 'Account block entry not found.' })
 
     return false
   }
@@ -154,8 +151,7 @@ async function doesUnblockServerExist (accountId: number, host: string, res: exp
   const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host)
   if (!serverBlock) {
     res.status(404)
-       .send({ error: 'Server block entry not found.' })
-       .end()
+       .json({ error: 'Server block entry not found.' })
 
     return false
   }
diff --git a/server/middlewares/validators/bulk.ts b/server/middlewares/validators/bulk.ts
new file mode 100644 (file)
index 0000000..f9b0f56
--- /dev/null
@@ -0,0 +1,41 @@
+import * as express from 'express'
+import { body } from 'express-validator'
+import { isBulkRemoveCommentsOfScopeValid } from '@server/helpers/custom-validators/bulk'
+import { doesAccountNameWithHostExist } from '@server/helpers/middlewares'
+import { UserRight } from '@shared/models'
+import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model'
+import { logger } from '../../helpers/logger'
+import { areValidationErrors } from './utils'
+
+const bulkRemoveCommentsOfValidator = [
+  body('accountName').exists().withMessage('Should have an account name with host'),
+  body('scope')
+    .custom(isBulkRemoveCommentsOfScopeValid).withMessage('Should have a valid scope'),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    logger.debug('Checking bulkRemoveCommentsOfValidator parameters', { parameters: req.body })
+
+    if (areValidationErrors(req, res)) return
+    if (!await doesAccountNameWithHostExist(req.body.accountName, res)) return
+
+    const user = res.locals.oauth.token.User
+    const body = req.body as BulkRemoveCommentsOfBody
+
+    if (body.scope === 'instance' && user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) !== true) {
+      return res.status(403)
+      .json({
+        error: 'User cannot remove any comments of this instance.'
+      })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  bulkRemoveCommentsOfValidator
+}
+
+// ---------------------------------------------------------------------------
index 6d60271e62099cfb3f82fbb93dcc0c58de564545..7c890ce6dab569e166b50a56812bfcf4346e0cda 100644 (file)
@@ -1,19 +1,17 @@
+import * as Bluebird from 'bluebird'
+import { uniq } from 'lodash'
+import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
 import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
+import { getServerActor } from '@server/models/application/application'
+import { MAccount, MAccountId, MUserAccountId } from '@server/typings/models'
+import { VideoPrivacy } from '@shared/models'
 import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
-import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
-import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
-import { AccountModel } from '../account/account'
-import { ActorModel } from '../activitypub/actor'
-import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
-import { VideoModel } from './video'
-import { VideoChannelModel } from './video-channel'
 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
+import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
 import { regexpCapture } from '../../helpers/regexp'
-import { uniq } from 'lodash'
-import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
-import * as Bluebird from 'bluebird'
+import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
 import {
   MComment,
   MCommentAP,
@@ -25,9 +23,11 @@ import {
   MCommentOwnerVideoFeed,
   MCommentOwnerVideoReply
 } from '../../typings/models/video'
-import { MUserAccountId } from '@server/typings/models'
-import { VideoPrivacy } from '@shared/models'
-import { getServerActor } from '@server/models/application/application'
+import { AccountModel } from '../account/account'
+import { ActorModel } from '../activitypub/actor'
+import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
+import { VideoModel } from './video'
+import { VideoChannelModel } from './video-channel'
 
 enum ScopeNames {
   WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -415,6 +415,43 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       .findAll(query)
   }
 
+  static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
+    const accountWhere = filter.onVideosOfAccount
+      ? { id: filter.onVideosOfAccount.id }
+      : {}
+
+    const query = {
+      limit: 1000,
+      where: {
+        deletedAt: null,
+        accountId: ofAccount.id
+      },
+      include: [
+        {
+          model: VideoModel,
+          required: true,
+          include: [
+            {
+              model: VideoChannelModel,
+              required: true,
+              include: [
+                {
+                  model: AccountModel,
+                  required: true,
+                  where: accountWhere
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+
+    return VideoCommentModel
+      .scope([ ScopeNames.WITH_ACCOUNT ])
+      .findAll(query)
+  }
+
   static async getStats () {
     const totalLocalVideoComments = await VideoCommentModel.count({
       include: [
@@ -450,7 +487,9 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
         videoId,
         accountId: {
           [Op.notIn]: buildLocalAccountIdsIn()
-        }
+        },
+        // Do not delete Tombstones
+        deletedAt: null
       }
     }
 
diff --git a/server/tests/api/check-params/bulk.ts b/server/tests/api/check-params/bulk.ts
new file mode 100644 (file)
index 0000000..432858b
--- /dev/null
@@ -0,0 +1,88 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import {
+  cleanupTests,
+  createUser,
+  flushAndRunServer,
+  ServerInfo,
+  setAccessTokensToServers,
+  userLogin
+} from '../../../../shared/extra-utils'
+import { makePostBodyRequest } from '../../../../shared/extra-utils/requests/requests'
+
+describe('Test bulk API validators', function () {
+  let server: ServerInfo
+  let userAccessToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(120000)
+
+    server = await flushAndRunServer(1)
+    await setAccessTokensToServers([ server ])
+
+    const user = { username: 'user1', password: 'password' }
+    await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
+
+    userAccessToken = await userLogin(server, user)
+  })
+
+  describe('When removing comments of', function () {
+    const path = '/api/v1/bulk/remove-comments-of'
+
+    it('Should fail with an unauthenticated user', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        path,
+        fields: { accountName: 'user1', scope: 'my-videos' },
+        statusCodeExpected: 401
+      })
+    })
+
+    it('Should fail with an unknown account', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        token: server.accessToken,
+        path,
+        fields: { accountName: 'user2', scope: 'my-videos' },
+        statusCodeExpected: 404
+      })
+    })
+
+    it('Should fail with an invalid scope', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        token: server.accessToken,
+        path,
+        fields: { accountName: 'user1', scope: 'my-videoss' },
+        statusCodeExpected: 400
+      })
+    })
+
+    it('Should fail to delete comments of the instance without the appropriate rights', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        token: userAccessToken,
+        path,
+        fields: { accountName: 'user1', scope: 'instance' },
+        statusCodeExpected: 403
+      })
+    })
+
+    it('Should succeed with the correct params', async function () {
+      await makePostBodyRequest({
+        url: server.url,
+        token: server.accessToken,
+        path,
+        fields: { accountName: 'user1', scope: 'instance' },
+        statusCodeExpected: 204
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index ef152f55ceab4784f8b5ae3b84c9f99ced26029b..93ffd98b199c90d5093e035fc230fb38459e7a8b 100644 (file)
@@ -1,5 +1,6 @@
 import './accounts'
 import './blocklist'
+import './bulk'
 import './config'
 import './contact-form'
 import './debug'
diff --git a/server/tests/api/server/bulk.ts b/server/tests/api/server/bulk.ts
new file mode 100644 (file)
index 0000000..63321d4
--- /dev/null
@@ -0,0 +1,198 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import 'mocha'
+import * as chai from 'chai'
+import { VideoComment } from '@shared/models/videos/video-comment.model'
+import {
+  addVideoCommentThread,
+  bulkRemoveCommentsOf,
+  cleanupTests,
+  createUser,
+  flushAndRunMultipleServers,
+  getVideoCommentThreads,
+  getVideosList,
+  ServerInfo,
+  setAccessTokensToServers,
+  uploadVideo,
+  userLogin,
+  waitJobs,
+  addVideoCommentReply
+} from '../../../../shared/extra-utils/index'
+import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
+import { Video } from '@shared/models'
+
+const expect = chai.expect
+
+describe('Test bulk actions', function () {
+  const commentsUser3: { videoId: number, commentId: number }[] = []
+
+  let servers: ServerInfo[] = []
+  let user1AccessToken: string
+  let user2AccessToken: string
+  let user3AccessToken: string
+
+  before(async function () {
+    this.timeout(30000)
+
+    servers = await flushAndRunMultipleServers(2)
+
+    // Get the access tokens
+    await setAccessTokensToServers(servers)
+
+    {
+      const user = { username: 'user1', password: 'password' }
+      await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: user.username, password: user.password })
+
+      user1AccessToken = await userLogin(servers[0], user)
+    }
+
+    {
+      const user = { username: 'user2', password: 'password' }
+      await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: user.username, password: user.password })
+
+      user2AccessToken = await userLogin(servers[0], user)
+    }
+
+    {
+      const user = { username: 'user3', password: 'password' }
+      await createUser({ url: servers[1].url, accessToken: servers[1].accessToken, username: user.username, password: user.password })
+
+      user3AccessToken = await userLogin(servers[1], user)
+    }
+
+    await doubleFollow(servers[0], servers[1])
+  })
+
+  describe('Bulk remove comments', function () {
+    async function checkInstanceCommentsRemoved () {
+      {
+        const res = await getVideosList(servers[0].url)
+        const videos = res.body.data as Video[]
+
+        // Server 1 should not have these comments anymore
+        for (const video of videos) {
+          const resThreads = await getVideoCommentThreads(servers[0].url, video.id, 0, 10)
+          const comments = resThreads.body.data as VideoComment[]
+          const comment = comments.find(c => c.text === 'comment by user 3')
+
+          expect(comment).to.not.exist
+        }
+      }
+
+      {
+        const res = await getVideosList(servers[1].url)
+        const videos = res.body.data as Video[]
+
+        // Server 1 should not have these comments on videos of server 1
+        for (const video of videos) {
+          const resThreads = await getVideoCommentThreads(servers[1].url, video.id, 0, 10)
+          const comments = resThreads.body.data as VideoComment[]
+          const comment = comments.find(c => c.text === 'comment by user 3')
+
+          if (video.account.host === 'localhost:' + servers[0].port) {
+            expect(comment).to.not.exist
+          } else {
+            expect(comment).to.exist
+          }
+        }
+      }
+    }
+
+    before(async function () {
+      this.timeout(60000)
+
+      await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video 1 server 1' })
+      await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video 2 server 1' })
+      await uploadVideo(servers[0].url, user1AccessToken, { name: 'video 3 server 1' })
+
+      await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video 1 server 2' })
+
+      await waitJobs(servers)
+
+      {
+        const res = await getVideosList(servers[0].url)
+        for (const video of res.body.data) {
+          await addVideoCommentThread(servers[0].url, servers[0].accessToken, video.id, 'comment by root server 1')
+          await addVideoCommentThread(servers[0].url, user1AccessToken, video.id, 'comment by user 1')
+          await addVideoCommentThread(servers[0].url, user2AccessToken, video.id, 'comment by user 2')
+        }
+      }
+
+      {
+        const res = await getVideosList(servers[1].url)
+        for (const video of res.body.data) {
+          await addVideoCommentThread(servers[1].url, servers[1].accessToken, video.id, 'comment by root server 2')
+
+          const res = await addVideoCommentThread(servers[1].url, user3AccessToken, video.id, 'comment by user 3')
+          commentsUser3.push({ videoId: video.id, commentId: res.body.comment.id })
+        }
+      }
+
+      await waitJobs(servers)
+    })
+
+    it('Should delete comments of an account on my videos', async function () {
+      this.timeout(60000)
+
+      await bulkRemoveCommentsOf({
+        url: servers[0].url,
+        token: user1AccessToken,
+        attributes: {
+          accountName: 'user2',
+          scope: 'my-videos'
+        }
+      })
+
+      await waitJobs(servers)
+
+      for (const server of servers) {
+        const res = await getVideosList(server.url)
+
+        for (const video of res.body.data) {
+          const resThreads = await getVideoCommentThreads(server.url, video.id, 0, 10)
+          const comments = resThreads.body.data as VideoComment[]
+          const comment = comments.find(c => c.text === 'comment by user 2')
+
+          if (video.name === 'video 3 server 1') {
+            expect(comment).to.not.exist
+          } else {
+            expect(comment).to.exist
+          }
+        }
+      }
+    })
+
+    it('Should delete comments of an account on the instance', async function () {
+      this.timeout(60000)
+
+      await bulkRemoveCommentsOf({
+        url: servers[0].url,
+        token: servers[0].accessToken,
+        attributes: {
+          accountName: 'user3@localhost:' + servers[1].port,
+          scope: 'instance'
+        }
+      })
+
+      await waitJobs(servers)
+
+      await checkInstanceCommentsRemoved()
+    })
+
+    it('Should not re create the comment on video update', async function () {
+      this.timeout(60000)
+
+      for (const obj of commentsUser3) {
+        await addVideoCommentReply(servers[1].url, user3AccessToken, obj.videoId, obj.commentId, 'comment by user 3 bis')
+      }
+
+      await waitJobs(servers)
+
+      await checkInstanceCommentsRemoved()
+    })
+  })
+
+  after(async function () {
+    await cleanupTests(servers)
+  })
+})
diff --git a/shared/extra-utils/bulk/bulk.ts b/shared/extra-utils/bulk/bulk.ts
new file mode 100644 (file)
index 0000000..d6798ce
--- /dev/null
@@ -0,0 +1,24 @@
+import { BulkRemoveCommentsOfBody } from "@shared/models/bulk/bulk-remove-comments-of-body.model"
+import { makePostBodyRequest } from "../requests/requests"
+
+function bulkRemoveCommentsOf (options: {
+  url: string
+  token: string
+  attributes: BulkRemoveCommentsOfBody
+  expectedStatus?: number
+}) {
+  const { url, token, attributes, expectedStatus } = options
+  const path = '/api/v1/bulk/remove-comments-of'
+
+  return makePostBodyRequest({
+    url,
+    path,
+    token,
+    fields: attributes,
+    statusCodeExpected: expectedStatus || 204
+  })
+}
+
+export {
+  bulkRemoveCommentsOf
+}
index d3f010b20210a7633f352e0b9bfcce26e6492351..2ac0c6338500e04014311fd3e524621f65a45840 100644 (file)
@@ -1,4 +1,5 @@
 export * from './server/activitypub'
+export * from './bulk/bulk'
 export * from './cli/cli'
 export * from './server/clients'
 export * from './server/config'
diff --git a/shared/models/bulk/bulk-remove-comments-of-body.model.ts b/shared/models/bulk/bulk-remove-comments-of-body.model.ts
new file mode 100644 (file)
index 0000000..31e018c
--- /dev/null
@@ -0,0 +1,4 @@
+export interface BulkRemoveCommentsOfBody {
+  accountName: string
+  scope: 'my-videos' | 'instance'
+}