aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-05-14 16:56:15 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-05-29 09:21:26 +0200
commit444c0a0e017824fb4ce526281a22c4abe0a13c50 (patch)
tree6a3c1ea8c4995361c582176257d1e1315287411d
parent99139e7753e20ab0fba8eae5638d3dd3e792fe43 (diff)
downloadPeerTube-444c0a0e017824fb4ce526281a22c4abe0a13c50.tar.gz
PeerTube-444c0a0e017824fb4ce526281a22c4abe0a13c50.tar.zst
PeerTube-444c0a0e017824fb4ce526281a22c4abe0a13c50.zip
Add ability to bulk delete comments
-rw-r--r--server/controllers/api/bulk.ts41
-rw-r--r--server/controllers/api/index.ts20
-rw-r--r--server/controllers/api/videos/comment.ts35
-rw-r--r--server/helpers/custom-validators/bulk.ts9
-rw-r--r--server/lib/activitypub/send/send-delete.ts12
-rw-r--r--server/lib/video-comment.ts29
-rw-r--r--server/middlewares/validators/blocklist.ts12
-rw-r--r--server/middlewares/validators/bulk.ts41
-rw-r--r--server/models/video/video-comment.ts67
-rw-r--r--server/tests/api/check-params/bulk.ts88
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/server/bulk.ts198
-rw-r--r--shared/extra-utils/bulk/bulk.ts24
-rw-r--r--shared/extra-utils/index.ts1
-rw-r--r--shared/models/bulk/bulk-remove-comments-of-body.model.ts4
15 files changed, 516 insertions, 66 deletions
diff --git a/server/controllers/api/bulk.ts b/server/controllers/api/bulk.ts
new file mode 100644
index 000000000..1fe139c92
--- /dev/null
+++ b/server/controllers/api/bulk.ts
@@ -0,0 +1,41 @@
1import * as express from 'express'
2import { asyncMiddleware, authenticate } from '../../middlewares'
3import { bulkRemoveCommentsOfValidator } from '@server/middlewares/validators/bulk'
4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model'
6import { removeComment } from '@server/lib/video-comment'
7
8const bulkRouter = express.Router()
9
10bulkRouter.post('/remove-comments-of',
11 authenticate,
12 asyncMiddleware(bulkRemoveCommentsOfValidator),
13 asyncMiddleware(bulkRemoveCommentsOf)
14)
15
16// ---------------------------------------------------------------------------
17
18export {
19 bulkRouter
20}
21
22// ---------------------------------------------------------------------------
23
24async function bulkRemoveCommentsOf (req: express.Request, res: express.Response) {
25 const account = res.locals.account
26 const body = req.body as BulkRemoveCommentsOfBody
27 const user = res.locals.oauth.token.User
28
29 const filter = body.scope === 'my-videos'
30 ? { onVideosOfAccount: user.Account }
31 : {}
32
33 const comments = await VideoCommentModel.listForBulkDelete(account, filter)
34
35 // Don't wait result
36 res.sendStatus(204)
37
38 for (const comment of comments) {
39 await removeComment(comment)
40 }
41}
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index 7bec6c527..c334a26b4 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -1,20 +1,21 @@
1import * as cors from 'cors'
1import * as express from 'express' 2import * as express from 'express'
3import * as RateLimit from 'express-rate-limit'
4import { badRequest } from '../../helpers/express-utils'
5import { CONFIG } from '../../initializers/config'
6import { accountsRouter } from './accounts'
7import { bulkRouter } from './bulk'
2import { configRouter } from './config' 8import { configRouter } from './config'
3import { jobsRouter } from './jobs' 9import { jobsRouter } from './jobs'
4import { oauthClientsRouter } from './oauth-clients' 10import { oauthClientsRouter } from './oauth-clients'
11import { overviewsRouter } from './overviews'
12import { pluginRouter } from './plugins'
13import { searchRouter } from './search'
5import { serverRouter } from './server' 14import { serverRouter } from './server'
6import { usersRouter } from './users' 15import { usersRouter } from './users'
7import { accountsRouter } from './accounts'
8import { videosRouter } from './videos'
9import { badRequest } from '../../helpers/express-utils'
10import { videoChannelRouter } from './video-channel' 16import { videoChannelRouter } from './video-channel'
11import * as cors from 'cors'
12import { searchRouter } from './search'
13import { overviewsRouter } from './overviews'
14import { videoPlaylistRouter } from './video-playlist' 17import { videoPlaylistRouter } from './video-playlist'
15import { CONFIG } from '../../initializers/config' 18import { videosRouter } from './videos'
16import { pluginRouter } from './plugins'
17import * as RateLimit from 'express-rate-limit'
18 19
19const apiRouter = express.Router() 20const apiRouter = express.Router()
20 21
@@ -31,6 +32,7 @@ const apiRateLimiter = RateLimit({
31apiRouter.use(apiRateLimiter) 32apiRouter.use(apiRateLimiter)
32 33
33apiRouter.use('/server', serverRouter) 34apiRouter.use('/server', serverRouter)
35apiRouter.use('/bulk', bulkRouter)
34apiRouter.use('/oauth-clients', oauthClientsRouter) 36apiRouter.use('/oauth-clients', oauthClientsRouter)
35apiRouter.use('/config', configRouter) 37apiRouter.use('/config', configRouter)
36apiRouter.use('/users', usersRouter) 38apiRouter.use('/users', usersRouter)
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index 5070bb3c0..bdd3cf9e2 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,11 +1,12 @@
1import * as express from 'express' 1import * as express from 'express'
2import { cloneDeep } from 'lodash'
3import { ResultList } from '../../../../shared/models' 2import { ResultList } from '../../../../shared/models'
4import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' 3import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model'
5import { logger } from '../../../helpers/logger' 4import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
6import { getFormattedObjects } from '../../../helpers/utils' 5import { getFormattedObjects } from '../../../helpers/utils'
7import { sequelizeTypescript } from '../../../initializers/database' 6import { sequelizeTypescript } from '../../../initializers/database'
8import { buildFormattedCommentTree, createVideoComment, markCommentAsDeleted } from '../../../lib/video-comment' 7import { Notifier } from '../../../lib/notifier'
8import { Hooks } from '../../../lib/plugins/hooks'
9import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment'
9import { 10import {
10 asyncMiddleware, 11 asyncMiddleware,
11 asyncRetryTransactionMiddleware, 12 asyncRetryTransactionMiddleware,
@@ -23,12 +24,8 @@ import {
23 removeVideoCommentValidator, 24 removeVideoCommentValidator,
24 videoCommentThreadsSortValidator 25 videoCommentThreadsSortValidator
25} from '../../../middlewares/validators' 26} from '../../../middlewares/validators'
26import { VideoCommentModel } from '../../../models/video/video-comment'
27import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
28import { AccountModel } from '../../../models/account/account' 27import { AccountModel } from '../../../models/account/account'
29import { Notifier } from '../../../lib/notifier' 28import { VideoCommentModel } from '../../../models/video/video-comment'
30import { Hooks } from '../../../lib/plugins/hooks'
31import { sendDeleteVideoComment } from '../../../lib/activitypub/send'
32 29
33const auditLogger = auditLoggerFactory('comments') 30const auditLogger = auditLoggerFactory('comments')
34const videoCommentRouter = express.Router() 31const videoCommentRouter = express.Router()
@@ -149,9 +146,7 @@ async function addVideoCommentThread (req: express.Request, res: express.Respons
149 146
150 Hooks.runAction('action:api.video-thread.created', { comment }) 147 Hooks.runAction('action:api.video-thread.created', { comment })
151 148
152 return res.json({ 149 return res.json({ comment: comment.toFormattedJSON() })
153 comment: comment.toFormattedJSON()
154 }).end()
155} 150}
156 151
157async function addVideoCommentReply (req: express.Request, res: express.Response) { 152async function addVideoCommentReply (req: express.Request, res: express.Response) {
@@ -173,27 +168,15 @@ async function addVideoCommentReply (req: express.Request, res: express.Response
173 168
174 Hooks.runAction('action:api.video-comment-reply.created', { comment }) 169 Hooks.runAction('action:api.video-comment-reply.created', { comment })
175 170
176 return res.json({ comment: comment.toFormattedJSON() }).end() 171 return res.json({ comment: comment.toFormattedJSON() })
177} 172}
178 173
179async function removeVideoComment (req: express.Request, res: express.Response) { 174async function removeVideoComment (req: express.Request, res: express.Response) {
180 const videoCommentInstance = res.locals.videoCommentFull 175 const videoCommentInstance = res.locals.videoCommentFull
181 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
182
183 await sequelizeTypescript.transaction(async t => {
184 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
185 await sendDeleteVideoComment(videoCommentInstance, t)
186 }
187 176
188 markCommentAsDeleted(videoCommentInstance) 177 await removeComment(videoCommentInstance)
189
190 await videoCommentInstance.save()
191 })
192 178
193 auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON())) 179 auditLogger.delete(getAuditIdFromRes(res), new CommentAuditView(videoCommentInstance.toFormattedJSON()))
194 logger.info('Video comment %d deleted.', videoCommentInstance.id)
195
196 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore })
197 180
198 return res.type('json').status(204).end() 181 return res.type('json').status(204)
199} 182}
diff --git a/server/helpers/custom-validators/bulk.ts b/server/helpers/custom-validators/bulk.ts
new file mode 100644
index 000000000..9e0ce0be1
--- /dev/null
+++ b/server/helpers/custom-validators/bulk.ts
@@ -0,0 +1,9 @@
1function isBulkRemoveCommentsOfScopeValid (value: string) {
2 return value === 'my-videos' || value === 'instance'
3}
4
5// ---------------------------------------------------------------------------
6
7export {
8 isBulkRemoveCommentsOfScopeValid
9}
diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts
index fd3f06dec..2afd2c05d 100644
--- a/server/lib/activitypub/send/send-delete.ts
+++ b/server/lib/activitypub/send/send-delete.ts
@@ -1,15 +1,15 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
2import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub' 3import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub'
4import { logger } from '../../../helpers/logger'
3import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/activitypub/actor'
4import { VideoCommentModel } from '../../../models/video/video-comment' 6import { VideoCommentModel } from '../../../models/video/video-comment'
5import { VideoShareModel } from '../../../models/video/video-share' 7import { VideoShareModel } from '../../../models/video/video-share'
8import { MActorUrl } from '../../../typings/models'
9import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../typings/models/video'
10import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
6import { getDeleteActivityPubUrl } from '../url' 11import { getDeleteActivityPubUrl } from '../url'
7import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' 12import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils'
8import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience'
9import { logger } from '../../../helpers/logger'
10import { MCommentOwnerVideoReply, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../typings/models/video'
11import { MActorUrl } from '../../../typings/models'
12import { getServerActor } from '@server/models/application/application'
13 13
14async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) { 14async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) {
15 logger.info('Creating job to broadcast delete of video %s.', video.url) 15 logger.info('Creating job to broadcast delete of video %s.', video.url)
@@ -42,7 +42,7 @@ async function sendDeleteActor (byActor: ActorModel, t: Transaction) {
42 return broadcastToFollowers(activity, byActor, actorsInvolved, t) 42 return broadcastToFollowers(activity, byActor, actorsInvolved, t)
43} 43}
44 44
45async function sendDeleteVideoComment (videoComment: MCommentOwnerVideoReply, t: Transaction) { 45async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, t: Transaction) {
46 logger.info('Creating job to send delete of comment %s.', videoComment.url) 46 logger.info('Creating job to send delete of comment %s.', videoComment.url)
47 47
48 const isVideoOrigin = videoComment.Video.isOwned() 48 const isVideoOrigin = videoComment.Video.isOwned()
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 516c912a9..97aa639fb 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -1,10 +1,32 @@
1import { cloneDeep } from 'lodash'
1import * as Sequelize from 'sequelize' 2import * as Sequelize from 'sequelize'
3import { logger } from '@server/helpers/logger'
4import { sequelizeTypescript } from '@server/initializers/database'
2import { ResultList } from '../../shared/models' 5import { ResultList } from '../../shared/models'
3import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' 6import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model'
4import { VideoCommentModel } from '../models/video/video-comment' 7import { VideoCommentModel } from '../models/video/video-comment'
8import { MAccountDefault, MComment, MCommentOwnerVideoReply, MVideoFullLight, MCommentOwnerVideo } from '../typings/models'
9import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
5import { getVideoCommentActivityPubUrl } from './activitypub/url' 10import { getVideoCommentActivityPubUrl } from './activitypub/url'
6import { sendCreateVideoComment } from './activitypub/send' 11import { Hooks } from './plugins/hooks'
7import { MAccountDefault, MComment, MCommentOwnerVideoReply, MVideoFullLight } from '../typings/models' 12
13async function removeComment (videoCommentInstance: MCommentOwnerVideo) {
14 const videoCommentInstanceBefore = cloneDeep(videoCommentInstance)
15
16 await sequelizeTypescript.transaction(async t => {
17 if (videoCommentInstance.isOwned() || videoCommentInstance.Video.isOwned()) {
18 await sendDeleteVideoComment(videoCommentInstance, t)
19 }
20
21 markCommentAsDeleted(videoCommentInstance)
22
23 await videoCommentInstance.save()
24 })
25
26 logger.info('Video comment %d deleted.', videoCommentInstance.id)
27
28 Hooks.runAction('action:api.video-comment.deleted', { comment: videoCommentInstanceBefore })
29}
8 30
9async function createVideoComment (obj: { 31async function createVideoComment (obj: {
10 text: string 32 text: string
@@ -73,7 +95,7 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
73 return thread 95 return thread
74} 96}
75 97
76function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void { 98function markCommentAsDeleted (comment: MComment): void {
77 comment.text = '' 99 comment.text = ''
78 comment.deletedAt = new Date() 100 comment.deletedAt = new Date()
79 comment.accountId = null 101 comment.accountId = null
@@ -82,6 +104,7 @@ function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void {
82// --------------------------------------------------------------------------- 104// ---------------------------------------------------------------------------
83 105
84export { 106export {
107 removeComment,
85 createVideoComment, 108 createVideoComment,
86 buildFormattedCommentTree, 109 buildFormattedCommentTree,
87 markCommentAsDeleted 110 markCommentAsDeleted
diff --git a/server/middlewares/validators/blocklist.ts b/server/middlewares/validators/blocklist.ts
index 27224ff9b..c24fa9609 100644
--- a/server/middlewares/validators/blocklist.ts
+++ b/server/middlewares/validators/blocklist.ts
@@ -24,8 +24,7 @@ const blockAccountValidator = [
24 24
25 if (user.Account.id === accountToBlock.id) { 25 if (user.Account.id === accountToBlock.id) {
26 res.status(409) 26 res.status(409)
27 .send({ error: 'You cannot block yourself.' }) 27 .json({ error: 'You cannot block yourself.' })
28 .end()
29 28
30 return 29 return
31 } 30 }
@@ -80,8 +79,7 @@ const blockServerValidator = [
80 79
81 if (host === WEBSERVER.HOST) { 80 if (host === WEBSERVER.HOST) {
82 return res.status(409) 81 return res.status(409)
83 .send({ error: 'You cannot block your own server.' }) 82 .json({ error: 'You cannot block your own server.' })
84 .end()
85 } 83 }
86 84
87 const server = await ServerModel.loadOrCreateByHost(host) 85 const server = await ServerModel.loadOrCreateByHost(host)
@@ -139,8 +137,7 @@ async function doesUnblockAccountExist (accountId: number, targetAccountId: numb
139 const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId) 137 const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId)
140 if (!accountBlock) { 138 if (!accountBlock) {
141 res.status(404) 139 res.status(404)
142 .send({ error: 'Account block entry not found.' }) 140 .json({ error: 'Account block entry not found.' })
143 .end()
144 141
145 return false 142 return false
146 } 143 }
@@ -154,8 +151,7 @@ async function doesUnblockServerExist (accountId: number, host: string, res: exp
154 const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host) 151 const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host)
155 if (!serverBlock) { 152 if (!serverBlock) {
156 res.status(404) 153 res.status(404)
157 .send({ error: 'Server block entry not found.' }) 154 .json({ error: 'Server block entry not found.' })
158 .end()
159 155
160 return false 156 return false
161 } 157 }
diff --git a/server/middlewares/validators/bulk.ts b/server/middlewares/validators/bulk.ts
new file mode 100644
index 000000000..f9b0f565a
--- /dev/null
+++ b/server/middlewares/validators/bulk.ts
@@ -0,0 +1,41 @@
1import * as express from 'express'
2import { body } from 'express-validator'
3import { isBulkRemoveCommentsOfScopeValid } from '@server/helpers/custom-validators/bulk'
4import { doesAccountNameWithHostExist } from '@server/helpers/middlewares'
5import { UserRight } from '@shared/models'
6import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model'
7import { logger } from '../../helpers/logger'
8import { areValidationErrors } from './utils'
9
10const bulkRemoveCommentsOfValidator = [
11 body('accountName').exists().withMessage('Should have an account name with host'),
12 body('scope')
13 .custom(isBulkRemoveCommentsOfScopeValid).withMessage('Should have a valid scope'),
14
15 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
16 logger.debug('Checking bulkRemoveCommentsOfValidator parameters', { parameters: req.body })
17
18 if (areValidationErrors(req, res)) return
19 if (!await doesAccountNameWithHostExist(req.body.accountName, res)) return
20
21 const user = res.locals.oauth.token.User
22 const body = req.body as BulkRemoveCommentsOfBody
23
24 if (body.scope === 'instance' && user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) !== true) {
25 return res.status(403)
26 .json({
27 error: 'User cannot remove any comments of this instance.'
28 })
29 }
30
31 return next()
32 }
33]
34
35// ---------------------------------------------------------------------------
36
37export {
38 bulkRemoveCommentsOfValidator
39}
40
41// ---------------------------------------------------------------------------
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 6d60271e6..7c890ce6d 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -1,19 +1,17 @@
1import * as Bluebird from 'bluebird'
2import { uniq } from 'lodash'
3import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 4import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
5import { getServerActor } from '@server/models/application/application'
6import { MAccount, MAccountId, MUserAccountId } from '@server/typings/models'
7import { VideoPrivacy } from '@shared/models'
2import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 8import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
3import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 9import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
4import { VideoComment } from '../../../shared/models/videos/video-comment.model' 10import { VideoComment } from '../../../shared/models/videos/video-comment.model'
5import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
6import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
7import { AccountModel } from '../account/account'
8import { ActorModel } from '../activitypub/actor'
9import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
10import { VideoModel } from './video'
11import { VideoChannelModel } from './video-channel'
12import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' 11import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
12import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
13import { regexpCapture } from '../../helpers/regexp' 13import { regexpCapture } from '../../helpers/regexp'
14import { uniq } from 'lodash' 14import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
15import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
16import * as Bluebird from 'bluebird'
17import { 15import {
18 MComment, 16 MComment,
19 MCommentAP, 17 MCommentAP,
@@ -25,9 +23,11 @@ import {
25 MCommentOwnerVideoFeed, 23 MCommentOwnerVideoFeed,
26 MCommentOwnerVideoReply 24 MCommentOwnerVideoReply
27} from '../../typings/models/video' 25} from '../../typings/models/video'
28import { MUserAccountId } from '@server/typings/models' 26import { AccountModel } from '../account/account'
29import { VideoPrivacy } from '@shared/models' 27import { ActorModel } from '../activitypub/actor'
30import { getServerActor } from '@server/models/application/application' 28import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
29import { VideoModel } from './video'
30import { VideoChannelModel } from './video-channel'
31 31
32enum ScopeNames { 32enum ScopeNames {
33 WITH_ACCOUNT = 'WITH_ACCOUNT', 33 WITH_ACCOUNT = 'WITH_ACCOUNT',
@@ -415,6 +415,43 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
415 .findAll(query) 415 .findAll(query)
416 } 416 }
417 417
418 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
419 const accountWhere = filter.onVideosOfAccount
420 ? { id: filter.onVideosOfAccount.id }
421 : {}
422
423 const query = {
424 limit: 1000,
425 where: {
426 deletedAt: null,
427 accountId: ofAccount.id
428 },
429 include: [
430 {
431 model: VideoModel,
432 required: true,
433 include: [
434 {
435 model: VideoChannelModel,
436 required: true,
437 include: [
438 {
439 model: AccountModel,
440 required: true,
441 where: accountWhere
442 }
443 ]
444 }
445 ]
446 }
447 ]
448 }
449
450 return VideoCommentModel
451 .scope([ ScopeNames.WITH_ACCOUNT ])
452 .findAll(query)
453 }
454
418 static async getStats () { 455 static async getStats () {
419 const totalLocalVideoComments = await VideoCommentModel.count({ 456 const totalLocalVideoComments = await VideoCommentModel.count({
420 include: [ 457 include: [
@@ -450,7 +487,9 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
450 videoId, 487 videoId,
451 accountId: { 488 accountId: {
452 [Op.notIn]: buildLocalAccountIdsIn() 489 [Op.notIn]: buildLocalAccountIdsIn()
453 } 490 },
491 // Do not delete Tombstones
492 deletedAt: null
454 } 493 }
455 } 494 }
456 495
diff --git a/server/tests/api/check-params/bulk.ts b/server/tests/api/check-params/bulk.ts
new file mode 100644
index 000000000..432858b33
--- /dev/null
+++ b/server/tests/api/check-params/bulk.ts
@@ -0,0 +1,88 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import {
5 cleanupTests,
6 createUser,
7 flushAndRunServer,
8 ServerInfo,
9 setAccessTokensToServers,
10 userLogin
11} from '../../../../shared/extra-utils'
12import { makePostBodyRequest } from '../../../../shared/extra-utils/requests/requests'
13
14describe('Test bulk API validators', function () {
15 let server: ServerInfo
16 let userAccessToken: string
17
18 // ---------------------------------------------------------------
19
20 before(async function () {
21 this.timeout(120000)
22
23 server = await flushAndRunServer(1)
24 await setAccessTokensToServers([ server ])
25
26 const user = { username: 'user1', password: 'password' }
27 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
28
29 userAccessToken = await userLogin(server, user)
30 })
31
32 describe('When removing comments of', function () {
33 const path = '/api/v1/bulk/remove-comments-of'
34
35 it('Should fail with an unauthenticated user', async function () {
36 await makePostBodyRequest({
37 url: server.url,
38 path,
39 fields: { accountName: 'user1', scope: 'my-videos' },
40 statusCodeExpected: 401
41 })
42 })
43
44 it('Should fail with an unknown account', async function () {
45 await makePostBodyRequest({
46 url: server.url,
47 token: server.accessToken,
48 path,
49 fields: { accountName: 'user2', scope: 'my-videos' },
50 statusCodeExpected: 404
51 })
52 })
53
54 it('Should fail with an invalid scope', async function () {
55 await makePostBodyRequest({
56 url: server.url,
57 token: server.accessToken,
58 path,
59 fields: { accountName: 'user1', scope: 'my-videoss' },
60 statusCodeExpected: 400
61 })
62 })
63
64 it('Should fail to delete comments of the instance without the appropriate rights', async function () {
65 await makePostBodyRequest({
66 url: server.url,
67 token: userAccessToken,
68 path,
69 fields: { accountName: 'user1', scope: 'instance' },
70 statusCodeExpected: 403
71 })
72 })
73
74 it('Should succeed with the correct params', async function () {
75 await makePostBodyRequest({
76 url: server.url,
77 token: server.accessToken,
78 path,
79 fields: { accountName: 'user1', scope: 'instance' },
80 statusCodeExpected: 204
81 })
82 })
83 })
84
85 after(async function () {
86 await cleanupTests([ server ])
87 })
88})
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index ef152f55c..93ffd98b1 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -1,5 +1,6 @@
1import './accounts' 1import './accounts'
2import './blocklist' 2import './blocklist'
3import './bulk'
3import './config' 4import './config'
4import './contact-form' 5import './contact-form'
5import './debug' 6import './debug'
diff --git a/server/tests/api/server/bulk.ts b/server/tests/api/server/bulk.ts
new file mode 100644
index 000000000..63321d4bb
--- /dev/null
+++ b/server/tests/api/server/bulk.ts
@@ -0,0 +1,198 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { VideoComment } from '@shared/models/videos/video-comment.model'
6import {
7 addVideoCommentThread,
8 bulkRemoveCommentsOf,
9 cleanupTests,
10 createUser,
11 flushAndRunMultipleServers,
12 getVideoCommentThreads,
13 getVideosList,
14 ServerInfo,
15 setAccessTokensToServers,
16 uploadVideo,
17 userLogin,
18 waitJobs,
19 addVideoCommentReply
20} from '../../../../shared/extra-utils/index'
21import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
22import { Video } from '@shared/models'
23
24const expect = chai.expect
25
26describe('Test bulk actions', function () {
27 const commentsUser3: { videoId: number, commentId: number }[] = []
28
29 let servers: ServerInfo[] = []
30 let user1AccessToken: string
31 let user2AccessToken: string
32 let user3AccessToken: string
33
34 before(async function () {
35 this.timeout(30000)
36
37 servers = await flushAndRunMultipleServers(2)
38
39 // Get the access tokens
40 await setAccessTokensToServers(servers)
41
42 {
43 const user = { username: 'user1', password: 'password' }
44 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: user.username, password: user.password })
45
46 user1AccessToken = await userLogin(servers[0], user)
47 }
48
49 {
50 const user = { username: 'user2', password: 'password' }
51 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: user.username, password: user.password })
52
53 user2AccessToken = await userLogin(servers[0], user)
54 }
55
56 {
57 const user = { username: 'user3', password: 'password' }
58 await createUser({ url: servers[1].url, accessToken: servers[1].accessToken, username: user.username, password: user.password })
59
60 user3AccessToken = await userLogin(servers[1], user)
61 }
62
63 await doubleFollow(servers[0], servers[1])
64 })
65
66 describe('Bulk remove comments', function () {
67 async function checkInstanceCommentsRemoved () {
68 {
69 const res = await getVideosList(servers[0].url)
70 const videos = res.body.data as Video[]
71
72 // Server 1 should not have these comments anymore
73 for (const video of videos) {
74 const resThreads = await getVideoCommentThreads(servers[0].url, video.id, 0, 10)
75 const comments = resThreads.body.data as VideoComment[]
76 const comment = comments.find(c => c.text === 'comment by user 3')
77
78 expect(comment).to.not.exist
79 }
80 }
81
82 {
83 const res = await getVideosList(servers[1].url)
84 const videos = res.body.data as Video[]
85
86 // Server 1 should not have these comments on videos of server 1
87 for (const video of videos) {
88 const resThreads = await getVideoCommentThreads(servers[1].url, video.id, 0, 10)
89 const comments = resThreads.body.data as VideoComment[]
90 const comment = comments.find(c => c.text === 'comment by user 3')
91
92 if (video.account.host === 'localhost:' + servers[0].port) {
93 expect(comment).to.not.exist
94 } else {
95 expect(comment).to.exist
96 }
97 }
98 }
99 }
100
101 before(async function () {
102 this.timeout(60000)
103
104 await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video 1 server 1' })
105 await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video 2 server 1' })
106 await uploadVideo(servers[0].url, user1AccessToken, { name: 'video 3 server 1' })
107
108 await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video 1 server 2' })
109
110 await waitJobs(servers)
111
112 {
113 const res = await getVideosList(servers[0].url)
114 for (const video of res.body.data) {
115 await addVideoCommentThread(servers[0].url, servers[0].accessToken, video.id, 'comment by root server 1')
116 await addVideoCommentThread(servers[0].url, user1AccessToken, video.id, 'comment by user 1')
117 await addVideoCommentThread(servers[0].url, user2AccessToken, video.id, 'comment by user 2')
118 }
119 }
120
121 {
122 const res = await getVideosList(servers[1].url)
123 for (const video of res.body.data) {
124 await addVideoCommentThread(servers[1].url, servers[1].accessToken, video.id, 'comment by root server 2')
125
126 const res = await addVideoCommentThread(servers[1].url, user3AccessToken, video.id, 'comment by user 3')
127 commentsUser3.push({ videoId: video.id, commentId: res.body.comment.id })
128 }
129 }
130
131 await waitJobs(servers)
132 })
133
134 it('Should delete comments of an account on my videos', async function () {
135 this.timeout(60000)
136
137 await bulkRemoveCommentsOf({
138 url: servers[0].url,
139 token: user1AccessToken,
140 attributes: {
141 accountName: 'user2',
142 scope: 'my-videos'
143 }
144 })
145
146 await waitJobs(servers)
147
148 for (const server of servers) {
149 const res = await getVideosList(server.url)
150
151 for (const video of res.body.data) {
152 const resThreads = await getVideoCommentThreads(server.url, video.id, 0, 10)
153 const comments = resThreads.body.data as VideoComment[]
154 const comment = comments.find(c => c.text === 'comment by user 2')
155
156 if (video.name === 'video 3 server 1') {
157 expect(comment).to.not.exist
158 } else {
159 expect(comment).to.exist
160 }
161 }
162 }
163 })
164
165 it('Should delete comments of an account on the instance', async function () {
166 this.timeout(60000)
167
168 await bulkRemoveCommentsOf({
169 url: servers[0].url,
170 token: servers[0].accessToken,
171 attributes: {
172 accountName: 'user3@localhost:' + servers[1].port,
173 scope: 'instance'
174 }
175 })
176
177 await waitJobs(servers)
178
179 await checkInstanceCommentsRemoved()
180 })
181
182 it('Should not re create the comment on video update', async function () {
183 this.timeout(60000)
184
185 for (const obj of commentsUser3) {
186 await addVideoCommentReply(servers[1].url, user3AccessToken, obj.videoId, obj.commentId, 'comment by user 3 bis')
187 }
188
189 await waitJobs(servers)
190
191 await checkInstanceCommentsRemoved()
192 })
193 })
194
195 after(async function () {
196 await cleanupTests(servers)
197 })
198})
diff --git a/shared/extra-utils/bulk/bulk.ts b/shared/extra-utils/bulk/bulk.ts
new file mode 100644
index 000000000..d6798ceb7
--- /dev/null
+++ b/shared/extra-utils/bulk/bulk.ts
@@ -0,0 +1,24 @@
1import { BulkRemoveCommentsOfBody } from "@shared/models/bulk/bulk-remove-comments-of-body.model"
2import { makePostBodyRequest } from "../requests/requests"
3
4function bulkRemoveCommentsOf (options: {
5 url: string
6 token: string
7 attributes: BulkRemoveCommentsOfBody
8 expectedStatus?: number
9}) {
10 const { url, token, attributes, expectedStatus } = options
11 const path = '/api/v1/bulk/remove-comments-of'
12
13 return makePostBodyRequest({
14 url,
15 path,
16 token,
17 fields: attributes,
18 statusCodeExpected: expectedStatus || 204
19 })
20}
21
22export {
23 bulkRemoveCommentsOf
24}
diff --git a/shared/extra-utils/index.ts b/shared/extra-utils/index.ts
index d3f010b20..2ac0c6338 100644
--- a/shared/extra-utils/index.ts
+++ b/shared/extra-utils/index.ts
@@ -1,4 +1,5 @@
1export * from './server/activitypub' 1export * from './server/activitypub'
2export * from './bulk/bulk'
2export * from './cli/cli' 3export * from './cli/cli'
3export * from './server/clients' 4export * from './server/clients'
4export * from './server/config' 5export * 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
index 000000000..31e018c2a
--- /dev/null
+++ b/shared/models/bulk/bulk-remove-comments-of-body.model.ts
@@ -0,0 +1,4 @@
1export interface BulkRemoveCommentsOfBody {
2 accountName: string
3 scope: 'my-videos' | 'instance'
4}