video-comment.ts 13 KB

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