video-comment.ts 14 KB
Newer Older
1
import {
2
3
4
5
6
7
8
9
10
11
12
  AllowNull,
  BeforeDestroy,
  BelongsTo,
  Column,
  CreatedAt,
  DataType,
  ForeignKey,
  Is,
  Model,
  Scopes,
  Table,
13
14
  UpdatedAt
} from 'sequelize-typescript'
Chocobozzz's avatar
Chocobozzz committed
15
import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
16
import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
Chocobozzz's avatar
Chocobozzz committed
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'
Chocobozzz's avatar
Chocobozzz committed
20
import { sendDeleteVideoComment } from '../../lib/activitypub/send'
Chocobozzz's avatar
Chocobozzz committed
21
import { AccountModel } from '../account/account'
Chocobozzz's avatar
Chocobozzz committed
22
import { ActorModel } from '../activitypub/actor'
Chocobozzz's avatar
Chocobozzz committed
23
import { AvatarModel } from '../avatar/avatar'
Chocobozzz's avatar
Chocobozzz committed
24
import { ServerModel } from '../server/server'
25
import { buildBlockedAccountSQL, buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils'
26
import { VideoModel } from './video'
Chocobozzz's avatar
Chocobozzz committed
27
import { VideoChannelModel } from './video-channel'
28
29
import { getServerActor } from '../../helpers/utils'
import { UserModel } from '../account/user'
30
31
32
import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
import { regexpCapture } from '../../helpers/regexp'
import { uniq } from 'lodash'
33
import { FindOptions, literal, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
34

Chocobozzz's avatar
Chocobozzz committed
35
enum ScopeNames {
36
  WITH_ACCOUNT = 'WITH_ACCOUNT',
Chocobozzz's avatar
Chocobozzz committed
37
  WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
38
  WITH_VIDEO = 'WITH_VIDEO',
Chocobozzz's avatar
Chocobozzz committed
39
  ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
Chocobozzz's avatar
Chocobozzz committed
40
41
}

Chocobozzz's avatar
Chocobozzz committed
42
@Scopes(() => ({
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
  [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
    return {
      attributes: {
        include: [
          [
            Sequelize.literal(
              '(' +
                'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
                'SELECT COUNT("replies"."id") - (' +
                  'SELECT COUNT("replies"."id") ' +
                  'FROM "videoComment" AS "replies" ' +
                  'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
                  'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
                ')' +
                'FROM "videoComment" AS "replies" ' +
                'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
                'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
              ')'
            ),
            'totalReplies'
          ]
Chocobozzz's avatar
Chocobozzz committed
64
        ]
65
      }
Chocobozzz's avatar
Chocobozzz committed
66
    } as FindOptions
Chocobozzz's avatar
Chocobozzz committed
67
  },
Chocobozzz's avatar
Chocobozzz committed
68
  [ScopeNames.WITH_ACCOUNT]: {
Chocobozzz's avatar
Chocobozzz committed
69
    include: [
Chocobozzz's avatar
Chocobozzz committed
70
      {
Chocobozzz's avatar
Chocobozzz committed
71
        model: AccountModel,
Chocobozzz's avatar
Chocobozzz committed
72
73
        include: [
          {
Chocobozzz's avatar
Chocobozzz committed
74
            model: ActorModel,
Chocobozzz's avatar
Chocobozzz committed
75
76
            include: [
              {
Chocobozzz's avatar
Chocobozzz committed
77
                model: ServerModel,
Chocobozzz's avatar
Chocobozzz committed
78
                required: false
Chocobozzz's avatar
Chocobozzz committed
79
80
              },
              {
Chocobozzz's avatar
Chocobozzz committed
81
                model: AvatarModel,
Chocobozzz's avatar
Chocobozzz committed
82
                required: false
Chocobozzz's avatar
Chocobozzz committed
83
84
85
86
87
              }
            ]
          }
        ]
      }
Chocobozzz's avatar
Chocobozzz committed
88
    ]
89
90
91
92
  },
  [ScopeNames.WITH_IN_REPLY_TO]: {
    include: [
      {
Chocobozzz's avatar
Chocobozzz committed
93
        model: VideoCommentModel,
94
95
96
97
98
99
100
        as: 'InReplyToVideoComment'
      }
    ]
  },
  [ScopeNames.WITH_VIDEO]: {
    include: [
      {
Chocobozzz's avatar
Chocobozzz committed
101
        model: VideoModel,
Chocobozzz's avatar
Chocobozzz committed
102
103
104
        required: true,
        include: [
          {
Chocobozzz's avatar
Chocobozzz committed
105
            model: VideoChannelModel.unscoped(),
Chocobozzz's avatar
Chocobozzz committed
106
107
            required: true,
            include: [
108
109
110
111
              {
                model: ActorModel,
                required: true
              },
Chocobozzz's avatar
Chocobozzz committed
112
              {
Chocobozzz's avatar
Chocobozzz committed
113
                model: AccountModel,
Chocobozzz's avatar
Chocobozzz committed
114
115
116
                required: true,
                include: [
                  {
Chocobozzz's avatar
Chocobozzz committed
117
                    model: ActorModel,
Chocobozzz's avatar
Chocobozzz committed
118
119
120
121
122
123
124
                    required: true
                  }
                ]
              }
            ]
          }
        ]
125
      }
Chocobozzz's avatar
Chocobozzz committed
126
    ]
Chocobozzz's avatar
Chocobozzz committed
127
  }
Chocobozzz's avatar
Chocobozzz committed
128
}))
129
130
131
132
133
@Table({
  tableName: 'videoComment',
  indexes: [
    {
      fields: [ 'videoId' ]
Chocobozzz's avatar
Chocobozzz committed
134
135
136
    },
    {
      fields: [ 'videoId', 'originCommentId' ]
137
138
139
140
    },
    {
      fields: [ 'url' ],
      unique: true
Chocobozzz's avatar
Chocobozzz committed
141
142
143
    },
    {
      fields: [ 'accountId' ]
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
    }
  ]
})
export class VideoCommentModel extends Model<VideoCommentModel> {
  @CreatedAt
  createdAt: Date

  @UpdatedAt
  updatedAt: Date

  @AllowNull(false)
  @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
  @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
  url: string

  @AllowNull(false)
  @Column(DataType.TEXT)
  text: string

  @ForeignKey(() => VideoCommentModel)
  @Column
  originCommentId: number

  @BelongsTo(() => VideoCommentModel, {
    foreignKey: {
169
      name: 'originCommentId',
170
171
      allowNull: true
    },
172
    as: 'OriginVideoComment',
173
174
175
176
177
178
179
180
181
182
    onDelete: 'CASCADE'
  })
  OriginVideoComment: VideoCommentModel

  @ForeignKey(() => VideoCommentModel)
  @Column
  inReplyToCommentId: number

  @BelongsTo(() => VideoCommentModel, {
    foreignKey: {
183
      name: 'inReplyToCommentId',
184
185
      allowNull: true
    },
186
    as: 'InReplyToVideoComment',
187
188
    onDelete: 'CASCADE'
  })
189
  InReplyToVideoComment: VideoCommentModel | null
190
191
192
193
194
195
196
197
198
199
200
201
202

  @ForeignKey(() => VideoModel)
  @Column
  videoId: number

  @BelongsTo(() => VideoModel, {
    foreignKey: {
      allowNull: false
    },
    onDelete: 'CASCADE'
  })
  Video: VideoModel

Chocobozzz's avatar
Chocobozzz committed
203
  @ForeignKey(() => AccountModel)
204
  @Column
Chocobozzz's avatar
Chocobozzz committed
205
  accountId: number
206

Chocobozzz's avatar
Chocobozzz committed
207
  @BelongsTo(() => AccountModel, {
208
209
210
211
212
    foreignKey: {
      allowNull: false
    },
    onDelete: 'CASCADE'
  })
Chocobozzz's avatar
Chocobozzz committed
213
  Account: AccountModel
214

Chocobozzz's avatar
Chocobozzz committed
215
216
  static loadById (id: number, t?: Transaction) {
    const query: FindOptions = {
Chocobozzz's avatar
Chocobozzz committed
217
218
219
220
221
222
223
224
225
226
      where: {
        id
      }
    }

    if (t !== undefined) query.transaction = t

    return VideoCommentModel.findOne(query)
  }

Chocobozzz's avatar
Chocobozzz committed
227
228
  static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction) {
    const query: FindOptions = {
229
230
231
232
233
234
235
236
237
238
239
240
      where: {
        id
      }
    }

    if (t !== undefined) query.transaction = t

    return VideoCommentModel
      .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
      .findOne(query)
  }

241
  static loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction) {
Chocobozzz's avatar
Chocobozzz committed
242
    const query: FindOptions = {
243
244
245
246
247
248
249
      where: {
        url
      }
    }

    if (t !== undefined) query.transaction = t

250
    return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
251
  }
Chocobozzz's avatar
Chocobozzz committed
252

253
  static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction) {
Chocobozzz's avatar
Chocobozzz committed
254
    const query: FindOptions = {
Chocobozzz's avatar
Chocobozzz committed
255
256
      where: {
        url
257
258
259
260
261
262
263
      },
      include: [
        {
          attributes: [ 'id', 'url' ],
          model: VideoModel.unscoped()
        }
      ]
Chocobozzz's avatar
Chocobozzz committed
264
265
266
267
    }

    if (t !== undefined) query.transaction = t

268
    return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
Chocobozzz's avatar
Chocobozzz committed
269
270
  }

Chocobozzz's avatar
Chocobozzz committed
271
272
273
274
275
276
277
278
279
  static async listThreadsForApi (parameters: {
    videoId: number,
    start: number,
    count: number,
    sort: string,
    user?: UserModel
  }) {
    const { videoId, start, count, sort, user } = parameters

280
281
    const serverActor = await getServerActor()
    const serverAccountId = serverActor.Account.id
282
    const userAccountId = user ? user.Account.id : undefined
283

Chocobozzz's avatar
Chocobozzz committed
284
285
286
    const query = {
      offset: start,
      limit: count,
Chocobozzz's avatar
Chocobozzz committed
287
      order: getSort(sort),
Chocobozzz's avatar
Chocobozzz committed
288
      where: {
Chocobozzz's avatar
Chocobozzz committed
289
        videoId,
290
291
        inReplyToCommentId: null,
        accountId: {
Chocobozzz's avatar
Chocobozzz committed
292
          [Op.notIn]: Sequelize.literal(
293
294
295
            '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
          )
        }
Chocobozzz's avatar
Chocobozzz committed
296
297
298
      }
    }

Chocobozzz's avatar
Chocobozzz committed
299
    const scopes: (string | ScopeOptions)[] = [
300
301
302
303
304
305
      ScopeNames.WITH_ACCOUNT,
      {
        method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
      }
    ]

Chocobozzz's avatar
Chocobozzz committed
306
    return VideoCommentModel
307
      .scope(scopes)
Chocobozzz's avatar
Chocobozzz committed
308
309
310
311
312
313
      .findAndCountAll(query)
      .then(({ rows, count }) => {
        return { total: count, data: rows }
      })
  }

Chocobozzz's avatar
Chocobozzz committed
314
315
316
317
318
319
320
  static async listThreadCommentsForApi (parameters: {
    videoId: number,
    threadId: number,
    user?: UserModel
  }) {
    const { videoId, threadId, user } = parameters

321
322
    const serverActor = await getServerActor()
    const serverAccountId = serverActor.Account.id
323
    const userAccountId = user ? user.Account.id : undefined
324

Chocobozzz's avatar
Chocobozzz committed
325
    const query = {
Chocobozzz's avatar
Chocobozzz committed
326
      order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
Chocobozzz's avatar
Chocobozzz committed
327
328
      where: {
        videoId,
Chocobozzz's avatar
Chocobozzz committed
329
        [ Op.or ]: [
Chocobozzz's avatar
Chocobozzz committed
330
331
          { id: threadId },
          { originCommentId: threadId }
332
333
        ],
        accountId: {
Chocobozzz's avatar
Chocobozzz committed
334
          [Op.notIn]: Sequelize.literal(
335
336
337
            '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
          )
        }
Chocobozzz's avatar
Chocobozzz committed
338
339
340
      }
    }

341
342
343
344
345
346
347
    const scopes: any[] = [
      ScopeNames.WITH_ACCOUNT,
      {
        method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
      }
    ]

Chocobozzz's avatar
Chocobozzz committed
348
    return VideoCommentModel
349
      .scope(scopes)
Chocobozzz's avatar
Chocobozzz committed
350
351
352
353
354
355
      .findAndCountAll(query)
      .then(({ rows, count }) => {
        return { total: count, data: rows }
      })
  }

Chocobozzz's avatar
Chocobozzz committed
356
  static listThreadParentComments (comment: VideoCommentModel, t: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
Chocobozzz's avatar
Chocobozzz committed
357
    const query = {
Chocobozzz's avatar
Chocobozzz committed
358
      order: [ [ 'createdAt', order ] ] as Order,
Chocobozzz's avatar
Chocobozzz committed
359
360
      where: {
        id: {
Chocobozzz's avatar
Chocobozzz committed
361
          [ Op.in ]: Sequelize.literal('(' +
Chocobozzz's avatar
Chocobozzz committed
362
            'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
363
364
365
366
367
              `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
              'UNION ' +
              'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
              'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
            ') ' +
Chocobozzz's avatar
Chocobozzz committed
368
369
            'SELECT id FROM children' +
          ')'),
Chocobozzz's avatar
Chocobozzz committed
370
          [ Op.ne ]: comment.id
Chocobozzz's avatar
Chocobozzz committed
371
372
373
374
375
376
377
378
379
380
        }
      },
      transaction: t
    }

    return VideoCommentModel
      .scope([ ScopeNames.WITH_ACCOUNT ])
      .findAll(query)
  }

Chocobozzz's avatar
Chocobozzz committed
381
  static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
382
    const query = {
Chocobozzz's avatar
Chocobozzz committed
383
      order: [ [ 'createdAt', order ] ] as Order,
384
385
      offset: start,
      limit: count,
386
387
388
389
390
391
392
393
394
      where: {
        videoId
      },
      transaction: t
    }

    return VideoCommentModel.findAndCountAll(query)
  }

Chocobozzz's avatar
Chocobozzz committed
395
396
  static listForFeed (start: number, count: number, videoId?: number) {
    const query = {
Chocobozzz's avatar
Chocobozzz committed
397
      order: [ [ 'createdAt', 'DESC' ] ] as Order,
398
399
      offset: start,
      limit: count,
Chocobozzz's avatar
Chocobozzz committed
400
401
402
      where: {},
      include: [
        {
Chocobozzz's avatar
Chocobozzz committed
403
          attributes: [ 'name', 'uuid' ],
Chocobozzz's avatar
Chocobozzz committed
404
405
406
407
408
409
410
411
412
413
414
415
416
          model: VideoModel.unscoped(),
          required: true
        }
      ]
    }

    if (videoId) query.where['videoId'] = videoId

    return VideoCommentModel
      .scope([ ScopeNames.WITH_ACCOUNT ])
      .findAll(query)
  }

Chocobozzz's avatar
Chocobozzz committed
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
  static async getStats () {
    const totalLocalVideoComments = await VideoCommentModel.count({
      include: [
        {
          model: AccountModel,
          required: true,
          include: [
            {
              model: ActorModel,
              required: true,
              where: {
                serverId: null
              }
            }
          ]
        }
      ]
    })
    const totalVideoComments = await VideoCommentModel.count()

    return {
      totalLocalVideoComments,
      totalVideoComments
    }
  }

443
444
445
446
  static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
    const query = {
      where: {
        updatedAt: {
Chocobozzz's avatar
Chocobozzz committed
447
          [Op.lt]: beforeUpdatedAt
448
        },
449
450
451
        videoId,
        accountId: {
          [Op.notIn]: buildLocalAccountIdsIn()
Chocobozzz's avatar
Chocobozzz committed
452
        }
453
      }
454
455
456
457
458
    }

    return VideoCommentModel.destroy(query)
  }

Chocobozzz's avatar
Chocobozzz committed
459
460
461
462
  getCommentStaticPath () {
    return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
  }

Chocobozzz's avatar
Chocobozzz committed
463
464
465
466
  getThreadId (): number {
    return this.originCommentId || this.id
  }

Chocobozzz's avatar
Chocobozzz committed
467
468
469
470
  isOwned () {
    return this.Account.isOwned()
  }

471
  extractMentions () {
472
    let result: string[] = []
473
474

    const localMention = `@(${actorNameAlphabet}+)`
Chocobozzz's avatar
Chocobozzz committed
475
    const remoteMention = `${localMention}@${WEBSERVER.HOST}`
476

477
478
479
480
481
482
    const mentionRegex = this.isOwned()
      ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
      : '(?:' + remoteMention + ')'

    const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
    const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
483
484
    const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')

485
486
487
    result = result.concat(
      regexpCapture(this.text, firstMentionRegex)
        .map(([ , username1, username2 ]) => username1 || username2),
488

489
490
491
492
493
494
      regexpCapture(this.text, endMentionRegex)
        .map(([ , username1, username2 ]) => username1 || username2),

      regexpCapture(this.text, remoteMentionsRegex)
        .map(([ , username ]) => username)
    )
495

496
497
498
    // Include local mentions
    if (this.isOwned()) {
      const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
499

500
501
502
      result = result.concat(
        regexpCapture(this.text, localMentionsRegex)
          .map(([ , username ]) => username)
503
      )
504
505
506
    }

    return uniq(result)
507
508
  }

Chocobozzz's avatar
Chocobozzz committed
509
510
511
512
513
514
  toFormattedJSON () {
    return {
      id: this.id,
      url: this.url,
      text: this.text,
      threadId: this.originCommentId || this.id,
Chocobozzz's avatar
Chocobozzz committed
515
      inReplyToCommentId: this.inReplyToCommentId || null,
Chocobozzz's avatar
Chocobozzz committed
516
517
      videoId: this.videoId,
      createdAt: this.createdAt,
Chocobozzz's avatar
Chocobozzz committed
518
      updatedAt: this.updatedAt,
Chocobozzz's avatar
Chocobozzz committed
519
      totalReplies: this.get('totalReplies') || 0,
Chocobozzz's avatar
Chocobozzz committed
520
      account: this.Account.toFormattedJSON()
Chocobozzz's avatar
Chocobozzz committed
521
522
    } as VideoComment
  }
523

Chocobozzz's avatar
Chocobozzz committed
524
  toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
525
526
    let inReplyTo: string
    // New thread, so in AS we reply to the video
Chocobozzz's avatar
Chocobozzz committed
527
    if (this.inReplyToCommentId === null) {
528
529
530
531
532
      inReplyTo = this.Video.url
    } else {
      inReplyTo = this.InReplyToVideoComment.url
    }

Chocobozzz's avatar
Chocobozzz committed
533
534
535
536
537
538
539
540
541
542
543
    const tag: ActivityTagObject[] = []
    for (const parentComment of threadParentComments) {
      const actor = parentComment.Account.Actor

      tag.push({
        type: 'Mention',
        href: actor.url,
        name: `@${actor.preferredUsername}@${actor.getHost()}`
      })
    }

544
545
546
547
548
    return {
      type: 'Note' as 'Note',
      id: this.url,
      content: this.text,
      inReplyTo,
549
      updated: this.updatedAt.toISOString(),
550
      published: this.createdAt.toISOString(),
551
      url: this.url,
Chocobozzz's avatar
Chocobozzz committed
552
553
      attributedTo: this.Account.Actor.url,
      tag
554
555
    }
  }
556
}