1 import * as Bluebird from 'bluebird'
2 import { invert } from 'lodash'
3 import { literal, Op, WhereOptions } from 'sequelize'
18 } from 'sequelize-typescript'
19 import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
24 AbusePredefinedReasons,
25 abusePredefinedReasonsMap,
26 AbusePredefinedReasonsString,
31 } from '@shared/models'
32 import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
33 import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models'
34 import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
35 import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
36 import { ThumbnailModel } from '../video/thumbnail'
37 import { VideoModel } from '../video/video'
38 import { VideoBlacklistModel } from '../video/video-blacklist'
39 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
40 import { VideoAbuseModel } from './video-abuse'
41 import { VideoCommentAbuseModel } from './video-comment-abuse'
42 import { VideoCommentModel } from '../video/video-comment'
44 export enum ScopeNames {
49 [ScopeNames.FOR_API]: (options: {
52 searchReporter?: string
53 searchReportee?: string
57 searchVideoChannel?: string
58 videoIs?: AbuseVideoIs
62 predefinedReasonId?: number
68 serverAccountId: number
71 const whereAnd: WhereOptions[] = []
75 [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
80 const escapedSearch = AbuseModel.sequelize.escape('%' + options.search + '%')
86 { '$VideoAbuse.videoId$': { [Op.not]: null } },
87 searchAttribute(options.search, '$VideoAbuse.Video.name$')
92 { '$VideoAbuse.videoId$': { [Op.not]: null } },
93 searchAttribute(options.search, '$VideoAbuse.Video.VideoChannel.name$')
98 { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
99 literal(`"VideoAbuse"."deletedVideo"->>'name' ILIKE ${escapedSearch}`)
104 { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
105 literal(`"VideoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE ${escapedSearch}`)
108 searchAttribute(options.search, '$ReporterAccount.name$'),
109 searchAttribute(options.search, '$FlaggedAccount.name$')
114 if (options.id) whereAnd.push({ id: options.id })
115 if (options.state) whereAnd.push({ state: options.state })
117 if (options.videoIs === 'deleted') {
119 '$VideoAbuse.deletedVideo$': {
125 if (options.predefinedReasonId) {
128 [Op.contains]: [ options.predefinedReasonId ]
133 if (options.filter === 'account') {
140 const onlyBlacklisted = options.videoIs === 'blacklisted'
141 const videoRequired = !!(onlyBlacklisted || options.searchVideo || options.searchVideoChannel)
147 // we don't care about this count for deleted videos, so there are not included
151 'FROM "videoAbuse" ' +
152 'WHERE "videoId" = "VideoAbuse"."videoId" ' +
155 'countReportsForVideo'
158 // we don't care about this count for deleted videos, so there are not included
164 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
165 'FROM "videoAbuse" ' +
167 'WHERE t.id = "VideoAbuse".id' +
175 'SELECT count("videoAbuse"."id") ' +
176 'FROM "videoAbuse" ' +
177 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
178 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
179 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
180 'WHERE "account"."id" = "AbuseModel"."reporterAccountId" ' +
183 'countReportsForReporter__video'
188 'SELECT count(DISTINCT "videoAbuse"."id") ' +
189 'FROM "videoAbuse" ' +
190 `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "AbuseModel"."reporterAccountId" ` +
193 'countReportsForReporter__deletedVideo'
198 'SELECT count(DISTINCT "videoAbuse"."id") ' +
199 'FROM "videoAbuse" ' +
200 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
201 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
202 'INNER JOIN "account" ON ' +
203 '"videoChannel"."accountId" = "VideoAbuse->Video->VideoChannel"."accountId" ' +
204 `OR "videoChannel"."accountId" = CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
207 'countReportsForReportee__video'
212 'SELECT count(DISTINCT "videoAbuse"."id") ' +
213 'FROM "videoAbuse" ' +
214 `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuse->Video->VideoChannel"."accountId" ` +
215 `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
216 `CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
219 'countReportsForReportee__deletedVideo'
225 model: AccountModel.scope(AccountScopeNames.SUMMARY),
226 as: 'ReporterAccount',
228 where: searchAttribute(options.searchReporter, 'name')
231 model: AccountModel.scope(AccountScopeNames.SUMMARY),
232 as: 'FlaggedAccount',
234 where: searchAttribute(options.searchReportee, 'name')
237 model: VideoCommentAbuseModel.unscoped(),
238 required: options.filter === 'comment',
241 model: VideoCommentModel.unscoped(),
245 model: VideoModel.unscoped(),
246 attributes: [ 'name', 'id', 'uuid' ],
254 model: VideoAbuseModel,
255 required: options.filter === 'video' || !!options.videoIs || videoRequired,
259 required: videoRequired,
260 where: searchAttribute(options.searchVideo, 'name'),
263 model: ThumbnailModel
266 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: false } as SummaryOptions ] }),
267 where: searchAttribute(options.searchVideoChannel, 'name'),
271 model: AccountModel.scope(AccountScopeNames.SUMMARY),
277 attributes: [ 'id', 'reason', 'unfederated' ],
278 model: VideoBlacklistModel,
279 required: onlyBlacklisted
296 fields: [ 'reporterAccountId' ]
299 fields: [ 'flaggedAccountId' ]
303 export class AbuseModel extends Model<AbuseModel> {
307 @Is('VideoAbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
308 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
313 @Is('VideoAbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
319 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
320 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
321 moderationComment: string
325 @Column(DataType.ARRAY(DataType.INTEGER))
326 predefinedReasons: AbusePredefinedReasons[]
334 @ForeignKey(() => AccountModel)
336 reporterAccountId: number
338 @BelongsTo(() => AccountModel, {
340 name: 'reporterAccountId',
343 as: 'ReporterAccount',
346 ReporterAccount: AccountModel
348 @ForeignKey(() => AccountModel)
350 flaggedAccountId: number
352 @BelongsTo(() => AccountModel, {
354 name: 'flaggedAccountId',
357 as: 'FlaggedAccount',
360 FlaggedAccount: AccountModel
362 @HasOne(() => VideoCommentAbuseModel, {
369 VideoCommentAbuse: VideoCommentAbuseModel
371 @HasOne(() => VideoAbuseModel, {
378 VideoAbuse: VideoAbuseModel
380 // FIXME: deprecated in 2.3. Remove these validators
381 static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuse> {
382 const videoWhere: WhereOptions = {}
384 if (videoId) videoWhere.videoId = videoId
385 if (uuid) videoWhere.deletedVideo = { uuid }
390 model: VideoAbuseModel,
399 return AbuseModel.findOne(query)
402 static loadById (id: number): Bluebird<MAbuse> {
409 return AbuseModel.findOne(query)
412 static listForApi (parameters: {
419 serverAccountId: number
420 user?: MUserAccountId
423 predefinedReason?: AbusePredefinedReasonsString
425 videoIs?: AbuseVideoIs
428 searchReporter?: string
429 searchReportee?: string
431 searchVideoChannel?: string
451 const userAccountId = user ? user.Account.id : undefined
452 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
457 order: getSort(sort),
458 col: 'AbuseModel.id',
479 { method: [ ScopeNames.FOR_API, filters ] }
481 .findAndCountAll(query)
482 .then(({ rows, count }) => {
483 return { total: count, data: rows }
487 toFormattedJSON (this: MAbuseFormattable): Abuse {
488 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
489 const countReportsForVideo = this.get('countReportsForVideo') as number
490 const nthReportForVideo = this.get('nthReportForVideo') as number
491 const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
492 const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number
493 const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
494 const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
496 let video: VideoAbuse
497 let comment: VideoCommentAbuse
499 if (this.VideoAbuse) {
500 const abuseModel = this.VideoAbuse
501 const entity = abuseModel.Video || abuseModel.deletedVideo
509 startAt: abuseModel.startAt,
510 endAt: abuseModel.endAt,
512 deleted: !abuseModel.Video,
513 blacklisted: abuseModel.Video?.isBlacklisted() || false,
514 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
515 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
519 if (this.VideoCommentAbuse) {
520 const abuseModel = this.VideoCommentAbuse
521 const entity = abuseModel.VideoComment || abuseModel.deletedComment
527 deleted: !abuseModel.VideoComment,
531 name: entity.Video.name,
532 uuid: entity.Video.uuid
542 reporterAccount: this.ReporterAccount.toFormattedJSON(),
546 label: AbuseModel.getStateLabel(this.state)
549 moderationComment: this.moderationComment,
554 createdAt: this.createdAt,
555 updatedAt: this.updatedAt,
556 count: countReportsForVideo || 0,
557 nth: nthReportForVideo || 0,
558 countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
559 countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0),
561 // FIXME: deprecated in 2.3, remove this
567 toActivityPubObject (this: MAbuseAP): AbuseObject {
568 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
570 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
572 const startAt = this.VideoAbuse?.startAt
573 const endAt = this.VideoAbuse?.endAt
576 type: 'Flag' as 'Flag',
577 content: this.reason,
579 tag: predefinedReasons.map(r => ({
580 type: 'Hashtag' as 'Hashtag',
588 private static getStateLabel (id: number) {
589 return ABUSE_STATES[id] || 'Unknown'
592 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
593 return (predefinedReasons || [])
594 .filter(r => r in AbusePredefinedReasons)
595 .map(r => invert(abusePredefinedReasonsMap)[r] as AbusePredefinedReasonsString)