X-Git-Url: https://git.immae.eu/?a=blobdiff_plain;f=server%2Fmodels%2Fabuse%2Fabuse.ts;h=4c6a96a86d3f882c0afd06ca5035aa2118d8f3d9;hb=77239b425a8e00822a53c9907415832a473c3eb6;hp=9c17c4d5183a6fe21b049a656e9b5d2f3ea104e1;hpb=4f32032fed8587ea97d45e235b167e8958efd81f;p=github%2FChocobozzz%2FPeerTube.git diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts index 9c17c4d51..4c6a96a86 100644 --- a/server/models/abuse/abuse.ts +++ b/server/models/abuse/abuse.ts @@ -1,6 +1,5 @@ -import * as Bluebird from 'bluebird' import { invert } from 'lodash' -import { literal, Op, WhereOptions } from 'sequelize' +import { literal, Op, QueryTypes } from 'sequelize' import { AllowNull, BelongsTo, @@ -17,27 +16,31 @@ import { UpdatedAt } from 'sequelize-typescript' import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses' +import { abusePredefinedReasonsMap } from '@shared/core-utils' import { - Abuse, AbuseFilter, AbuseObject, AbusePredefinedReasons, - abusePredefinedReasonsMap, AbusePredefinedReasonsString, AbuseState, AbuseVideoIs, - VideoAbuse, - VideoCommentAbuse + AdminAbuse, + AdminVideoAbuse, + AdminVideoCommentAbuse, + UserAbuse, + UserVideoAbuse } from '@shared/models' +import { AttributesOnly } from '@shared/typescript-utils' import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants' -import { MAbuse, MAbuseAP, MAbuseFormattable, MUserAccountId } from '../../types/models' +import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models' import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' -import { buildBlockedAccountSQL, getSort, searchAttribute, throwIfNotValid } from '../utils' +import { getSort, throwIfNotValid } from '../utils' import { ThumbnailModel } from '../video/thumbnail' -import { VideoModel } from '../video/video' +import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video' import { VideoBlacklistModel } from '../video/video-blacklist' import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel' -import { VideoCommentModel } from '../video/video-comment' +import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment' +import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder' import { VideoAbuseModel } from './video-abuse' import { VideoCommentAbuseModel } from './video-comment-abuse' @@ -46,103 +49,20 @@ 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 whereAnd: WhereOptions[] = [] - - whereAnd.push({ - reporterAccountId: { - [Op.notIn]: literal('(' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')') - } - }) - - if (options.search) { - const escapedSearch = AbuseModel.sequelize.escape('%' + options.search + '%') - - whereAnd.push({ - [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) whereAnd.push({ id: options.id }) - if (options.state) whereAnd.push({ state: options.state }) - - if (options.videoIs === 'deleted') { - whereAnd.push({ - '$VideoAbuse.deletedVideo$': { - [Op.not]: null - } - }) - } - - if (options.predefinedReasonId) { - whereAnd.push({ - predefinedReasons: { - [Op.contains]: [ options.predefinedReasonId ] - } - }) - } - - if (options.filter === 'account') { - whereAnd.push({ - videoId: null, - commentId: null - }) - } - - const onlyBlacklisted = options.videoIs === 'blacklisted' - const videoRequired = !!(onlyBlacklisted || options.searchVideo || options.searchVideoChannel) - + [ScopeNames.FOR_API]: () => { return { attributes: { include: [ + [ + literal( + '(' + + 'SELECT count(*) ' + + 'FROM "abuseMessage" ' + + 'WHERE "abuseId" = "AbuseModel"."id"' + + ')' + ), + 'countMessages' + ], [ // we don't care about this count for deleted videos, so there are not included literal( @@ -193,10 +113,13 @@ export enum ScopeNames { }, include: [ { - model: AccountModel.scope(AccountScopeNames.SUMMARY), - as: 'ReporterAccount', - required: !!options.searchReporter, - where: searchAttribute(options.searchReporter, 'name') + model: AccountModel.scope({ + method: [ + AccountScopeNames.SUMMARY, + { actorRequired: false } as AccountSummaryOptions + ] + }), + as: 'ReporterAccount' }, { model: AccountModel.scope({ @@ -205,17 +128,13 @@ export enum ScopeNames { { actorRequired: false } as AccountSummaryOptions ] }), - as: 'FlaggedAccount', - required: !!options.searchReportee, - where: searchAttribute(options.searchReportee, 'name') + as: 'FlaggedAccount' }, { model: VideoCommentAbuseModel.unscoped(), - required: options.filter === 'comment', include: [ { model: VideoCommentModel.unscoped(), - required: false, include: [ { model: VideoModel.unscoped(), @@ -227,16 +146,13 @@ export enum ScopeNames { }, { model: VideoAbuseModel.unscoped(), - required: options.filter === 'video' || !!options.videoIs || videoRequired, include: [ { attributes: [ 'id', 'uuid', 'name', 'nsfw' ], model: VideoModel.unscoped(), - required: videoRequired, - where: searchAttribute(options.searchVideo, 'name'), include: [ { - attributes: [ 'filename', 'fileUrl' ], + attributes: [ 'filename', 'fileUrl', 'type' ], model: ThumbnailModel }, { @@ -246,23 +162,18 @@ export enum ScopeNames { { withAccount: false, actorRequired: false } as ChannelSummaryOptions ] }), - - where: searchAttribute(options.searchVideoChannel, 'name'), - required: !!options.searchVideoChannel + required: false }, { attributes: [ 'id', 'reason', 'unfederated' ], - model: VideoBlacklistModel, - required: onlyBlacklisted + required: false, + model: VideoBlacklistModel } ] } ] } - ], - where: { - [Op.and]: whereAnd - } + ] } } })) @@ -277,7 +188,7 @@ export enum ScopeNames { } ] }) -export class AbuseModel extends Model { +export class AbuseModel extends Model>> { @AllowNull(false) @Default(null) @@ -354,39 +265,69 @@ export class AbuseModel extends Model { }) VideoAbuse: VideoAbuseModel - // FIXME: deprecated in 2.3. Remove these validators - static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird { - const videoWhere: WhereOptions = {} - - if (videoId) videoWhere.videoId = videoId - if (uuid) videoWhere.deletedVideo = { uuid } - + static loadByIdWithReporter (id: number): Promise { const query = { + where: { + id + }, include: [ { - model: VideoAbuseModel, - required: true, - where: videoWhere + model: AccountModel, + as: 'ReporterAccount' } - ], - where: { - id - } + ] } + return AbuseModel.findOne(query) } - static loadById (id: number): Bluebird { + static loadFull (id: number): Promise { const query = { where: { id - } + }, + include: [ + { + model: AccountModel.scope(AccountScopeNames.SUMMARY), + required: false, + as: 'ReporterAccount' + }, + { + model: AccountModel.scope(AccountScopeNames.SUMMARY), + as: 'FlaggedAccount' + }, + { + model: VideoAbuseModel, + required: false, + include: [ + { + model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ]) + } + ] + }, + { + model: VideoCommentAbuseModel, + required: false, + include: [ + { + model: VideoCommentModel.scope([ + CommentScopeNames.WITH_ACCOUNT + ]), + include: [ + { + model: VideoModel + } + ] + } + ] + } + ] } return AbuseModel.findOne(query) } - static listForApi (parameters: { + static async listForAdminApi (parameters: { start: number count: number sort: string @@ -428,15 +369,10 @@ export class AbuseModel extends Model { 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, @@ -451,79 +387,106 @@ export class AbuseModel extends Model { 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) + static async listForUserApi (parameters: { + user: MUserAccountId - const countReportsForVideo = this.get('countReportsForVideo') as number - const nthReportForVideo = this.get('nthReportForVideo') as number + start: number + count: number + sort: string - const countReportsForReporter = this.get('countReportsForReporter') as number - const countReportsForReportee = this.get('countReportsForReportee') as number + id?: number + search?: string + state?: AbuseState + }) { + const { + start, + count, + sort, + search, + user, + state, + id + } = parameters - let video: VideoAbuse - let comment: VideoCommentAbuse + const queryOptions: BuildAbusesQueryOptions = { + start, + count, + sort, + id, + search, + state, + reporterAccountId: user.Account.id + } - if (this.VideoAbuse) { - const abuseModel = this.VideoAbuse - const entity = abuseModel.Video || abuseModel.deletedVideo + const [ total, data ] = await Promise.all([ + AbuseModel.internalCountForApi(queryOptions), + AbuseModel.internalListForApi(queryOptions) + ]) - video = { - id: entity.id, - uuid: entity.uuid, - name: entity.name, - nsfw: entity.nsfw, + return { total, data } + } - startAt: abuseModel.startAt, - endAt: abuseModel.endAt, + buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) { + // Associated video comment could have been destroyed if the video has been deleted + if (!this.VideoCommentAbuse?.VideoComment) return null - deleted: !abuseModel.Video, - blacklisted: abuseModel.Video?.isBlacklisted() || false, - thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(), + const entity = this.VideoCommentAbuse.VideoComment - channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel, + return { + id: entity.id, + threadId: entity.getThreadId(), - countReports: countReportsForVideo, - nthReport: nthReportForVideo + text: entity.text ?? '', + + deleted: entity.isDeleted(), + + video: { + id: entity.Video.id, + name: entity.Video.name, + uuid: entity.Video.uuid } } + } - if (this.VideoCommentAbuse) { - const abuseModel = this.VideoCommentAbuse - const entity = abuseModel.VideoComment || abuseModel.deletedComment + buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse { + if (!this.VideoAbuse) return null - comment = { - id: entity.id, - text: entity.text, + const abuseModel = this.VideoAbuse + const entity = abuseModel.Video || abuseModel.deletedVideo - deleted: !abuseModel.VideoComment, + return { + id: entity.id, + uuid: entity.uuid, + name: entity.name, + nsfw: entity.nsfw, - video: { - id: entity.Video.id, - name: entity.Video.name, - uuid: entity.Video.uuid - } - } + 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 } + } + + buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse { + const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) return { id: this.id, reason: this.reason, predefinedReasons, - reporterAccount: this.ReporterAccount - ? this.ReporterAccount.toFormattedJSON() - : null, - flaggedAccount: this.FlaggedAccount ? this.FlaggedAccount.toFormattedJSON() : null, @@ -533,23 +496,60 @@ export class AbuseModel extends Model { label: AbuseModel.getStateLabel(this.state) }, - moderationComment: this.moderationComment, + countMessages, + + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } + + toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse { + const countReportsForVideo = this.get('countReportsForVideo') as number + const nthReportForVideo = this.get('nthReportForVideo') as number + const countReportsForReporter = this.get('countReportsForReporter') as number + const countReportsForReportee = this.get('countReportsForReportee') as number + + const countMessages = this.get('countMessages') as number + + const baseVideo = this.buildBaseVideoAbuse() + const video: AdminVideoAbuse = baseVideo + ? Object.assign(baseVideo, { + countReports: countReportsForVideo, + nthReport: nthReportForVideo + }) + : null + + const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse() + + const abuse = this.buildBaseAbuse(countMessages || 0) + + return Object.assign(abuse, { video, comment, - createdAt: this.createdAt, - updatedAt: this.updatedAt, + moderationComment: this.moderationComment, + + reporterAccount: this.ReporterAccount + ? this.ReporterAccount.toFormattedJSON() + : null, countReportsForReporter: (countReportsForReporter || 0), - countReportsForReportee: (countReportsForReportee || 0), + countReportsForReportee: (countReportsForReportee || 0) + }) + } - // FIXME: deprecated in 2.3, remove this - startAt: null, - endAt: null, - count: countReportsForVideo || 0, - nth: nthReportForVideo || 0 - } + toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse { + const countMessages = this.get('countMessages') as number + + const video = this.buildBaseVideoAbuse() + const comment = this.buildBaseVideoCommentAbuse() + const abuse = this.buildBaseAbuse(countMessages || 0) + + return Object.assign(abuse, { + video, + comment + }) } toActivityPubObject (this: MAbuseAP): AbuseObject { @@ -563,6 +563,7 @@ export class AbuseModel extends Model { return { type: 'Flag' as 'Flag', content: this.reason, + mediaType: 'text/markdown', object, tag: predefinedReasons.map(r => ({ type: 'Hashtag' as 'Hashtag', @@ -573,13 +574,51 @@ export class AbuseModel extends Model { } } + 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' } private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] { + const invertedPredefinedReasons = invert(abusePredefinedReasonsMap) + return (predefinedReasons || []) - .filter(r => r in AbusePredefinedReasons) - .map(r => invert(abusePredefinedReasonsMap)[r] as AbusePredefinedReasonsString) + .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString) + .filter(v => !!v) } }