]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/abuse/abuse.ts
Use 3 tables to represent abuses
[github/Chocobozzz/PeerTube.git] / server / models / abuse / abuse.ts
similarity index 52%
rename from server/models/video/video-abuse.ts
rename to server/models/abuse/abuse.ts
index 1319332f0738fa80b645d67f42d71c8fd33b20cc..4f99f9c9b836e7a6955fa0f2229995f4fc8096b6 100644 (file)
@@ -1,5 +1,6 @@
 import * as Bluebird from 'bluebird'
-import { literal, Op } from 'sequelize'
+import { invert } from 'lodash'
+import { literal, Op, WhereOptions } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -8,36 +9,35 @@ import {
   DataType,
   Default,
   ForeignKey,
+  HasOne,
   Is,
   Model,
   Scopes,
   Table,
   UpdatedAt
 } from 'sequelize-typescript'
-import { VideoAbuseVideoIs } from '@shared/models/videos/abuse/video-abuse-video-is.type'
-import {
-  VideoAbuseState,
-  VideoDetails,
-  VideoAbusePredefinedReasons,
-  VideoAbusePredefinedReasonsString,
-  videoAbusePredefinedReasonsMap
-} from '../../../shared'
-import { VideoAbuseObject } from '../../../shared/models/activitypub/objects'
-import { VideoAbuse } from '../../../shared/models/videos'
+import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
 import {
-  isVideoAbuseModerationCommentValid,
-  isVideoAbuseReasonValid,
-  isVideoAbuseStateValid
-} from '../../helpers/custom-validators/video-abuses'
-import { CONSTRAINTS_FIELDS, VIDEO_ABUSE_STATES } from '../../initializers/constants'
-import { MUserAccountId, MVideoAbuse, MVideoAbuseFormattable, MVideoAbuseVideo } from '../../types/models'
-import { AccountModel } from '../account/account'
+  Abuse,
+  AbuseObject,
+  AbusePredefinedReasons,
+  abusePredefinedReasonsMap,
+  AbusePredefinedReasonsString,
+  AbuseState,
+  AbuseVideoIs,
+  VideoAbuse
+} from '@shared/models'
+import { AbuseFilter } from '@shared/models/moderation/abuse/abuse-filter'
+import { CONSTRAINTS_FIELDS, ABUSE_STATES } from '../../initializers/constants'
+import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models'
+import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
 import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils'
-import { ThumbnailModel } from './thumbnail'
-import { VideoModel } from './video'
-import { VideoBlacklistModel } from './video-blacklist'
-import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
-import { invert } from 'lodash'
+import { ThumbnailModel } from '../video/thumbnail'
+import { VideoModel } from '../video/video'
+import { VideoBlacklistModel } from '../video/video-blacklist'
+import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
+import { VideoAbuseModel } from './video-abuse'
+import { VideoCommentAbuseModel } from './video-comment-abuse'
 
 export enum ScopeNames {
   FOR_API = 'FOR_API'
@@ -49,20 +49,26 @@ export enum ScopeNames {
     search?: string
     searchReporter?: string
     searchReportee?: string
+
+    // video releated
     searchVideo?: string
     searchVideoChannel?: string
+    videoIs?: AbuseVideoIs
 
     // filters
     id?: number
     predefinedReasonId?: number
+    filter?: AbuseFilter
 
-    state?: VideoAbuseState
-    videoIs?: VideoAbuseVideoIs
+    state?: AbuseState
 
     // accountIds
     serverAccountId: number
     userAccountId: number
   }) => {
+    const onlyBlacklisted = options.videoIs === 'blacklisted'
+    const videoRequired = !!(onlyBlacklisted || options.searchVideo || options.searchVideoChannel)
+
     const where = {
       reporterAccountId: {
         [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')')
@@ -70,33 +76,36 @@ export enum ScopeNames {
     }
 
     if (options.search) {
+      const escapedSearch = AbuseModel.sequelize.escape('%' + options.search + '%')
+
       Object.assign(where, {
         [Op.or]: [
           {
             [Op.and]: [
-              { videoId: { [Op.not]: null } },
-              searchAttribute(options.search, '$Video.name$')
+              { '$VideoAbuse.videoId$': { [Op.not]: null } },
+              searchAttribute(options.search, '$VideoAbuse.Video.name$')
             ]
           },
           {
             [Op.and]: [
-              { videoId: { [Op.not]: null } },
-              searchAttribute(options.search, '$Video.VideoChannel.name$')
+              { '$VideoAbuse.videoId$': { [Op.not]: null } },
+              searchAttribute(options.search, '$VideoAbuse.Video.VideoChannel.name$')
             ]
           },
           {
             [Op.and]: [
-              { deletedVideo: { [Op.not]: null } },
-              { deletedVideo: searchAttribute(options.search, 'name') }
+              { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
+              literal(`"VideoAbuse"."deletedVideo"->>'name' ILIKE ${escapedSearch}`)
             ]
           },
           {
             [Op.and]: [
-              { deletedVideo: { [Op.not]: null } },
-              { deletedVideo: { channel: searchAttribute(options.search, 'displayName') } }
+              { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
+              literal(`"VideoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE ${escapedSearch}`)
             ]
           },
-          searchAttribute(options.search, '$Account.name$')
+          searchAttribute(options.search, '$ReporterAccount.name$'),
+          searchAttribute(options.search, '$FlaggedAccount.name$')
         ]
       })
     }
@@ -106,7 +115,7 @@ export enum ScopeNames {
 
     if (options.videoIs === 'deleted') {
       Object.assign(where, {
-        deletedVideo: {
+        '$VideoAbuse.deletedVideo$': {
           [Op.not]: null
         }
       })
@@ -120,8 +129,6 @@ export enum ScopeNames {
       })
     }
 
-    const onlyBlacklisted = options.videoIs === 'blacklisted'
-
     return {
       attributes: {
         include: [
@@ -131,7 +138,7 @@ export enum ScopeNames {
               '(' +
                 'SELECT count(*) ' +
                 'FROM "videoAbuse" ' +
-                'WHERE "videoId" = "VideoAbuseModel"."videoId" ' +
+                'WHERE "videoId" = "VideoAbuse"."videoId" ' +
               ')'
             ),
             'countReportsForVideo'
@@ -146,7 +153,7 @@ export enum ScopeNames {
                          'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
                   'FROM "videoAbuse" ' +
                 ') t ' +
-                'WHERE t.id = "VideoAbuseModel".id ' +
+                'WHERE t.id = "VideoAbuse".id' +
               ')'
             ),
             'nthReportForVideo'
@@ -159,7 +166,7 @@ export enum ScopeNames {
                 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
                 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
                 'INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ' +
-                'WHERE "account"."id" = "VideoAbuseModel"."reporterAccountId" ' +
+                'WHERE "account"."id" = "AbuseModel"."reporterAccountId" ' +
               ')'
             ),
             'countReportsForReporter__video'
@@ -169,7 +176,7 @@ export enum ScopeNames {
               '(' +
                 'SELECT count(DISTINCT "videoAbuse"."id") ' +
                 'FROM "videoAbuse" ' +
-                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuseModel"."reporterAccountId" ` +
+                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "AbuseModel"."reporterAccountId" ` +
               ')'
             ),
             'countReportsForReporter__deletedVideo'
@@ -182,8 +189,8 @@ export enum ScopeNames {
                 'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
                 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
                 'INNER JOIN "account" ON ' +
-                      '"videoChannel"."accountId" = "Video->VideoChannel"."accountId" ' +
-                   `OR "videoChannel"."accountId" = CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
+                      '"videoChannel"."accountId" = "VideoAbuse->Video->VideoChannel"."accountId" ' +
+                   `OR "videoChannel"."accountId" = CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
               ')'
             ),
             'countReportsForReportee__video'
@@ -193,9 +200,9 @@ export enum ScopeNames {
               '(' +
                 'SELECT count(DISTINCT "videoAbuse"."id") ' +
                 'FROM "videoAbuse" ' +
-                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "Video->VideoChannel"."accountId" ` +
+                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuse->Video->VideoChannel"."accountId" ` +
                    `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
-                      `CAST("VideoAbuseModel"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
+                      `CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
               ')'
             ),
             'countReportsForReportee__deletedVideo'
@@ -204,32 +211,47 @@ export enum ScopeNames {
       },
       include: [
         {
-          model: AccountModel,
+          model: AccountModel.scope(AccountScopeNames.SUMMARY),
+          as: 'ReporterAccount',
           required: true,
           where: searchAttribute(options.searchReporter, 'name')
         },
         {
-          model: VideoModel,
-          required: !!(onlyBlacklisted || options.searchVideo || options.searchReportee || options.searchVideoChannel),
-          where: searchAttribute(options.searchVideo, 'name'),
+          model: AccountModel.scope(AccountScopeNames.SUMMARY),
+          as: 'FlaggedAccount',
+          required: true,
+          where: searchAttribute(options.searchReportee, 'name')
+        },
+        {
+          model: VideoAbuseModel,
+          required: options.filter === 'video' || !!options.videoIs || videoRequired,
           include: [
             {
-              model: ThumbnailModel
-            },
-            {
-              model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: true } as SummaryOptions ] }),
-              where: searchAttribute(options.searchVideoChannel, 'name'),
+              model: VideoModel,
+              required: videoRequired,
+              where: searchAttribute(options.searchVideo, 'name'),
               include: [
                 {
-                  model: AccountModel,
-                  where: searchAttribute(options.searchReportee, 'name')
+                  model: ThumbnailModel
+                },
+                {
+                  model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: false } as SummaryOptions ] }),
+                  where: searchAttribute(options.searchVideoChannel, 'name'),
+                  required: true,
+                  include: [
+                    {
+                      model: AccountModel.scope(AccountScopeNames.SUMMARY),
+                      required: true,
+                      where: searchAttribute(options.searchReportee, 'name')
+                    }
+                  ]
+                },
+                {
+                  attributes: [ 'id', 'reason', 'unfederated' ],
+                  model: VideoBlacklistModel,
+                  required: onlyBlacklisted
                 }
               ]
-            },
-            {
-              attributes: [ 'id', 'reason', 'unfederated' ],
-              model: VideoBlacklistModel,
-              required: onlyBlacklisted
             }
           ]
         }
@@ -239,55 +261,40 @@ export enum ScopeNames {
   }
 }))
 @Table({
-  tableName: 'videoAbuse',
+  tableName: 'abuse',
   indexes: [
     {
-      fields: [ 'videoId' ]
+      fields: [ 'reporterAccountId' ]
     },
     {
-      fields: [ 'reporterAccountId' ]
+      fields: [ 'flaggedAccountId' ]
     }
   ]
 })
-export class VideoAbuseModel extends Model<VideoAbuseModel> {
+export class AbuseModel extends Model<AbuseModel> {
 
   @AllowNull(false)
   @Default(null)
-  @Is('VideoAbuseReason', value => throwIfNotValid(value, isVideoAbuseReasonValid, 'reason'))
-  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.REASON.max))
+  @Is('VideoAbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
   reason: string
 
   @AllowNull(false)
   @Default(null)
-  @Is('VideoAbuseState', value => throwIfNotValid(value, isVideoAbuseStateValid, 'state'))
+  @Is('VideoAbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
   @Column
-  state: VideoAbuseState
+  state: AbuseState
 
   @AllowNull(true)
   @Default(null)
-  @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isVideoAbuseModerationCommentValid, 'moderationComment', true))
-  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEO_ABUSES.MODERATION_COMMENT.max))
+  @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
+  @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
   moderationComment: string
 
-  @AllowNull(true)
-  @Default(null)
-  @Column(DataType.JSONB)
-  deletedVideo: VideoDetails
-
   @AllowNull(true)
   @Default(null)
   @Column(DataType.ARRAY(DataType.INTEGER))
-  predefinedReasons: VideoAbusePredefinedReasons[]
-
-  @AllowNull(true)
-  @Default(null)
-  @Column
-  startAt: number
-
-  @AllowNull(true)
-  @Default(null)
-  @Column
-  endAt: number
+  predefinedReasons: AbusePredefinedReasons[]
 
   @CreatedAt
   createdAt: Date
@@ -301,36 +308,65 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
 
   @BelongsTo(() => AccountModel, {
     foreignKey: {
+      name: 'reporterAccountId',
       allowNull: true
     },
+    as: 'ReporterAccount',
     onDelete: 'set null'
   })
-  Account: AccountModel
+  ReporterAccount: AccountModel
 
-  @ForeignKey(() => VideoModel)
+  @ForeignKey(() => AccountModel)
   @Column
-  videoId: number
+  flaggedAccountId: number
 
-  @BelongsTo(() => VideoModel, {
+  @BelongsTo(() => AccountModel, {
     foreignKey: {
+      name: 'flaggedAccountId',
       allowNull: true
     },
+    as: 'FlaggedAccount',
     onDelete: 'set null'
   })
-  Video: VideoModel
+  FlaggedAccount: AccountModel
+
+  @HasOne(() => VideoCommentAbuseModel, {
+    foreignKey: {
+      name: 'abuseId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoCommentAbuse: VideoCommentAbuseModel
 
-  static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MVideoAbuse> {
-    const videoAttributes = {}
-    if (videoId) videoAttributes['videoId'] = videoId
-    if (uuid) videoAttributes['deletedVideo'] = { uuid }
+  @HasOne(() => VideoAbuseModel, {
+    foreignKey: {
+      name: 'abuseId',
+      allowNull: false
+    },
+    onDelete: 'cascade'
+  })
+  VideoAbuse: VideoAbuseModel
+
+  static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuse> {
+    const videoWhere: WhereOptions = {}
+
+    if (videoId) videoWhere.videoId = videoId
+    if (uuid) videoWhere.deletedVideo = { uuid }
 
     const query = {
+      include: [
+        {
+          model: VideoAbuseModel,
+          required: true,
+          where: videoWhere
+        }
+      ],
       where: {
-        id,
-        ...videoAttributes
+        id
       }
     }
-    return VideoAbuseModel.findOne(query)
+    return AbuseModel.findOne(query)
   }
 
   static listForApi (parameters: {
@@ -338,13 +374,15 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     count: number
     sort: string
 
+    filter?: AbuseFilter
+
     serverAccountId: number
     user?: MUserAccountId
 
     id?: number
-    predefinedReason?: VideoAbusePredefinedReasonsString
-    state?: VideoAbuseState
-    videoIs?: VideoAbuseVideoIs
+    predefinedReason?: AbusePredefinedReasonsString
+    state?: AbuseState
+    videoIs?: AbuseVideoIs
 
     search?: string
     searchReporter?: string
@@ -364,24 +402,26 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
       predefinedReason,
       searchReportee,
       searchVideo,
+      filter,
       searchVideoChannel,
       searchReporter,
       id
     } = parameters
 
     const userAccountId = user ? user.Account.id : undefined
-    const predefinedReasonId = predefinedReason ? videoAbusePredefinedReasonsMap[predefinedReason] : undefined
+    const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
 
     const query = {
       offset: start,
       limit: count,
       order: getSort(sort),
-      col: 'VideoAbuseModel.id',
+      col: 'AbuseModel.id',
       distinct: true
     }
 
     const filters = {
       id,
+      filter,
       predefinedReasonId,
       search,
       state,
@@ -394,7 +434,7 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
       userAccountId
     }
 
-    return VideoAbuseModel
+    return AbuseModel
       .scope([
         { method: [ ScopeNames.FOR_API, filters ] }
       ])
@@ -404,8 +444,8 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
       })
   }
 
-  toFormattedJSON (this: MVideoAbuseFormattable): VideoAbuse {
-    const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
+  toFormattedJSON (this: MAbuseFormattable): Abuse {
+    const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
     const countReportsForVideo = this.get('countReportsForVideo') as number
     const nthReportForVideo = this.get('nthReportForVideo') as number
     const countReportsForReporterVideo = this.get('countReportsForReporter__video') as number
@@ -413,51 +453,70 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
     const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
     const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
 
-    const video = this.Video
-      ? this.Video
-      : this.deletedVideo
+    let video: VideoAbuse
+
+    if (this.VideoAbuse) {
+      const abuseModel = this.VideoAbuse
+      const entity = abuseModel.Video || abuseModel.deletedVideo
+
+      video = {
+        id: entity.id,
+        uuid: entity.uuid,
+        name: entity.name,
+        nsfw: entity.nsfw,
+
+        startAt: abuseModel.startAt,
+        endAt: abuseModel.endAt,
+
+        deleted: !abuseModel.Video,
+        blacklisted: abuseModel.Video?.isBlacklisted() || false,
+        thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
+        channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
+      }
+    }
 
     return {
       id: this.id,
       reason: this.reason,
       predefinedReasons,
-      reporterAccount: this.Account.toFormattedJSON(),
+
+      reporterAccount: this.ReporterAccount.toFormattedJSON(),
+
       state: {
         id: this.state,
-        label: VideoAbuseModel.getStateLabel(this.state)
+        label: AbuseModel.getStateLabel(this.state)
       },
+
       moderationComment: this.moderationComment,
-      video: {
-        id: video.id,
-        uuid: video.uuid,
-        name: video.name,
-        nsfw: video.nsfw,
-        deleted: !this.Video,
-        blacklisted: this.Video?.isBlacklisted() || false,
-        thumbnailPath: this.Video?.getMiniatureStaticPath(),
-        channel: this.Video?.VideoChannel.toFormattedJSON() || this.deletedVideo?.channel
-      },
+
+      video,
+      comment: null,
+
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
-      startAt: this.startAt,
-      endAt: this.endAt,
       count: countReportsForVideo || 0,
       nth: nthReportForVideo || 0,
       countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
-      countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0)
+      countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0),
+
+      // FIXME: deprecated in 2.3, remove this
+      startAt: null,
+      endAt: null
     }
   }
 
-  toActivityPubObject (this: MVideoAbuseVideo): VideoAbuseObject {
-    const predefinedReasons = VideoAbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
+  toActivityPubObject (this: MAbuseAP): AbuseObject {
+    const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
+
+    const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
 
-    const startAt = this.startAt
-    const endAt = this.endAt
+    const startAt = this.VideoAbuse?.startAt
+    const endAt = this.VideoAbuse?.endAt
 
     return {
       type: 'Flag' as 'Flag',
       content: this.reason,
-      object: this.Video.url,
+      object,
       tag: predefinedReasons.map(r => ({
         type: 'Hashtag' as 'Hashtag',
         name: r
@@ -468,12 +527,12 @@ export class VideoAbuseModel extends Model<VideoAbuseModel> {
   }
 
   private static getStateLabel (id: number) {
-    return VIDEO_ABUSE_STATES[id] || 'Unknown'
+    return ABUSE_STATES[id] || 'Unknown'
   }
 
-  private static getPredefinedReasonsStrings (predefinedReasons: VideoAbusePredefinedReasons[]): VideoAbusePredefinedReasonsString[] {
+  private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
     return (predefinedReasons || [])
-      .filter(r => r in VideoAbusePredefinedReasons)
-      .map(r => invert(videoAbusePredefinedReasonsMap)[r] as VideoAbusePredefinedReasonsString)
+      .filter(r => r in AbusePredefinedReasons)
+      .map(r => invert(abusePredefinedReasonsMap)[r] as AbusePredefinedReasonsString)
   }
 }