ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

abuse.ts 17 KiB

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