2
1

abuse.ts 16 KB

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