]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
Use local object URLs for feeds
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3 AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
4 UpdatedAt
5 } from 'sequelize-typescript'
6 import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
7 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
8 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
9 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
10 import { CONSTRAINTS_FIELDS } from '../../initializers'
11 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
12 import { AccountModel } from '../account/account'
13 import { ActorModel } from '../activitypub/actor'
14 import { AvatarModel } from '../avatar/avatar'
15 import { ServerModel } from '../server/server'
16 import { getSort, throwIfNotValid } from '../utils'
17 import { VideoModel } from './video'
18 import { VideoChannelModel } from './video-channel'
19
20 enum ScopeNames {
21 WITH_ACCOUNT = 'WITH_ACCOUNT',
22 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
23 WITH_VIDEO = 'WITH_VIDEO',
24 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
25 }
26
27 @Scopes({
28 [ScopeNames.ATTRIBUTES_FOR_API]: {
29 attributes: {
30 include: [
31 [
32 Sequelize.literal(
33 '(SELECT COUNT("replies"."id") ' +
34 'FROM "videoComment" AS "replies" ' +
35 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
36 ),
37 'totalReplies'
38 ]
39 ]
40 }
41 },
42 [ScopeNames.WITH_ACCOUNT]: {
43 include: [
44 {
45 model: () => AccountModel,
46 include: [
47 {
48 model: () => ActorModel,
49 include: [
50 {
51 model: () => ServerModel,
52 required: false
53 },
54 {
55 model: () => AvatarModel,
56 required: false
57 }
58 ]
59 }
60 ]
61 }
62 ]
63 },
64 [ScopeNames.WITH_IN_REPLY_TO]: {
65 include: [
66 {
67 model: () => VideoCommentModel,
68 as: 'InReplyToVideoComment'
69 }
70 ]
71 },
72 [ScopeNames.WITH_VIDEO]: {
73 include: [
74 {
75 model: () => VideoModel,
76 required: true,
77 include: [
78 {
79 model: () => VideoChannelModel.unscoped(),
80 required: true,
81 include: [
82 {
83 model: () => AccountModel,
84 required: true,
85 include: [
86 {
87 model: () => ActorModel,
88 required: true
89 }
90 ]
91 }
92 ]
93 }
94 ]
95 }
96 ]
97 }
98 })
99 @Table({
100 tableName: 'videoComment',
101 indexes: [
102 {
103 fields: [ 'videoId' ]
104 },
105 {
106 fields: [ 'videoId', 'originCommentId' ]
107 },
108 {
109 fields: [ 'url' ],
110 unique: true
111 }
112 ]
113 })
114 export class VideoCommentModel extends Model<VideoCommentModel> {
115 @CreatedAt
116 createdAt: Date
117
118 @UpdatedAt
119 updatedAt: Date
120
121 @AllowNull(false)
122 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
123 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
124 url: string
125
126 @AllowNull(false)
127 @Column(DataType.TEXT)
128 text: string
129
130 @ForeignKey(() => VideoCommentModel)
131 @Column
132 originCommentId: number
133
134 @BelongsTo(() => VideoCommentModel, {
135 foreignKey: {
136 name: 'originCommentId',
137 allowNull: true
138 },
139 as: 'OriginVideoComment',
140 onDelete: 'CASCADE'
141 })
142 OriginVideoComment: VideoCommentModel
143
144 @ForeignKey(() => VideoCommentModel)
145 @Column
146 inReplyToCommentId: number
147
148 @BelongsTo(() => VideoCommentModel, {
149 foreignKey: {
150 name: 'inReplyToCommentId',
151 allowNull: true
152 },
153 as: 'InReplyToVideoComment',
154 onDelete: 'CASCADE'
155 })
156 InReplyToVideoComment: VideoCommentModel
157
158 @ForeignKey(() => VideoModel)
159 @Column
160 videoId: number
161
162 @BelongsTo(() => VideoModel, {
163 foreignKey: {
164 allowNull: false
165 },
166 onDelete: 'CASCADE'
167 })
168 Video: VideoModel
169
170 @ForeignKey(() => AccountModel)
171 @Column
172 accountId: number
173
174 @BelongsTo(() => AccountModel, {
175 foreignKey: {
176 allowNull: false
177 },
178 onDelete: 'CASCADE'
179 })
180 Account: AccountModel
181
182 @BeforeDestroy
183 static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
184 if (!instance.Account || !instance.Account.Actor) {
185 instance.Account = await instance.$get('Account', {
186 include: [ ActorModel ],
187 transaction: options.transaction
188 }) as AccountModel
189 }
190
191 if (!instance.Video) {
192 instance.Video = await instance.$get('Video', {
193 include: [
194 {
195 model: VideoChannelModel,
196 include: [
197 {
198 model: AccountModel,
199 include: [
200 {
201 model: ActorModel
202 }
203 ]
204 }
205 ]
206 }
207 ],
208 transaction: options.transaction
209 }) as VideoModel
210 }
211
212 if (instance.isOwned()) {
213 await sendDeleteVideoComment(instance, options.transaction)
214 }
215 }
216
217 static loadById (id: number, t?: Sequelize.Transaction) {
218 const query: IFindOptions<VideoCommentModel> = {
219 where: {
220 id
221 }
222 }
223
224 if (t !== undefined) query.transaction = t
225
226 return VideoCommentModel.findOne(query)
227 }
228
229 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
230 const query: IFindOptions<VideoCommentModel> = {
231 where: {
232 id
233 }
234 }
235
236 if (t !== undefined) query.transaction = t
237
238 return VideoCommentModel
239 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
240 .findOne(query)
241 }
242
243 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
244 const query: IFindOptions<VideoCommentModel> = {
245 where: {
246 url
247 }
248 }
249
250 if (t !== undefined) query.transaction = t
251
252 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
253 }
254
255 static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Sequelize.Transaction) {
256 const query: IFindOptions<VideoCommentModel> = {
257 where: {
258 url
259 }
260 }
261
262 if (t !== undefined) query.transaction = t
263
264 return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
265 }
266
267 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
268 const query = {
269 offset: start,
270 limit: count,
271 order: getSort(sort),
272 where: {
273 videoId,
274 inReplyToCommentId: null
275 }
276 }
277
278 return VideoCommentModel
279 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
280 .findAndCountAll(query)
281 .then(({ rows, count }) => {
282 return { total: count, data: rows }
283 })
284 }
285
286 static listThreadCommentsForApi (videoId: number, threadId: number) {
287 const query = {
288 order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ],
289 where: {
290 videoId,
291 [ Sequelize.Op.or ]: [
292 { id: threadId },
293 { originCommentId: threadId }
294 ]
295 }
296 }
297
298 return VideoCommentModel
299 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
300 .findAndCountAll(query)
301 .then(({ rows, count }) => {
302 return { total: count, data: rows }
303 })
304 }
305
306 static listThreadParentComments (comment: VideoCommentModel, t: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
307 const query = {
308 order: [ [ 'createdAt', order ] ],
309 where: {
310 id: {
311 [ Sequelize.Op.in ]: Sequelize.literal('(' +
312 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
313 'SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ' + comment.id + ' UNION ' +
314 'SELECT p.id, p."inReplyToCommentId" from "videoComment" p ' +
315 'INNER JOIN children c ON c."inReplyToCommentId" = p.id) ' +
316 'SELECT id FROM children' +
317 ')'),
318 [ Sequelize.Op.ne ]: comment.id
319 }
320 },
321 transaction: t
322 }
323
324 return VideoCommentModel
325 .scope([ ScopeNames.WITH_ACCOUNT ])
326 .findAll(query)
327 }
328
329 static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Sequelize.Transaction, order: 'ASC' | 'DESC' = 'ASC') {
330 const query = {
331 order: [ [ 'createdAt', order ] ],
332 offset: start,
333 limit: count,
334 where: {
335 videoId
336 },
337 transaction: t
338 }
339
340 return VideoCommentModel.findAndCountAll(query)
341 }
342
343 static listForFeed (start: number, count: number, videoId?: number) {
344 const query = {
345 order: [ [ 'createdAt', 'DESC' ] ],
346 offset: start,
347 limit: count,
348 where: {},
349 include: [
350 {
351 attributes: [ 'name', 'uuid' ],
352 model: VideoModel.unscoped(),
353 required: true
354 }
355 ]
356 }
357
358 if (videoId) query.where['videoId'] = videoId
359
360 return VideoCommentModel
361 .scope([ ScopeNames.WITH_ACCOUNT ])
362 .findAll(query)
363 }
364
365 static async getStats () {
366 const totalLocalVideoComments = await VideoCommentModel.count({
367 include: [
368 {
369 model: AccountModel,
370 required: true,
371 include: [
372 {
373 model: ActorModel,
374 required: true,
375 where: {
376 serverId: null
377 }
378 }
379 ]
380 }
381 ]
382 })
383 const totalVideoComments = await VideoCommentModel.count()
384
385 return {
386 totalLocalVideoComments,
387 totalVideoComments
388 }
389 }
390
391 getThreadId (): number {
392 return this.originCommentId || this.id
393 }
394
395 isOwned () {
396 return this.Account.isOwned()
397 }
398
399 toFormattedJSON () {
400 return {
401 id: this.id,
402 url: this.url,
403 text: this.text,
404 threadId: this.originCommentId || this.id,
405 inReplyToCommentId: this.inReplyToCommentId || null,
406 videoId: this.videoId,
407 createdAt: this.createdAt,
408 updatedAt: this.updatedAt,
409 totalReplies: this.get('totalReplies') || 0,
410 account: this.Account.toFormattedJSON()
411 } as VideoComment
412 }
413
414 toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
415 let inReplyTo: string
416 // New thread, so in AS we reply to the video
417 if (this.inReplyToCommentId === null) {
418 inReplyTo = this.Video.url
419 } else {
420 inReplyTo = this.InReplyToVideoComment.url
421 }
422
423 const tag: ActivityTagObject[] = []
424 for (const parentComment of threadParentComments) {
425 const actor = parentComment.Account.Actor
426
427 tag.push({
428 type: 'Mention',
429 href: actor.url,
430 name: `@${actor.preferredUsername}@${actor.getHost()}`
431 })
432 }
433
434 return {
435 type: 'Note' as 'Note',
436 id: this.url,
437 content: this.text,
438 inReplyTo,
439 updated: this.updatedAt.toISOString(),
440 published: this.createdAt.toISOString(),
441 url: this.url,
442 attributedTo: this.Account.Actor.url,
443 tag
444 }
445 }
446 }