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