1 import { invert } from 'lodash'
2 import { literal, Op, QueryTypes } from 'sequelize'
17 } from 'sequelize-typescript'
18 import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
19 import { abusePredefinedReasonsMap } from '@shared/core-utils'
23 AbusePredefinedReasons,
24 AbusePredefinedReasonsString,
29 AdminVideoCommentAbuse,
32 } from '@shared/models'
33 import { AttributesOnly } from '@shared/typescript-utils'
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<Partial<AttributesOnly<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 static loadByIdWithReporter (id: number): Promise<MAbuseReporter> {
276 as: 'ReporterAccount'
281 return AbuseModel.findOne(query)
284 static loadFull (id: number): Promise<MAbuseFull> {
291 model: AccountModel.scope(AccountScopeNames.SUMMARY),
293 as: 'ReporterAccount'
296 model: AccountModel.scope(AccountScopeNames.SUMMARY),
300 model: VideoAbuseModel,
304 model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
309 model: VideoCommentAbuseModel,
313 model: VideoCommentModel.scope([
314 CommentScopeNames.WITH_ACCOUNT
327 return AbuseModel.findOne(query)
330 static async listForAdminApi (parameters: {
337 serverAccountId: number
338 user?: MUserAccountId
341 predefinedReason?: AbusePredefinedReasonsString
343 videoIs?: AbuseVideoIs
346 searchReporter?: string
347 searchReportee?: string
349 searchVideoChannel?: string
369 const userAccountId = user ? user.Account.id : undefined
370 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
372 const queryOptions: BuildAbusesQueryOptions = {
390 const [ total, data ] = await Promise.all([
391 AbuseModel.internalCountForApi(queryOptions),
392 AbuseModel.internalListForApi(queryOptions)
395 return { total, data }
398 static async listForUserApi (parameters: {
419 const queryOptions: BuildAbusesQueryOptions = {
426 reporterAccountId: user.Account.id
429 const [ total, data ] = await Promise.all([
430 AbuseModel.internalCountForApi(queryOptions),
431 AbuseModel.internalListForApi(queryOptions)
434 return { total, data }
437 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
438 // Associated video comment could have been destroyed if the video has been deleted
439 if (!this.VideoCommentAbuse || !this.VideoCommentAbuse.VideoComment) return null
441 const entity = this.VideoCommentAbuse.VideoComment
445 threadId: entity.getThreadId(),
447 text: entity.text ?? '',
449 deleted: entity.isDeleted(),
453 name: entity.Video.name,
454 uuid: entity.Video.uuid
459 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
460 if (!this.VideoAbuse) return null
462 const abuseModel = this.VideoAbuse
463 const entity = abuseModel.Video || abuseModel.deletedVideo
471 startAt: abuseModel.startAt,
472 endAt: abuseModel.endAt,
474 deleted: !abuseModel.Video,
475 blacklisted: abuseModel.Video?.isBlacklisted() || false,
476 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
478 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
482 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
483 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
490 flaggedAccount: this.FlaggedAccount
491 ? this.FlaggedAccount.toFormattedJSON()
496 label: AbuseModel.getStateLabel(this.state)
501 createdAt: this.createdAt,
502 updatedAt: this.updatedAt
506 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
507 const countReportsForVideo = this.get('countReportsForVideo') as number
508 const nthReportForVideo = this.get('nthReportForVideo') as number
510 const countReportsForReporter = this.get('countReportsForReporter') as number
511 const countReportsForReportee = this.get('countReportsForReportee') as number
513 const countMessages = this.get('countMessages') as number
515 const baseVideo = this.buildBaseVideoAbuse()
516 const video: AdminVideoAbuse = baseVideo
517 ? Object.assign(baseVideo, {
518 countReports: countReportsForVideo,
519 nthReport: nthReportForVideo
523 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
525 const abuse = this.buildBaseAbuse(countMessages || 0)
527 return Object.assign(abuse, {
531 moderationComment: this.moderationComment,
533 reporterAccount: this.ReporterAccount
534 ? this.ReporterAccount.toFormattedJSON()
537 countReportsForReporter: (countReportsForReporter || 0),
538 countReportsForReportee: (countReportsForReportee || 0)
542 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
543 const countMessages = this.get('countMessages') as number
545 const video = this.buildBaseVideoAbuse()
546 const comment = this.buildBaseVideoCommentAbuse()
547 const abuse = this.buildBaseAbuse(countMessages || 0)
549 return Object.assign(abuse, {
555 toActivityPubObject (this: MAbuseAP): AbuseObject {
556 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
558 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
560 const startAt = this.VideoAbuse?.startAt
561 const endAt = this.VideoAbuse?.endAt
564 type: 'Flag' as 'Flag',
565 content: this.reason,
567 tag: predefinedReasons.map(r => ({
568 type: 'Hashtag' as 'Hashtag',
576 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
577 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
579 type: QueryTypes.SELECT as QueryTypes.SELECT,
583 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
584 if (total === null) return 0
586 return parseInt(total, 10)
589 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
590 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
592 type: QueryTypes.SELECT as QueryTypes.SELECT,
596 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
597 const ids = rows.map(r => r.id)
599 if (ids.length === 0) return []
601 return AbuseModel.scope(ScopeNames.FOR_API)
603 order: getSort(parameters.sort),
612 private static getStateLabel (id: number) {
613 return ABUSE_STATES[id] || 'Unknown'
616 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
617 const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
619 return (predefinedReasons || [])
620 .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)