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