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