123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869 |
- import { uniq } from 'lodash'
- import { FindAndCountOptions, FindOptions, Op, Order, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
- import {
- AllowNull,
- BelongsTo,
- Column,
- CreatedAt,
- DataType,
- ForeignKey,
- HasMany,
- Is,
- Model,
- Scopes,
- Table,
- UpdatedAt
- } from 'sequelize-typescript'
- import { getServerActor } from '@server/models/application/application'
- import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
- import { VideoPrivacy } from '@shared/models'
- import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
- import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
- import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/video-comment.model'
- import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
- import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
- import { regexpCapture } from '../../helpers/regexp'
- import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants'
- import {
- MComment,
- MCommentAdminFormattable,
- MCommentAP,
- MCommentFormattable,
- MCommentId,
- MCommentOwner,
- MCommentOwnerReplyVideoLight,
- MCommentOwnerVideo,
- MCommentOwnerVideoFeed,
- MCommentOwnerVideoReply,
- MVideoImmutable
- } from '../../types/models/video'
- import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
- import { AccountModel } from '../account/account'
- import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor'
- import {
- buildBlockedAccountSQL,
- buildBlockedAccountSQLOptimized,
- buildLocalAccountIdsIn,
- getCommentSort,
- searchAttribute,
- throwIfNotValid
- } from '../utils'
- import { VideoModel } from './video'
- import { VideoChannelModel } from './video-channel'
- export enum ScopeNames {
- WITH_ACCOUNT = 'WITH_ACCOUNT',
- WITH_ACCOUNT_FOR_API = 'WITH_ACCOUNT_FOR_API',
- WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
- WITH_VIDEO = 'WITH_VIDEO',
- ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
- }
- @Scopes(() => ({
- [ScopeNames.ATTRIBUTES_FOR_API]: (blockerAccountIds: number[]) => {
- return {
- attributes: {
- include: [
- [
- Sequelize.literal(
- '(' +
- 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
- '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'
- ],
- [
- Sequelize.literal(
- '(' +
- 'SELECT COUNT("replies"."id") ' +
- 'FROM "videoComment" AS "replies" ' +
- 'INNER JOIN "video" ON "video"."id" = "replies"."videoId" ' +
- 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ' +
- 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
- 'AND "replies"."accountId" = "videoChannel"."accountId"' +
- ')'
- ),
- 'totalRepliesFromVideoAuthor'
- ]
- ]
- }
- } as FindOptions
- },
- [ScopeNames.WITH_ACCOUNT]: {
- include: [
- {
- model: AccountModel
- }
- ]
- },
- [ScopeNames.WITH_ACCOUNT_FOR_API]: {
- include: [
- {
- model: AccountModel.unscoped(),
- include: [
- {
- attributes: {
- exclude: unusedActorAttributesForAPI
- },
- model: ActorModel, // Default scope includes avatar and server
- required: true
- }
- ]
- }
- ]
- },
- [ScopeNames.WITH_IN_REPLY_TO]: {
- include: [
- {
- model: VideoCommentModel,
- as: 'InReplyToVideoComment'
- }
- ]
- },
- [ScopeNames.WITH_VIDEO]: {
- include: [
- {
- model: VideoModel,
- required: true,
- include: [
- {
- model: VideoChannelModel,
- required: true,
- include: [
- {
- model: AccountModel,
- required: true
- }
- ]
- }
- ]
- }
- ]
- }
- }))
- @Table({
- tableName: 'videoComment',
- indexes: [
- {
- fields: [ 'videoId' ]
- },
- {
- fields: [ 'videoId', 'originCommentId' ]
- },
- {
- fields: [ 'url' ],
- unique: true
- },
- {
- fields: [ 'accountId' ]
- },
- {
- fields: [
- { name: 'createdAt', order: 'DESC' }
- ]
- }
- ]
- })
- export class VideoCommentModel extends Model {
- @CreatedAt
- createdAt: Date
- @UpdatedAt
- updatedAt: Date
- @AllowNull(true)
- @Column(DataType.DATE)
- deletedAt: 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: true
- },
- onDelete: 'CASCADE'
- })
- Account: AccountModel
- @HasMany(() => VideoCommentAbuseModel, {
- foreignKey: {
- name: 'videoCommentId',
- allowNull: true
- },
- onDelete: 'set null'
- })
- CommentAbuses: VideoCommentAbuseModel[]
- static loadById (id: number, t?: Transaction): Promise<MComment> {
- const query: FindOptions = {
- where: {
- id
- }
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel.findOne(query)
- }
- static loadByIdAndPopulateVideoAndAccountAndReply (id: number, t?: Transaction): Promise<MCommentOwnerVideoReply> {
- 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 loadByUrlAndPopulateAccountAndVideo (url: string, t?: Transaction): Promise<MCommentOwnerVideo> {
- const query: FindOptions = {
- where: {
- url
- }
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO ]).findOne(query)
- }
- static loadByUrlAndPopulateReplyAndVideoUrlAndAccount (url: string, t?: Transaction): Promise<MCommentOwnerReplyVideoLight> {
- const query: FindOptions = {
- where: {
- url
- },
- include: [
- {
- attributes: [ 'id', 'url' ],
- model: VideoModel.unscoped()
- }
- ]
- }
- if (t !== undefined) query.transaction = t
- return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
- }
- static listCommentsForApi (parameters: {
- start: number
- count: number
- sort: string
- isLocal?: boolean
- search?: string
- searchAccount?: string
- searchVideo?: string
- }) {
- const { start, count, sort, isLocal, search, searchAccount, searchVideo } = parameters
- const where: WhereOptions = {
- deletedAt: null
- }
- const whereAccount: WhereOptions = {}
- const whereActor: WhereOptions = {}
- const whereVideo: WhereOptions = {}
- if (isLocal === true) {
- Object.assign(whereActor, {
- serverId: null
- })
- } else if (isLocal === false) {
- Object.assign(whereActor, {
- serverId: {
- [Op.ne]: null
- }
- })
- }
- if (search) {
- Object.assign(where, {
- [Op.or]: [
- searchAttribute(search, 'text'),
- searchAttribute(search, '$Account.Actor.preferredUsername$'),
- searchAttribute(search, '$Account.name$'),
- searchAttribute(search, '$Video.name$')
- ]
- })
- }
- if (searchAccount) {
- Object.assign(whereActor, {
- [Op.or]: [
- searchAttribute(searchAccount, '$Account.Actor.preferredUsername$'),
- searchAttribute(searchAccount, '$Account.name$')
- ]
- })
- }
- if (searchVideo) {
- Object.assign(whereVideo, searchAttribute(searchVideo, 'name'))
- }
- const query: FindAndCountOptions = {
- offset: start,
- limit: count,
- order: getCommentSort(sort),
- where,
- include: [
- {
- model: AccountModel.unscoped(),
- required: true,
- where: whereAccount,
- include: [
- {
- attributes: {
- exclude: unusedActorAttributesForAPI
- },
- model: ActorModel, // Default scope includes avatar and server
- required: true,
- where: whereActor
- }
- ]
- },
- {
- model: VideoModel.unscoped(),
- required: true,
- where: whereVideo
- }
- ]
- }
- return VideoCommentModel
- .findAndCountAll(query)
- .then(({ rows, count }) => {
- return { total: count, data: rows }
- })
- }
- static async listThreadsForApi (parameters: {
- videoId: number
- isVideoOwned: boolean
- start: number
- count: number
- sort: string
- user?: MUserAccountId
- }) {
- const { videoId, isVideoOwned, start, count, sort, user } = parameters
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
- const query = {
- offset: start,
- limit: count,
- order: getCommentSort(sort),
- where: {
- [Op.and]: [
- {
- videoId
- },
- {
- inReplyToCommentId: null
- },
- {
- [Op.or]: [
- {
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
- )
- }
- },
- {
- accountId: null
- }
- ]
- }
- ]
- }
- }
- const scopes: (string | ScopeOptions)[] = [
- ScopeNames.WITH_ACCOUNT_FOR_API,
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
- }
- ]
- return VideoCommentModel
- .scope(scopes)
- .findAndCountAll(query)
- .then(({ rows, count }) => {
- return { total: count, data: rows }
- })
- }
- static async listThreadCommentsForApi (parameters: {
- videoId: number
- isVideoOwned: boolean
- threadId: number
- user?: MUserAccountId
- }) {
- const { videoId, threadId, user, isVideoOwned } = parameters
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ videoId, user, isVideoOwned })
- const query = {
- order: [ [ 'createdAt', 'ASC' ], [ 'updatedAt', 'ASC' ] ] as Order,
- where: {
- videoId,
- [Op.or]: [
- { id: threadId },
- { originCommentId: threadId }
- ],
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
- )
- }
- }
- }
- const scopes: any[] = [
- ScopeNames.WITH_ACCOUNT_FOR_API,
- {
- method: [ ScopeNames.ATTRIBUTES_FOR_API, blockerAccountIds ]
- }
- ]
- return VideoCommentModel
- .scope(scopes)
- .findAndCountAll(query)
- .then(({ rows, count }) => {
- return { total: count, data: rows }
- })
- }
- static listThreadParentComments (comment: MCommentId, t: Transaction, order: 'ASC' | 'DESC' = 'ASC'): Promise<MCommentOwner[]> {
- 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 async listAndCountByVideoForAP (video: MVideoImmutable, start: number, count: number, t?: Transaction) {
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({
- videoId: video.id,
- isVideoOwned: video.isOwned()
- })
- const query = {
- order: [ [ 'createdAt', 'ASC' ] ] as Order,
- offset: start,
- limit: count,
- where: {
- videoId: video.id,
- accountId: {
- [Op.notIn]: Sequelize.literal(
- '(' + buildBlockedAccountSQL(blockerAccountIds) + ')'
- )
- }
- },
- transaction: t
- }
- return VideoCommentModel.findAndCountAll<MComment>(query)
- }
- static async listForFeed (parameters: {
- start: number
- count: number
- videoId?: number
- accountId?: number
- videoChannelId?: number
- }): Promise<MCommentOwnerVideoFeed[]> {
- const serverActor = await getServerActor()
- const { start, count, videoId, accountId, videoChannelId } = parameters
- const whereAnd: WhereOptions[] = buildBlockedAccountSQLOptimized(
- '"VideoCommentModel"."accountId"',
- [ serverActor.Account.id, '"Video->VideoChannel"."accountId"' ]
- )
- if (accountId) {
- whereAnd.push({
- [Op.eq]: accountId
- })
- }
- const accountWhere = {
- [Op.and]: whereAnd
- }
- const videoChannelWhere = videoChannelId ? { id: videoChannelId } : undefined
- const query = {
- order: [ [ 'createdAt', 'DESC' ] ] as Order,
- offset: start,
- limit: count,
- where: {
- deletedAt: null,
- accountId: accountWhere
- },
- include: [
- {
- attributes: [ 'name', 'uuid' ],
- model: VideoModel.unscoped(),
- required: true,
- where: {
- privacy: VideoPrivacy.PUBLIC
- },
- include: [
- {
- attributes: [ 'accountId' ],
- model: VideoChannelModel.unscoped(),
- required: true,
- where: videoChannelWhere
- }
- ]
- }
- ]
- }
- if (videoId) query.where['videoId'] = videoId
- return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
- .findAll(query)
- }
- static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
- const accountWhere = filter.onVideosOfAccount
- ? { id: filter.onVideosOfAccount.id }
- : {}
- const query = {
- limit: 1000,
- where: {
- deletedAt: null,
- accountId: ofAccount.id
- },
- include: [
- {
- model: VideoModel,
- required: true,
- include: [
- {
- model: VideoChannelModel,
- required: true,
- include: [
- {
- model: AccountModel,
- required: true,
- where: accountWhere
- }
- ]
- }
- ]
- }
- ]
- }
- 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,
- accountId: {
- [Op.notIn]: buildLocalAccountIdsIn()
- },
- // Do not delete Tombstones
- deletedAt: null
- }
- }
- return VideoCommentModel.destroy(query)
- }
- getCommentStaticPath () {
- return this.Video.getWatchStaticPath() + ';threadId=' + this.getThreadId()
- }
- getThreadId (): number {
- return this.originCommentId || this.id
- }
- isOwned () {
- if (!this.Account) {
- return false
- }
- return this.Account.isOwned()
- }
- isDeleted () {
- return this.deletedAt !== null
- }
- 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 (this: MCommentFormattable) {
- return {
- id: this.id,
- url: this.url,
- text: this.text,
- threadId: this.getThreadId(),
- inReplyToCommentId: this.inReplyToCommentId || null,
- videoId: this.videoId,
- createdAt: this.createdAt,
- updatedAt: this.updatedAt,
- deletedAt: this.deletedAt,
- isDeleted: this.isDeleted(),
- totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
- totalReplies: this.get('totalReplies') || 0,
- account: this.Account
- ? this.Account.toFormattedJSON()
- : null
- } as VideoComment
- }
- toFormattedAdminJSON (this: MCommentAdminFormattable) {
- return {
- id: this.id,
- url: this.url,
- text: this.text,
- threadId: this.getThreadId(),
- inReplyToCommentId: this.inReplyToCommentId || null,
- videoId: this.videoId,
- createdAt: this.createdAt,
- updatedAt: this.updatedAt,
- video: {
- id: this.Video.id,
- uuid: this.Video.uuid,
- name: this.Video.name
- },
- account: this.Account
- ? this.Account.toFormattedJSON()
- : null
- } as VideoCommentAdmin
- }
- toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
- 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
- }
- if (this.isDeleted()) {
- return {
- id: this.url,
- type: 'Tombstone',
- formerType: 'Note',
- inReplyTo,
- published: this.createdAt.toISOString(),
- updated: this.updatedAt.toISOString(),
- deleted: this.deletedAt.toISOString()
- }
- }
- const tag: ActivityTagObject[] = []
- for (const parentComment of threadParentComments) {
- if (!parentComment.Account) continue
- 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
- }
- }
- private static async buildBlockerAccountIds (options: {
- videoId: number
- isVideoOwned: boolean
- user?: MUserAccountId
- }) {
- const { videoId, user, isVideoOwned } = options
- const serverActor = await getServerActor()
- const blockerAccountIds = [ serverActor.Account.id ]
- if (user) blockerAccountIds.push(user.Account.id)
- if (isVideoOwned) {
- const videoOwnerAccount = await AccountModel.loadAccountIdFromVideo(videoId)
- blockerAccountIds.push(videoOwnerAccount.id)
- }
- return blockerAccountIds
- }
- }
|