1 import * as Bluebird from 'bluebird'
2 import { literal, Op } from 'sequelize'
16 } from 'sequelize-typescript'
17 import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
21 VideoAbusePredefinedReasons,
22 VideoAbusePredefinedReasonsString,
23 videoAbusePredefinedReasonsMap
24 } from '../../../shared'
25 import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
26 import { VideoAbuse } from '../../../shared/models/videos'
28 isVideoAbuseModerationCommentValid,
29 isVideoAbuseReasonValid,
30 isVideoAbuseStateValid
31 } from '../../helpers/custom-validators/video-abuses'
32 import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
33 import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../types/models'
34 import { AccountModel } from '../account/account'
35 import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
36 import { ThumbnailModel } from './thumbnail'
37 import { VideoModel } from './video'
38 import { VideoBlacklistModel } from './video-blacklist'
39 import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
40 import { invert } from 'lodash'
42 export enum ScopeNames {
47 [ScopeNames.FOR_API]: (options: {
50 searchReporter?: string
51 searchReportee?: string
53 searchVideoChannel?: string
57 predefinedReasonId?: number
59 state?: VideoAbuseState
60 videoIs?: VideoAbuseVideoIs
63 serverAccountId: number
68 [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
73 Object.assign(where, {
77 { videoId: { [Op.not]: null } },
78 searchAttribute(options.search, '$Video.name$')
83 { videoId: { [Op.not]: null } },
84 searchAttribute(options.search, '$Video.VideoChannel.name$')
89 { deletedVideo: { [Op.not]: null } },
90 { deletedVideo: searchAttribute(options.search, 'name') }
95 { deletedVideo: { [Op.not]: null } },
96 { deletedVideo: { channel: searchAttribute(options.search, 'displayName') } }
99 searchAttribute(options.search, '$Account.name$')
104 if (options.id) Object.assign(where, { id: options.id })
105 if (options.state) Object.assign(where, { state: options.state })
107 if (options.videoIs === 'deleted') {
108 Object.assign(where, {
115 if (options.predefinedReasonId) {
116 Object.assign(where, {
118 [Op.contains]: [ options.predefinedReasonId ]
123 const onlyBlacklisted = options.videoIs === 'blacklisted'
129 // we don't care about this count for deleted videos, so there are not included
133 'FROM "videoAbuse" ' +
134 'WHERE "videoId" = "VideoAbuseModel"."videoId" ' +
137 'countReportsForVideo'
140 // we don't care about this count for deleted videos, so there are not included
146 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
147 'FROM "videoAbuse" ' +
149 'WHERE t.id = "VideoAbuseModel".id ' +
157 'SELECT count("videoAbuse"."id") ' +
158 'FROM "videoAbuse" ' +
159 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
160 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
161 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
162 'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' +
165 'countReportsForReporter__video'
170 'SELECT count(DISTINCT "videoAbuse"."id") ' +
171 'FROM "videoAbuse" ' +
172 `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` +
175 'countReportsForReporter__deletedVideo'
180 'SELECT count(DISTINCT "videoAbuse"."id") ' +
181 'FROM "videoAbuse" ' +
182 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
183 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
184 'INNER JOIN "account" ON ' +
185 '"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' +
186 `OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
189 'countReportsForReportee__video'
194 'SELECT count(DISTINCT "videoAbuse"."id") ' +
195 'FROM "videoAbuse" ' +
196 `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` +
197 `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
198 `CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
201 'countReportsForReportee__deletedVideo'
209 where: searchAttribute(options.searchReporter, 'name')
213 required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel),
214 where: searchAttribute(options.searchVideo, 'name'),
217 model: ThumbnailModel
220 model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
221 where: searchAttribute(options.searchVideoChannel, 'name'),
225 where: searchAttribute(options.searchReportee, 'name')
230 attributes: [ 'id', 'reason', 'unfederated' ],
231 model: VideoBlacklistModel,
232 required: onlyBlacklisted
242 tableName: 'videoAbuse',
245 fields: [ 'videoId' ]
248 fields: [ 'reporterAccountId' ]
252 export class VideoAbuseModel extends Model<VideoAbuseModel> {
256 @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
257 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
262 @Is('VideoAbuseState', value => throwIfNotValid(value, isVideoAbuseStateValid, 'state'))
264 state: VideoAbuseState
268 @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true))
269 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
270 moderationComment: string
274 @Column(DataType.JSONB)
275 deletedVideo: VideoDetails
279 @Column(DataType.ARRAY(DataType.INTEGER))
280 predefinedReasons: VideoAbusePredefinedReasons[]
298 @ForeignKey(() => AccountModel)
300 reporterAccountId: number
302 @BelongsTo(() => AccountModel, {
308 Account: AccountModel
310 @ForeignKey(() => VideoModel)
314 @BelongsTo(() => VideoModel, {
322 static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> {
323 const videoAttributes = {}
324 if (videoId) videoAttributes['videoId'] = videoId
325 if (uuid) videoAttributes['deletedVideo'] = { uuid }
333 return VideoAbuseModel.findOne(query)
336 static listForApi (parameters: {
341 serverAccountId: number
342 user?: MUserAccountId
345 predefinedReason?: VideoAbusePredefinedReasonsString
346 state?: VideoAbuseState
347 videoIs?: VideoAbuseVideoIs
350 searchReporter?: string
351 searchReportee?: string
353 searchVideoChannel?: string
372 const userAccountId = user ? user.Account.id : undefined
373 const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined
378 order: getSort(sort),
379 col: 'VideoAbuseModel.id',
397 return VideoAbuseModel
399 { method: [ ScopeNames.FOR_API, filters ] }
401 .findAndCountAll(query)
402 .then(({ rows, count }) => {
403 return { total: count, data: rows }
407 toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse {
408 const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
409 const countReportsForVideo = this.get('countReportsForVideo') as number
410 const nthReportForVideo = this.get('nthReportForVideo') as number
411 const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
412 const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number
413 const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
414 const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
416 const video = this.Video
424 reporterAccount: this.Account.toFormattedJSON(),
427 label: VideoAbuseModel.getStateLabel(this.state)
429 moderationComment: this.moderationComment,
435 deleted: !this.Video,
436 blacklisted: this.Video?.isBlacklisted() || false,
437 thumbnailPath: this.Video?.getMiniatureStaticPath(),
438 channel: this.Video?.VideoChannel.toFormattedJSON() || this.deletedVideo?.channel
440 createdAt: this.createdAt,
441 updatedAt: this.updatedAt,
442 startAt: this.startAt,
444 count: countReportsForVideo || 0,
445 nth: nthReportForVideo || 0,
446 countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
447 countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0)
451 toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject {
452 const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
454 const startAt = this.startAt
455 const endAt = this.endAt
458 type: 'Flag' as 'Flag',
459 content: this.reason,
460 object: this.Video.url,
461 tag: predefinedReasons.map(r => ({
462 type: 'Hashtag' as 'Hashtag',
470 private static getStateLabel (id: number) {
471 return VIDEO_ABUSE_STATES[id] || 'Unknown'
474 private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] {
475 return (predefinedReasons || [])
476 .filter(r => r in VideoAbusePredefinedReasons)
477 .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString)