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