123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564 |
- import {
- AllowNull,
- BeforeDestroy,
- BelongsTo,
- Column,
- CreatedAt,
- DataType,
- ForeignKey,
- Is,
- Model,
- Scopes,
- Table,
- UpdatedAt
- } from 'sequelize-typescript'
- import { ActivityTagObject } from '../../../shared/models/activitypub/objects/common-objects'
- import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
- import { VideoComment } from '../../../shared/models/videos/video-comment.model'
- import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
- import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
- import { sendDeleteVideoComment } from '../../lib/activitypub/send'
- import { AccountModel } from '../account/account'
- import { ActorModel } from '../activitypub/actor'
- import { AvatarModel } from '../avatar/avatar'
- import { ServerModel } from '../server/server'
- import { buildBlockedAccountSQL, getSort, throwIfNotValid } from '../utils'
- import { VideoModel } from './video'
- import { VideoChannelModel } from './video-channel'
- import { getServerActor } from '../../helpers/utils'
- import { UserModel } from '../account/user'
- import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
- import { regexpCapture } from '../../helpers/regexp'
- import { uniq } from 'lodash'
- import { FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction } from 'sequelize'
- enum ScopeNames {
- WITH_ACCOUNT = 'WITH_ACCOUNT',
- WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
- WITH_VIDEO = 'WITH_VIDEO',
- ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
- }
- @Scopes(() => ({
- [ScopeNames.ATTRIBUTES_FOR_API]: (serverAccountId: number, userAccountId?: number) => {
- return {
- attributes: {
- include: [
- [
- Sequelize.literal(
- '(' +
- 'WITH "blocklist" AS (' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')' +
- 'SELECT COUNT("replies"."id") - (' +
- 'SELECT COUNT("replies"."id") ' +
- 'FROM "videoComment" AS "replies" ' +
- 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
- 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
- ')' +
- 'FROM "videoComment" AS "replies" ' +
- 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
- 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
- ')'
- ),
- 'totalReplies'
- ]
- ]
- }
- } as FindOptions
- },
- [ScopeNames.WITH_ACCOUNT]: {
- include: [
- {
- model: AccountModel,
- include: [
- {
- model: ActorModel,
- include: [
- {
- model: ServerModel,
- required: false
- },
- {
- model: AvatarModel,
- required: false
- }
- ]
- }
- ]
- }
- ]
- },
- [ScopeNames.WITH_IN_REPLY_TO]: {
- include: [
- {
- model: VideoCommentModel,
- as: 'InReplyToVideoComment'
- }
- ]
- },
- [ScopeNames.WITH_VIDEO]: {
- include: [
- {
- model: VideoModel,
- required: true,
- include: [
- {
- model: VideoChannelModel.unscoped(),
- required: true,
- include: [
- {
- model: AccountModel,
- required: true,
- include: [
- {
- model: ActorModel,
- required: true
- }
- ]
- }
- ]
- }
- ]
- }
- ]
- }
- }))
- @Table({
- tableName: 'videoComment',
- indexes: [
- {
- fields: [ 'videoId' ]
- },
- {
- fields: [ 'videoId', 'originCommentId' ]
- },
- {
- fields: [ 'url' ],
- unique: true
- },
- {
- fields: [ 'accountId' ]
- }
- ]
- })
- export class VideoCommentModel extends Model<VideoCommentModel> {
- @CreatedAt
- createdAt: Date
- @UpdatedAt
- updatedAt: Date
- @AllowNull(false)
- @Is('VideoCommentUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
- @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.URL.max))
- url: string
- @AllowNull(false)
- @Column(DataType.TEXT)
- text: string
- @ForeignKey(() => VideoCommentModel)
- @Column
- originCommentId: number
- @BelongsTo(() => VideoCommentModel, {
- foreignKey: {
- name: 'originCommentId',
- allowNull: true
- },
- as: 'OriginVideoComment',
- onDelete: 'CASCADE'
- })
- OriginVideoComment: VideoCommentModel
- @ForeignKey(() => VideoCommentModel)
- @Column
- inReplyToCommentId: number
- @BelongsTo(() => VideoCommentModel, {
- foreignKey: {
- name: 'inReplyToCommentId',
- allowNull: true
- },
- as: 'InReplyToVideoComment',
- onDelete: 'CASCADE'
- })
- InReplyToVideoComment: VideoCommentModel | null
- @ForeignKey(() => VideoModel)
- @Column
- videoId: number
- @BelongsTo(() => VideoModel, {
- foreignKey: {
- allowNull: false
- },
- onDelete: 'CASCADE'
- })
- Video: VideoModel
- @ForeignKey(() => AccountModel)
- @Column
- accountId: number
- @BelongsTo(() => AccountModel, {
- foreignKey: {
- allowNull: false
- },
- onDelete: 'CASCADE'
- })
- Account: AccountModel
- @BeforeDestroy
- static async sendDeleteIfOwned (instance: VideoCommentModel, options) {
- if (!instance.Account || !instance.Account.Actor) {
- instance.Account = await instance.$get('Account', {
- include: [ ActorModel ],
- transaction: options.transaction
- }) as AccountModel
- }
- if (!instance.Video) {
- instance.Video = await instance.$get('Video', {
- include: [
- {
- model: VideoChannelModel,
- include: [
- {
- model: AccountModel,
- include: [
- {
- model: ActorModel
- }
- ]
- }
- ]
- }
- ],
- transaction: options.transaction
- }) as VideoModel
- }
- if (instance.isOwned()) {
- await sendDeleteVideoComment(instance, options.transaction)
- }
- }
- static loadById (id: number, t?: Transaction) {
- const query: FindOptions = {
- where: {
- id
- }
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel.findOne(query)
- }
- static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction) {
- const query: FindOptions = {
- where: {
- id
- }
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel
- .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
- .findOne(query)
- }
- static loadByUrlAndPopulateAccount (url: string, t?: Transaction) {
- const query: FindOptions = {
- where: {
- url
- }
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT ]).findOne(query)
- }
- static loadByUrlAndPopulateReplyAndVideo (url: string, t?: Transaction) {
- const query: FindOptions = {
- where: {
- url
- }
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_VIDEO ]).findOne(query)
- }
- static async listThreadsForApi (videoId: number, start: number, count: number, sort: string, user?: UserModel) {
- const serverActor = await getServerActor()
- const serverAccountId = serverActor.Account.id
- const userAccountId = user ? user.Account.id : undefined
- const query = {
- offset: start,
- limit: count,
- order: getSort(sort),
- where: {
- videoId,
- inReplyToCommentId: null,
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
- )
- }
- }
- }
- const scopes: (string | ScopeOptions)[] = [
- ScopeNames.WITH_ACCOUNT,
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
- }
- ]
- return VideoCommentModel
- .scope(scopes)
- .findAndCountAll(query)
- .then(({ rows, count }) => {
- return { total: count, data: rows }
- })
- }
- static async listThreadCommentsForApi (videoId: number, threadId: number, user?: UserModel) {
- const serverActor = await getServerActor()
- const serverAccountId = serverActor.Account.id
- const userAccountId = user ? user.Account.id : undefined
- const query = {
- order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
- where: {
- videoId,
- [ Op.or ]: [
- { id: threadId },
- { originCommentId: threadId }
- ],
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(serverAccountId, userAccountId) + ')'
- )
- }
- }
- }
- const scopes: any[] = [
- ScopeNames.WITH_ACCOUNT,
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, serverAccountId, userAccountId ]
- }
- ]
- return VideoCommentModel
- .scope(scopes)
- .findAndCountAll(query)
- .then(({ rows, count }) => {
- return { total: count, data: rows }
- })
- }
- static listThreadParentComments (comment: VideoCommentModel, t: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
- const query = {
- order: [ [ 'createdAt', order ] ] as Order,
- where: {
- id: {
- [ Op.in ]: Sequelize.literal('(' +
- 'WITH RECURSIVE children (id, "inReplyToCommentId") AS ( ' +
- `SELECT id, "inReplyToCommentId" FROM "videoComment" WHERE id = ${comment.id} ` +
- 'UNION ' +
- 'SELECT "parent"."id", "parent"."inReplyToCommentId" FROM "videoComment" "parent" ' +
- 'INNER JOIN "children" ON "children"."inReplyToCommentId" = "parent"."id"' +
- ') ' +
- 'SELECT id FROM children' +
- ')'),
- [ Op.ne ]: comment.id
- }
- },
- transaction: t
- }
- return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
- .findAll(query)
- }
- static listAndCountByVideoId (videoId: number, start: number, count: number, t?: Transaction, order: 'ASC' | 'DESC' = 'ASC') {
- const query = {
- order: [ [ 'createdAt', order ] ] as Order,
- offset: start,
- limit: count,
- where: {
- videoId
- },
- transaction: t
- }
- return VideoCommentModel.findAndCountAll(query)
- }
- static listForFeed (start: number, count: number, videoId?: number) {
- const query = {
- order: [ [ 'createdAt', 'DESC' ] ] as Order,
- offset: start,
- limit: count,
- where: {},
- include: [
- {
- attributes: [ 'name', 'uuid' ],
- model: VideoModel.unscoped(),
- required: true
- }
- ]
- }
- if (videoId) query.where['videoId'] = videoId
- return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
- .findAll(query)
- }
- static async getStats () {
- const totalLocalVideoComments = await VideoCommentModel.count({
- include: [
- {
- model: AccountModel,
- required: true,
- include: [
- {
- model: ActorModel,
- required: true,
- where: {
- serverId: null
- }
- }
- ]
- }
- ]
- })
- const totalVideoComments = await VideoCommentModel.count()
- return {
- totalLocalVideoComments,
- totalVideoComments
- }
- }
- static cleanOldCommentsOf (videoId: number, beforeUpdatedAt: Date) {
- const query = {
- where: {
- updatedAt: {
- [Op.lt]: beforeUpdatedAt
- },
- videoId
- }
- }
- return VideoCommentModel.destroy(query)
- }
- getCommentStaticPath () {
- return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
- }
- getThreadId (): number {
- return this.originCommentId || this.id
- }
- isOwned () {
- return this.Account.isOwned()
- }
- extractMentions () {
- let result: string[] = []
- const localMention = `@(${actorNameAlphabet}+)`
- const remoteMention = `${localMention}@${WEBSERVER.HOST}`
- const mentionRegex = this.isOwned()
- ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions?
- : '(?:' + remoteMention + ')'
- const firstMentionRegex = new RegExp(`^${mentionRegex} `, 'g')
- const endMentionRegex = new RegExp(` ${mentionRegex}$`, 'g')
- const remoteMentionsRegex = new RegExp(' ' + remoteMention + ' ', 'g')
- result = result.concat(
- regexpCapture(this.text, firstMentionRegex)
- .map(([ , username1, username2 ]) => username1 || username2),
- regexpCapture(this.text, endMentionRegex)
- .map(([ , username1, username2 ]) => username1 || username2),
- regexpCapture(this.text, remoteMentionsRegex)
- .map(([ , username ]) => username)
- )
- // Include local mentions
- if (this.isOwned()) {
- const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g')
- result = result.concat(
- regexpCapture(this.text, localMentionsRegex)
- .map(([ , username ]) => username)
- )
- }
- return uniq(result)
- }
- toFormattedJSON () {
- return {
- id: this.id,
- url: this.url,
- text: this.text,
- threadId: this.originCommentId || this.id,
- inReplyToCommentId: this.inReplyToCommentId || null,
- videoId: this.videoId,
- createdAt: this.createdAt,
- updatedAt: this.updatedAt,
- totalReplies: this.get('totalReplies') || 0,
- account: this.Account.toFormattedJSON()
- } as VideoComment
- }
- toActivityPubObject (threadParentComments: VideoCommentModel[]): VideoCommentObject {
- let inReplyTo: string
- // New thread, so in AS we reply to the video
- if (this.inReplyToCommentId === null) {
- inReplyTo = this.Video.url
- } else {
- inReplyTo = this.InReplyToVideoComment.url
- }
- const tag: ActivityTagObject[] = []
- for (const parentComment of threadParentComments) {
- const actor = parentComment.Account.Actor
- tag.push({
- type: 'Mention',
- href: actor.url,
- name: `@${actor.preferredUsername}@${actor.getHost()}`
- })
- }
- return {
- type: 'Note' as 'Note',
- id: this.url,
- content: this.text,
- inReplyTo,
- updated: this.updatedAt.toISOString(),
- published: this.createdAt.toISOString(),
- url: this.url,
- attributedTo: this.Account.Actor.url,
- tag
- }
- }
- }
|