]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blobdiff - server/models/abuse/abuse.ts
Add new abuses tests
[github/Chocobozzz/PeerTube.git] / server / models / abuse / abuse.ts
index 4f99f9c9b836e7a6955fa0f2229995f4fc8096b6..dffd503b391ee648decdf1dc8d490160e653313a 100644 (file)
@@ -1,6 +1,6 @@
 import * as Bluebird from 'bluebird'
 import { invert } from 'lodash'
-import { literal, Op, WhereOptions } from 'sequelize'
+import { literal, Op, QueryTypes, WhereOptions } from 'sequelize'
 import {
   AllowNull,
   BelongsTo,
@@ -19,23 +19,26 @@ import {
 import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
 import {
   Abuse,
+  AbuseFilter,
   AbuseObject,
   AbusePredefinedReasons,
   abusePredefinedReasonsMap,
   AbusePredefinedReasonsString,
   AbuseState,
   AbuseVideoIs,
-  VideoAbuse
+  VideoAbuse,
+  VideoCommentAbuse
 } from '@shared/models'
-import { AbuseFilter } from '@shared/models/moderation/abuse/abuse-filter'
-import { CONSTRAINTS_FIELDS, ABUSE_STATES } from '../../initializers/constants'
+import { ABUSE_STATES, CONSTRAINTS_FIELDS } 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 { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
+import { getSort, throwIfNotValid } from '../utils'
 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 { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
+import { VideoCommentModel } from '../video/video-comment'
+import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
 import { VideoAbuseModel } from './video-abuse'
 import { VideoCommentAbuseModel } from './video-comment-abuse'
 
@@ -44,91 +47,7 @@ export enum ScopeNames {
 }
 
 @Scopes(() => ({
-  [ScopeNames.FOR_API]: (options: {
-    // search
-    search?: string
-    searchReporter?: string
-    searchReportee?: string
-
-    // video releated
-    searchVideo?: string
-    searchVideoChannel?: string
-    videoIs?: AbuseVideoIs
-
-    // filters
-    id?: number
-    predefinedReasonId?: number
-    filter?: AbuseFilter
-
-    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 ]) + ')')
-      }
-    }
-
-    if (options.search) {
-      const escapedSearch = AbuseModel.sequelize.escape('%' + options.search + '%')
-
-      Object.assign(where, {
-        [Op.or]: [
-          {
-            [Op.and]: [
-              { '$VideoAbuse.videoId$': { [Op.not]: null } },
-              searchAttribute(options.search, '$VideoAbuse.Video.name$')
-            ]
-          },
-          {
-            [Op.and]: [
-              { '$VideoAbuse.videoId$': { [Op.not]: null } },
-              searchAttribute(options.search, '$VideoAbuse.Video.VideoChannel.name$')
-            ]
-          },
-          {
-            [Op.and]: [
-              { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
-              literal(`"VideoAbuse"."deletedVideo"->>'name' ILIKE ${escapedSearch}`)
-            ]
-          },
-          {
-            [Op.and]: [
-              { '$VideoAbuse.deletedVideo$': { [Op.not]: null } },
-              literal(`"VideoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE ${escapedSearch}`)
-            ]
-          },
-          searchAttribute(options.search, '$ReporterAccount.name$'),
-          searchAttribute(options.search, '$FlaggedAccount.name$')
-        ]
-      })
-    }
-
-    if (options.id) Object.assign(where, { id: options.id })
-    if (options.state) Object.assign(where, { state: options.state })
-
-    if (options.videoIs === 'deleted') {
-      Object.assign(where, {
-        '$VideoAbuse.deletedVideo$': {
-          [Op.not]: null
-        }
-      })
-    }
-
-    if (options.predefinedReasonId) {
-      Object.assign(where, {
-        predefinedReasons: {
-          [Op.contains]: [ options.predefinedReasonId ]
-        }
-      })
-    }
-
+  [ScopeNames.FOR_API]: () => {
     return {
       attributes: {
         include: [
@@ -138,7 +57,7 @@ export enum ScopeNames {
               '(' +
                 'SELECT count(*) ' +
                 'FROM "videoAbuse" ' +
-                'WHERE "videoId" = "VideoAbuse"."videoId" ' +
+                'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
               ')'
             ),
             'countReportsForVideo'
@@ -153,7 +72,7 @@ export enum ScopeNames {
                          'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
                   'FROM "videoAbuse" ' +
                 ') t ' +
-                'WHERE t.id = "VideoAbuse".id' +
+                'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
               ')'
             ),
             'nthReportForVideo'
@@ -161,102 +80,88 @@ export enum ScopeNames {
           [
             literal(
               '(' +
-                'SELECT count("videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                '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" = "AbuseModel"."reporterAccountId" ' +
-              ')'
-            ),
-            'countReportsForReporter__video'
-          ],
-          [
-            literal(
-              '(' +
-                'SELECT count(DISTINCT "videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "AbuseModel"."reporterAccountId" ` +
+                'SELECT count("abuse"."id") ' +
+                'FROM "abuse" ' +
+                'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
               ')'
             ),
-            'countReportsForReporter__deletedVideo'
+            'countReportsForReporter'
           ],
           [
             literal(
               '(' +
-                'SELECT count(DISTINCT "videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                'INNER JOIN "video" ON "video"."id" = "videoAbuse"."videoId" ' +
-                'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
-                'INNER JOIN "account" ON ' +
-                      '"videoChannel"."accountId" = "VideoAbuse->Video->VideoChannel"."accountId" ' +
-                   `OR "videoChannel"."accountId" = CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
+                'SELECT count("abuse"."id") ' +
+                'FROM "abuse" ' +
+                'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
               ')'
             ),
-            'countReportsForReportee__video'
-          ],
-          [
-            literal(
-              '(' +
-                'SELECT count(DISTINCT "videoAbuse"."id") ' +
-                'FROM "videoAbuse" ' +
-                `WHERE CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = "VideoAbuse->Video->VideoChannel"."accountId" ` +
-                   `OR CAST("deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) = ` +
-                      `CAST("VideoAbuse"."deletedVideo"->'channel'->'ownerAccount'->>'id' AS INTEGER) ` +
-              ')'
-            ),
-            'countReportsForReportee__deletedVideo'
+            'countReportsForReportee'
           ]
         ]
       },
       include: [
         {
-          model: AccountModel.scope(AccountScopeNames.SUMMARY),
-          as: 'ReporterAccount',
-          required: true,
-          where: searchAttribute(options.searchReporter, 'name')
+          model: AccountModel.scope({
+            method: [
+              AccountScopeNames.SUMMARY,
+              { actorRequired: false } as AccountSummaryOptions
+            ]
+          }),
+          as: 'ReporterAccount'
         },
         {
-          model: AccountModel.scope(AccountScopeNames.SUMMARY),
-          as: 'FlaggedAccount',
-          required: true,
-          where: searchAttribute(options.searchReportee, 'name')
+          model: AccountModel.scope({
+            method: [
+              AccountScopeNames.SUMMARY,
+              { actorRequired: false } as AccountSummaryOptions
+            ]
+          }),
+          as: 'FlaggedAccount'
         },
         {
-          model: VideoAbuseModel,
-          required: options.filter === 'video' || !!options.videoIs || videoRequired,
+          model: VideoCommentAbuseModel.unscoped(),
           include: [
             {
-              model: VideoModel,
-              required: videoRequired,
-              where: searchAttribute(options.searchVideo, 'name'),
+              model: VideoCommentModel.unscoped(),
               include: [
                 {
+                  model: VideoModel.unscoped(),
+                  attributes: [ 'name', 'id', 'uuid' ]
+                }
+              ]
+            }
+          ]
+        },
+        {
+          model: VideoAbuseModel.unscoped(),
+          include: [
+            {
+              attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
+              model: VideoModel.unscoped(),
+              include: [
+                {
+                  attributes: [ 'filename', 'fileUrl' ],
                   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')
-                    }
-                  ]
+                  model: VideoChannelModel.scope({
+                    method: [
+                      VideoChannelScopeNames.SUMMARY,
+                      { withAccount: false, actorRequired: false } as ChannelSummaryOptions
+                    ]
+                  }),
+                  required: false
                 },
                 {
                   attributes: [ 'id', 'reason', 'unfederated' ],
-                  model: VideoBlacklistModel,
-                  required: onlyBlacklisted
+                  required: false,
+                  model: VideoBlacklistModel
                 }
               ]
             }
           ]
         }
-      ],
-      where
+      ]
     }
   }
 }))
@@ -275,19 +180,19 @@ export class AbuseModel extends Model<AbuseModel> {
 
   @AllowNull(false)
   @Default(null)
-  @Is('VideoAbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
+  @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
   reason: string
 
   @AllowNull(false)
   @Default(null)
-  @Is('VideoAbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
+  @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
   @Column
   state: AbuseState
 
   @AllowNull(true)
   @Default(null)
-  @Is('VideoAbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
+  @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
   @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
   moderationComment: string
 
@@ -348,6 +253,7 @@ export class AbuseModel extends Model<AbuseModel> {
   })
   VideoAbuse: VideoAbuseModel
 
+  // FIXME: deprecated in 2.3. Remove these validators
   static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuse> {
     const videoWhere: WhereOptions = {}
 
@@ -369,7 +275,17 @@ export class AbuseModel extends Model<AbuseModel> {
     return AbuseModel.findOne(query)
   }
 
-  static listForApi (parameters: {
+  static loadById (id: number): Bluebird<MAbuse> {
+    const query = {
+      where: {
+        id
+      }
+    }
+
+    return AbuseModel.findOne(query)
+  }
+
+  static async listForApi (parameters: {
     start: number
     count: number
     sort: string
@@ -411,15 +327,10 @@ export class AbuseModel extends Model<AbuseModel> {
     const userAccountId = user ? user.Account.id : undefined
     const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
 
-    const query = {
-      offset: start,
-      limit: count,
-      order: getSort(sort),
-      col: 'AbuseModel.id',
-      distinct: true
-    }
-
-    const filters = {
+    const queryOptions: BuildAbusesQueryOptions = {
+      start,
+      count,
+      sort,
       id,
       filter,
       predefinedReasonId,
@@ -434,26 +345,25 @@ export class AbuseModel extends Model<AbuseModel> {
       userAccountId
     }
 
-    return AbuseModel
-      .scope([
-        { method: [ ScopeNames.FOR_API, filters ] }
-      ])
-      .findAndCountAll(query)
-      .then(({ rows, count }) => {
-        return { total: count, data: rows }
-      })
+    const [ total, data ] = await Promise.all([
+      AbuseModel.internalCountForApi(queryOptions),
+      AbuseModel.internalListForApi(queryOptions)
+    ])
+
+    return { total, data }
   }
 
   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
-    const countReportsForReporterDeletedVideo = this.get('countReportsForReporter__deletedVideo') as number
-    const countReportsForReporteeVideo = this.get('countReportsForReportee__video') as number
-    const countReportsForReporteeDeletedVideo = this.get('countReportsForReportee__deletedVideo') as number
 
-    let video: VideoAbuse
+    const countReportsForReporter = this.get('countReportsForReporter') as number
+    const countReportsForReportee = this.get('countReportsForReportee') as number
+
+    let video: VideoAbuse = null
+    let comment: VideoCommentAbuse = null
 
     if (this.VideoAbuse) {
       const abuseModel = this.VideoAbuse
@@ -471,7 +381,29 @@ export class AbuseModel extends Model<AbuseModel> {
         deleted: !abuseModel.Video,
         blacklisted: abuseModel.Video?.isBlacklisted() || false,
         thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
-        channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
+
+        channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel,
+
+        countReports: countReportsForVideo,
+        nthReport: nthReportForVideo
+      }
+    }
+
+    if (this.VideoCommentAbuse) {
+      const abuseModel = this.VideoCommentAbuse
+      const entity = abuseModel.VideoComment
+
+      comment = {
+        id: entity.id,
+        text: entity.text ?? '',
+
+        deleted: entity.isDeleted(),
+
+        video: {
+          id: entity.Video.id,
+          name: entity.Video.name,
+          uuid: entity.Video.uuid
+        }
       }
     }
 
@@ -480,7 +412,13 @@ export class AbuseModel extends Model<AbuseModel> {
       reason: this.reason,
       predefinedReasons,
 
-      reporterAccount: this.ReporterAccount.toFormattedJSON(),
+      reporterAccount: this.ReporterAccount
+        ? this.ReporterAccount.toFormattedJSON()
+        : null,
+
+      flaggedAccount: this.FlaggedAccount
+        ? this.FlaggedAccount.toFormattedJSON()
+        : null,
 
       state: {
         id: this.state,
@@ -490,18 +428,19 @@ export class AbuseModel extends Model<AbuseModel> {
       moderationComment: this.moderationComment,
 
       video,
-      comment: null,
+      comment,
 
       createdAt: this.createdAt,
       updatedAt: this.updatedAt,
-      count: countReportsForVideo || 0,
-      nth: nthReportForVideo || 0,
-      countReportsForReporter: (countReportsForReporterVideo || 0) + (countReportsForReporterDeletedVideo || 0),
-      countReportsForReportee: (countReportsForReporteeVideo || 0) + (countReportsForReporteeDeletedVideo || 0),
+
+      countReportsForReporter: (countReportsForReporter || 0),
+      countReportsForReportee: (countReportsForReportee || 0),
 
       // FIXME: deprecated in 2.3, remove this
       startAt: null,
-      endAt: null
+      endAt: null,
+      count: countReportsForVideo || 0,
+      nth: nthReportForVideo || 0
     }
   }
 
@@ -526,6 +465,42 @@ export class AbuseModel extends Model<AbuseModel> {
     }
   }
 
+  private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
+    const { query, replacements } = buildAbuseListQuery(parameters, 'count')
+    const options = {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements
+    }
+
+    const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
+    if (total === null) return 0
+
+    return parseInt(total, 10)
+  }
+
+  private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
+    const { query, replacements } = buildAbuseListQuery(parameters, 'id')
+    const options = {
+      type: QueryTypes.SELECT as QueryTypes.SELECT,
+      replacements
+    }
+
+    const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
+    const ids = rows.map(r => r.id)
+
+    if (ids.length === 0) return []
+
+    return AbuseModel.scope(ScopeNames.FOR_API)
+                     .findAll({
+                       order: getSort(parameters.sort),
+                       where: {
+                         id: {
+                           [Op.in]: ids
+                         }
+                       }
+                     })
+  }
+
   private static getStateLabel (id: number) {
     return ABUSE_STATES[id] || 'Unknown'
   }