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