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