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