import { abusePredefinedReasonsMap, forceNumber } from '@peertube/peertube-core-utils' import { AbuseFilter, AbuseObject, AbusePredefinedReasonsString, AbusePredefinedReasonsType, AbuseVideoIs, AdminAbuse, AdminVideoAbuse, AdminVideoCommentAbuse, UserAbuse, UserVideoAbuse, type AbuseStateType, AbuseState } from '@peertube/peertube-models' import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses.js' import invert from 'lodash-es/invert.js' import { Op, QueryTypes, literal } from 'sequelize' import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, HasOne, Is, Scopes, Table, UpdatedAt } from 'sequelize-typescript' import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants.js' import { MAbuseAP, MAbuseAdminFormattable, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models/index.js' import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account.js' import { SequelizeModel, getSort, parseAggregateResult, throwIfNotValid } from '../shared/index.js' import { ThumbnailModel } from '../video/thumbnail.js' import { VideoBlacklistModel } from '../video/video-blacklist.js' import { SummaryOptions as ChannelSummaryOptions, VideoChannelModel, ScopeNames as VideoChannelScopeNames } from '../video/video-channel.js' import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment.js' import { VideoModel, ScopeNames as VideoScopeNames } from '../video/video.js' import { BuildAbusesQueryOptions, buildAbuseListQuery } from './sql/abuse-query-builder.js' import { VideoAbuseModel } from './video-abuse.js' import { VideoCommentAbuseModel } from './video-comment-abuse.js' export enum ScopeNames { FOR_API = 'FOR_API' } @Scopes(() => ({ [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( '(' + 'SELECT count(*) ' + 'FROM "videoAbuse" ' + 'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' + ')' ), 'countReportsForVideo' ], [ // we don't care about this count for deleted videos, so there are not included literal( '(' + 'SELECT t.nth ' + 'FROM ( ' + 'SELECT id, ' + 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' + 'FROM "videoAbuse" ' + ') t ' + 'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' + ')' ), 'nthReportForVideo' ], [ literal( '(' + 'SELECT count("abuse"."id") ' + 'FROM "abuse" ' + 'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' + ')' ), 'countReportsForReporter' ], [ literal( '(' + 'SELECT count("abuse"."id") ' + 'FROM "abuse" ' + 'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' + ')' ), 'countReportsForReportee' ] ] }, include: [ { model: AccountModel.scope({ method: [ AccountScopeNames.SUMMARY, { actorRequired: false } as AccountSummaryOptions ] }), as: 'ReporterAccount' }, { model: AccountModel.scope({ method: [ AccountScopeNames.SUMMARY, { actorRequired: false } as AccountSummaryOptions ] }), as: 'FlaggedAccount' }, { model: VideoCommentAbuseModel.unscoped(), include: [ { 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', 'type' ], model: ThumbnailModel }, { model: VideoChannelModel.scope({ method: [ VideoChannelScopeNames.SUMMARY, { withAccount: false, actorRequired: false } as ChannelSummaryOptions ] }), required: false }, { attributes: [ 'id', 'reason', 'unfederated' ], required: false, model: VideoBlacklistModel } ] } ] } ] } } })) @Table({ tableName: 'abuse', indexes: [ { fields: [ 'reporterAccountId' ] }, { fields: [ 'flaggedAccountId' ] } ] }) export class AbuseModel extends SequelizeModel { @AllowNull(false) @Default(null) @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason')) @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max)) reason: string @AllowNull(false) @Default(null) @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state')) @Column state: AbuseStateType @AllowNull(true) @Default(null) @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true)) @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max)) moderationComment: string @AllowNull(true) @Default(null) @Column(DataType.ARRAY(DataType.INTEGER)) predefinedReasons: AbusePredefinedReasonsType[] @AllowNull(true) @Column processedAt: Date @CreatedAt createdAt: Date @UpdatedAt updatedAt: Date @ForeignKey(() => AccountModel) @Column reporterAccountId: number @BelongsTo(() => AccountModel, { foreignKey: { name: 'reporterAccountId', allowNull: true }, as: 'ReporterAccount', onDelete: 'set null' }) ReporterAccount: Awaited @ForeignKey(() => AccountModel) @Column flaggedAccountId: number @BelongsTo(() => AccountModel, { foreignKey: { name: 'flaggedAccountId', allowNull: true }, as: 'FlaggedAccount', onDelete: 'set null' }) FlaggedAccount: Awaited @HasOne(() => VideoCommentAbuseModel, { foreignKey: { name: 'abuseId', allowNull: false }, onDelete: 'cascade' }) VideoCommentAbuse: Awaited @HasOne(() => VideoAbuseModel, { foreignKey: { name: 'abuseId', allowNull: false }, onDelete: 'cascade' }) VideoAbuse: Awaited static loadByIdWithReporter (id: number): Promise { const query = { where: { id }, include: [ { model: AccountModel, as: 'ReporterAccount' } ] } return AbuseModel.findOne(query) } 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 async listForAdminApi (parameters: { start: number count: number sort: string filter?: AbuseFilter serverAccountId: number user?: MUserAccountId id?: number predefinedReason?: AbusePredefinedReasonsString state?: AbuseStateType videoIs?: AbuseVideoIs search?: string searchReporter?: string searchReportee?: string searchVideo?: string searchVideoChannel?: string }) { const { start, count, sort, search, user, serverAccountId, state, videoIs, predefinedReason, searchReportee, searchVideo, filter, searchVideoChannel, searchReporter, id } = parameters const userAccountId = user ? user.Account.id : undefined const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined const queryOptions: BuildAbusesQueryOptions = { start, count, sort, id, filter, predefinedReasonId, search, state, videoIs, searchReportee, searchVideo, searchVideoChannel, searchReporter, serverAccountId, userAccountId } const [ total, data ] = await Promise.all([ AbuseModel.internalCountForApi(queryOptions), AbuseModel.internalListForApi(queryOptions) ]) return { total, data } } static async listForUserApi (parameters: { user: MUserAccountId start: number count: number sort: string id?: number search?: string state?: AbuseStateType }) { const { start, count, sort, search, user, state, id } = parameters const queryOptions: BuildAbusesQueryOptions = { start, count, sort, id, search, state, reporterAccountId: user.Account.id } const [ total, data ] = await Promise.all([ AbuseModel.internalCountForApi(queryOptions), AbuseModel.internalListForApi(queryOptions) ]) return { total, data } } // --------------------------------------------------------------------------- static getStats () { const query = `SELECT ` + `AVG(EXTRACT(EPOCH FROM ("processedAt" - "createdAt") * 1000)) ` + `FILTER (WHERE "processedAt" IS NOT NULL AND "createdAt" > CURRENT_DATE - INTERVAL '3 months')` + `AS "avgResponseTime", ` + // "processedAt" has been introduced in PeerTube 6.1 so also check the abuse state to check processed abuses `COUNT(*) FILTER (WHERE "processedAt" IS NOT NULL OR "state" != ${AbuseState.PENDING}) AS "processedAbuses", ` + `COUNT(*) AS "totalAbuses" ` + `FROM "abuse"` return AbuseModel.sequelize.query(query, { type: QueryTypes.SELECT, raw: true }).then(([ row ]) => { return { totalAbuses: parseAggregateResult(row.totalAbuses), totalAbusesProcessed: parseAggregateResult(row.processedAbuses), averageAbuseResponseTimeMs: row?.avgResponseTime ? forceNumber(row.avgResponseTime) : null } }) } // --------------------------------------------------------------------------- buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) { // Associated video comment could have been destroyed if the video has been deleted if (!this.VideoCommentAbuse?.VideoComment) return null const entity = this.VideoCommentAbuse.VideoComment return { id: entity.id, threadId: entity.getThreadId(), text: entity.text ?? '', deleted: entity.isDeleted(), video: { id: entity.Video.id, name: entity.Video.name, uuid: entity.Video.uuid } } } buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse { if (!this.VideoAbuse) return null const abuseModel = this.VideoAbuse const entity = abuseModel.Video || abuseModel.deletedVideo return { 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 } } buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse { const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) return { id: this.id, reason: this.reason, predefinedReasons, flaggedAccount: this.FlaggedAccount ? this.FlaggedAccount.toFormattedJSON() : null, state: { id: this.state, label: AbuseModel.getStateLabel(this.state) }, 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, moderationComment: this.moderationComment, reporterAccount: this.ReporterAccount ? this.ReporterAccount.toFormattedJSON() : null, countReportsForReporter: (countReportsForReporter || 0), countReportsForReportee: (countReportsForReportee || 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 { const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons) const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url const startAt = this.VideoAbuse?.startAt const endAt = this.VideoAbuse?.endAt return { type: 'Flag' as 'Flag', content: this.reason, mediaType: 'text/markdown', object, tag: predefinedReasons.map(r => ({ type: 'Hashtag' as 'Hashtag', name: r })), startAt, endAt } } 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: AbusePredefinedReasonsType[]): AbusePredefinedReasonsString[] { const invertedPredefinedReasons = invert(abusePredefinedReasonsMap) return (predefinedReasons || []) .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString) .filter(v => !!v) } }