From 444c0a0e017824fb4ce526281a22c4abe0a13c50 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 14 May 2020 16:56:15 +0200 Subject: [PATCH] Add ability to bulk delete comments --- server/controllers/api/bulk.ts | 41 ++++ server/controllers/api/index.ts | 20 +- server/controllers/api/videos/comment.ts | 35 +--- server/helpers/custom-validators/bulk.ts | 9 + server/lib/activitypub/send/send-delete.ts | 12 +- server/lib/video-comment.ts | 29 ++- server/middlewares/validators/blocklist.ts | 12 +- server/middlewares/validators/bulk.ts | 41 ++++ server/models/video/video-comment.ts | 67 ++++-- server/tests/api/check-params/bulk.ts | 88 ++++++++ server/tests/api/check-params/index.ts | 1 + server/tests/api/server/bulk.ts | 198 ++++++++++++++++++ shared/extra-utils/bulk/bulk.ts | 24 +++ shared/extra-utils/index.ts | 1 + .../bulk-remove-comments-of-body.model.ts | 4 + 15 files changed, 516 insertions(+), 66 deletions(-) create mode 100644 server/controllers/api/bulk.ts create mode 100644 server/helpers/custom-validators/bulk.ts create mode 100644 server/middlewares/validators/bulk.ts create mode 100644 server/tests/api/check-params/bulk.ts create mode 100644 server/tests/api/server/bulk.ts create mode 100644 shared/extra-utils/bulk/bulk.ts create mode 100644 shared/models/bulk/bulk-remove-comments-of-body.model.ts 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 @@ +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) + } +} 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 @@ +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) 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 @@ 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 index 000000000..9e0ce0be1 --- /dev/null +++ b/server/helpers/custom-validators/bulk.ts @@ -0,0 +1,9 @@ +function isBulkRemoveCommentsOfScopeValid (value: string) { + return value === 'my-videos' || value === 'instance' +} + +// --------------------------------------------------------------------------- + +export { + isBulkRemoveCommentsOfScopeValid +} 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 @@ 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() 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 @@ +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): 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 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 = [ 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 index 000000000..f9b0f565a --- /dev/null +++ b/server/middlewares/validators/bulk.ts @@ -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 +} + +// --------------------------------------------------------------------------- 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 @@ +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 { .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 { 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 index 000000000..432858b33 --- /dev/null +++ b/server/tests/api/check-params/bulk.ts @@ -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 ]) + }) +}) 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 @@ 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 index 000000000..63321d4bb --- /dev/null +++ b/server/tests/api/server/bulk.ts @@ -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 index 000000000..d6798ceb7 --- /dev/null +++ b/shared/extra-utils/bulk/bulk.ts @@ -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 +} 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 @@ 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 index 000000000..31e018c2a --- /dev/null +++ b/shared/models/bulk/bulk-remove-comments-of-body.model.ts @@ -0,0 +1,4 @@ +export interface BulkRemoveCommentsOfBody { + accountName: string + scope: 'my-videos' | 'instance' +} -- 2.41.0