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