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/abuse'
23 AbusePredefinedReasons,
24 AbusePredefinedReasonsString,
29 AdminVideoCommentAbuse,
32 } from '@shared/models'
33 import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
34 import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
35 import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
36 import { getSort, throwIfNotValid } from '../utils'
37 import { ThumbnailModel } from '../video/thumbnail'
38 import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
39 import { VideoBlacklistModel } from '../video/video-blacklist'
40 import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
41 import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
42 import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
43 import { VideoAbuseModel } from './video-abuse'
44 import { VideoCommentAbuseModel } from './video-comment-abuse'
46 export enum ScopeNames {
51 [ScopeNames.FOR_API]: () => {
59 'FROM "abuseMessage" ' +
60 'WHERE "abuseId" = "AbuseModel"."id"' +
66 // we don't care about this count for deleted videos, so there are not included
70 'FROM "videoAbuse" ' +
71 'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
74 'countReportsForVideo'
77 // we don't care about this count for deleted videos, so there are not included
83 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
84 'FROM "videoAbuse" ' +
86 'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
94 'SELECT count("abuse"."id") ' +
96 'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
99 'countReportsForReporter'
104 'SELECT count("abuse"."id") ' +
106 'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
109 'countReportsForReportee'
115 model: AccountModel.scope({
117 AccountScopeNames.SUMMARY,
118 { actorRequired: false } as AccountSummaryOptions
121 as: 'ReporterAccount'
124 model: AccountModel.scope({
126 AccountScopeNames.SUMMARY,
127 { actorRequired: false } as AccountSummaryOptions
133 model: VideoCommentAbuseModel.unscoped(),
136 model: VideoCommentModel.unscoped(),
139 model: VideoModel.unscoped(),
140 attributes: [ 'name', 'id', 'uuid' ]
147 model: VideoAbuseModel.unscoped(),
150 attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
151 model: VideoModel.unscoped(),
154 attributes: [ 'filename', 'fileUrl', 'type' ],
155 model: ThumbnailModel
158 model: VideoChannelModel.scope({
160 VideoChannelScopeNames.SUMMARY,
161 { withAccount: false, actorRequired: false } as ChannelSummaryOptions
167 attributes: [ 'id', 'reason', 'unfederated' ],
169 model: VideoBlacklistModel
183 fields: [ 'reporterAccountId' ]
186 fields: [ 'flaggedAccountId' ]
190 export class AbuseModel extends Model {
194 @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
195 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
200 @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
206 @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
207 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
208 moderationComment: string
212 @Column(DataType.ARRAY(DataType.INTEGER))
213 predefinedReasons: AbusePredefinedReasons[]
221 @ForeignKey(() => AccountModel)
223 reporterAccountId: number
225 @BelongsTo(() => AccountModel, {
227 name: 'reporterAccountId',
230 as: 'ReporterAccount',
233 ReporterAccount: AccountModel
235 @ForeignKey(() => AccountModel)
237 flaggedAccountId: number
239 @BelongsTo(() => AccountModel, {
241 name: 'flaggedAccountId',
244 as: 'FlaggedAccount',
247 FlaggedAccount: AccountModel
249 @HasOne(() => VideoCommentAbuseModel, {
256 VideoCommentAbuse: VideoCommentAbuseModel
258 @HasOne(() => VideoAbuseModel, {
265 VideoAbuse: VideoAbuseModel
267 static loadByIdWithReporter (id: number): Promise<MAbuseReporter> {
275 as: 'ReporterAccount'
280 return AbuseModel.findOne(query)
283 static loadFull (id: number): Promise<MAbuseFull> {
290 model: AccountModel.scope(AccountScopeNames.SUMMARY),
292 as: 'ReporterAccount'
295 model: AccountModel.scope(AccountScopeNames.SUMMARY),
299 model: VideoAbuseModel,
303 model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
308 model: VideoCommentAbuseModel,
312 model: VideoCommentModel.scope([
313 CommentScopeNames.WITH_ACCOUNT
326 return AbuseModel.findOne(query)
329 static async listForAdminApi (parameters: {
336 serverAccountId: number
337 user?: MUserAccountId
340 predefinedReason?: AbusePredefinedReasonsString
342 videoIs?: AbuseVideoIs
345 searchReporter?: string
346 searchReportee?: string
348 searchVideoChannel?: string
368 const userAccountId = user ? user.Account.id : undefined
369 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
371 const queryOptions: BuildAbusesQueryOptions = {
389 const [ total, data ] = await Promise.all([
390 AbuseModel.internalCountForApi(queryOptions),
391 AbuseModel.internalListForApi(queryOptions)
394 return { total, data }
397 static async listForUserApi (parameters: {
418 const queryOptions: BuildAbusesQueryOptions = {
425 reporterAccountId: user.Account.id
428 const [ total, data ] = await Promise.all([
429 AbuseModel.internalCountForApi(queryOptions),
430 AbuseModel.internalListForApi(queryOptions)
433 return { total, data }
436 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
437 if (!this.VideoCommentAbuse) return null
439 const abuseModel = this.VideoCommentAbuse
440 const entity = abuseModel.VideoComment
444 threadId: entity.getThreadId(),
446 text: entity.text ?? '',
448 deleted: entity.isDeleted(),
452 name: entity.Video.name,
453 uuid: entity.Video.uuid
458 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
459 if (!this.VideoAbuse) return null
461 const abuseModel = this.VideoAbuse
462 const entity = abuseModel.Video || abuseModel.deletedVideo
470 startAt: abuseModel.startAt,
471 endAt: abuseModel.endAt,
473 deleted: !abuseModel.Video,
474 blacklisted: abuseModel.Video?.isBlacklisted() || false,
475 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
477 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
481 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
482 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
489 flaggedAccount: this.FlaggedAccount
490 ? this.FlaggedAccount.toFormattedJSON()
495 label: AbuseModel.getStateLabel(this.state)
500 createdAt: this.createdAt,
501 updatedAt: this.updatedAt
505 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
506 const countReportsForVideo = this.get('countReportsForVideo') as number
507 const nthReportForVideo = this.get('nthReportForVideo') as number
509 const countReportsForReporter = this.get('countReportsForReporter') as number
510 const countReportsForReportee = this.get('countReportsForReportee') as number
512 const countMessages = this.get('countMessages') as number
514 const baseVideo = this.buildBaseVideoAbuse()
515 const video: AdminVideoAbuse = baseVideo
516 ? Object.assign(baseVideo, {
517 countReports: countReportsForVideo,
518 nthReport: nthReportForVideo
522 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
524 const abuse = this.buildBaseAbuse(countMessages || 0)
526 return Object.assign(abuse, {
530 moderationComment: this.moderationComment,
532 reporterAccount: this.ReporterAccount
533 ? this.ReporterAccount.toFormattedJSON()
536 countReportsForReporter: (countReportsForReporter || 0),
537 countReportsForReportee: (countReportsForReportee || 0)
541 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
542 const countMessages = this.get('countMessages') as number
544 const video = this.buildBaseVideoAbuse()
545 const comment = this.buildBaseVideoCommentAbuse()
546 const abuse = this.buildBaseAbuse(countMessages || 0)
548 return Object.assign(abuse, {
554 toActivityPubObject (this: MAbuseAP): AbuseObject {
555 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
557 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
559 const startAt = this.VideoAbuse?.startAt
560 const endAt = this.VideoAbuse?.endAt
563 type: 'Flag' as 'Flag',
564 content: this.reason,
566 tag: predefinedReasons.map(r => ({
567 type: 'Hashtag' as 'Hashtag',
575 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
576 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
578 type: QueryTypes.SELECT as QueryTypes.SELECT,
582 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
583 if (total === null) return 0
585 return parseInt(total, 10)
588 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
589 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
591 type: QueryTypes.SELECT as QueryTypes.SELECT,
595 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
596 const ids = rows.map(r => r.id)
598 if (ids.length === 0) return []
600 return AbuseModel.scope(ScopeNames.FOR_API)
602 order: getSort(parameters.sort),
611 private static getStateLabel (id: number) {
612 return ABUSE_STATES[id] || 'Unknown'
615 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
616 const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
618 return (predefinedReasons || [])
619 .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)