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