はじまりの大地
このコミットが含まれているのは:
@@ -0,0 +1,113 @@
|
||||
import { FindOptions } from 'sequelize'
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses.js'
|
||||
import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models/index.js'
|
||||
import { AbuseMessage } from '@peertube/peertube-models'
|
||||
import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account.js'
|
||||
import { SequelizeModel, getSort, throwIfNotValid } from '../shared/index.js'
|
||||
import { AbuseModel } from './abuse.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'abuseMessage',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'abuseId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'accountId' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AbuseMessageModel extends SequelizeModel<AbuseMessageModel> {
|
||||
|
||||
@AllowNull(false)
|
||||
@Is('AbuseMessage', value => throwIfNotValid(value, isAbuseMessageValid, 'message'))
|
||||
@Column(DataType.TEXT)
|
||||
message: string
|
||||
|
||||
@AllowNull(false)
|
||||
@Column
|
||||
byModerator: boolean
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date
|
||||
|
||||
@ForeignKey(() => AccountModel)
|
||||
@Column
|
||||
accountId: number
|
||||
|
||||
@BelongsTo(() => AccountModel, {
|
||||
foreignKey: {
|
||||
name: 'accountId',
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'set null'
|
||||
})
|
||||
Account: Awaited<AccountModel>
|
||||
|
||||
@ForeignKey(() => AbuseModel)
|
||||
@Column
|
||||
abuseId: number
|
||||
|
||||
@BelongsTo(() => AbuseModel, {
|
||||
foreignKey: {
|
||||
name: 'abuseId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
Abuse: Awaited<AbuseModel>
|
||||
|
||||
static listForApi (abuseId: number) {
|
||||
const getQuery = (forCount: boolean) => {
|
||||
const query: FindOptions = {
|
||||
where: { abuseId },
|
||||
order: getSort('createdAt')
|
||||
}
|
||||
|
||||
if (forCount !== true) {
|
||||
query.include = [
|
||||
{
|
||||
model: AccountModel.scope(AccountScopeNames.SUMMARY),
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
AbuseMessageModel.count(getQuery(true)),
|
||||
AbuseMessageModel.findAll(getQuery(false))
|
||||
]).then(([ total, data ]) => ({ total, data }))
|
||||
}
|
||||
|
||||
static loadByIdAndAbuseId (messageId: number, abuseId: number): Promise<MAbuseMessage> {
|
||||
return AbuseMessageModel.findOne({
|
||||
where: {
|
||||
id: messageId,
|
||||
abuseId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
toFormattedJSON (this: MAbuseMessageFormattable): AbuseMessage {
|
||||
const account = this.Account
|
||||
? this.Account.toFormattedSummaryJSON()
|
||||
: null
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
createdAt: this.createdAt,
|
||||
|
||||
byModerator: this.byModerator,
|
||||
message: this.message,
|
||||
|
||||
account
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
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" IN (SELECT "videoId" FROM "videoAbuse" WHERE "abuseId" = "AbuseModel"."id") ' +
|
||||
'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, "abuseId", row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
|
||||
'FROM "videoAbuse" ' +
|
||||
') t ' +
|
||||
'WHERE t."abuseId" = "AbuseModel"."id" ' +
|
||||
')'
|
||||
),
|
||||
'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<AbuseModel> {
|
||||
|
||||
@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<AccountModel>
|
||||
|
||||
@ForeignKey(() => AccountModel)
|
||||
@Column
|
||||
flaggedAccountId: number
|
||||
|
||||
@BelongsTo(() => AccountModel, {
|
||||
foreignKey: {
|
||||
name: 'flaggedAccountId',
|
||||
allowNull: true
|
||||
},
|
||||
as: 'FlaggedAccount',
|
||||
onDelete: 'set null'
|
||||
})
|
||||
FlaggedAccount: Awaited<AccountModel>
|
||||
|
||||
@HasOne(() => VideoCommentAbuseModel, {
|
||||
foreignKey: {
|
||||
name: 'abuseId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
VideoCommentAbuse: Awaited<VideoCommentAbuseModel>
|
||||
|
||||
@HasOne(() => VideoAbuseModel, {
|
||||
foreignKey: {
|
||||
name: 'abuseId',
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
VideoAbuse: Awaited<VideoAbuseModel>
|
||||
|
||||
static loadByIdWithReporter (id: number): Promise<MAbuseReporter> {
|
||||
const query = {
|
||||
where: {
|
||||
id
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: AccountModel,
|
||||
as: 'ReporterAccount'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return AbuseModel.findOne(query)
|
||||
}
|
||||
|
||||
static loadFull (id: number): Promise<MAbuseFull> {
|
||||
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<any>(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
|
||||
}
|
||||
},
|
||||
limit: parameters.count
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { forceNumber } from '@peertube/peertube-core-utils'
|
||||
import { AbuseFilter, AbuseStateType, AbuseVideoIs } from '@peertube/peertube-models'
|
||||
import { exists } from '@server/helpers/custom-validators/misc.js'
|
||||
import { buildBlockedAccountSQL, buildSortDirectionAndField } from '../../shared/index.js'
|
||||
|
||||
export type BuildAbusesQueryOptions = {
|
||||
start: number
|
||||
count: number
|
||||
sort: string
|
||||
|
||||
// search
|
||||
search?: string
|
||||
searchReporter?: string
|
||||
searchReportee?: string
|
||||
|
||||
// video related
|
||||
searchVideo?: string
|
||||
searchVideoChannel?: string
|
||||
videoIs?: AbuseVideoIs
|
||||
|
||||
// filters
|
||||
id?: number
|
||||
predefinedReasonId?: number
|
||||
filter?: AbuseFilter
|
||||
|
||||
state?: AbuseStateType
|
||||
|
||||
// accountIds
|
||||
serverAccountId?: number
|
||||
userAccountId?: number
|
||||
|
||||
reporterAccountId?: number
|
||||
}
|
||||
|
||||
function buildAbuseListQuery (options: BuildAbusesQueryOptions, type: 'count' | 'id') {
|
||||
const whereAnd: string[] = []
|
||||
const replacements: any = {}
|
||||
|
||||
const joins = [
|
||||
'LEFT JOIN "videoAbuse" ON "videoAbuse"."abuseId" = "abuse"."id"',
|
||||
'LEFT JOIN "video" ON "videoAbuse"."videoId" = "video"."id"',
|
||||
'LEFT JOIN "videoBlacklist" ON "videoBlacklist"."videoId" = "video"."id"',
|
||||
'LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id"',
|
||||
'LEFT JOIN "account" "reporterAccount" ON "reporterAccount"."id" = "abuse"."reporterAccountId"',
|
||||
'LEFT JOIN "account" "flaggedAccount" ON "flaggedAccount"."id" = "abuse"."flaggedAccountId"',
|
||||
'LEFT JOIN "commentAbuse" ON "commentAbuse"."abuseId" = "abuse"."id"',
|
||||
'LEFT JOIN "videoComment" ON "commentAbuse"."videoCommentId" = "videoComment"."id"'
|
||||
]
|
||||
|
||||
if (options.serverAccountId || options.userAccountId) {
|
||||
whereAnd.push(
|
||||
'"abuse"."reporterAccountId" IS NULL OR ' +
|
||||
'"abuse"."reporterAccountId" NOT IN (' + buildBlockedAccountSQL([ options.serverAccountId, options.userAccountId ]) + ')'
|
||||
)
|
||||
}
|
||||
|
||||
if (options.reporterAccountId) {
|
||||
whereAnd.push('"abuse"."reporterAccountId" = :reporterAccountId')
|
||||
replacements.reporterAccountId = options.reporterAccountId
|
||||
}
|
||||
|
||||
if (options.search) {
|
||||
const searchWhereOr = [
|
||||
'"video"."name" ILIKE :search',
|
||||
'"videoChannel"."name" ILIKE :search',
|
||||
`"videoAbuse"."deletedVideo"->>'name' ILIKE :search`,
|
||||
`"videoAbuse"."deletedVideo"->'channel'->>'displayName' ILIKE :search`,
|
||||
'"reporterAccount"."name" ILIKE :search',
|
||||
'"flaggedAccount"."name" ILIKE :search'
|
||||
]
|
||||
|
||||
replacements.search = `%${options.search}%`
|
||||
whereAnd.push('(' + searchWhereOr.join(' OR ') + ')')
|
||||
}
|
||||
|
||||
if (options.searchVideo) {
|
||||
whereAnd.push('"video"."name" ILIKE :searchVideo')
|
||||
replacements.searchVideo = `%${options.searchVideo}%`
|
||||
}
|
||||
|
||||
if (options.searchVideoChannel) {
|
||||
whereAnd.push('"videoChannel"."name" ILIKE :searchVideoChannel')
|
||||
replacements.searchVideoChannel = `%${options.searchVideoChannel}%`
|
||||
}
|
||||
|
||||
if (options.id) {
|
||||
whereAnd.push('"abuse"."id" = :id')
|
||||
replacements.id = options.id
|
||||
}
|
||||
|
||||
if (options.state) {
|
||||
whereAnd.push('"abuse"."state" = :state')
|
||||
replacements.state = options.state
|
||||
}
|
||||
|
||||
if (options.videoIs === 'deleted') {
|
||||
whereAnd.push('"videoAbuse"."deletedVideo" IS NOT NULL')
|
||||
} else if (options.videoIs === 'blacklisted') {
|
||||
whereAnd.push('"videoBlacklist"."id" IS NOT NULL')
|
||||
}
|
||||
|
||||
if (options.predefinedReasonId) {
|
||||
whereAnd.push(':predefinedReasonId = ANY("abuse"."predefinedReasons")')
|
||||
replacements.predefinedReasonId = options.predefinedReasonId
|
||||
}
|
||||
|
||||
if (options.filter === 'video') {
|
||||
whereAnd.push('"videoAbuse"."id" IS NOT NULL')
|
||||
} else if (options.filter === 'comment') {
|
||||
whereAnd.push('"commentAbuse"."id" IS NOT NULL')
|
||||
} else if (options.filter === 'account') {
|
||||
whereAnd.push('"videoAbuse"."id" IS NULL AND "commentAbuse"."id" IS NULL')
|
||||
}
|
||||
|
||||
if (options.searchReporter) {
|
||||
whereAnd.push('"reporterAccount"."name" ILIKE :searchReporter')
|
||||
replacements.searchReporter = `%${options.searchReporter}%`
|
||||
}
|
||||
|
||||
if (options.searchReportee) {
|
||||
whereAnd.push('"flaggedAccount"."name" ILIKE :searchReportee')
|
||||
replacements.searchReportee = `%${options.searchReportee}%`
|
||||
}
|
||||
|
||||
const prefix = type === 'count'
|
||||
? 'SELECT COUNT("abuse"."id") AS "total"'
|
||||
: 'SELECT "abuse"."id" '
|
||||
|
||||
let suffix = ''
|
||||
if (type !== 'count') {
|
||||
|
||||
if (options.sort) {
|
||||
const order = buildAbuseOrder(options.sort)
|
||||
suffix += `${order} `
|
||||
}
|
||||
|
||||
if (exists(options.count)) {
|
||||
const count = forceNumber(options.count)
|
||||
suffix += `LIMIT ${count} `
|
||||
}
|
||||
|
||||
if (exists(options.start)) {
|
||||
const start = forceNumber(options.start)
|
||||
suffix += `OFFSET ${start} `
|
||||
}
|
||||
}
|
||||
|
||||
const where = whereAnd.length !== 0
|
||||
? `WHERE ${whereAnd.join(' AND ')}`
|
||||
: ''
|
||||
|
||||
return {
|
||||
query: `${prefix} FROM "abuse" ${joins.join(' ')} ${where} ${suffix}`,
|
||||
replacements
|
||||
}
|
||||
}
|
||||
|
||||
function buildAbuseOrder (value: string) {
|
||||
const { direction, field } = buildSortDirectionAndField(value)
|
||||
|
||||
return `ORDER BY "abuse"."${field}" ${direction}`
|
||||
}
|
||||
|
||||
export {
|
||||
buildAbuseListQuery
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type VideoDetails } from '@peertube/peertube-models'
|
||||
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { VideoModel } from '../video/video.js'
|
||||
import { AbuseModel } from './abuse.js'
|
||||
import { SequelizeModel } from '../shared/index.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'videoAbuse',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'abuseId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'videoId' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class VideoAbuseModel extends SequelizeModel<VideoAbuseModel> {
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Column
|
||||
startAt: number
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Column
|
||||
endAt: number
|
||||
|
||||
@AllowNull(true)
|
||||
@Default(null)
|
||||
@Column(DataType.JSONB)
|
||||
deletedVideo: VideoDetails
|
||||
|
||||
@ForeignKey(() => AbuseModel)
|
||||
@Column
|
||||
abuseId: number
|
||||
|
||||
@BelongsTo(() => AbuseModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
Abuse: Awaited<AbuseModel>
|
||||
|
||||
@ForeignKey(() => VideoModel)
|
||||
@Column
|
||||
videoId: number
|
||||
|
||||
@BelongsTo(() => VideoModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'set null'
|
||||
})
|
||||
Video: Awaited<VideoModel>
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BelongsTo, Column, CreatedAt, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
|
||||
import { VideoCommentModel } from '../video/video-comment.js'
|
||||
import { AbuseModel } from './abuse.js'
|
||||
import { SequelizeModel } from '../shared/index.js'
|
||||
|
||||
@Table({
|
||||
tableName: 'commentAbuse',
|
||||
indexes: [
|
||||
{
|
||||
fields: [ 'abuseId' ]
|
||||
},
|
||||
{
|
||||
fields: [ 'videoCommentId' ]
|
||||
}
|
||||
]
|
||||
})
|
||||
export class VideoCommentAbuseModel extends SequelizeModel<VideoCommentAbuseModel> {
|
||||
|
||||
@CreatedAt
|
||||
createdAt: Date
|
||||
|
||||
@UpdatedAt
|
||||
updatedAt: Date
|
||||
|
||||
@ForeignKey(() => AbuseModel)
|
||||
@Column
|
||||
abuseId: number
|
||||
|
||||
@BelongsTo(() => AbuseModel, {
|
||||
foreignKey: {
|
||||
allowNull: false
|
||||
},
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
Abuse: Awaited<AbuseModel>
|
||||
|
||||
@ForeignKey(() => VideoCommentModel)
|
||||
@Column
|
||||
videoCommentId: number
|
||||
|
||||
@BelongsTo(() => VideoCommentModel, {
|
||||
foreignKey: {
|
||||
allowNull: true
|
||||
},
|
||||
onDelete: 'set null'
|
||||
})
|
||||
VideoComment: Awaited<VideoCommentModel>
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする