]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
fb6078ed889411496331fda3be8118b0fb547b05
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Bluebird from 'bluebird'
2 import { uniq } from 'lodash'
3 import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
4 import {
5 AllowNull,
6 BeforeDestroy,
7 BelongsTo,
8 Column,
9 CreatedAt,
10 DataType,
11 ForeignKey,
12 HasMany,
13 Is,
14 Model,
15 Scopes,
16 Table,
17 UpdatedAt
18 } from 'sequelize-typescript'
19 import { logger } from '@server/helpers/logger'
20 import { getServerActor } from '@server/models/application/application'
21 import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
22 import { VideoPrivacy } from '@shared/models'
23 import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
24 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
25 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
26 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
27 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
28 import { regexpCapture } from '../../helpers/regexp'
29 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
30 import {
31 MComment,
32 MCommentAP,
33 MCommentFormattable,
34 MCommentId,
35 MCommentOwner,
36 MCommentOwnerReplyVideoLight,
37 MCommentOwnerVideo,
38 MCommentOwnerVideoFeed,
39 MCommentOwnerVideoReply,
40 MVideoImmutable
41 } from '../../types/models/video'
42 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
43 import { AccountModel } from '../account/account'
44 import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
45 import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getCommentSort, throwIfNotValid } from '../utils'
46 import { VideoModel } from './video'
47 import { VideoChannelModel } from './video-channel'
48
49 enum ScopeNames {
50 WITH_ACCOUNT = 'WITH_ACCOUNT',
51 WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
52 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
53 WITH_VIDEO = 'WITH_VIDEO',
54 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
55 }
56
57 @Scopes(() => ({
58 [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
59 return {
60 attributes: {
61 include: [
62 [
63 Sequelize.literal(
64 '(' +
65 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
66 'SELECT COUNT("replies"."id") - (' +
67 'SELECT COUNT("replies"."id") ' +
68 'FROM "videoComment" AS "replies" ' +
69 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
70 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
71 ')' +
72 'FROM "videoComment" AS "replies" ' +
73 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
74 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
75 ')'
76 ),
77 'totalReplies'
78 ],
79 [
80 Sequelize.literal(
81 '(' +
82 'SELECT COUNT("replies"."id") ' +
83 'FROM "videoComment" AS "replies" ' +
84 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
85 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
86 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
87 'AND "replies"."accountId" = "videoChannel"."accountId"' +
88 ')'
89 ),
90 'totalRepliesFromVideoAuthor'
91 ]
92 ]
93 }
94 } as FindOptions
95 },
96 [ScopeNames.WITH_ACCOUNT]: {
97 include: [
98 {
99 model: AccountModel
100 }
101 ]
102 },
103 [ScopeNames.WITH_ACCOUNT_FOR_API]: {
104 include: [
105 {
106 model: AccountModel.unscoped(),
107 include: [
108 {
109 attributes: {
110 exclude: unusedActorAttributesForAPI
111 },
112 model: ActorModel, // Default scope includes avatar and server
113 required: true
114 }
115 ]
116 }
117 ]
118 },
119 [ScopeNames.WITH_IN_REPLY_TO]: {
120 include: [
121 {
122 model: VideoCommentModel,
123 as: 'InReplyToVideoComment'
124 }
125 ]
126 },
127 [ScopeNames.WITH_VIDEO]: {
128 include: [
129 {
130 model: VideoModel,
131 required: true,
132 include: [
133 {
134 model: VideoChannelModel,
135 required: true,
136 include: [
137 {
138 model: AccountModel,
139 required: true
140 }
141 ]
142 }
143 ]
144 }
145 ]
146 }
147 }))
148 @Table({
149 tableName: 'videoComment',
150 indexes: [
151 {
152 fields: [ 'videoId' ]
153 },
154 {
155 fields: [ 'videoId', 'originCommentId' ]
156 },
157 {
158 fields: [ 'url' ],
159 unique: true
160 },
161 {
162 fields: [ 'accountId' ]
163 },
164 {
165 fields: [
166 { name: 'createdAt', order: 'DESC' }
167 ]
168 }
169 ]
170 })
171 export class VideoCommentModel extends Model<VideoCommentModel> {
172 @CreatedAt
173 createdAt: Date
174
175 @UpdatedAt
176 updatedAt: Date
177
178 @AllowNull(true)
179 @Column(DataType.DATE)
180 deletedAt: Date
181
182 @AllowNull(false)
183 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
184 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
185 url: string
186
187 @AllowNull(false)
188 @Column(DataType.TEXT)
189 text: string
190
191 @ForeignKey(() => VideoCommentModel)
192 @Column
193 originCommentId: number
194
195 @BelongsTo(() => VideoCommentModel, {
196 foreignKey: {
197 name: 'originCommentId',
198 allowNull: true
199 },
200 as: 'OriginVideoComment',
201 onDelete: 'CASCADE'
202 })
203 OriginVideoComment: VideoCommentModel
204
205 @ForeignKey(() => VideoCommentModel)
206 @Column
207 inReplyToCommentId: number
208
209 @BelongsTo(() => VideoCommentModel, {
210 foreignKey: {
211 name: 'inReplyToCommentId',
212 allowNull: true
213 },
214 as: 'InReplyToVideoComment',
215 onDelete: 'CASCADE'
216 })
217 InReplyToVideoComment: VideoCommentModel | null
218
219 @ForeignKey(() => VideoModel)
220 @Column
221 videoId: number
222
223 @BelongsTo(() => VideoModel, {
224 foreignKey: {
225 allowNull: false
226 },
227 onDelete: 'CASCADE'
228 })
229 Video: VideoModel
230
231 @ForeignKey(() => AccountModel)
232 @Column
233 accountId: number
234
235 @BelongsTo(() => AccountModel, {
236 foreignKey: {
237 allowNull: true
238 },
239 onDelete: 'CASCADE'
240 })
241 Account: AccountModel
242
243 @HasMany(() => VideoCommentAbuseModel, {
244 foreignKey: {
245 name: 'commentId',
246 allowNull: true
247 },
248 onDelete: 'set null'
249 })
250 CommentAbuses: VideoCommentAbuseModel[]
251
252 @BeforeDestroy
253 static async saveEssentialDataToAbuses (instance: VideoCommentModel, options) {
254 const tasks: Promise<any>[] = []
255
256 if (!Array.isArray(instance.CommentAbuses)) {
257 instance.CommentAbuses = await instance.$get('CommentAbuses')
258
259 if (instance.CommentAbuses.length === 0) return undefined
260 }
261
262 if (!instance.Video) {
263 instance.Video = await instance.$get('Video')
264 }
265
266 logger.info('Saving video comment %s for abuse.', instance.url)
267
268 const details = Object.assign(instance.toFormattedJSON(), {
269 Video: {
270 id: instance.Video.id,
271 name: instance.Video.name,
272 uuid: instance.Video.uuid
273 }
274 })
275
276 for (const abuse of instance.CommentAbuses) {
277 abuse.deletedComment = details
278
279 tasks.push(abuse.save({ transaction: options.transaction }))
280 }
281
282 Promise.all(tasks)
283 .catch(err => {
284 logger.error('Some errors when saving details of comment %s in its abuses before destroy hook.', instance.url, { err })
285 })
286
287 return undefined
288 }
289
290 static loadById (id: number, t?: Transaction): Bluebird<MComment> {
291 const query: FindOptions = {
292 where: {
293 id
294 }
295 }
296
297 if (t !== undefined) query.transaction = t
298
299 return VideoCommentModel.findOne(query)
300 }
301
302 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction): Bluebird<MCommentOwnerVideoReply> {
303 const query: FindOptions = {
304 where: {
305 id
306 }
307 }
308
309 if (t !== undefined) query.transaction = t
310
311 return VideoCommentModel
312 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
313 .findOne(query)
314 }
315
316 static loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction): Bluebird<MCommentOwnerVideo> {
317 const query: FindOptions = {
318 where: {
319 url
320 }
321 }
322
323 if (t !== undefined) query.transaction = t
324
325 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
326 }
327
328 static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction): Bluebird<MCommentOwnerReplyVideoLight> {
329 const query: FindOptions = {
330 where: {
331 url
332 },
333 include: [
334 {
335 attributes: [ 'id', 'url' ],
336 model: VideoModel.unscoped()
337 }
338 ]
339 }
340
341 if (t !== undefined) query.transaction = t
342
343 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
344 }
345
346 static async listThreadsForApi (parameters: {
347 videoId: number
348 isVideoOwned: boolean
349 start: number
350 count: number
351 sort: string
352 user?: MUserAccountId
353 }) {
354 const { videoId, isVideoOwned, start, count, sort, user } = parameters
355
356 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
357
358 const query = {
359 offset: start,
360 limit: count,
361 order: getCommentSort(sort),
362 where: {
363 [Op.and]: [
364 {
365 videoId
366 },
367 {
368 inReplyToCommentId: null
369 },
370 {
371 [Op.or]: [
372 {
373 accountId: {
374 [Op.notIn]: Sequelize.literal(
375 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
376 )
377 }
378 },
379 {
380 accountId: null
381 }
382 ]
383 }
384 ]
385 }
386 }
387
388 const scopes: (string | ScopeOptions)[] = [
389 ScopeNames.WITH_ACCOUNT_FOR_API,
390 {
391 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
392 }
393 ]
394
395 return VideoCommentModel
396 .scope(scopes)
397 .findAndCountAll(query)
398 .then(({ rows, count }) => {
399 return { total: count, data: rows }
400 })
401 }
402
403 static async listThreadCommentsForApi (parameters: {
404 videoId: number
405 isVideoOwned: boolean
406 threadId: number
407 user?: MUserAccountId
408 }) {
409 const { videoId, threadId, user, isVideoOwned } = parameters
410
411 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
412
413 const query = {
414 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
415 where: {
416 videoId,
417 [Op.or]: [
418 { id: threadId },
419 { originCommentId: threadId }
420 ],
421 accountId: {
422 [Op.notIn]: Sequelize.literal(
423 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
424 )
425 }
426 }
427 }
428
429 const scopes: any[] = [
430 ScopeNames.WITH_ACCOUNT_FOR_API,
431 {
432 method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
433 }
434 ]
435
436 return VideoCommentModel
437 .scope(scopes)
438 .findAndCountAll(query)
439 .then(({ rows, count }) => {
440 return { total: count, data: rows }
441 })
442 }
443
444 static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Bluebird<MCommentOwner[]> {
445 const query = {
446 order: [ [ 'createdAt', order ] ] as Order,
447 where: {
448 id: {
449 [Op.in]: Sequelize.literal('(' +
450 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
451 `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
452 'UNION ' +
453 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
454 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
455 ') ' +
456 'SELECT id FROM children' +
457 ')'),
458 [Op.ne]: comment.id
459 }
460 },
461 transaction: t
462 }
463
464 return VideoCommentModel
465 .scope([ ScopeNames.WITH_ACCOUNT ])
466 .findAll(query)
467 }
468
469 static async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
470 const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
471 videoId: video.id,
472 isVideoOwned: video.isOwned()
473 })
474
475 const query = {
476 order: [ [ 'createdAt', 'ASC' ] ] as Order,
477 offset: start,
478 limit: count,
479 where: {
480 videoId: video.id,
481 accountId: {
482 [Op.notIn]: Sequelize.literal(
483 '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
484 )
485 }
486 },
487 transaction: t
488 }
489
490 return VideoCommentModel.findAndCountAll<MComment>(query)
491 }
492
493 static async listForFeed (parameters: {
494 start: number
495 count: number
496 videoId?: number
497 accountId?: number
498 videoChannelId?: number
499 }): Promise<MCommentOwnerVideoFeed[]> {
500 const serverActor = await getServerActor()
501 const { start, count, videoId, accountId, videoChannelId } = parameters
502
503 const accountExclusion = {
504 [Op.notIn]: Sequelize.literal(
505 '(' + buildBlockedAccountSQL([ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]) + ')'
506 )
507 }
508 const accountWhere = accountId
509 ? {
510 [Op.and]: {
511 ...accountExclusion,
512 [Op.eq]: accountId
513 }
514 }
515 : accountExclusion
516
517 const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
518
519 const query = {
520 order: [ [ 'createdAt', 'DESC' ] ] as Order,
521 offset: start,
522 limit: count,
523 where: {
524 deletedAt: null,
525 accountId: accountWhere
526 },
527 include: [
528 {
529 attributes: [ 'name', 'uuid' ],
530 model: VideoModel.unscoped(),
531 required: true,
532 where: {
533 privacy: VideoPrivacy.PUBLIC
534 },
535 include: [
536 {
537 attributes: [ 'accountId' ],
538 model: VideoChannelModel.unscoped(),
539 required: true,
540 where: videoChannelWhere
541 }
542 ]
543 }
544 ]
545 }
546
547 if (videoId) query.where['videoId'] = videoId
548
549 return VideoCommentModel
550 .scope([ ScopeNames.WITH_ACCOUNT ])
551 .findAll(query)
552 }
553
554 static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
555 const accountWhere = filter.onVideosOfAccount
556 ? { id: filter.onVideosOfAccount.id }
557 : {}
558
559 const query = {
560 limit: 1000,
561 where: {
562 deletedAt: null,
563 accountId: ofAccount.id
564 },
565 include: [
566 {
567 model: VideoModel,
568 required: true,
569 include: [
570 {
571 model: VideoChannelModel,
572 required: true,
573 include: [
574 {
575 model: AccountModel,
576 required: true,
577 where: accountWhere
578 }
579 ]
580 }
581 ]
582 }
583 ]
584 }
585
586 return VideoCommentModel
587 .scope([ ScopeNames.WITH_ACCOUNT ])
588 .findAll(query)
589 }
590
591 static async getStats () {
592 const totalLocalVideoComments = await VideoCommentModel.count({
593 include: [
594 {
595 model: AccountModel,
596 required: true,
597 include: [
598 {
599 model: ActorModel,
600 required: true,
601 where: {
602 serverId: null
603 }
604 }
605 ]
606 }
607 ]
608 })
609 const totalVideoComments = await VideoCommentModel.count()
610
611 return {
612 totalLocalVideoComments,
613 totalVideoComments
614 }
615 }
616
617 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
618 const query = {
619 where: {
620 updatedAt: {
621 [Op.lt]: beforeUpdatedAt
622 },
623 videoId,
624 accountId: {
625 [Op.notIn]: buildLocalAccountIdsIn()
626 },
627 // Do not delete Tombstones
628 deletedAt: null
629 }
630 }
631
632 return VideoCommentModel.destroy(query)
633 }
634
635 getCommentStaticPath () {
636 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
637 }
638
639 getThreadId (): number {
640 return this.originCommentId || this.id
641 }
642
643 isOwned () {
644 if (!this.Account) {
645 return false
646 }
647
648 return this.Account.isOwned()
649 }
650
651 isDeleted () {
652 return this.deletedAt !== null
653 }
654
655 extractMentions () {
656 let result: string[] = []
657
658 const localMention = `@(${actorNameAlphabet}+)`
659 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
660
661 const mentionRegex = this.isOwned()
662 ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
663 : '(?:' + remoteMention + ')'
664
665 const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
666 const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
667 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
668
669 result = result.concat(
670 regexpCapture(this.text, firstMentionRegex)
671 .map(([ , username1, username2 ]) => username1 || username2),
672
673 regexpCapture(this.text, endMentionRegex)
674 .map(([ , username1, username2 ]) => username1 || username2),
675
676 regexpCapture(this.text, remoteMentionsRegex)
677 .map(([ , username ]) => username)
678 )
679
680 // Include local mentions
681 if (this.isOwned()) {
682 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
683
684 result = result.concat(
685 regexpCapture(this.text, localMentionsRegex)
686 .map(([ , username ]) => username)
687 )
688 }
689
690 return uniq(result)
691 }
692
693 toFormattedJSON (this: MCommentFormattable) {
694 return {
695 id: this.id,
696 url: this.url,
697 text: this.text,
698 threadId: this.originCommentId || this.id,
699 inReplyToCommentId: this.inReplyToCommentId || null,
700 videoId: this.videoId,
701 createdAt: this.createdAt,
702 updatedAt: this.updatedAt,
703 deletedAt: this.deletedAt,
704 isDeleted: this.isDeleted(),
705 totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
706 totalReplies: this.get('totalReplies') || 0,
707 account: this.Account ? this.Account.toFormattedJSON() : null
708 } as VideoComment
709 }
710
711 toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
712 let inReplyTo: string
713 // New thread, so in AS we reply to the video
714 if (this.inReplyToCommentId === null) {
715 inReplyTo = this.Video.url
716 } else {
717 inReplyTo = this.InReplyToVideoComment.url
718 }
719
720 if (this.isDeleted()) {
721 return {
722 id: this.url,
723 type: 'Tombstone',
724 formerType: 'Note',
725 inReplyTo,
726 published: this.createdAt.toISOString(),
727 updated: this.updatedAt.toISOString(),
728 deleted: this.deletedAt.toISOString()
729 }
730 }
731
732 const tag: ActivityTagObject[] = []
733 for (const parentComment of threadParentComments) {
734 if (!parentComment.Account) continue
735
736 const actor = parentComment.Account.Actor
737
738 tag.push({
739 type: 'Mention',
740 href: actor.url,
741 name: `@${actor.preferredUsername}@${actor.getHost()}`
742 })
743 }
744
745 return {
746 type: 'Note' as 'Note',
747 id: this.url,
748 content: this.text,
749 inReplyTo,
750 updated: this.updatedAt.toISOString(),
751 published: this.createdAt.toISOString(),
752 url: this.url,
753 attributedTo: this.Account.Actor.url,
754 tag
755 }
756 }
757
758 private static async buildBlockerAccountIds (options: {
759 videoId: number
760 isVideoOwned: boolean
761 user?: MUserAccountId
762 }) {
763 const { videoId, user, isVideoOwned } = options
764
765 const serverActor = await getServerActor()
766 const blockerAccountIds = [ serverActor.Account.id ]
767
768 if (user) blockerAccountIds.push(user.Account.id)
769
770 if (isVideoOwned) {
771 const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
772 blockerAccountIds.push(videoOwnerAccount.id)
773 }
774
775 return blockerAccountIds
776 }
777 }