1 import * as Bluebird from 'bluebird'
2 import { invert } from 'lodash'
3 import { literal, Op, QueryTypes, WhereOptions } from 'sequelize'
18 } from 'sequelize-typescript'
19 import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
20 import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
24 AbusePredefinedReasons,
25 AbusePredefinedReasonsString,
30 AdminVideoCommentAbuse,
33 } from '@shared/models'
34 import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35 import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
36 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
37 import { getSort, throwIfNotValid } from '../utils'
38 import { ThumbnailModel } from '../video/thumbnail'
39 import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
40 import { VideoBlacklistModel } from '../video/video-blacklist'
41 import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
42 import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
43 import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
44 import { VideoAbuseModel } from './video-abuse'
45 import { VideoCommentAbuseModel } from './video-comment-abuse'
47 export enum ScopeNames {
52 [ScopeNames.FOR_API]: () => {
60 'FROM "abuseMessage" ' +
61 'WHERE "abuseId" = "AbuseModel"."id"' +
67 // we don't care about this count for deleted videos, so there are not included
71 'FROM "videoAbuse" ' +
72 'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
75 'countReportsForVideo'
78 // we don't care about this count for deleted videos, so there are not included
84 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
85 'FROM "videoAbuse" ' +
87 'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
95 'SELECT count("abuse"."id") ' +
97 'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
100 'countReportsForReporter'
105 'SELECT count("abuse"."id") ' +
107 'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
110 'countReportsForReportee'
116 model: AccountModel.scope({
118 AccountScopeNames.SUMMARY,
119 { actorRequired: false } as AccountSummaryOptions
122 as: 'ReporterAccount'
125 model: AccountModel.scope({
127 AccountScopeNames.SUMMARY,
128 { actorRequired: false } as AccountSummaryOptions
134 model: VideoCommentAbuseModel.unscoped(),
137 model: VideoCommentModel.unscoped(),
140 model: VideoModel.unscoped(),
141 attributes: [ 'name', 'id', 'uuid' ]
148 model: VideoAbuseModel.unscoped(),
151 attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
152 model: VideoModel.unscoped(),
155 attributes: [ 'filename', 'fileUrl', 'type' ],
156 model: ThumbnailModel
159 model: VideoChannelModel.scope({
161 VideoChannelScopeNames.SUMMARY,
162 { withAccount: false, actorRequired: false } as ChannelSummaryOptions
168 attributes: [ 'id', 'reason', 'unfederated' ],
170 model: VideoBlacklistModel
184 fields: [ 'reporterAccountId' ]
187 fields: [ 'flaggedAccountId' ]
191 export class AbuseModel extends Model<AbuseModel> {
195 @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
196 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
201 @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
207 @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
208 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
209 moderationComment: string
213 @Column(DataType.ARRAY(DataType.INTEGER))
214 predefinedReasons: AbusePredefinedReasons[]
222 @ForeignKey(() => AccountModel)
224 reporterAccountId: number
226 @BelongsTo(() => AccountModel, {
228 name: 'reporterAccountId',
231 as: 'ReporterAccount',
234 ReporterAccount: AccountModel
236 @ForeignKey(() => AccountModel)
238 flaggedAccountId: number
240 @BelongsTo(() => AccountModel, {
242 name: 'flaggedAccountId',
245 as: 'FlaggedAccount',
248 FlaggedAccount: AccountModel
250 @HasOne(() => VideoCommentAbuseModel, {
257 VideoCommentAbuse: VideoCommentAbuseModel
259 @HasOne(() => VideoAbuseModel, {
266 VideoAbuse: VideoAbuseModel
268 // FIXME: deprecated in 2.3. Remove these validators
269 static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuseReporter> {
270 const videoWhere: WhereOptions = {}
272 if (videoId) videoWhere.videoId = videoId
273 if (uuid) videoWhere.deletedVideo = { uuid }
278 model: VideoAbuseModel,
284 as: 'ReporterAccount'
291 return AbuseModel.findOne(query)
294 static loadByIdWithReporter (id: number): Bluebird<MAbuseReporter> {
302 as: 'ReporterAccount'
307 return AbuseModel.findOne(query)
310 static loadFull (id: number): Bluebird<MAbuseFull> {
317 model: AccountModel.scope(AccountScopeNames.SUMMARY),
319 as: 'ReporterAccount'
322 model: AccountModel.scope(AccountScopeNames.SUMMARY),
326 model: VideoAbuseModel,
330 model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
335 model: VideoCommentAbuseModel,
339 model: VideoCommentModel.scope([
340 CommentScopeNames.WITH_ACCOUNT
353 return AbuseModel.findOne(query)
356 static async listForAdminApi (parameters: {
363 serverAccountId: number
364 user?: MUserAccountId
367 predefinedReason?: AbusePredefinedReasonsString
369 videoIs?: AbuseVideoIs
372 searchReporter?: string
373 searchReportee?: string
375 searchVideoChannel?: string
395 const userAccountId = user ? user.Account.id : undefined
396 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
398 const queryOptions: BuildAbusesQueryOptions = {
416 const [ total, data ] = await Promise.all([
417 AbuseModel.internalCountForApi(queryOptions),
418 AbuseModel.internalListForApi(queryOptions)
421 return { total, data }
424 static async listForUserApi (parameters: {
445 const queryOptions: BuildAbusesQueryOptions = {
452 reporterAccountId: user.Account.id
455 const [ total, data ] = await Promise.all([
456 AbuseModel.internalCountForApi(queryOptions),
457 AbuseModel.internalListForApi(queryOptions)
460 return { total, data }
463 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
464 if (!this.VideoCommentAbuse) return null
466 const abuseModel = this.VideoCommentAbuse
467 const entity = abuseModel.VideoComment
471 threadId: entity.getThreadId(),
473 text: entity.text ?? '',
475 deleted: entity.isDeleted(),
479 name: entity.Video.name,
480 uuid: entity.Video.uuid
485 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
486 if (!this.VideoAbuse) return null
488 const abuseModel = this.VideoAbuse
489 const entity = abuseModel.Video || abuseModel.deletedVideo
497 startAt: abuseModel.startAt,
498 endAt: abuseModel.endAt,
500 deleted: !abuseModel.Video,
501 blacklisted: abuseModel.Video?.isBlacklisted() || false,
502 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
504 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
508 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
509 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
516 flaggedAccount: this.FlaggedAccount
517 ? this.FlaggedAccount.toFormattedJSON()
522 label: AbuseModel.getStateLabel(this.state)
527 createdAt: this.createdAt,
528 updatedAt: this.updatedAt
532 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
533 const countReportsForVideo = this.get('countReportsForVideo') as number
534 const nthReportForVideo = this.get('nthReportForVideo') as number
536 const countReportsForReporter = this.get('countReportsForReporter') as number
537 const countReportsForReportee = this.get('countReportsForReportee') as number
539 const countMessages = this.get('countMessages') as number
541 const baseVideo = this.buildBaseVideoAbuse()
542 const video: AdminVideoAbuse = baseVideo
543 ? Object.assign(baseVideo, {
544 countReports: countReportsForVideo,
545 nthReport: nthReportForVideo
549 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
551 const abuse = this.buildBaseAbuse(countMessages || 0)
553 return Object.assign(abuse, {
557 moderationComment: this.moderationComment,
559 reporterAccount: this.ReporterAccount
560 ? this.ReporterAccount.toFormattedJSON()
563 countReportsForReporter: (countReportsForReporter || 0),
564 countReportsForReportee: (countReportsForReportee || 0),
566 // FIXME: deprecated in 2.3, remove this
569 count: countReportsForVideo || 0,
570 nth: nthReportForVideo || 0
574 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
575 const countMessages = this.get('countMessages') as number
577 const video = this.buildBaseVideoAbuse()
578 const comment = this.buildBaseVideoCommentAbuse()
579 const abuse = this.buildBaseAbuse(countMessages || 0)
581 return Object.assign(abuse, {
587 toActivityPubObject (this: MAbuseAP): AbuseObject {
588 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
590 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
592 const startAt = this.VideoAbuse?.startAt
593 const endAt = this.VideoAbuse?.endAt
596 type: 'Flag' as 'Flag',
597 content: this.reason,
599 tag: predefinedReasons.map(r => ({
600 type: 'Hashtag' as 'Hashtag',
608 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
609 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
611 type: QueryTypes.SELECT as QueryTypes.SELECT,
615 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
616 if (total === null) return 0
618 return parseInt(total, 10)
621 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
622 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
624 type: QueryTypes.SELECT as QueryTypes.SELECT,
628 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
629 const ids = rows.map(r => r.id)
631 if (ids.length === 0) return []
633 return AbuseModel.scope(ScopeNames.FOR_API)
635 order: getSort(parameters.sort),
644 private static getStateLabel (id: number) {
645 return ABUSE_STATES[id] || 'Unknown'
648 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
649 const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
651 return (predefinedReasons || [])
652 .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)