]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/video/video-comment.ts
Handle HTML is comments
[github/Chocobozzz/PeerTube.git] / server / models / video / video-comment.ts
1 import * as Sequelize from 'sequelize'
2 import {
3 AfterDestroy, AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, IFindOptions, Is, Model, Scopes, Table,
4 UpdatedAt
5 } from 'sequelize-typescript'
6 import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
7 import { VideoComment } from '../../../shared/models/videos/video-comment.model'
8 import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
9 import { CONSTRAINTS_FIELDS } from '../../initializers'
10 import { sendDeleteVideoComment } from '../../lib/activitypub/send'
11 import { AccountModel } from '../account/account'
12 import { ActorModel } from '../activitypub/actor'
13 import { AvatarModel } from '../avatar/avatar'
14 import { ServerModel } from '../server/server'
15 import { getSort, throwIfNotValid } from '../utils'
16 import { VideoModel } from './video'
17 import { VideoChannelModel } from './video-channel'
18
19 enum ScopeNames {
20 WITH_ACCOUNT = 'WITH_ACCOUNT',
21 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
22 WITH_VIDEO = 'WITH_VIDEO',
23 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
24 }
25
26 @Scopes({
27 [ScopeNames.ATTRIBUTES_FOR_API]: {
28 attributes: {
29 include: [
30 [
31 Sequelize.literal(
32 '(SELECT COUNT("replies"."id") ' +
33 'FROM "videoComment" AS "replies" ' +
34 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
35 ),
36 'totalReplies'
37 ]
38 ]
39 }
40 },
41 [ScopeNames.WITH_ACCOUNT]: {
42 include: [
43 {
44 model: () => AccountModel,
45 include: [
46 {
47 model: () => ActorModel,
48 include: [
49 {
50 model: () => ServerModel,
51 required: false
52 },
53 {
54 model: () => AvatarModel,
55 required: false
56 }
57 ]
58 }
59 ]
60 }
61 ]
62 },
63 [ScopeNames.WITH_IN_REPLY_TO]: {
64 include: [
65 {
66 model: () => VideoCommentModel,
67 as: 'InReplyToVideoComment'
68 }
69 ]
70 },
71 [ScopeNames.WITH_VIDEO]: {
72 include: [
73 {
74 model: () => VideoModel,
75 required: true,
76 include: [
77 {
78 model: () => VideoChannelModel.unscoped(),
79 required: true,
80 include: [
81 {
82 model: () => AccountModel,
83 required: true,
84 include: [
85 {
86 model: () => ActorModel,
87 required: true
88 }
89 ]
90 }
91 ]
92 }
93 ]
94 }
95 ]
96 }
97 })
98 @Table({
99 tableName: 'videoComment',
100 indexes: [
101 {
102 fields: [ 'videoId' ]
103 },
104 {
105 fields: [ 'videoId', 'originCommentId' ]
106 }
107 ]
108 })
109 export class VideoCommentModel extends Model<VideoCommentModel> {
110 @CreatedAt
111 createdAt: Date
112
113 @UpdatedAt
114 updatedAt: Date
115
116 @AllowNull(false)
117 @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
118 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
119 url: string
120
121 @AllowNull(false)
122 @Column(DataType.TEXT)
123 text: string
124
125 @ForeignKey(() => VideoCommentModel)
126 @Column
127 originCommentId: number
128
129 @BelongsTo(() => VideoCommentModel, {
130 foreignKey: {
131 name: 'originCommentId',
132 allowNull: true
133 },
134 as: 'OriginVideoComment',
135 onDelete: 'CASCADE'
136 })
137 OriginVideoComment: VideoCommentModel
138
139 @ForeignKey(() => VideoCommentModel)
140 @Column
141 inReplyToCommentId: number
142
143 @BelongsTo(() => VideoCommentModel, {
144 foreignKey: {
145 name: 'inReplyToCommentId',
146 allowNull: true
147 },
148 as: 'InReplyToVideoComment',
149 onDelete: 'CASCADE'
150 })
151 InReplyToVideoComment: VideoCommentModel
152
153 @ForeignKey(() => VideoModel)
154 @Column
155 videoId: number
156
157 @BelongsTo(() => VideoModel, {
158 foreignKey: {
159 allowNull: false
160 },
161 onDelete: 'CASCADE'
162 })
163 Video: VideoModel
164
165 @ForeignKey(() => AccountModel)
166 @Column
167 accountId: number
168
169 @BelongsTo(() => AccountModel, {
170 foreignKey: {
171 allowNull: false
172 },
173 onDelete: 'CASCADE'
174 })
175 Account: AccountModel
176
177 @AfterDestroy
178 static async sendDeleteIfOwned (instance: VideoCommentModel) {
179 if (instance.isOwned()) {
180 await sendDeleteVideoComment(instance, undefined)
181 }
182 }
183
184 static loadById (id: number, t?: Sequelize.Transaction) {
185 const query: IFindOptions<VideoCommentModel> = {
186 where: {
187 id
188 }
189 }
190
191 if (t !== undefined) query.transaction = t
192
193 return VideoCommentModel.findOne(query)
194 }
195
196 static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Sequelize.Transaction) {
197 const query: IFindOptions<VideoCommentModel> = {
198 where: {
199 id
200 }
201 }
202
203 if (t !== undefined) query.transaction = t
204
205 return VideoCommentModel
206 .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
207 .findOne(query)
208 }
209
210 static loadByUrl (url: string, t?: Sequelize.Transaction) {
211 const query: IFindOptions<VideoCommentModel> = {
212 where: {
213 url
214 }
215 }
216
217 if (t !== undefined) query.transaction = t
218
219 return VideoCommentModel.findOne(query)
220 }
221
222 static loadByUrlAndPopulateAccount (url: string, t?: Sequelize.Transaction) {
223 const query: IFindOptions<VideoCommentModel> = {
224 where: {
225 url
226 }
227 }
228
229 if (t !== undefined) query.transaction = t
230
231 return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
232 }
233
234 static listThreadsForApi (videoId: number, start: number, count: number, sort: string) {
235 const query = {
236 offset: start,
237 limit: count,
238 order: [ getSort(sort) ],
239 where: {
240 videoId,
241 inReplyToCommentId: null
242 }
243 }
244
245 return VideoCommentModel
246 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
247 .findAndCountAll(query)
248 .then(({ rows, count }) => {
249 return { total: count, data: rows }
250 })
251 }
252
253 static listThreadCommentsForApi (videoId: number, threadId: number) {
254 const query = {
255 order: [ [ 'createdAt', 'ASC' ] ],
256 where: {
257 videoId,
258 [ Sequelize.Op.or ]: [
259 { id: threadId },
260 { originCommentId: threadId }
261 ]
262 }
263 }
264
265 return VideoCommentModel
266 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
267 .findAndCountAll(query)
268 .then(({ rows, count }) => {
269 return { total: count, data: rows }
270 })
271 }
272
273 isOwned () {
274 return this.Account.isOwned()
275 }
276
277 toFormattedJSON () {
278 return {
279 id: this.id,
280 url: this.url,
281 text: this.text,
282 threadId: this.originCommentId || this.id,
283 inReplyToCommentId: this.inReplyToCommentId || null,
284 videoId: this.videoId,
285 createdAt: this.createdAt,
286 updatedAt: this.updatedAt,
287 totalReplies: this.get('totalReplies') || 0,
288 account: this.Account.toFormattedJSON()
289 } as VideoComment
290 }
291
292 toActivityPubObject (): VideoCommentObject {
293 let inReplyTo: string
294 // New thread, so in AS we reply to the video
295 if (this.inReplyToCommentId === null) {
296 inReplyTo = this.Video.url
297 } else {
298 inReplyTo = this.InReplyToVideoComment.url
299 }
300
301 return {
302 type: 'Note' as 'Note',
303 id: this.url,
304 content: this.text,
305 inReplyTo,
306 updated: this.updatedAt.toISOString(),
307 published: this.createdAt.toISOString(),
308 url: this.url,
309 attributedTo: this.Account.Actor.url
310 }
311 }
312 }