diff options
author | Chocobozzz <me@florianbigard.com> | 2020-05-14 16:56:15 +0200 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2020-05-29 09:21:26 +0200 |
commit | 444c0a0e017824fb4ce526281a22c4abe0a13c50 (patch) | |
tree | 6a3c1ea8c4995361c582176257d1e1315287411d | |
parent | 99139e7753e20ab0fba8eae5638d3dd3e792fe43 (diff) | |
download | PeerTube-444c0a0e017824fb4ce526281a22c4abe0a13c50.tar.gz PeerTube-444c0a0e017824fb4ce526281a22c4abe0a13c50.tar.zst PeerTube-444c0a0e017824fb4ce526281a22c4abe0a13c50.zip |
Add ability to bulk delete comments
-rw-r--r-- | server/controllers/api/bulk.ts | 41 | ||||
-rw-r--r-- | server/controllers/api/index.ts | 20 | ||||
-rw-r--r-- | server/controllers/api/videos/comment.ts | 35 | ||||
-rw-r--r-- | server/helpers/custom-validators/bulk.ts | 9 | ||||
-rw-r--r-- | server/lib/activitypub/send/send-delete.ts | 12 | ||||
-rw-r--r-- | server/lib/video-comment.ts | 29 | ||||
-rw-r--r-- | server/middlewares/validators/blocklist.ts | 12 | ||||
-rw-r--r-- | server/middlewares/validators/bulk.ts | 41 | ||||
-rw-r--r-- | server/models/video/video-comment.ts | 67 | ||||
-rw-r--r-- | server/tests/api/check-params/bulk.ts | 88 | ||||
-rw-r--r-- | server/tests/api/check-params/index.ts | 1 | ||||
-rw-r--r-- | server/tests/api/server/bulk.ts | 198 | ||||
-rw-r--r-- | shared/extra-utils/bulk/bulk.ts | 24 | ||||
-rw-r--r-- | shared/extra-utils/index.ts | 1 | ||||
-rw-r--r-- | shared/models/bulk/bulk-remove-comments-of-body.model.ts | 4 |
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { asyncMiddleware, authenticate } from '../../middlewares' | ||
3 | import { bulkRemoveCommentsOfValidator } from '@server/middlewares/validators/bulk' | ||
4 | import { VideoCommentModel } from '@server/models/video/video-comment' | ||
5 | import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model' | ||
6 | import { removeComment } from '@server/lib/video-comment' | ||
7 | |||
8 | const bulkRouter = express.Router() | ||
9 | |||
10 | bulkRouter.post('/remove-comments-of', | ||
11 | authenticate, | ||
12 | asyncMiddleware(bulkRemoveCommentsOfValidator), | ||
13 | asyncMiddleware(bulkRemoveCommentsOf) | ||
14 | ) | ||
15 | |||
16 | // --------------------------------------------------------------------------- | ||
17 | |||
18 | export { | ||
19 | bulkRouter | ||
20 | } | ||
21 | |||
22 | // --------------------------------------------------------------------------- | ||
23 | |||
24 | async 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 @@ | |||
1 | import * as cors from 'cors' | ||
1 | import * as express from 'express' | 2 | import * as express from 'express' |
3 | import * as RateLimit from 'express-rate-limit' | ||
4 | import { badRequest } from '../../helpers/express-utils' | ||
5 | import { CONFIG } from '../../initializers/config' | ||
6 | import { accountsRouter } from './accounts' | ||
7 | import { bulkRouter } from './bulk' | ||
2 | import { configRouter } from './config' | 8 | import { configRouter } from './config' |
3 | import { jobsRouter } from './jobs' | 9 | import { jobsRouter } from './jobs' |
4 | import { oauthClientsRouter } from './oauth-clients' | 10 | import { oauthClientsRouter } from './oauth-clients' |
11 | import { overviewsRouter } from './overviews' | ||
12 | import { pluginRouter } from './plugins' | ||
13 | import { searchRouter } from './search' | ||
5 | import { serverRouter } from './server' | 14 | import { serverRouter } from './server' |
6 | import { usersRouter } from './users' | 15 | import { usersRouter } from './users' |
7 | import { accountsRouter } from './accounts' | ||
8 | import { videosRouter } from './videos' | ||
9 | import { badRequest } from '../../helpers/express-utils' | ||
10 | import { videoChannelRouter } from './video-channel' | 16 | import { videoChannelRouter } from './video-channel' |
11 | import * as cors from 'cors' | ||
12 | import { searchRouter } from './search' | ||
13 | import { overviewsRouter } from './overviews' | ||
14 | import { videoPlaylistRouter } from './video-playlist' | 17 | import { videoPlaylistRouter } from './video-playlist' |
15 | import { CONFIG } from '../../initializers/config' | 18 | import { videosRouter } from './videos' |
16 | import { pluginRouter } from './plugins' | ||
17 | import * as RateLimit from 'express-rate-limit' | ||
18 | 19 | ||
19 | const apiRouter = express.Router() | 20 | const apiRouter = express.Router() |
20 | 21 | ||
@@ -31,6 +32,7 @@ const apiRateLimiter = RateLimit({ | |||
31 | apiRouter.use(apiRateLimiter) | 32 | apiRouter.use(apiRateLimiter) |
32 | 33 | ||
33 | apiRouter.use('/server', serverRouter) | 34 | apiRouter.use('/server', serverRouter) |
35 | apiRouter.use('/bulk', bulkRouter) | ||
34 | apiRouter.use('/oauth-clients', oauthClientsRouter) | 36 | apiRouter.use('/oauth-clients', oauthClientsRouter) |
35 | apiRouter.use('/config', configRouter) | 37 | apiRouter.use('/config', configRouter) |
36 | apiRouter.use('/users', usersRouter) | 38 | apiRouter.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 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { cloneDeep } from 'lodash' | ||
3 | import { ResultList } from '../../../../shared/models' | 2 | import { ResultList } from '../../../../shared/models' |
4 | import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' | 3 | import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' |
5 | import { logger } from '../../../helpers/logger' | 4 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' |
6 | import { getFormattedObjects } from '../../../helpers/utils' | 5 | import { getFormattedObjects } from '../../../helpers/utils' |
7 | import { sequelizeTypescript } from '../../../initializers/database' | 6 | import { sequelizeTypescript } from '../../../initializers/database' |
8 | import { buildFormattedCommentTree, createVideoComment, markCommentAsDeleted } from '../../../lib/video-comment' | 7 | import { Notifier } from '../../../lib/notifier' |
8 | import { Hooks } from '../../../lib/plugins/hooks' | ||
9 | import { buildFormattedCommentTree, createVideoComment, removeComment } from '../../../lib/video-comment' | ||
9 | import { | 10 | import { |
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' |
26 | import { VideoCommentModel } from '../../../models/video/video-comment' | ||
27 | import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' | ||
28 | import { AccountModel } from '../../../models/account/account' | 27 | import { AccountModel } from '../../../models/account/account' |
29 | import { Notifier } from '../../../lib/notifier' | 28 | import { VideoCommentModel } from '../../../models/video/video-comment' |
30 | import { Hooks } from '../../../lib/plugins/hooks' | ||
31 | import { sendDeleteVideoComment } from '../../../lib/activitypub/send' | ||
32 | 29 | ||
33 | const auditLogger = auditLoggerFactory('comments') | 30 | const auditLogger = auditLoggerFactory('comments') |
34 | const videoCommentRouter = express.Router() | 31 | const 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 | ||
157 | async function addVideoCommentReply (req: express.Request, res: express.Response) { | 152 | async 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 | ||
179 | async function removeVideoComment (req: express.Request, res: express.Response) { | 174 | async 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 @@ | |||
1 | function isBulkRemoveCommentsOfScopeValid (value: string) { | ||
2 | return value === 'my-videos' || value === 'instance' | ||
3 | } | ||
4 | |||
5 | // --------------------------------------------------------------------------- | ||
6 | |||
7 | export { | ||
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 @@ | |||
1 | import { Transaction } from 'sequelize' | 1 | import { Transaction } from 'sequelize' |
2 | import { getServerActor } from '@server/models/application/application' | ||
2 | import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub' | 3 | import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub' |
4 | import { logger } from '../../../helpers/logger' | ||
3 | import { ActorModel } from '../../../models/activitypub/actor' | 5 | import { ActorModel } from '../../../models/activitypub/actor' |
4 | import { VideoCommentModel } from '../../../models/video/video-comment' | 6 | import { VideoCommentModel } from '../../../models/video/video-comment' |
5 | import { VideoShareModel } from '../../../models/video/video-share' | 7 | import { VideoShareModel } from '../../../models/video/video-share' |
8 | import { MActorUrl } from '../../../typings/models' | ||
9 | import { MCommentOwnerVideo, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../typings/models/video' | ||
10 | import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience' | ||
6 | import { getDeleteActivityPubUrl } from '../url' | 11 | import { getDeleteActivityPubUrl } from '../url' |
7 | import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' | 12 | import { broadcastToActors, broadcastToFollowers, sendVideoRelatedActivity, unicastTo } from './utils' |
8 | import { audiencify, getActorsInvolvedInVideo, getVideoCommentAudience } from '../audience' | ||
9 | import { logger } from '../../../helpers/logger' | ||
10 | import { MCommentOwnerVideoReply, MVideoAccountLight, MVideoPlaylistFullSummary } from '../../../typings/models/video' | ||
11 | import { MActorUrl } from '../../../typings/models' | ||
12 | import { getServerActor } from '@server/models/application/application' | ||
13 | 13 | ||
14 | async function sendDeleteVideo (video: MVideoAccountLight, transaction: Transaction) { | 14 | async 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 | ||
45 | async function sendDeleteVideoComment (videoComment: MCommentOwnerVideoReply, t: Transaction) { | 45 | async 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 @@ | |||
1 | import { cloneDeep } from 'lodash' | ||
1 | import * as Sequelize from 'sequelize' | 2 | import * as Sequelize from 'sequelize' |
3 | import { logger } from '@server/helpers/logger' | ||
4 | import { sequelizeTypescript } from '@server/initializers/database' | ||
2 | import { ResultList } from '../../shared/models' | 5 | import { ResultList } from '../../shared/models' |
3 | import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' | 6 | import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' |
4 | import { VideoCommentModel } from '../models/video/video-comment' | 7 | import { VideoCommentModel } from '../models/video/video-comment' |
8 | import { MAccountDefault, MComment, MCommentOwnerVideoReply, MVideoFullLight, MCommentOwnerVideo } from '../typings/models' | ||
9 | import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' | ||
5 | import { getVideoCommentActivityPubUrl } from './activitypub/url' | 10 | import { getVideoCommentActivityPubUrl } from './activitypub/url' |
6 | import { sendCreateVideoComment } from './activitypub/send' | 11 | import { Hooks } from './plugins/hooks' |
7 | import { MAccountDefault, MComment, MCommentOwnerVideoReply, MVideoFullLight } from '../typings/models' | 12 | |
13 | async 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 | ||
9 | async function createVideoComment (obj: { | 31 | async 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 | ||
76 | function markCommentAsDeleted (comment: MCommentOwnerVideoReply): void { | 98 | function 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 | ||
84 | export { | 106 | export { |
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 @@ | |||
1 | import * as express from 'express' | ||
2 | import { body } from 'express-validator' | ||
3 | import { isBulkRemoveCommentsOfScopeValid } from '@server/helpers/custom-validators/bulk' | ||
4 | import { doesAccountNameWithHostExist } from '@server/helpers/middlewares' | ||
5 | import { UserRight } from '@shared/models' | ||
6 | import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model' | ||
7 | import { logger } from '../../helpers/logger' | ||
8 | import { areValidationErrors } from './utils' | ||
9 | |||
10 | const 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 | |||
37 | export { | ||
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 @@ | |||
1 | import * as Bluebird from 'bluebird' | ||
2 | import { uniq } from 'lodash' | ||
3 | import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize' | ||
1 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' | 4 | import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' |
5 | import { getServerActor } from '@server/models/application/application' | ||
6 | import { MAccount, MAccountId, MUserAccountId } from '@server/typings/models' | ||
7 | import { VideoPrivacy } from '@shared/models' | ||
2 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' | 8 | import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' |
3 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' | 9 | import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' |
4 | import { VideoComment } from '../../../shared/models/videos/video-comment.model' | 10 | import { VideoComment } from '../../../shared/models/videos/video-comment.model' |
5 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
6 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' | ||
7 | import { AccountModel } from '../account/account' | ||
8 | import { ActorModel } from '../activitypub/actor' | ||
9 | import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' | ||
10 | import { VideoModel } from './video' | ||
11 | import { VideoChannelModel } from './video-channel' | ||
12 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' | 11 | import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' |
12 | import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' | ||
13 | import { regexpCapture } from '../../helpers/regexp' | 13 | import { regexpCapture } from '../../helpers/regexp' |
14 | import { uniq } from 'lodash' | 14 | import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' |
15 | import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize' | ||
16 | import * as Bluebird from 'bluebird' | ||
17 | import { | 15 | import { |
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' |
28 | import { MUserAccountId } from '@server/typings/models' | 26 | import { AccountModel } from '../account/account' |
29 | import { VideoPrivacy } from '@shared/models' | 27 | import { ActorModel } from '../activitypub/actor' |
30 | import { getServerActor } from '@server/models/application/application' | 28 | import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' |
29 | import { VideoModel } from './video' | ||
30 | import { VideoChannelModel } from './video-channel' | ||
31 | 31 | ||
32 | enum ScopeNames { | 32 | enum 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 | |||
3 | import 'mocha' | ||
4 | import { | ||
5 | cleanupTests, | ||
6 | createUser, | ||
7 | flushAndRunServer, | ||
8 | ServerInfo, | ||
9 | setAccessTokensToServers, | ||
10 | userLogin | ||
11 | } from '../../../../shared/extra-utils' | ||
12 | import { makePostBodyRequest } from '../../../../shared/extra-utils/requests/requests' | ||
13 | |||
14 | describe('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 @@ | |||
1 | import './accounts' | 1 | import './accounts' |
2 | import './blocklist' | 2 | import './blocklist' |
3 | import './bulk' | ||
3 | import './config' | 4 | import './config' |
4 | import './contact-form' | 5 | import './contact-form' |
5 | import './debug' | 6 | import './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 | |||
3 | import 'mocha' | ||
4 | import * as chai from 'chai' | ||
5 | import { VideoComment } from '@shared/models/videos/video-comment.model' | ||
6 | import { | ||
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' | ||
21 | import { doubleFollow } from '../../../../shared/extra-utils/server/follows' | ||
22 | import { Video } from '@shared/models' | ||
23 | |||
24 | const expect = chai.expect | ||
25 | |||
26 | describe('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 @@ | |||
1 | import { BulkRemoveCommentsOfBody } from "@shared/models/bulk/bulk-remove-comments-of-body.model" | ||
2 | import { makePostBodyRequest } from "../requests/requests" | ||
3 | |||
4 | function 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 | |||
22 | export { | ||
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 @@ | |||
1 | export * from './server/activitypub' | 1 | export * from './server/activitypub' |
2 | export * from './bulk/bulk' | ||
2 | export * from './cli/cli' | 3 | export * from './cli/cli' |
3 | export * from './server/clients' | 4 | export * from './server/clients' |
4 | export * from './server/config' | 5 | 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 index 000000000..31e018c2a --- /dev/null +++ b/shared/models/bulk/bulk-remove-comments-of-body.model.ts | |||
@@ -0,0 +1,4 @@ | |||
1 | export interface BulkRemoveCommentsOfBody { | ||
2 | accountName: string | ||
3 | scope: 'my-videos' | 'instance' | ||
4 | } | ||