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