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'
23 AbusePredefinedReasons,
24 abusePredefinedReasonsMap,
25 AbusePredefinedReasonsString,
30 AdminVideoCommentAbuse,
33 } from '@shared/models'
34 import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
35 import { MAbuse, MAbuseAdminFormattable, MAbuseAP, MUserAccountId, MAbuseUserFormattable } 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 { 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 { 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<MAbuse> {
270 const videoWhere: WhereOptions = {}
272 if (videoId) videoWhere.videoId = videoId
273 if (uuid) videoWhere.deletedVideo = { uuid }
278 model: VideoAbuseModel,
287 return AbuseModel.findOne(query)
290 static loadById (id: number): Bluebird<MAbuse> {
297 return AbuseModel.findOne(query)
300 static async listForAdminApi (parameters: {
307 serverAccountId: number
308 user?: MUserAccountId
311 predefinedReason?: AbusePredefinedReasonsString
313 videoIs?: AbuseVideoIs
316 searchReporter?: string
317 searchReportee?: string
319 searchVideoChannel?: string
339 const userAccountId = user ? user.Account.id : undefined
340 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
342 const queryOptions: BuildAbusesQueryOptions = {
360 const [ total, data ] = await Promise.all([
361 AbuseModel.internalCountForApi(queryOptions),
362 AbuseModel.internalListForApi(queryOptions)
365 return { total, data }
368 static async listForUserApi (parameters: {
389 const queryOptions: BuildAbusesQueryOptions = {
396 reporterAccountId: user.Account.id
399 const [ total, data ] = await Promise.all([
400 AbuseModel.internalCountForApi(queryOptions),
401 AbuseModel.internalListForApi(queryOptions)
404 return { total, data }
407 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
408 if (!this.VideoCommentAbuse) return null
410 const abuseModel = this.VideoCommentAbuse
411 const entity = abuseModel.VideoComment
415 threadId: entity.getThreadId(),
417 text: entity.text ?? '',
419 deleted: entity.isDeleted(),
423 name: entity.Video.name,
424 uuid: entity.Video.uuid
429 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
430 if (!this.VideoAbuse) return null
432 const abuseModel = this.VideoAbuse
433 const entity = abuseModel.Video || abuseModel.deletedVideo
441 startAt: abuseModel.startAt,
442 endAt: abuseModel.endAt,
444 deleted: !abuseModel.Video,
445 blacklisted: abuseModel.Video?.isBlacklisted() || false,
446 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
448 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel,
452 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
453 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
460 flaggedAccount: this.FlaggedAccount
461 ? this.FlaggedAccount.toFormattedJSON()
466 label: AbuseModel.getStateLabel(this.state)
469 moderationComment: this.moderationComment,
473 createdAt: this.createdAt,
474 updatedAt: this.updatedAt
478 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
479 const countReportsForVideo = this.get('countReportsForVideo') as number
480 const nthReportForVideo = this.get('nthReportForVideo') as number
482 const countReportsForReporter = this.get('countReportsForReporter') as number
483 const countReportsForReportee = this.get('countReportsForReportee') as number
485 const countMessages = this.get('countMessages') as number
487 const baseVideo = this.buildBaseVideoAbuse()
488 const video: AdminVideoAbuse = baseVideo
489 ? Object.assign(baseVideo, {
490 countReports: countReportsForVideo,
491 nthReport: nthReportForVideo
495 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
497 const abuse = this.buildBaseAbuse(countMessages || 0)
499 return Object.assign(abuse, {
503 reporterAccount: this.ReporterAccount
504 ? this.ReporterAccount.toFormattedJSON()
507 countReportsForReporter: (countReportsForReporter || 0),
508 countReportsForReportee: (countReportsForReportee || 0),
510 // FIXME: deprecated in 2.3, remove this
513 count: countReportsForVideo || 0,
514 nth: nthReportForVideo || 0
518 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
519 const countMessages = this.get('countMessages') as number
521 const video = this.buildBaseVideoAbuse()
522 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
523 const abuse = this.buildBaseAbuse(countMessages || 0)
525 return Object.assign(abuse, {
531 toActivityPubObject (this: MAbuseAP): AbuseObject {
532 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
534 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
536 const startAt = this.VideoAbuse?.startAt
537 const endAt = this.VideoAbuse?.endAt
540 type: 'Flag' as 'Flag',
541 content: this.reason,
543 tag: predefinedReasons.map(r => ({
544 type: 'Hashtag' as 'Hashtag',
552 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
553 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
555 type: QueryTypes.SELECT as QueryTypes.SELECT,
559 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
560 if (total === null) return 0
562 return parseInt(total, 10)
565 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
566 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
568 type: QueryTypes.SELECT as QueryTypes.SELECT,
572 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
573 const ids = rows.map(r => r.id)
575 if (ids.length === 0) return []
577 return AbuseModel.scope(ScopeNames.FOR_API)
579 order: getSort(parameters.sort),
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)