]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
Create a dedicated table to track video thumbnails
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3 AllowNull,
4 BeforeDestroy,
5 BelongsTo,
6 Column,
7 CreatedAt,
8 DataType,
9 ForeignKey,
10 IFindOptions,
11 Is,
12 Model,
13 Scopes,
14 Table,
15 UpdatedAt
16 } from 'sequelize-typescript'
17 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
18 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
19 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
20 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
21 import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
22 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
23 import { AccountModel } from '../account/account'
24 import { ActorModel } from '../activitypub/actor'
25 import { AvatarModel } from '../avatar/avatar'
26 import { ServerModel } from '../server/server'
27 import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
28 import { VideoModel } from './video'
29 import { VideoChannelModel } from './video-channel'
30 import { getServerActor } from '../../helpers/utils'
31 import { UserModel } from '../account/user'
32 import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
33 import { regexpCapture } from '../../helpers/regexp'
34 import { uniq } from 'lodash'
35
36 enum ScopeNames {
37 WITH_ACCOUNT = 'WITH_ACCOUNT',
38 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
39 WITH_VIDEO = 'WITH_VIDEO',
40 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
41 }
42
43 @Scopes({
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 ]
65 ]
66 }
67 }
68 },
69 [ScopeNames.WITH_ACCOUNT]: {
70 include: [
71 {
72 model: () => AccountModel,
73 include: [
74 {
75 model: () => ActorModel,
76 include: [
77 {
78 model: () => ServerModel,
79 required: false
80 },
81 {
82 model: () => AvatarModel,
83 required: false
84 }
85 ]
86 }
87 ]
88 }
89 ]
90 },
91 [ScopeNames.WITH_IN_REPLY_TO]: {
92 include: [
93 {
94 model: () => VideoCommentModel,
95 as: 'InReplyToVideoComment'
96 }
97 ]
98 },
99 [ScopeNames.WITH_VIDEO]: {
100 include: [
101 {
102 model: () => VideoModel,
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 ]
122 }
123 ]
124 }
125 })
126 @Table({
127 tableName: 'videoComment',
128 indexes: [
129 {
130 fields: [ 'videoId' ]
131 },
132 {
133 fields: [ 'videoId', 'originCommentId' ]
134 },
135 {
136 fields: [ 'url' ],
137 unique: true
138 },
139 {
140 fields: [ 'accountId' ]
141 }
142 ]
143 })
144 export 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: {
166 name: 'originCommentId',
167 allowNull: true
168 },
169 as: 'OriginVideoComment',
170 onDelete: 'CASCADE'
171 })
172 OriginVideoComment: VideoCommentModel
173
174 @ForeignKey(() => VideoCommentModel)
175 @Column
176 inReplyToCommentId: number
177
178 @BelongsTo(() => VideoCommentModel, {
179 foreignKey: {
180 name: 'inReplyToCommentId',
181 allowNull: true
182 },
183 as: 'InReplyToVideoComment',
184 onDelete: 'CASCADE'
185 })
186 InReplyToVideoComment: VideoCommentModel | null
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
200 @ForeignKey(() => AccountModel)
201 @Column
202 accountId: number
203
204 @BelongsTo(() => AccountModel, {
205 foreignKey: {
206 allowNull: false
207 },
208 onDelete: 'CASCADE'
209 })
210 Account: AccountModel
211
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
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
242 if (instance.isOwned()) {
243 await sendDeleteVideoComment(instance, options.transaction)
244 }
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
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
273 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
274 const query: IFindOptions<VideoCommentModel> = {
275 where: {
276 url
277 }
278 }
279
280 if (t !== undefined) query.transaction = t
281
282 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
283 }
284
285 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
286 const query: IFindOptions<VideoCommentModel> = {
287 where: {
288 url
289 }
290 }
291
292 if (t !== undefined) query.transaction = t
293
294 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
295 }
296
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
300 const userAccountId = user ? user.Account.id : undefined
301
302 const query = {
303 offset: start,
304 limit: count,
305 order: getSort(sort),
306 where: {
307 videoId,
308 inReplyToCommentId: null,
309 accountId: {
310 [Sequelize.Op.notIn]: Sequelize.literal(
311 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
312 )
313 }
314 }
315 }
316
317 // FIXME: typings
318 const scopes: any[] = [
319 ScopeNames.WITH_ACCOUNT,
320 {
321 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
322 }
323 ]
324
325 return VideoCommentModel
326 .scope(scopes)
327 .findAndCountAll(query)
328 .then(({ rows, count }) => {
329 return { total: count, data: rows }
330 })
331 }
332
333 static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
334 const serverActor = await getServerActor()
335 const serverAccountId = serverActor.Account.id
336 const userAccountId = user ? user.Account.id : undefined
337
338 const query = {
339 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
340 where: {
341 videoId,
342 [ Sequelize.Op.or ]: [
343 { id: threadId },
344 { originCommentId: threadId }
345 ],
346 accountId: {
347 [Sequelize.Op.notIn]: Sequelize.literal(
348 '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
349 )
350 }
351 }
352 }
353
354 const scopes: any[] = [
355 ScopeNames.WITH_ACCOUNT,
356 {
357 method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
358 }
359 ]
360
361 return VideoCommentModel
362 .scope(scopes)
363 .findAndCountAll(query)
364 .then(({ rows, count }) => {
365 return { total: count, data: rows }
366 })
367 }
368
369 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
370 const query = {
371 order: [ [ 'createdAt', order ] ],
372 where: {
373 id: {
374 [ Sequelize.Op.in ]: Sequelize.literal('(' +
375 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
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 ') ' +
381 'SELECT id FROM children' +
382 ')'),
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
394 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
395 const query = {
396 order: [ [ 'createdAt', order ] ],
397 offset: start,
398 limit: count,
399 where: {
400 videoId
401 },
402 transaction: t
403 }
404
405 return VideoCommentModel.findAndCountAll(query)
406 }
407
408 static listForFeed (start: number, count: number, videoId?: number) {
409 const query = {
410 order: [ [ 'createdAt', 'DESC' ] ],
411 offset: start,
412 limit: count,
413 where: {},
414 include: [
415 {
416 attributes: [ 'name', 'uuid' ],
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
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
456 static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
457 const query = {
458 where: {
459 updatedAt: {
460 [Sequelize.Op.lt]: beforeUpdatedAt
461 },
462 videoId
463 }
464 }
465
466 return VideoCommentModel.destroy(query)
467 }
468
469 getCommentStaticPath () {
470 return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
471 }
472
473 getThreadId (): number {
474 return this.originCommentId || this.id
475 }
476
477 isOwned () {
478 return this.Account.isOwned()
479 }
480
481 extractMentions () {
482 let result: string[] = []
483
484 const localMention = `@(${actorNameAlphabet}+)`
485 const remoteMention = `${localMention}@${WEBSERVER.HOST}`
486
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')
493 const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
494
495 result = result.concat(
496 regexpCapture(this.text, firstMentionRegex)
497 .map(([ , username1, username2 ]) => username1 || username2),
498
499 regexpCapture(this.text, endMentionRegex)
500 .map(([ , username1, username2 ]) => username1 || username2),
501
502 regexpCapture(this.text, remoteMentionsRegex)
503 .map(([ , username ]) => username)
504 )
505
506 // Include local mentions
507 if (this.isOwned()) {
508 const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
509
510 result = result.concat(
511 regexpCapture(this.text, localMentionsRegex)
512 .map(([ , username ]) => username)
513 )
514 }
515
516 return uniq(result)
517 }
518
519 toFormattedJSON () {
520 return {
521 id: this.id,
522 url: this.url,
523 text: this.text,
524 threadId: this.originCommentId || this.id,
525 inReplyToCommentId: this.inReplyToCommentId || null,
526 videoId: this.videoId,
527 createdAt: this.createdAt,
528 updatedAt: this.updatedAt,
529 totalReplies: this.get('totalReplies') || 0,
530 account: this.Account.toFormattedJSON()
531 } as VideoComment
532 }
533
534 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
535 let inReplyTo: string
536 // New thread, so in AS we reply to the video
537 if (this.inReplyToCommentId === null) {
538 inReplyTo = this.Video.url
539 } else {
540 inReplyTo = this.InReplyToVideoComment.url
541 }
542
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
554 return {
555 type: 'Note' as 'Note',
556 id: this.url,
557 content: this.text,
558 inReplyTo,
559 updated: this.updatedAt.toISOString(),
560 published: this.createdAt.toISOString(),
561 url: this.url,
562 attributedTo: this.Account.Actor.url,
563 tag
564 }
565 }
566 }