]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/abuse/abuse.ts
Move typescript utils in its own directory
[github/Chocobozzz/PeerTube.git] / server / models / abuse / abuse.ts
CommitLineData
d95d1559 1import { invert } from 'lodash'
7a4ea932 2import { literal, Op, QueryTypes } from 'sequelize'
86521a67 3import {
feb34f6b
C
4 AllowNull,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 Default,
10 ForeignKey,
d95d1559 11 HasOne,
feb34f6b
C
12 Is,
13 Model,
14 Scopes,
15 Table,
16 UpdatedAt
86521a67 17} from 'sequelize-typescript'
d95d1559 18import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
6b5f72be 19import { abusePredefinedReasonsMap } from '@shared/core-utils'
268eebed 20import {
57f6896f 21 AbuseFilter,
d95d1559
C
22 AbuseObject,
23 AbusePredefinedReasons,
d95d1559
C
24 AbusePredefinedReasonsString,
25 AbuseState,
26 AbuseVideoIs,
edbc9325 27 AdminAbuse,
94148c90 28 AdminVideoAbuse,
edbc9325
C
29 AdminVideoCommentAbuse,
30 UserAbuse,
31 UserVideoAbuse
d95d1559 32} from '@shared/models'
6b5f72be 33import { AttributesOnly } from '@shared/typescript-utils'
57f6896f 34import { ABUSE_STATES, CONSTRAINTS_FIELDS } from '../../initializers/constants'
594d3e48 35import { MAbuseAdminFormattable, MAbuseAP, MAbuseFull, MAbuseReporter, MAbuseUserFormattable, MUserAccountId } from '../../types/models'
4f32032f 36import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
811cef14 37import { getSort, throwIfNotValid } from '../utils'
d95d1559 38import { ThumbnailModel } from '../video/thumbnail'
594d3e48 39import { ScopeNames as VideoScopeNames, VideoModel } from '../video/video'
d95d1559 40import { VideoBlacklistModel } from '../video/video-blacklist'
4f32032f 41import { ScopeNames as VideoChannelScopeNames, SummaryOptions as ChannelSummaryOptions, VideoChannelModel } from '../video/video-channel'
594d3e48 42import { ScopeNames as CommentScopeNames, VideoCommentModel } from '../video/video-comment'
811cef14 43import { buildAbuseListQuery, BuildAbusesQueryOptions } from './abuse-query-builder'
d95d1559
C
44import { VideoAbuseModel } from './video-abuse'
45import { VideoCommentAbuseModel } from './video-comment-abuse'
3fd3ab2d 46
844db39e
RK
47export enum ScopeNames {
48 FOR_API = 'FOR_API'
49}
50
51@Scopes(() => ({
811cef14 52 [ScopeNames.FOR_API]: () => {
844db39e 53 return {
5fd4ca00
RK
54 attributes: {
55 include: [
edbc9325
C
56 [
57 literal(
58 '(' +
59 'SELECT count(*) ' +
60 'FROM "abuseMessage" ' +
61 'WHERE "abuseId" = "AbuseModel"."id"' +
62 ')'
63 ),
64 'countMessages'
65 ],
5fd4ca00 66 [
efa012ed 67 // we don't care about this count for deleted videos, so there are not included
5fd4ca00
RK
68 literal(
69 '(' +
0251197e
RK
70 'SELECT count(*) ' +
71 'FROM "videoAbuse" ' +
4f32032f 72 'WHERE "videoId" = "VideoAbuse"."videoId" AND "videoId" IS NOT NULL' +
5fd4ca00
RK
73 ')'
74 ),
75 'countReportsForVideo'
76 ],
77 [
efa012ed 78 // we don't care about this count for deleted videos, so there are not included
5fd4ca00
RK
79 literal(
80 '(' +
81 'SELECT t.nth ' +
82 'FROM ( ' +
83 'SELECT id, ' +
84 'row_number() OVER (PARTITION BY "videoId" ORDER BY "createdAt") AS nth ' +
85 'FROM "videoAbuse" ' +
86 ') t ' +
4f32032f 87 'WHERE t.id = "VideoAbuse".id AND t.id IS NOT NULL' +
5fd4ca00
RK
88 ')'
89 ),
90 'nthReportForVideo'
91 ],
92 [
93 literal(
94 '(' +
4f32032f
C
95 'SELECT count("abuse"."id") ' +
96 'FROM "abuse" ' +
97 'WHERE "abuse"."reporterAccountId" = "AbuseModel"."reporterAccountId"' +
efa012ed
RK
98 ')'
99 ),
4f32032f 100 'countReportsForReporter'
efa012ed
RK
101 ],
102 [
103 literal(
104 '(' +
4f32032f
C
105 'SELECT count("abuse"."id") ' +
106 'FROM "abuse" ' +
107 'WHERE "abuse"."flaggedAccountId" = "AbuseModel"."flaggedAccountId"' +
5fd4ca00
RK
108 ')'
109 ),
4f32032f 110 'countReportsForReportee'
5fd4ca00
RK
111 ]
112 ]
113 },
86521a67
RK
114 include: [
115 {
811cef14
C
116 model: AccountModel.scope({
117 method: [
118 AccountScopeNames.SUMMARY,
119 { actorRequired: false } as AccountSummaryOptions
120 ]
121 }),
122 as: 'ReporterAccount'
86521a67
RK
123 },
124 {
4f32032f
C
125 model: AccountModel.scope({
126 method: [
127 AccountScopeNames.SUMMARY,
128 { actorRequired: false } as AccountSummaryOptions
129 ]
130 }),
811cef14 131 as: 'FlaggedAccount'
d95d1559 132 },
57f6896f
C
133 {
134 model: VideoCommentAbuseModel.unscoped(),
57f6896f
C
135 include: [
136 {
137 model: VideoCommentModel.unscoped(),
57f6896f
C
138 include: [
139 {
140 model: VideoModel.unscoped(),
4f32032f 141 attributes: [ 'name', 'id', 'uuid' ]
57f6896f
C
142 }
143 ]
144 }
145 ]
146 },
d95d1559 147 {
4f32032f 148 model: VideoAbuseModel.unscoped(),
86521a67
RK
149 include: [
150 {
4f32032f
C
151 attributes: [ 'id', 'uuid', 'name', 'nsfw' ],
152 model: VideoModel.unscoped(),
0d3a2982
RK
153 include: [
154 {
8ca56654 155 attributes: [ 'filename', 'fileUrl', 'type' ],
d95d1559
C
156 model: ThumbnailModel
157 },
158 {
4f32032f
C
159 model: VideoChannelModel.scope({
160 method: [
161 VideoChannelScopeNames.SUMMARY,
162 { withAccount: false, actorRequired: false } as ChannelSummaryOptions
163 ]
164 }),
811cef14 165 required: false
d95d1559
C
166 },
167 {
168 attributes: [ 'id', 'reason', 'unfederated' ],
811cef14
C
169 required: false,
170 model: VideoBlacklistModel
0d3a2982
RK
171 }
172 ]
86521a67
RK
173 }
174 ]
86521a67 175 }
811cef14 176 ]
86521a67 177 }
844db39e 178 }
86521a67 179}))
3fd3ab2d 180@Table({
d95d1559 181 tableName: 'abuse',
3fd3ab2d 182 indexes: [
55fa55a9 183 {
d95d1559 184 fields: [ 'reporterAccountId' ]
55fa55a9
C
185 },
186 {
d95d1559 187 fields: [ 'flaggedAccountId' ]
55fa55a9 188 }
e02643f3 189 ]
3fd3ab2d 190})
16c016e8 191export class AbuseModel extends Model<Partial<AttributesOnly<AbuseModel>>> {
e02643f3 192
3fd3ab2d 193 @AllowNull(false)
1506307f 194 @Default(null)
4f32032f 195 @Is('AbuseReason', value => throwIfNotValid(value, isAbuseReasonValid, 'reason'))
d95d1559 196 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.REASON.max))
3fd3ab2d 197 reason: string
21e0727a 198
268eebed
C
199 @AllowNull(false)
200 @Default(null)
4f32032f 201 @Is('AbuseState', value => throwIfNotValid(value, isAbuseStateValid, 'state'))
268eebed 202 @Column
d95d1559 203 state: AbuseState
268eebed
C
204
205 @AllowNull(true)
206 @Default(null)
4f32032f 207 @Is('AbuseModerationComment', value => throwIfNotValid(value, isAbuseModerationCommentValid, 'moderationComment', true))
d95d1559 208 @Column(DataType.STRING(CONSTRAINTS_FIELDS.ABUSES.MODERATION_COMMENT.max))
268eebed
C
209 moderationComment: string
210
1ebddadd
RK
211 @AllowNull(true)
212 @Default(null)
213 @Column(DataType.ARRAY(DataType.INTEGER))
d95d1559 214 predefinedReasons: AbusePredefinedReasons[]
1ebddadd 215
3fd3ab2d
C
216 @CreatedAt
217 createdAt: Date
21e0727a 218
3fd3ab2d
C
219 @UpdatedAt
220 updatedAt: Date
e02643f3 221
3fd3ab2d
C
222 @ForeignKey(() => AccountModel)
223 @Column
224 reporterAccountId: number
55fa55a9 225
3fd3ab2d 226 @BelongsTo(() => AccountModel, {
55fa55a9 227 foreignKey: {
d95d1559 228 name: 'reporterAccountId',
68d19a0a 229 allowNull: true
55fa55a9 230 },
d95d1559 231 as: 'ReporterAccount',
68d19a0a 232 onDelete: 'set null'
55fa55a9 233 })
d95d1559 234 ReporterAccount: AccountModel
3fd3ab2d 235
d95d1559 236 @ForeignKey(() => AccountModel)
3fd3ab2d 237 @Column
d95d1559 238 flaggedAccountId: number
55fa55a9 239
d95d1559 240 @BelongsTo(() => AccountModel, {
55fa55a9 241 foreignKey: {
d95d1559 242 name: 'flaggedAccountId',
68d19a0a 243 allowNull: true
55fa55a9 244 },
d95d1559 245 as: 'FlaggedAccount',
68d19a0a 246 onDelete: 'set null'
55fa55a9 247 })
d95d1559
C
248 FlaggedAccount: AccountModel
249
250 @HasOne(() => VideoCommentAbuseModel, {
251 foreignKey: {
252 name: 'abuseId',
253 allowNull: false
254 },
255 onDelete: 'cascade'
256 })
257 VideoCommentAbuse: VideoCommentAbuseModel
3fd3ab2d 258
d95d1559
C
259 @HasOne(() => VideoAbuseModel, {
260 foreignKey: {
261 name: 'abuseId',
262 allowNull: false
263 },
264 onDelete: 'cascade'
265 })
266 VideoAbuse: VideoAbuseModel
267
b49f22d8 268 static loadByIdWithReporter (id: number): Promise<MAbuseReporter> {
57f6896f
C
269 const query = {
270 where: {
271 id
94148c90
C
272 },
273 include: [
274 {
275 model: AccountModel,
276 as: 'ReporterAccount'
277 }
278 ]
57f6896f
C
279 }
280
281 return AbuseModel.findOne(query)
282 }
283
b49f22d8 284 static loadFull (id: number): Promise<MAbuseFull> {
594d3e48
C
285 const query = {
286 where: {
287 id
288 },
289 include: [
290 {
291 model: AccountModel.scope(AccountScopeNames.SUMMARY),
292 required: false,
293 as: 'ReporterAccount'
294 },
295 {
296 model: AccountModel.scope(AccountScopeNames.SUMMARY),
297 as: 'FlaggedAccount'
298 },
299 {
300 model: VideoAbuseModel,
301 required: false,
302 include: [
303 {
304 model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
305 }
306 ]
307 },
308 {
309 model: VideoCommentAbuseModel,
310 required: false,
311 include: [
312 {
313 model: VideoCommentModel.scope([
314 CommentScopeNames.WITH_ACCOUNT
315 ]),
316 include: [
317 {
318 model: VideoModel
319 }
320 ]
321 }
322 ]
323 }
324 ]
325 }
326
327 return AbuseModel.findOne(query)
328 }
329
edbc9325 330 static async listForAdminApi (parameters: {
a1587156
C
331 start: number
332 count: number
333 sort: string
feb34f6b 334
d95d1559
C
335 filter?: AbuseFilter
336
f0a47bc9
C
337 serverAccountId: number
338 user?: MUserAccountId
feb34f6b
C
339
340 id?: number
d95d1559
C
341 predefinedReason?: AbusePredefinedReasonsString
342 state?: AbuseState
343 videoIs?: AbuseVideoIs
feb34f6b
C
344
345 search?: string
346 searchReporter?: string
347 searchReportee?: string
348 searchVideo?: string
349 searchVideoChannel?: string
f0a47bc9 350 }) {
feb34f6b
C
351 const {
352 start,
353 count,
354 sort,
355 search,
356 user,
357 serverAccountId,
358 state,
359 videoIs,
1ebddadd 360 predefinedReason,
feb34f6b
C
361 searchReportee,
362 searchVideo,
d95d1559 363 filter,
feb34f6b
C
364 searchVideoChannel,
365 searchReporter,
366 id
367 } = parameters
368
f0a47bc9 369 const userAccountId = user ? user.Account.id : undefined
d95d1559 370 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
f0a47bc9 371
811cef14
C
372 const queryOptions: BuildAbusesQueryOptions = {
373 start,
374 count,
375 sort,
feb34f6b 376 id,
d95d1559 377 filter,
1ebddadd 378 predefinedReasonId,
feb34f6b
C
379 search,
380 state,
381 videoIs,
382 searchReportee,
383 searchVideo,
384 searchVideoChannel,
385 searchReporter,
844db39e
RK
386 serverAccountId,
387 userAccountId
388 }
389
811cef14
C
390 const [ total, data ] = await Promise.all([
391 AbuseModel.internalCountForApi(queryOptions),
392 AbuseModel.internalListForApi(queryOptions)
393 ])
394
395 return { total, data }
55fa55a9
C
396 }
397
edbc9325
C
398 static async listForUserApi (parameters: {
399 user: MUserAccountId
4f32032f 400
edbc9325
C
401 start: number
402 count: number
403 sort: string
4f32032f 404
edbc9325
C
405 id?: number
406 search?: string
407 state?: AbuseState
408 }) {
409 const {
410 start,
411 count,
412 sort,
413 search,
414 user,
415 state,
416 id
417 } = parameters
5fd4ca00 418
edbc9325
C
419 const queryOptions: BuildAbusesQueryOptions = {
420 start,
421 count,
422 sort,
423 id,
424 search,
425 state,
426 reporterAccountId: user.Account.id
427 }
428
429 const [ total, data ] = await Promise.all([
430 AbuseModel.internalCountForApi(queryOptions),
431 AbuseModel.internalListForApi(queryOptions)
432 ])
433
434 return { total, data }
435 }
d95d1559 436
edbc9325 437 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
494df940
C
438 // Associated video comment could have been destroyed if the video has been deleted
439 if (!this.VideoCommentAbuse || !this.VideoCommentAbuse.VideoComment) return null
d95d1559 440
494df940 441 const entity = this.VideoCommentAbuse.VideoComment
d95d1559 442
edbc9325
C
443 return {
444 id: entity.id,
445 threadId: entity.getThreadId(),
d95d1559 446
edbc9325 447 text: entity.text ?? '',
4f32032f 448
edbc9325 449 deleted: entity.isDeleted(),
4f32032f 450
edbc9325
C
451 video: {
452 id: entity.Video.id,
453 name: entity.Video.name,
454 uuid: entity.Video.uuid
d95d1559
C
455 }
456 }
edbc9325 457 }
68d19a0a 458
edbc9325
C
459 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
460 if (!this.VideoAbuse) return null
57f6896f 461
edbc9325
C
462 const abuseModel = this.VideoAbuse
463 const entity = abuseModel.Video || abuseModel.deletedVideo
8ca56654 464
edbc9325
C
465 return {
466 id: entity.id,
467 uuid: entity.uuid,
468 name: entity.name,
469 nsfw: entity.nsfw,
57f6896f 470
edbc9325
C
471 startAt: abuseModel.startAt,
472 endAt: abuseModel.endAt,
57f6896f 473
edbc9325
C
474 deleted: !abuseModel.Video,
475 blacklisted: abuseModel.Video?.isBlacklisted() || false,
476 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
477
594d3e48 478 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
57f6896f 479 }
edbc9325
C
480 }
481
482 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
483 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
57f6896f 484
3fd3ab2d
C
485 return {
486 id: this.id,
487 reason: this.reason,
1ebddadd 488 predefinedReasons,
d95d1559 489
4f32032f
C
490 flaggedAccount: this.FlaggedAccount
491 ? this.FlaggedAccount.toFormattedJSON()
492 : null,
d95d1559 493
268eebed
C
494 state: {
495 id: this.state,
d95d1559 496 label: AbuseModel.getStateLabel(this.state)
268eebed 497 },
d95d1559 498
edbc9325
C
499 countMessages,
500
501 createdAt: this.createdAt,
502 updatedAt: this.updatedAt
503 }
504 }
505
506 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
507 const countReportsForVideo = this.get('countReportsForVideo') as number
508 const nthReportForVideo = this.get('nthReportForVideo') as number
509
510 const countReportsForReporter = this.get('countReportsForReporter') as number
511 const countReportsForReportee = this.get('countReportsForReportee') as number
512
513 const countMessages = this.get('countMessages') as number
514
515 const baseVideo = this.buildBaseVideoAbuse()
516 const video: AdminVideoAbuse = baseVideo
517 ? Object.assign(baseVideo, {
518 countReports: countReportsForVideo,
519 nthReport: nthReportForVideo
520 })
521 : null
522
523 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
524
525 const abuse = this.buildBaseAbuse(countMessages || 0)
526
527 return Object.assign(abuse, {
d95d1559 528 video,
57f6896f 529 comment,
d95d1559 530
94148c90
C
531 moderationComment: this.moderationComment,
532
edbc9325
C
533 reporterAccount: this.ReporterAccount
534 ? this.ReporterAccount.toFormattedJSON()
535 : null,
4f32032f
C
536
537 countReportsForReporter: (countReportsForReporter || 0),
7a4ea932 538 countReportsForReportee: (countReportsForReportee || 0)
edbc9325
C
539 })
540 }
541
542 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
543 const countMessages = this.get('countMessages') as number
544
545 const video = this.buildBaseVideoAbuse()
94148c90 546 const comment = this.buildBaseVideoCommentAbuse()
edbc9325
C
547 const abuse = this.buildBaseAbuse(countMessages || 0)
548
549 return Object.assign(abuse, {
550 video,
551 comment
552 })
3fd3ab2d
C
553 }
554
d95d1559
C
555 toActivityPubObject (this: MAbuseAP): AbuseObject {
556 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
557
558 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
1ebddadd 559
d95d1559
C
560 const startAt = this.VideoAbuse?.startAt
561 const endAt = this.VideoAbuse?.endAt
1ebddadd 562
3fd3ab2d
C
563 return {
564 type: 'Flag' as 'Flag',
565 content: this.reason,
d95d1559 566 object,
1ebddadd
RK
567 tag: predefinedReasons.map(r => ({
568 type: 'Hashtag' as 'Hashtag',
569 name: r
570 })),
571 startAt,
572 endAt
3fd3ab2d
C
573 }
574 }
268eebed 575
811cef14
C
576 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
577 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
578 const options = {
579 type: QueryTypes.SELECT as QueryTypes.SELECT,
580 replacements
581 }
582
583 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
584 if (total === null) return 0
585
586 return parseInt(total, 10)
587 }
588
589 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
590 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
591 const options = {
592 type: QueryTypes.SELECT as QueryTypes.SELECT,
593 replacements
594 }
595
596 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
597 const ids = rows.map(r => r.id)
598
599 if (ids.length === 0) return []
600
601 return AbuseModel.scope(ScopeNames.FOR_API)
602 .findAll({
603 order: getSort(parameters.sort),
604 where: {
605 id: {
606 [Op.in]: ids
607 }
608 }
609 })
610 }
611
268eebed 612 private static getStateLabel (id: number) {
d95d1559 613 return ABUSE_STATES[id] || 'Unknown'
268eebed 614 }
1ebddadd 615
d95d1559 616 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
bd45d503
C
617 const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
618
1ebddadd 619 return (predefinedReasons || [])
bd45d503
C
620 .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)
621 .filter(v => !!v)
1ebddadd 622 }
55fa55a9 623}