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