]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/models/abuse/abuse.ts
Check threads resolve on non federated videos
[github/Chocobozzz/PeerTube.git] / server / models / abuse / abuse.ts
CommitLineData
feb34f6b 1import * as Bluebird from 'bluebird'
d95d1559 2import { invert } from 'lodash'
811cef14 3import { literal, Op, QueryTypes, WhereOptions } from 'sequelize'
86521a67 4import {
feb34f6b
C
5 AllowNull,
6 BelongsTo,
7 Column,
8 CreatedAt,
9 DataType,
10 Default,
11 ForeignKey,
d95d1559 12 HasOne,
feb34f6b
C
13 Is,
14 Model,
15 Scopes,
16 Table,
17 UpdatedAt
86521a67 18} from 'sequelize-typescript'
d95d1559 19import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
bd45d503 20import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
268eebed 21import {
57f6896f 22 AbuseFilter,
d95d1559
C
23 AbuseObject,
24 AbusePredefinedReasons,
d95d1559
C
25 AbusePredefinedReasonsString,
26 AbuseState,
27 AbuseVideoIs,
edbc9325 28 AdminAbuse,
94148c90 29 AdminVideoAbuse,
edbc9325
C
30 AdminVideoCommentAbuse,
31 UserAbuse,
32 UserVideoAbuse
d95d1559 33} from '@shared/models'
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})
d95d1559 191export class AbuseModel extends Model<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
57f6896f 268 // FIXME: deprecated in 2.3. Remove these validators
94148c90 269 static loadByIdAndVideoId (id: number, videoId?: number, uuid?: string): Bluebird<MAbuseReporter> {
d95d1559
C
270 const videoWhere: WhereOptions = {}
271
272 if (videoId) videoWhere.videoId = videoId
273 if (uuid) videoWhere.deletedVideo = { uuid }
68d19a0a 274
268eebed 275 const query = {
d95d1559
C
276 include: [
277 {
278 model: VideoAbuseModel,
279 required: true,
280 where: videoWhere
94148c90
C
281 },
282 {
283 model: AccountModel,
284 as: 'ReporterAccount'
d95d1559
C
285 }
286 ],
268eebed 287 where: {
d95d1559 288 id
268eebed
C
289 }
290 }
d95d1559 291 return AbuseModel.findOne(query)
268eebed
C
292 }
293
94148c90 294 static loadByIdWithReporter (id: number): Bluebird<MAbuseReporter> {
57f6896f
C
295 const query = {
296 where: {
297 id
94148c90
C
298 },
299 include: [
300 {
301 model: AccountModel,
302 as: 'ReporterAccount'
303 }
304 ]
57f6896f
C
305 }
306
307 return AbuseModel.findOne(query)
308 }
309
594d3e48
C
310 static loadFull (id: number): Bluebird<MAbuseFull> {
311 const query = {
312 where: {
313 id
314 },
315 include: [
316 {
317 model: AccountModel.scope(AccountScopeNames.SUMMARY),
318 required: false,
319 as: 'ReporterAccount'
320 },
321 {
322 model: AccountModel.scope(AccountScopeNames.SUMMARY),
323 as: 'FlaggedAccount'
324 },
325 {
326 model: VideoAbuseModel,
327 required: false,
328 include: [
329 {
330 model: VideoModel.scope([ VideoScopeNames.WITH_ACCOUNT_DETAILS ])
331 }
332 ]
333 },
334 {
335 model: VideoCommentAbuseModel,
336 required: false,
337 include: [
338 {
339 model: VideoCommentModel.scope([
340 CommentScopeNames.WITH_ACCOUNT
341 ]),
342 include: [
343 {
344 model: VideoModel
345 }
346 ]
347 }
348 ]
349 }
350 ]
351 }
352
353 return AbuseModel.findOne(query)
354 }
355
edbc9325 356 static async listForAdminApi (parameters: {
a1587156
C
357 start: number
358 count: number
359 sort: string
feb34f6b 360
d95d1559
C
361 filter?: AbuseFilter
362
f0a47bc9
C
363 serverAccountId: number
364 user?: MUserAccountId
feb34f6b
C
365
366 id?: number
d95d1559
C
367 predefinedReason?: AbusePredefinedReasonsString
368 state?: AbuseState
369 videoIs?: AbuseVideoIs
feb34f6b
C
370
371 search?: string
372 searchReporter?: string
373 searchReportee?: string
374 searchVideo?: string
375 searchVideoChannel?: string
f0a47bc9 376 }) {
feb34f6b
C
377 const {
378 start,
379 count,
380 sort,
381 search,
382 user,
383 serverAccountId,
384 state,
385 videoIs,
1ebddadd 386 predefinedReason,
feb34f6b
C
387 searchReportee,
388 searchVideo,
d95d1559 389 filter,
feb34f6b
C
390 searchVideoChannel,
391 searchReporter,
392 id
393 } = parameters
394
f0a47bc9 395 const userAccountId = user ? user.Account.id : undefined
d95d1559 396 const predefinedReasonId = predefinedReason ? abusePredefinedReasonsMap[predefinedReason] : undefined
f0a47bc9 397
811cef14
C
398 const queryOptions: BuildAbusesQueryOptions = {
399 start,
400 count,
401 sort,
feb34f6b 402 id,
d95d1559 403 filter,
1ebddadd 404 predefinedReasonId,
feb34f6b
C
405 search,
406 state,
407 videoIs,
408 searchReportee,
409 searchVideo,
410 searchVideoChannel,
411 searchReporter,
844db39e
RK
412 serverAccountId,
413 userAccountId
414 }
415
811cef14
C
416 const [ total, data ] = await Promise.all([
417 AbuseModel.internalCountForApi(queryOptions),
418 AbuseModel.internalListForApi(queryOptions)
419 ])
420
421 return { total, data }
55fa55a9
C
422 }
423
edbc9325
C
424 static async listForUserApi (parameters: {
425 user: MUserAccountId
4f32032f 426
edbc9325
C
427 start: number
428 count: number
429 sort: string
4f32032f 430
edbc9325
C
431 id?: number
432 search?: string
433 state?: AbuseState
434 }) {
435 const {
436 start,
437 count,
438 sort,
439 search,
440 user,
441 state,
442 id
443 } = parameters
5fd4ca00 444
edbc9325
C
445 const queryOptions: BuildAbusesQueryOptions = {
446 start,
447 count,
448 sort,
449 id,
450 search,
451 state,
452 reporterAccountId: user.Account.id
453 }
454
455 const [ total, data ] = await Promise.all([
456 AbuseModel.internalCountForApi(queryOptions),
457 AbuseModel.internalListForApi(queryOptions)
458 ])
459
460 return { total, data }
461 }
d95d1559 462
edbc9325
C
463 buildBaseVideoCommentAbuse (this: MAbuseUserFormattable) {
464 if (!this.VideoCommentAbuse) return null
d95d1559 465
edbc9325
C
466 const abuseModel = this.VideoCommentAbuse
467 const entity = abuseModel.VideoComment
d95d1559 468
edbc9325
C
469 return {
470 id: entity.id,
471 threadId: entity.getThreadId(),
d95d1559 472
edbc9325 473 text: entity.text ?? '',
4f32032f 474
edbc9325 475 deleted: entity.isDeleted(),
4f32032f 476
edbc9325
C
477 video: {
478 id: entity.Video.id,
479 name: entity.Video.name,
480 uuid: entity.Video.uuid
d95d1559
C
481 }
482 }
edbc9325 483 }
68d19a0a 484
edbc9325
C
485 buildBaseVideoAbuse (this: MAbuseUserFormattable): UserVideoAbuse {
486 if (!this.VideoAbuse) return null
57f6896f 487
edbc9325
C
488 const abuseModel = this.VideoAbuse
489 const entity = abuseModel.Video || abuseModel.deletedVideo
8ca56654 490
edbc9325
C
491 return {
492 id: entity.id,
493 uuid: entity.uuid,
494 name: entity.name,
495 nsfw: entity.nsfw,
57f6896f 496
edbc9325
C
497 startAt: abuseModel.startAt,
498 endAt: abuseModel.endAt,
57f6896f 499
edbc9325
C
500 deleted: !abuseModel.Video,
501 blacklisted: abuseModel.Video?.isBlacklisted() || false,
502 thumbnailPath: abuseModel.Video?.getMiniatureStaticPath(),
503
594d3e48 504 channel: abuseModel.Video?.VideoChannel.toFormattedJSON() || abuseModel.deletedVideo?.channel
57f6896f 505 }
edbc9325
C
506 }
507
508 buildBaseAbuse (this: MAbuseUserFormattable, countMessages: number): UserAbuse {
509 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
57f6896f 510
3fd3ab2d
C
511 return {
512 id: this.id,
513 reason: this.reason,
1ebddadd 514 predefinedReasons,
d95d1559 515
4f32032f
C
516 flaggedAccount: this.FlaggedAccount
517 ? this.FlaggedAccount.toFormattedJSON()
518 : null,
d95d1559 519
268eebed
C
520 state: {
521 id: this.state,
d95d1559 522 label: AbuseModel.getStateLabel(this.state)
268eebed 523 },
d95d1559 524
edbc9325
C
525 countMessages,
526
527 createdAt: this.createdAt,
528 updatedAt: this.updatedAt
529 }
530 }
531
532 toFormattedAdminJSON (this: MAbuseAdminFormattable): AdminAbuse {
533 const countReportsForVideo = this.get('countReportsForVideo') as number
534 const nthReportForVideo = this.get('nthReportForVideo') as number
535
536 const countReportsForReporter = this.get('countReportsForReporter') as number
537 const countReportsForReportee = this.get('countReportsForReportee') as number
538
539 const countMessages = this.get('countMessages') as number
540
541 const baseVideo = this.buildBaseVideoAbuse()
542 const video: AdminVideoAbuse = baseVideo
543 ? Object.assign(baseVideo, {
544 countReports: countReportsForVideo,
545 nthReport: nthReportForVideo
546 })
547 : null
548
549 const comment: AdminVideoCommentAbuse = this.buildBaseVideoCommentAbuse()
550
551 const abuse = this.buildBaseAbuse(countMessages || 0)
552
553 return Object.assign(abuse, {
d95d1559 554 video,
57f6896f 555 comment,
d95d1559 556
94148c90
C
557 moderationComment: this.moderationComment,
558
edbc9325
C
559 reporterAccount: this.ReporterAccount
560 ? this.ReporterAccount.toFormattedJSON()
561 : null,
4f32032f
C
562
563 countReportsForReporter: (countReportsForReporter || 0),
564 countReportsForReportee: (countReportsForReportee || 0),
d95d1559
C
565
566 // FIXME: deprecated in 2.3, remove this
567 startAt: null,
4f32032f
C
568 endAt: null,
569 count: countReportsForVideo || 0,
570 nth: nthReportForVideo || 0
edbc9325
C
571 })
572 }
573
574 toFormattedUserJSON (this: MAbuseUserFormattable): UserAbuse {
575 const countMessages = this.get('countMessages') as number
576
577 const video = this.buildBaseVideoAbuse()
94148c90 578 const comment = this.buildBaseVideoCommentAbuse()
edbc9325
C
579 const abuse = this.buildBaseAbuse(countMessages || 0)
580
581 return Object.assign(abuse, {
582 video,
583 comment
584 })
3fd3ab2d
C
585 }
586
d95d1559
C
587 toActivityPubObject (this: MAbuseAP): AbuseObject {
588 const predefinedReasons = AbuseModel.getPredefinedReasonsStrings(this.predefinedReasons)
589
590 const object = this.VideoAbuse?.Video?.url || this.VideoCommentAbuse?.VideoComment?.url || this.FlaggedAccount.Actor.url
1ebddadd 591
d95d1559
C
592 const startAt = this.VideoAbuse?.startAt
593 const endAt = this.VideoAbuse?.endAt
1ebddadd 594
3fd3ab2d
C
595 return {
596 type: 'Flag' as 'Flag',
597 content: this.reason,
d95d1559 598 object,
1ebddadd
RK
599 tag: predefinedReasons.map(r => ({
600 type: 'Hashtag' as 'Hashtag',
601 name: r
602 })),
603 startAt,
604 endAt
3fd3ab2d
C
605 }
606 }
268eebed 607
811cef14
C
608 private static async internalCountForApi (parameters: BuildAbusesQueryOptions) {
609 const { query, replacements } = buildAbuseListQuery(parameters, 'count')
610 const options = {
611 type: QueryTypes.SELECT as QueryTypes.SELECT,
612 replacements
613 }
614
615 const [ { total } ] = await AbuseModel.sequelize.query<{ total: string }>(query, options)
616 if (total === null) return 0
617
618 return parseInt(total, 10)
619 }
620
621 private static async internalListForApi (parameters: BuildAbusesQueryOptions) {
622 const { query, replacements } = buildAbuseListQuery(parameters, 'id')
623 const options = {
624 type: QueryTypes.SELECT as QueryTypes.SELECT,
625 replacements
626 }
627
628 const rows = await AbuseModel.sequelize.query<{ id: string }>(query, options)
629 const ids = rows.map(r => r.id)
630
631 if (ids.length === 0) return []
632
633 return AbuseModel.scope(ScopeNames.FOR_API)
634 .findAll({
635 order: getSort(parameters.sort),
636 where: {
637 id: {
638 [Op.in]: ids
639 }
640 }
641 })
642 }
643
268eebed 644 private static getStateLabel (id: number) {
d95d1559 645 return ABUSE_STATES[id] || 'Unknown'
268eebed 646 }
1ebddadd 647
d95d1559 648 private static getPredefinedReasonsStrings (predefinedReasons: AbusePredefinedReasons[]): AbusePredefinedReasonsString[] {
bd45d503
C
649 const invertedPredefinedReasons = invert(abusePredefinedReasonsMap)
650
1ebddadd 651 return (predefinedReasons || [])
bd45d503
C
652 .map(r => invertedPredefinedReasons[r] as AbusePredefinedReasonsString)
653 .filter(v => !!v)
1ebddadd 654 }
55fa55a9 655}