Merge remote-tracking branch 'origin/pr/1785' into develop
[oweals/peertube.git] / server / models / video / video-comment.ts
1 import {
2   AllowNull,
3   BeforeDestroy,
4   BelongsTo,
5   Column,
6   CreatedAt,
7   DataType,
8   ForeignKey,
9   Is,
10   Model,
11   Scopes,
12   Table,
13   UpdatedAt
14 } from 'sequelize-typescript'
15 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
16 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
17 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
18 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
19 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
20 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
21 import { AccountModel } from '../account/account'
22 import { ActorModel } from '../activitypub/actor'
23 import { AvatarModel } from '../avatar/avatar'
24 import { ServerModel } from '../server/server'
25 import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
26 import { VideoModel } from './video'
27 import { VideoChannelModel } from './video-channel'
28 import { getServerActor } from '../../helpers/utils'
29 import { UserModel } from '../account/user'
30 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
31 import { regexpCapture } from '../../helpers/regexp'
32 import { uniq } from 'lodash'
33 import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
34
35 enum ScopeNames {
36   WITH_ACCOUNT = 'WITH_ACCOUNT',
37   WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
38   WITH_VIDEO = 'WITH_VIDEO',
39   ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
40 }
41
42 @Scopes(() => ({
43   [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
44     return {
45       attributes: {
46         include: [
47           [
48             Sequelize.literal(
49               '(' +
50                 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
51                 'SELECT COUNT("replies"."id") - (' +
52                   'SELECT COUNT("replies"."id") ' +
53                   'FROM "videoComment" AS "replies" ' +
54                   'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
55                   'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
56                 ')' +
57                 'FROM "videoComment" AS "replies" ' +
58                 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
59                 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
60               ')'
61             ),
62             'totalReplies'
63           ]
64         ]
65       }
66     } as FindOptions
67   },
68   [ScopeNames.WITH_ACCOUNT]: {
69     include: [
70       {
71         model: AccountModel,
72         include: [
73           {
74             model: ActorModel,
75             include: [
76               {
77                 model: ServerModel,
78                 required: false
79               },
80               {
81                 model: AvatarModel,
82                 required: false
83               }
84             ]
85           }
86         ]
87       }
88     ]
89   },
90   [ScopeNames.WITH_IN_REPLY_TO]: {
91     include: [
92       {
93         model: VideoCommentModel,
94         as: 'InReplyToVideoComment'
95       }
96     ]
97   },
98   [ScopeNames.WITH_VIDEO]: {
99     include: [
100       {
101         model: VideoModel,
102         required: true,
103         include: [
104           {
105             model: VideoChannelModel.unscoped(),
106             required: true,
107             include: [
108               {
109                 model: AccountModel,
110                 required: true,
111                 include: [
112                   {
113                     model: ActorModel,
114                     required: true
115                   }
116                 ]
117               }
118             ]
119           }
120         ]
121       }
122     ]
123   }
124 }))
125 @Table({
126   tableName: 'videoComment',
127   indexes: [
128     {
129       fields: [ 'videoId' ]
130     },
131     {
132       fields: [ 'videoId', 'originCommentId' ]
133     },
134     {
135       fields: [ 'url' ],
136       unique: true
137     },
138     {
139       fields: [ 'accountId' ]
140     }
141   ]
142 })
143 export class VideoCommentModel extends Model<VideoCommentModel> {
144   @CreatedAt
145   createdAt: Date
146
147   @UpdatedAt
148   updatedAt: Date
149
150   @AllowNull(false)
151   @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
152   @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
153   url: string
154
155   @AllowNull(false)
156   @Column(DataType.TEXT)
157   text: string
158
159   @ForeignKey(() => VideoCommentModel)
160   @Column
161   originCommentId: number
162
163   @BelongsTo(() => VideoCommentModel, {
164     foreignKey: {
165       name: 'originCommentId',
166       allowNull: true
167     },
168     as: 'OriginVideoComment',
169     onDelete: 'CASCADE'
170   })
171   OriginVideoComment: VideoCommentModel
172
173   @ForeignKey(() => VideoCommentModel)
174   @Column
175   inReplyToCommentId: number
176
177   @BelongsTo(() => VideoCommentModel, {
178     foreignKey: {
179       name: 'inReplyToCommentId',
180       allowNull: true
181     },
182     as: 'InReplyToVideoComment',
183     onDelete: 'CASCADE'
184   })
185   InReplyToVideoComment: VideoCommentModel | null
186
187   @ForeignKey(() => VideoModel)
188   @Column
189   videoId: number
190
191   @BelongsTo(() => VideoModel, {
192     foreignKey: {
193       allowNull: false
194     },
195     onDelete: 'CASCADE'
196   })
197   Video: VideoModel
198
199   @ForeignKey(() => AccountModel)
200   @Column
201   accountId: number
202
203   @BelongsTo(() => AccountModel, {
204     foreignKey: {
205       allowNull: false
206     },
207     onDelete: 'CASCADE'
208   })
209   Account: AccountModel
210
211   @BeforeDestroy
212   static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
213     if (!instance.Account || !instance.Account.Actor) {
214       instance.Account = await instance.$get('Account', {
215         include: [ ActorModel ],
216         transaction: options.transaction
217       }) as AccountModel
218     }
219
220     if (!instance.Video) {
221       instance.Video = await instance.$get('Video', {
222         include: [
223           {
224             model: VideoChannelModel,
225             include: [
226               {
227                 model: AccountModel,
228                 include: [
229                   {
230                     model: ActorModel
231                   }
232                 ]
233               }
234             ]
235           }
236         ],
237         transaction: options.transaction
238       }) as VideoModel
239     }
240
241     if (instance.isOwned()) {
242       await sendDeleteVideoComment(instance, options.transaction)
243     }
244   }
245
246   static loadById (id: number, t?: Transaction) {
247     const query: FindOptions = {
248       where: {
249         id
250       }
251     }
252
253     if (t !== undefined) query.transaction = t
254
255     return VideoCommentModel.findOne(query)
256   }
257
258   static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction) {
259     const query: FindOptions = {
260       where: {
261         id
262       }
263     }
264
265     if (t !== undefined) query.transaction = t
266
267     return VideoCommentModel
268       .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
269       .findOne(query)
270   }
271
272   static loadByUrlAndPopulateAccount (url: string, t?: Transaction) {
273     const query: FindOptions = {
274       where: {
275         url
276       }
277     }
278
279     if (t !== undefined) query.transaction = t
280
281     return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
282   }
283
284   static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Transaction) {
285     const query: FindOptions = {
286       where: {
287         url
288       }
289     }
290
291     if (t !== undefined) query.transaction = t
292
293     return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
294   }
295
296   static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
297     const serverActor = await getServerActor()
298     const serverAccountId = serverActor.Account.id
299     const userAccountId = user ? user.Account.id : undefined
300
301     const query = {
302       offset: start,
303       limit: count,
304       order: getSort(sort),
305       where: {
306         videoId,
307         inReplyToCommentId: null,
308         accountId: {
309           [Op.notIn]: Sequelize.literal(
310             '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
311           )
312         }
313       }
314     }
315
316     const scopes: (string | ScopeOptions)[] = [
317       ScopeNames.WITH_ACCOUNT,
318       {
319         method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
320       }
321     ]
322
323     return VideoCommentModel
324       .scope(scopes)
325       .findAndCountAll(query)
326       .then(({ rows, count }) => {
327         return { total: count, data: rows }
328       })
329   }
330
331   static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
332     const serverActor = await getServerActor()
333     const serverAccountId = serverActor.Account.id
334     const userAccountId = user ? user.Account.id : undefined
335
336     const query = {
337       order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
338       where: {
339         videoId,
340         [ Op.or ]: [
341           { id: threadId },
342           { originCommentId: threadId }
343         ],
344         accountId: {
345           [Op.notIn]: Sequelize.literal(
346             '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
347           )
348         }
349       }
350     }
351
352     const scopes: any[] = [
353       ScopeNames.WITH_ACCOUNT,
354       {
355         method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
356       }
357     ]
358
359     return VideoCommentModel
360       .scope(scopes)
361       .findAndCountAll(query)
362       .then(({ rows, count }) => {
363         return { total: count, data: rows }
364       })
365   }
366
367   static listThreadParentComments (comment: VideoCommentModel, t: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
368     const query = {
369       order: [ [ 'createdAt', order ] ] as Order,
370       where: {
371         id: {
372           [ Op.in ]: Sequelize.literal('(' +
373             'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
374               `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
375               'UNION ' +
376               'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
377               'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
378             ') ' +
379             'SELECT id FROM children' +
380           ')'),
381           [ Op.ne ]: comment.id
382         }
383       },
384       transaction: t
385     }
386
387     return VideoCommentModel
388       .scope([ ScopeNames.WITH_ACCOUNT ])
389       .findAll(query)
390   }
391
392   static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
393     const query = {
394       order: [ [ 'createdAt', order ] ] as Order,
395       offset: start,
396       limit: count,
397       where: {
398         videoId
399       },
400       transaction: t
401     }
402
403     return VideoCommentModel.findAndCountAll(query)
404   }
405
406   static listForFeed (start: number, count: number, videoId?: number) {
407     const query = {
408       order: [ [ 'createdAt', 'DESC' ] ] as Order,
409       offset: start,
410       limit: count,
411       where: {},
412       include: [
413         {
414           attributes: [ 'name', 'uuid' ],
415           model: VideoModel.unscoped(),
416           required: true
417         }
418       ]
419     }
420
421     if (videoId) query.where['videoId'] = videoId
422
423     return VideoCommentModel
424       .scope([ ScopeNames.WITH_ACCOUNT ])
425       .findAll(query)
426   }
427
428   static async getStats () {
429     const totalLocalVideoComments = await VideoCommentModel.count({
430       include: [
431         {
432           model: AccountModel,
433           required: true,
434           include: [
435             {
436               model: ActorModel,
437               required: true,
438               where: {
439                 serverId: null
440               }
441             }
442           ]
443         }
444       ]
445     })
446     const totalVideoComments = await VideoCommentModel.count()
447
448     return {
449       totalLocalVideoComments,
450       totalVideoComments
451     }
452   }
453
454   static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
455     const query = {
456       where: {
457         updatedAt: {
458           [Op.lt]: beforeUpdatedAt
459         },
460         videoId
461       }
462     }
463
464     return VideoCommentModel.destroy(query)
465   }
466
467   getCommentStaticPath () {
468     return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
469   }
470
471   getThreadId (): number {
472     return this.originCommentId || this.id
473   }
474
475   isOwned () {
476     return this.Account.isOwned()
477   }
478
479   extractMentions () {
480     let result: string[] = []
481
482     const localMention = `@(${actorNameAlphabet}+)`
483     const remoteMention = `${localMention}@${WEBSERVER.HOST}`
484
485     const mentionRegex = this.isOwned()
486       ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
487       : '(?:' + remoteMention + ')'
488
489     const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
490     const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
491     const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
492
493     result = result.concat(
494       regexpCapture(this.text, firstMentionRegex)
495         .map(([ , username1, username2 ]) => username1 || username2),
496
497       regexpCapture(this.text, endMentionRegex)
498         .map(([ , username1, username2 ]) => username1 || username2),
499
500       regexpCapture(this.text, remoteMentionsRegex)
501         .map(([ , username ]) => username)
502     )
503
504     // Include local mentions
505     if (this.isOwned()) {
506       const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
507
508       result = result.concat(
509         regexpCapture(this.text, localMentionsRegex)
510           .map(([ , username ]) => username)
511       )
512     }
513
514     return uniq(result)
515   }
516
517   toFormattedJSON () {
518     return {
519       id: this.id,
520       url: this.url,
521       text: this.text,
522       threadId: this.originCommentId || this.id,
523       inReplyToCommentId: this.inReplyToCommentId || null,
524       videoId: this.videoId,
525       createdAt: this.createdAt,
526       updatedAt: this.updatedAt,
527       totalReplies: this.get('totalReplies') || 0,
528       account: this.Account.toFormattedJSON()
529     } as VideoComment
530   }
531
532   toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
533     let inReplyTo: string
534     // New thread, so in AS we reply to the video
535     if (this.inReplyToCommentId === null) {
536       inReplyTo = this.Video.url
537     } else {
538       inReplyTo = this.InReplyToVideoComment.url
539     }
540
541     const tag: ActivityTagObject[] = []
542     for (const parentComment of threadParentComments) {
543       const actor = parentComment.Account.Actor
544
545       tag.push({
546         type: 'Mention',
547         href: actor.url,
548         name: `@${actor.preferredUsername}@${actor.getHost()}`
549       })
550     }
551
552     return {
553       type: 'Note' as 'Note',
554       id: this.url,
555       content: this.text,
556       inReplyTo,
557       updated: this.updatedAt.toISOString(),
558       published: this.createdAt.toISOString(),
559       url: this.url,
560       attributedTo: this.Account.Actor.url,
561       tag
562     }
563   }
564 }