video-comment.ts 14 KB

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