From 57f6896f67cfc570cf3605dd94b0778101b2d9b9 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 7 Jul 2020 10:57:04 +0200 Subject: [PATCH] Implement abuses check params --- server/controllers/api/abuse.ts | 10 +- server/helpers/custom-validators/abuses.ts | 17 +- .../custom-validators/video-comments.ts | 81 +++++- server/helpers/middlewares/abuses.ts | 13 +- server/helpers/middlewares/accounts.ts | 4 +- server/middlewares/validators/abuse.ts | 74 +++-- .../validators/videos/video-comments.ts | 70 +---- server/models/abuse/abuse.ts | 91 ++++-- server/models/abuse/video-comment-abuse.ts | 2 +- server/models/video/video-comment.ts | 65 ++++- server/models/video/video.ts | 4 +- server/tests/api/check-params/abuses.ts | 271 ++++++++++++++++++ server/tests/api/check-params/index.ts | 1 + server/tests/api/check-params/video-abuses.ts | 6 - server/types/models/moderation/abuse.ts | 9 +- server/typings/express/index.d.ts | 1 + shared/extra-utils/moderation/abuses.ts | 88 ++++-- .../moderation/abuse/abuse-create.model.ts | 7 +- .../models/moderation/abuse/abuse-filter.ts | 1 - .../moderation/abuse/abuse-filter.type.ts | 1 + shared/models/moderation/abuse/abuse.model.ts | 10 +- shared/models/moderation/abuse/index.ts | 1 + 22 files changed, 665 insertions(+), 162 deletions(-) create mode 100644 server/tests/api/check-params/abuses.ts delete mode 100644 shared/models/moderation/abuse/abuse-filter.ts create mode 100644 shared/models/moderation/abuse/abuse-filter.type.ts diff --git a/server/controllers/api/abuse.ts b/server/controllers/api/abuse.ts index ee046cb3a..38808021d 100644 --- a/server/controllers/api/abuse.ts +++ b/server/controllers/api/abuse.ts @@ -23,7 +23,7 @@ import { AccountModel } from '../../models/account/account' const abuseRouter = express.Router() -abuseRouter.get('/abuse', +abuseRouter.get('/', authenticate, ensureUserHasRight(UserRight.MANAGE_ABUSES), paginationValidator, @@ -33,18 +33,18 @@ abuseRouter.get('/abuse', abuseListValidator, asyncMiddleware(listAbuses) ) -abuseRouter.put('/:videoId/abuse/:id', +abuseRouter.put('/:id', authenticate, ensureUserHasRight(UserRight.MANAGE_ABUSES), asyncMiddleware(abuseUpdateValidator), asyncRetryTransactionMiddleware(updateAbuse) ) -abuseRouter.post('/:videoId/abuse', +abuseRouter.post('/', authenticate, asyncMiddleware(abuseReportValidator), asyncRetryTransactionMiddleware(reportAbuse) ) -abuseRouter.delete('/:videoId/abuse/:id', +abuseRouter.delete('/:id', authenticate, ensureUserHasRight(UserRight.MANAGE_ABUSES), asyncMiddleware(abuseGetValidator), @@ -74,7 +74,7 @@ async function listAbuses (req: express.Request, res: express.Response) { count: req.query.count, sort: req.query.sort, id: req.query.id, - filter: 'video', + filter: req.query.filter, predefinedReason: req.query.predefinedReason, search: req.query.search, state: req.query.state, diff --git a/server/helpers/custom-validators/abuses.ts b/server/helpers/custom-validators/abuses.ts index a6a895c65..c21468caa 100644 --- a/server/helpers/custom-validators/abuses.ts +++ b/server/helpers/custom-validators/abuses.ts @@ -1,6 +1,6 @@ import validator from 'validator' -import { abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseVideoIs } from '@shared/models' -import { CONSTRAINTS_FIELDS, ABUSE_STATES } from '../../initializers/constants' +import { AbuseFilter, abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseVideoIs, AbuseCreate } from '@shared/models' +import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' import { exists, isArray } from './misc' const VIDEO_ABUSES_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.ABUSES @@ -13,7 +13,11 @@ function isAbusePredefinedReasonValid (value: AbusePredefinedReasonsString) { return exists(value) && value in abusePredefinedReasonsMap } -function isAbusePredefinedReasonsValid (value: AbusePredefinedReasonsString[]) { +function isAbuseFilterValid (value: AbuseFilter) { + return value === 'video' || value === 'comment' || value === 'account' +} + +function areAbusePredefinedReasonsValid (value: AbusePredefinedReasonsString[]) { return exists(value) && isArray(value) && value.every(v => v in abusePredefinedReasonsMap) } @@ -22,7 +26,9 @@ function isAbuseTimestampValid (value: number) { } function isAbuseTimestampCoherent (endAt: number, { req }) { - return exists(req.body.startAt) && endAt > req.body.startAt + const startAt = (req.body as AbuseCreate).video.startAt + + return exists(startAt) && endAt > startAt } function isAbuseModerationCommentValid (value: string) { @@ -44,8 +50,9 @@ function isAbuseVideoIsValid (value: AbuseVideoIs) { export { isAbuseReasonValid, + isAbuseFilterValid, isAbusePredefinedReasonValid, - isAbusePredefinedReasonsValid, + areAbusePredefinedReasonsValid as isAbusePredefinedReasonsValid, isAbuseTimestampValid, isAbuseTimestampCoherent, isAbuseModerationCommentValid, diff --git a/server/helpers/custom-validators/video-comments.ts b/server/helpers/custom-validators/video-comments.ts index 846f28b17..a01680cbe 100644 --- a/server/helpers/custom-validators/video-comments.ts +++ b/server/helpers/custom-validators/video-comments.ts @@ -1,6 +1,8 @@ -import 'multer' +import * as express from 'express' import validator from 'validator' +import { VideoCommentModel } from '@server/models/video/video-comment' import { CONSTRAINTS_FIELDS } from '../../initializers/constants' +import { MVideoId } from '@server/types/models' const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS @@ -8,8 +10,83 @@ function isValidVideoCommentText (value: string) { return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT) } +async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) { + const id = parseInt(idArg + '', 10) + const videoComment = await VideoCommentModel.loadById(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + .end() + + return false + } + + if (videoComment.videoId !== video.id) { + res.status(400) + .json({ error: 'Video comment is not associated to this video.' }) + .end() + + return false + } + + if (videoComment.inReplyToCommentId !== null) { + res.status(400) + .json({ error: 'Video comment is not a thread.' }) + .end() + + return false + } + + res.locals.videoCommentThread = videoComment + return true +} + +async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) { + const id = parseInt(idArg + '', 10) + const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + .end() + + return false + } + + if (videoComment.videoId !== video.id) { + res.status(400) + .json({ error: 'Video comment is not associated to this video.' }) + .end() + + return false + } + + res.locals.videoCommentFull = videoComment + return true +} + +async function doesCommentIdExist (idArg: number | string, res: express.Response) { + const id = parseInt(idArg + '', 10) + const videoComment = await VideoCommentModel.loadById(id) + + if (!videoComment) { + res.status(404) + .json({ error: 'Video comment thread not found' }) + + return false + } + + res.locals.videoComment = videoComment + + return true +} + // --------------------------------------------------------------------------- export { - isValidVideoCommentText + isValidVideoCommentText, + doesVideoCommentThreadExist, + doesVideoCommentExist, + doesCommentIdExist } diff --git a/server/helpers/middlewares/abuses.ts b/server/helpers/middlewares/abuses.ts index 3906f6760..b102273a2 100644 --- a/server/helpers/middlewares/abuses.ts +++ b/server/helpers/middlewares/abuses.ts @@ -17,7 +17,6 @@ async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: stri if (abuse === null) { res.status(404) .json({ error: 'Video abuse not found' }) - .end() return false } @@ -26,8 +25,18 @@ async function doesVideoAbuseExist (abuseIdArg: number | string, videoUUID: stri return true } -async function doesAbuseExist (abuseIdArg: number | string, videoUUID: string, res: Response) { +async function doesAbuseExist (abuseId: number | string, res: Response) { + const abuse = await AbuseModel.loadById(parseInt(abuseId + '', 10)) + if (!abuse) { + res.status(404) + .json({ error: 'Video abuse not found' }) + + return false + } + + res.locals.abuse = abuse + return true } // --------------------------------------------------------------------------- diff --git a/server/helpers/middlewares/accounts.ts b/server/helpers/middlewares/accounts.ts index bddea7eaa..29b4ed1a6 100644 --- a/server/helpers/middlewares/accounts.ts +++ b/server/helpers/middlewares/accounts.ts @@ -3,8 +3,8 @@ import { AccountModel } from '../../models/account/account' import * as Bluebird from 'bluebird' import { MAccountDefault } from '../../types/models' -function doesAccountIdExist (id: number, res: Response, sendNotFound = true) { - const promise = AccountModel.load(id) +function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { + const promise = AccountModel.load(parseInt(id + '', 10)) return doesAccountExist(promise, res, sendNotFound) } diff --git a/server/middlewares/validators/abuse.ts b/server/middlewares/validators/abuse.ts index f098e2ff9..048dbead0 100644 --- a/server/middlewares/validators/abuse.ts +++ b/server/middlewares/validators/abuse.ts @@ -1,6 +1,7 @@ import * as express from 'express' import { body, param, query } from 'express-validator' import { + isAbuseFilterValid, isAbuseModerationCommentValid, isAbusePredefinedReasonsValid, isAbusePredefinedReasonValid, @@ -11,29 +12,28 @@ import { isAbuseVideoIsValid } from '@server/helpers/custom-validators/abuses' import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '@server/helpers/custom-validators/misc' +import { doesCommentIdExist } from '@server/helpers/custom-validators/video-comments' import { logger } from '@server/helpers/logger' -import { doesAbuseExist, doesVideoAbuseExist, doesVideoExist } from '@server/helpers/middlewares' +import { doesAbuseExist, doesAccountIdExist, doesVideoAbuseExist, doesVideoExist } from '@server/helpers/middlewares' +import { AbuseCreate } from '@shared/models' import { areValidationErrors } from './utils' const abuseReportValidator = [ - param('videoId') + body('account.id') + .optional() + .custom(isIdValid) + .withMessage('Should have a valid accountId'), + + body('video.id') + .optional() .custom(isIdOrUUIDValid) - .not() - .isEmpty() .withMessage('Should have a valid videoId'), - body('reason') - .custom(isAbuseReasonValid) - .withMessage('Should have a valid reason'), - body('predefinedReasons') - .optional() - .custom(isAbusePredefinedReasonsValid) - .withMessage('Should have a valid list of predefined reasons'), - body('startAt') + body('video.startAt') .optional() .customSanitizer(toIntOrNull) .custom(isAbuseTimestampValid) .withMessage('Should have valid starting time value'), - body('endAt') + body('video.endAt') .optional() .customSanitizer(toIntOrNull) .custom(isAbuseTimestampValid) @@ -42,47 +42,70 @@ const abuseReportValidator = [ .custom(isAbuseTimestampCoherent) .withMessage('Should have a startAt timestamp beginning before endAt'), + body('comment.id') + .optional() + .custom(isIdValid) + .withMessage('Should have a valid commentId'), + + body('reason') + .custom(isAbuseReasonValid) + .withMessage('Should have a valid reason'), + + body('predefinedReasons') + .optional() + .custom(isAbusePredefinedReasonsValid) + .withMessage('Should have a valid list of predefined reasons'), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking abuseReport parameters', { parameters: req.body }) if (areValidationErrors(req, res)) return - if (!await doesVideoExist(req.params.videoId, res)) return - // TODO: check comment or video (exlusive) + const body: AbuseCreate = req.body + + if (body.video?.id && !await doesVideoExist(body.video.id, res)) return + if (body.account?.id && !await doesAccountIdExist(body.account.id, res)) return + if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return + + if (!body.video?.id && !body.account?.id && !body.comment?.id) { + res.status(400) + .json({ error: 'video id or account id or comment id is required.' }) + + return + } return next() } ] const abuseGetValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking abuseGetValidator parameters', { parameters: req.body }) if (areValidationErrors(req, res)) return - // if (!await doesAbuseExist(req.params.id, req.params.videoId, res)) return + if (!await doesAbuseExist(req.params.id, res)) return return next() } ] const abuseUpdateValidator = [ - param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), param('id').custom(isIdValid).not().isEmpty().withMessage('Should have a valid id'), + body('state') .optional() - .custom(isAbuseStateValid).withMessage('Should have a valid video abuse state'), + .custom(isAbuseStateValid).withMessage('Should have a valid abuse state'), body('moderationComment') .optional() - .custom(isAbuseModerationCommentValid).withMessage('Should have a valid video moderation comment'), + .custom(isAbuseModerationCommentValid).withMessage('Should have a valid moderation comment'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking abuseUpdateValidator parameters', { parameters: req.body }) if (areValidationErrors(req, res)) return - // if (!await doesAbuseExist(req.params.id, req.params.videoId, res)) return + if (!await doesAbuseExist(req.params.id, res)) return return next() } @@ -92,6 +115,10 @@ const abuseListValidator = [ query('id') .optional() .custom(isIdValid).withMessage('Should have a valid id'), + query('filter') + .optional() + .custom(isAbuseFilterValid) + .withMessage('Should have a valid filter'), query('predefinedReason') .optional() .custom(isAbusePredefinedReasonValid) @@ -151,10 +178,7 @@ const videoAbuseReportValidator = [ .optional() .customSanitizer(toIntOrNull) .custom(isAbuseTimestampValid) - .withMessage('Should have valid ending time value') - .bail() - .custom(isAbuseTimestampCoherent) - .withMessage('Should have a startAt timestamp beginning before endAt'), + .withMessage('Should have valid ending time value'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { logger.debug('Checking videoAbuseReport parameters', { parameters: req.body }) diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts index ef019fcf9..77f5c6ff3 100644 --- a/server/middlewares/validators/videos/video-comments.ts +++ b/server/middlewares/validators/videos/video-comments.ts @@ -3,13 +3,16 @@ import { body, param } from 'express-validator' import { MUserAccountUrl } from '@server/types/models' import { UserRight } from '../../../../shared' import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' -import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments' +import { + doesVideoCommentExist, + doesVideoCommentThreadExist, + isValidVideoCommentText +} from '../../../helpers/custom-validators/video-comments' import { logger } from '../../../helpers/logger' import { doesVideoExist } from '../../../helpers/middlewares' import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation' import { Hooks } from '../../../lib/plugins/hooks' -import { VideoCommentModel } from '../../../models/video/video-comment' -import { MCommentOwnerVideoReply, MVideo, MVideoFullLight, MVideoId } from '../../../types/models/video' +import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video' import { areValidationErrors } from '../utils' const listVideoCommentThreadsValidator = [ @@ -120,67 +123,10 @@ export { // --------------------------------------------------------------------------- -async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) { - const id = parseInt(idArg + '', 10) - const videoComment = await VideoCommentModel.loadById(id) - - if (!videoComment) { - res.status(404) - .json({ error: 'Video comment thread not found' }) - .end() - - return false - } - - if (videoComment.videoId !== video.id) { - res.status(400) - .json({ error: 'Video comment is not associated to this video.' }) - .end() - - return false - } - - if (videoComment.inReplyToCommentId !== null) { - res.status(400) - .json({ error: 'Video comment is not a thread.' }) - .end() - - return false - } - - res.locals.videoCommentThread = videoComment - return true -} - -async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) { - const id = parseInt(idArg + '', 10) - const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id) - - if (!videoComment) { - res.status(404) - .json({ error: 'Video comment thread not found' }) - .end() - - return false - } - - if (videoComment.videoId !== video.id) { - res.status(400) - .json({ error: 'Video comment is not associated to this video.' }) - .end() - - return false - } - - res.locals.videoCommentFull = videoComment - return true -} - function isVideoCommentsEnabled (video: MVideo, res: express.Response) { if (video.commentsEnabled !== true) { res.status(409) .json({ error: 'Video comments are disabled for this video.' }) - .end() return false } @@ -192,7 +138,7 @@ function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MC if (videoComment.isDeleted()) { res.status(409) .json({ error: 'This comment is already deleted' }) - .end() + return false } @@ -240,7 +186,7 @@ async function isVideoCommentAccepted (req: express.Request, res: express.Respon if (!acceptedResult || acceptedResult.accepted !== true) { logger.info('Refused local comment.', { acceptedResult, acceptParameters }) res.status(403) - .json({ error: acceptedResult.errorMessage || 'Refused local comment' }) + .json({ error: acceptedResult.errorMessage || 'Refused local comment' }) return false } diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts index 4f99f9c9b..087c77bd3 100644 --- a/server/models/abuse/abuse.ts +++ b/server/models/abuse/abuse.ts @@ -19,16 +19,17 @@ import { import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses' import { Abuse, + AbuseFilter, AbuseObject, AbusePredefinedReasons, abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseState, AbuseVideoIs, - VideoAbuse + VideoAbuse, + VideoCommentAbuse } from '@shared/models' -import { AbuseFilter } from '@shared/models/moderation/abuse/abuse-filter' -import { CONSTRAINTS_FIELDS, ABUSE_STATES } from '../../initializers/constants' +import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models' import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils' @@ -38,6 +39,7 @@ import { VideoBlacklistModel } from '../video/video-blacklist' import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' import { VideoAbuseModel } from './video-abuse' import { VideoCommentAbuseModel } from './video-comment-abuse' +import { VideoCommentModel } from '../video/video-comment' export enum ScopeNames { FOR_API = 'FOR_API' @@ -66,19 +68,18 @@ export enum ScopeNames { serverAccountId: number userAccountId: number }) => { - const onlyBlacklisted = options.videoIs === 'blacklisted' - const videoRequired = !!(onlyBlacklisted || options.searchVideo || options.searchVideoChannel) + const whereAnd: WhereOptions[] = [] - const where = { + whereAnd.push({ reporterAccountId: { [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')') } - } + }) if (options.search) { const escapedSearch = AbuseModel.sequelize.escape('%' + options.search + '%') - Object.assign(where, { + whereAnd.push({ [Op.or]: [ { [Op.and]: [ @@ -110,11 +111,11 @@ export enum ScopeNames { }) } - if (options.id) Object.assign(where, { id: options.id }) - if (options.state) Object.assign(where, { state: options.state }) + if (options.id) whereAnd.push({ id: options.id }) + if (options.state) whereAnd.push({ state: options.state }) if (options.videoIs === 'deleted') { - Object.assign(where, { + whereAnd.push({ '$VideoAbuse.deletedVideo$': { [Op.not]: null } @@ -122,13 +123,23 @@ export enum ScopeNames { } if (options.predefinedReasonId) { - Object.assign(where, { + whereAnd.push({ predefinedReasons: { [Op.contains]: [ options.predefinedReasonId ] } }) } + if (options.filter === 'account') { + whereAnd.push({ + videoId: null, + commentId: null + }) + } + + const onlyBlacklisted = options.videoIs === 'blacklisted' + const videoRequired = !!(onlyBlacklisted || options.searchVideo || options.searchVideoChannel) + return { attributes: { include: [ @@ -222,6 +233,23 @@ export enum ScopeNames { required: true, where: searchAttribute(options.searchReportee, 'name') }, + { + model: VideoCommentAbuseModel.unscoped(), + required: options.filter === 'comment', + include: [ + { + model: VideoCommentModel.unscoped(), + required: false, + include: [ + { + model: VideoModel.unscoped(), + attributes: [ 'name', 'id', 'uuid' ], + required: true + } + ] + } + ] + }, { model: VideoAbuseModel, required: options.filter === 'video' || !!options.videoIs || videoRequired, @@ -241,8 +269,7 @@ export enum ScopeNames { include: [ { model: AccountModel.scope(AccountScopeNames.SUMMARY), - required: true, - where: searchAttribute(options.searchReportee, 'name') + required: true } ] }, @@ -256,7 +283,9 @@ export enum ScopeNames { ] } ], - where + where: { + [Op.and]: whereAnd + } } } })) @@ -348,6 +377,7 @@ export class AbuseModel extends Model { }) VideoAbuse: VideoAbuseModel + // FIXME: deprecated in 2.3. Remove these validators static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird { const videoWhere: WhereOptions = {} @@ -369,6 +399,16 @@ export class AbuseModel extends Model { return AbuseModel.findOne(query) } + static loadById (id: number): Bluebird { + const query = { + where: { + id + } + } + + return AbuseModel.findOne(query) + } + static listForApi (parameters: { start: number count: number @@ -454,6 +494,7 @@ export class AbuseModel extends Model { const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number let video: VideoAbuse + let comment: VideoCommentAbuse if (this.VideoAbuse) { const abuseModel = this.VideoAbuse @@ -475,6 +516,24 @@ export class AbuseModel extends Model { } } + if (this.VideoCommentAbuse) { + const abuseModel = this.VideoCommentAbuse + const entity = abuseModel.VideoComment || abuseModel.deletedComment + + comment = { + id: entity.id, + text: entity.text, + + deleted: !abuseModel.VideoComment, + + video: { + id: entity.Video.id, + name: entity.Video.name, + uuid: entity.Video.uuid + } + } + } + return { id: this.id, reason: this.reason, @@ -490,7 +549,7 @@ export class AbuseModel extends Model { moderationComment: this.moderationComment, video, - comment: null, + comment, createdAt: this.createdAt, updatedAt: this.updatedAt, diff --git a/server/models/abuse/video-comment-abuse.ts b/server/models/abuse/video-comment-abuse.ts index b4cc2762e..de9f4d5fd 100644 --- a/server/models/abuse/video-comment-abuse.ts +++ b/server/models/abuse/video-comment-abuse.ts @@ -25,7 +25,7 @@ export class VideoCommentAbuseModel extends Model { @AllowNull(true) @Default(null) @Column(DataType.JSONB) - deletedComment: VideoComment + deletedComment: VideoComment & { Video: { name: string, id: number, uuid: string }} @ForeignKey(() => AbuseModel) @Column diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts index 90625d987..fb6078ed8 100644 --- a/server/models/video/video-comment.ts +++ b/server/models/video/video-comment.ts @@ -1,7 +1,22 @@ 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 { + AllowNull, + BeforeDestroy, + BelongsTo, + Column, + CreatedAt, + DataType, + ForeignKey, + HasMany, + Is, + Model, + Scopes, + Table, + UpdatedAt +} from 'sequelize-typescript' +import { logger } from '@server/helpers/logger' import { getServerActor } from '@server/models/application/application' import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' import { VideoPrivacy } from '@shared/models' @@ -24,6 +39,7 @@ import { MCommentOwnerVideoReply, MVideoImmutable } from '../../types/models/video' +import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' import { AccountModel } from '../account/account' import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils' @@ -224,6 +240,53 @@ export class VideoCommentModel extends Model { }) Account: AccountModel + @HasMany(() => VideoCommentAbuseModel, { + foreignKey: { + name: 'commentId', + allowNull: true + }, + onDelete: 'set null' + }) + CommentAbuses: VideoCommentAbuseModel[] + + @BeforeDestroy + static async saveEssentialDataToAbuses (instance: VideoCommentModel, options) { + const tasks: Promise[] = [] + + if (!Array.isArray(instance.CommentAbuses)) { + instance.CommentAbuses = await instance.$get('CommentAbuses') + + if (instance.CommentAbuses.length === 0) return undefined + } + + if (!instance.Video) { + instance.Video = await instance.$get('Video') + } + + logger.info('Saving video comment %s for abuse.', instance.url) + + const details = Object.assign(instance.toFormattedJSON(), { + Video: { + id: instance.Video.id, + name: instance.Video.name, + uuid: instance.Video.uuid + } + }) + + for (const abuse of instance.CommentAbuses) { + abuse.deletedComment = details + + tasks.push(abuse.save({ transaction: options.transaction })) + } + + Promise.all(tasks) + .catch(err => { + logger.error('Some errors when saving details of comment %s in its abuses before destroy hook.', instance.url, { err }) + }) + + return undefined + } + static loadById (id: number, t?: Transaction): Bluebird { const query: FindOptions = { where: { diff --git a/server/models/video/video.ts b/server/models/video/video.ts index 272bba0e1..43609587c 100644 --- a/server/models/video/video.ts +++ b/server/models/video/video.ts @@ -803,14 +803,14 @@ export class VideoModel extends Model { static async saveEssentialDataToAbuses (instance: VideoModel, options) { const tasks: Promise[] = [] - logger.info('Saving video abuses details of video %s.', instance.url) - if (!Array.isArray(instance.VideoAbuses)) { instance.VideoAbuses = await instance.$get('VideoAbuses') if (instance.VideoAbuses.length === 0) return undefined } + logger.info('Saving video abuses details of video %s.', instance.url) + const details = instance.toFormattedDetailsJSON() for (const abuse of instance.VideoAbuses) { diff --git a/server/tests/api/check-params/abuses.ts b/server/tests/api/check-params/abuses.ts new file mode 100644 index 000000000..ba7c0833f --- /dev/null +++ b/server/tests/api/check-params/abuses.ts @@ -0,0 +1,271 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import 'mocha' +import { AbuseCreate, AbuseState } from '@shared/models' +import { + cleanupTests, + createUser, + deleteAbuse, + flushAndRunServer, + makeGetRequest, + makePostBodyRequest, + ServerInfo, + setAccessTokensToServers, + updateAbuse, + uploadVideo, + userLogin +} from '../../../../shared/extra-utils' +import { + checkBadCountPagination, + checkBadSortPagination, + checkBadStartPagination +} from '../../../../shared/extra-utils/requests/check-api-params' + +// FIXME: deprecated in 2.3. Remove this controller + +describe('Test video abuses API validators', function () { + const basePath = '/api/v1/abuses/' + + let server: ServerInfo + let userAccessToken = '' + let abuseId: number + + // --------------------------------------------------------------- + + before(async function () { + this.timeout(30000) + + server = await flushAndRunServer(1) + + await setAccessTokensToServers([ server ]) + + const username = 'user1' + const password = 'my super password' + await createUser({ url: server.url, accessToken: server.accessToken, username: username, password: password }) + userAccessToken = await userLogin(server, { username, password }) + + const res = await uploadVideo(server.url, server.accessToken, {}) + server.video = res.body.video + }) + + describe('When listing abuses', function () { + const path = basePath + + it('Should fail with a bad start pagination', async function () { + await checkBadStartPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a bad count pagination', async function () { + await checkBadCountPagination(server.url, path, server.accessToken) + }) + + it('Should fail with an incorrect sort', async function () { + await checkBadSortPagination(server.url, path, server.accessToken) + }) + + it('Should fail with a non authenticated user', async function () { + await makeGetRequest({ + url: server.url, + path, + statusCodeExpected: 401 + }) + }) + + it('Should fail with a non admin user', async function () { + await makeGetRequest({ + url: server.url, + path, + token: userAccessToken, + statusCodeExpected: 403 + }) + }) + + it('Should fail with a bad id filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { id: 'toto' } }) + }) + + it('Should fail with a bad filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'toto' } }) + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { filter: 'videos' } }) + }) + + it('Should fail with bad predefined reason', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { predefinedReason: 'violentOrRepulsives' } }) + }) + + it('Should fail with a bad state filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 'toto' } }) + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { state: 0 } }) + }) + + it('Should fail with a bad videoIs filter', async function () { + await makeGetRequest({ url: server.url, path, token: server.accessToken, query: { videoIs: 'toto' } }) + }) + + it('Should succeed with the correct params', async function () { + const query = { + id: 13, + predefinedReason: 'violentOrRepulsive', + filter: 'comment', + state: 2, + videoIs: 'deleted' + } + + await makeGetRequest({ url: server.url, path, token: server.accessToken, query, statusCodeExpected: 200 }) + }) + }) + + describe('When reporting an abuse', function () { + const path = basePath + + it('Should fail with nothing', async function () { + const fields = {} + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a wrong video', async function () { + const fields = { video: { id: 'blabla' }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields }) + }) + + it('Should fail with an unknown video', async function () { + const fields = { video: { id: 42 }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 }) + }) + + it('Should fail with a wrong comment', async function () { + const fields = { comment: { id: 'blabla' }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields }) + }) + + it('Should fail with an unknown comment', async function () { + const fields = { comment: { id: 42 }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 }) + }) + + it('Should fail with a wrong account', async function () { + const fields = { account: { id: 'blabla' }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields }) + }) + + it('Should fail with an unknown account', async function () { + const fields = { account: { id: 42 }, reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 404 }) + }) + + it('Should fail with not account, comment or video', async function () { + const fields = { reason: 'my super reason' } + await makePostBodyRequest({ url: server.url, path: path, token: server.accessToken, fields, statusCodeExpected: 400 }) + }) + + it('Should fail with a non authenticated user', async function () { + const fields = { video: { id: server.video.id }, reason: 'my super reason' } + + await makePostBodyRequest({ url: server.url, path, token: 'hello', fields, statusCodeExpected: 401 }) + }) + + it('Should fail with a reason too short', async function () { + const fields = { video: { id: server.video.id }, reason: 'h' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with a too big reason', async function () { + const fields = { video: { id: server.video.id }, reason: 'super'.repeat(605) } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should succeed with the correct parameters (basic)', async function () { + const fields: AbuseCreate = { video: { id: server.video.id }, reason: 'my super reason' } + + const res = await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) + abuseId = res.body.abuse.id + }) + + it('Should fail with a wrong predefined reason', async function () { + const fields = { video: { id: server.video.id }, reason: 'my super reason', predefinedReasons: [ 'wrongPredefinedReason' ] } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail with negative timestamps', async function () { + const fields = { video: { id: server.video.id, startAt: -1 }, reason: 'my super reason' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should fail mith misordered startAt/endAt', async function () { + const fields = { video: { id: server.video.id, startAt: 5, endAt: 1 }, reason: 'my super reason' } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) + }) + + it('Should succeed with the corret parameters (advanced)', async function () { + const fields: AbuseCreate = { + video: { + id: server.video.id, + startAt: 1, + endAt: 5 + }, + reason: 'my super reason', + predefinedReasons: [ 'serverRules' ] + } + + await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields, statusCodeExpected: 200 }) + }) + }) + + describe('When updating an abuse', function () { + + it('Should fail with a non authenticated user', async function () { + await updateAbuse(server.url, 'blabla', abuseId, {}, 401) + }) + + it('Should fail with a non admin user', async function () { + await updateAbuse(server.url, userAccessToken, abuseId, {}, 403) + }) + + it('Should fail with a bad abuse id', async function () { + await updateAbuse(server.url, server.accessToken, 45, {}, 404) + }) + + it('Should fail with a bad state', async function () { + const body = { state: 5 } + await updateAbuse(server.url, server.accessToken, abuseId, body, 400) + }) + + it('Should fail with a bad moderation comment', async function () { + const body = { moderationComment: 'b'.repeat(3001) } + await updateAbuse(server.url, server.accessToken, abuseId, body, 400) + }) + + it('Should succeed with the correct params', async function () { + const body = { state: AbuseState.ACCEPTED } + await updateAbuse(server.url, server.accessToken, abuseId, body) + }) + }) + + describe('When deleting a video abuse', function () { + + it('Should fail with a non authenticated user', async function () { + await deleteAbuse(server.url, 'blabla', abuseId, 401) + }) + + it('Should fail with a non admin user', async function () { + await deleteAbuse(server.url, userAccessToken, abuseId, 403) + }) + + it('Should fail with a bad abuse id', async function () { + await deleteAbuse(server.url, server.accessToken, 45, 404) + }) + + it('Should succeed with the correct params', async function () { + await deleteAbuse(server.url, server.accessToken, abuseId) + }) + }) + + 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 93ffd98b1..0ee1f27aa 100644 --- a/server/tests/api/check-params/index.ts +++ b/server/tests/api/check-params/index.ts @@ -1,3 +1,4 @@ +import './abuses' import './accounts' import './blocklist' import './bulk' diff --git a/server/tests/api/check-params/video-abuses.ts b/server/tests/api/check-params/video-abuses.ts index f122baef4..3b361ca79 100644 --- a/server/tests/api/check-params/video-abuses.ts +++ b/server/tests/api/check-params/video-abuses.ts @@ -152,12 +152,6 @@ describe('Test video abuses API validators', function () { await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) }) - it('Should fail mith misordered startAt/endAt', async function () { - const fields = { reason: 'my super reason', startAt: 5, endAt: 1 } - - await makePostBodyRequest({ url: server.url, path, token: server.accessToken, fields }) - }) - it('Should succeed with the corret parameters (advanced)', async function () { const fields: VideoAbuseCreate = { reason: 'my super reason', predefinedReasons: [ 'serverRules' ], startAt: 1, endAt: 5 } diff --git a/server/types/models/moderation/abuse.ts b/server/types/models/moderation/abuse.ts index abbc93d6f..8e12be874 100644 --- a/server/types/models/moderation/abuse.ts +++ b/server/types/models/moderation/abuse.ts @@ -3,7 +3,7 @@ import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse import { PickWith } from '@shared/core-utils' import { AbuseModel } from '../../../models/abuse/abuse' import { MAccountDefault, MAccountFormattable, MAccountLight, MAccountUrl } from '../account' -import { MCommentOwner, MCommentUrl, MVideoUrl, MCommentOwnerVideo } from '../video' +import { MCommentOwner, MCommentUrl, MVideoUrl, MCommentOwnerVideo, MComment, MCommentVideo } from '../video' import { MVideo, MVideoAccountLightBlacklistAllFiles } from '../video/video' type Use = PickWith @@ -51,6 +51,10 @@ export type MCommentAbuseUrl = MCommentAbuse & UseCommentAbuse<'VideoComment', MCommentUrl> +export type MCommentAbuseFormattable = + MCommentAbuse & + UseCommentAbuse<'VideoComment', MComment & PickWith>> + // ############################################################################ export type MAbuseId = Pick @@ -94,4 +98,5 @@ export type MAbuseFull = export type MAbuseFormattable = MAbuse & Use<'ReporterAccount', MAccountFormattable> & - Use<'VideoAbuse', MVideoAbuseFormattable> + Use<'VideoAbuse', MVideoAbuseFormattable> & + Use<'VideoCommentAbuse', MCommentAbuseFormattable> diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts index 7595e6d86..b1afffcd4 100644 --- a/server/typings/express/index.d.ts +++ b/server/typings/express/index.d.ts @@ -91,6 +91,7 @@ declare module 'express' { accountVideoRate?: MAccountVideoRateAccountVideo + videoComment?: MComment videoCommentFull?: MCommentOwnerVideoReply videoCommentThread?: MComment diff --git a/shared/extra-utils/moderation/abuses.ts b/shared/extra-utils/moderation/abuses.ts index 48a51e2b8..1af703f92 100644 --- a/shared/extra-utils/moderation/abuses.ts +++ b/shared/extra-utils/moderation/abuses.ts @@ -1,25 +1,57 @@ -import * as request from 'supertest' -import { AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models' -import { makeDeleteRequest, makeGetRequest, makePutBodyRequest } from '../requests/requests' -function reportAbuse ( - url: string, - token: string, - videoId: number | string, - reason: string, - predefinedReasons?: AbusePredefinedReasonsString[], - startAt?: number, - endAt?: number, - specialStatus = 200 -) { - const path = '/api/v1/videos/' + videoId + '/abuse' - - return request(url) - .post(path) - .set('Accept', 'application/json') - .set('Authorization', 'Bearer ' + token) - .send({ reason, predefinedReasons, startAt, endAt }) - .expect(specialStatus) +import { AbuseFilter, AbusePredefinedReasonsString, AbuseState, AbuseUpdate, AbuseVideoIs } from '@shared/models' +import { makeDeleteRequest, makeGetRequest, makePostBodyRequest, makePutBodyRequest } from '../requests/requests' + +function reportAbuse (options: { + url: string + token: string + + reason: string + + accountId?: number + videoId?: number + commentId?: number + + predefinedReasons?: AbusePredefinedReasonsString[] + + startAt?: number + endAt?: number + + statusCodeExpected?: number +}) { + const path = '/api/v1/abuses' + + const video = options.videoId ? { + id: options.videoId, + startAt: options.startAt, + endAt: options.endAt + } : undefined + + const comment = options.commentId ? { + id: options.commentId + } : undefined + + const account = options.accountId ? { + id: options.accountId + } : undefined + + const body = { + account, + video, + comment, + + reason: options.reason, + predefinedReasons: options.predefinedReasons + } + + return makePostBodyRequest({ + url: options.url, + path, + token: options.token, + + fields: body, + statusCodeExpected: options.statusCodeExpected || 200 + }) } function getAbusesList (options: { @@ -28,6 +60,7 @@ function getAbusesList (options: { id?: number predefinedReason?: AbusePredefinedReasonsString search?: string + filter?: AbuseFilter, state?: AbuseState videoIs?: AbuseVideoIs searchReporter?: string @@ -41,6 +74,7 @@ function getAbusesList (options: { id, predefinedReason, search, + filter, state, videoIs, searchReporter, @@ -48,7 +82,7 @@ function getAbusesList (options: { searchVideo, searchVideoChannel } = options - const path = '/api/v1/videos/abuse' + const path = '/api/v1/abuses' const query = { sort: 'createdAt', @@ -56,6 +90,7 @@ function getAbusesList (options: { predefinedReason, search, state, + filter, videoIs, searchReporter, searchReportee, @@ -75,12 +110,11 @@ function getAbusesList (options: { function updateAbuse ( url: string, token: string, - videoId: string | number, - videoAbuseId: number, + abuseId: number, body: AbuseUpdate, statusCodeExpected = 204 ) { - const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId + const path = '/api/v1/abuses/' + abuseId return makePutBodyRequest({ url, @@ -91,8 +125,8 @@ function updateAbuse ( }) } -function deleteAbuse (url: string, token: string, videoId: string | number, videoAbuseId: number, statusCodeExpected = 204) { - const path = '/api/v1/videos/' + videoId + '/abuse/' + videoAbuseId +function deleteAbuse (url: string, token: string, abuseId: number, statusCodeExpected = 204) { + const path = '/api/v1/abuses/' + abuseId return makeDeleteRequest({ url, diff --git a/shared/models/moderation/abuse/abuse-create.model.ts b/shared/models/moderation/abuse/abuse-create.model.ts index c0d04e46d..b0358dbb9 100644 --- a/shared/models/moderation/abuse/abuse-create.model.ts +++ b/shared/models/moderation/abuse/abuse-create.model.ts @@ -1,11 +1,14 @@ import { AbusePredefinedReasonsString } from './abuse-reason.model' export interface AbuseCreate { - accountId: number - reason: string + predefinedReasons?: AbusePredefinedReasonsString[] + account?: { + id: number + } + video?: { id: number startAt?: number diff --git a/shared/models/moderation/abuse/abuse-filter.ts b/shared/models/moderation/abuse/abuse-filter.ts deleted file mode 100644 index 03303bbab..000000000 --- a/shared/models/moderation/abuse/abuse-filter.ts +++ /dev/null @@ -1 +0,0 @@ -export type AbuseFilter = 'video' | 'comment' diff --git a/shared/models/moderation/abuse/abuse-filter.type.ts b/shared/models/moderation/abuse/abuse-filter.type.ts new file mode 100644 index 000000000..7dafc6d77 --- /dev/null +++ b/shared/models/moderation/abuse/abuse-filter.type.ts @@ -0,0 +1 @@ +export type AbuseFilter = 'video' | 'comment' | 'account' diff --git a/shared/models/moderation/abuse/abuse.model.ts b/shared/models/moderation/abuse/abuse.model.ts index 9ff150c4a..a120803e6 100644 --- a/shared/models/moderation/abuse/abuse.model.ts +++ b/shared/models/moderation/abuse/abuse.model.ts @@ -9,6 +9,7 @@ export interface VideoAbuse { name: string uuid: string nsfw: boolean + deleted: boolean blacklisted: boolean @@ -21,8 +22,15 @@ export interface VideoAbuse { export interface VideoCommentAbuse { id: number - account?: Account + + video: { + id: number + name: string + uuid: string + } + text: string + deleted: boolean } diff --git a/shared/models/moderation/abuse/index.ts b/shared/models/moderation/abuse/index.ts index 32a6b4e6c..55046426a 100644 --- a/shared/models/moderation/abuse/index.ts +++ b/shared/models/moderation/abuse/index.ts @@ -1,4 +1,5 @@ export * from './abuse-create.model' +export * from './abuse-filter.type' export * from './abuse-reason.model' export * from './abuse-state.model' export * from './abuse-update.model' -- 2.41.0