|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802 |
- import { pick } from '@peertube/peertube-core-utils'
- import {
- ActivityTagObject,
- ActivityTombstoneObject,
- UserRight,
- VideoComment,
- VideoCommentForAdminOrUser,
- VideoCommentObject
- } from '@peertube/peertube-models'
- import { extractMentions } from '@server/helpers/mentions.js'
- import { getLocalApproveReplyActivityPubUrl } from '@server/lib/activitypub/url.js'
- import { getServerActor } from '@server/models/application/application.js'
- import { MAccount, MAccountId, MUserAccountId } from '@server/types/models/index.js'
- import { Op, Order, QueryTypes, Sequelize, Transaction } from 'sequelize'
- import {
- AllowNull,
- BelongsTo, Column,
- CreatedAt,
- DataType,
- ForeignKey,
- HasMany,
- Is, Scopes,
- Table,
- UpdatedAt
- } from 'sequelize-typescript'
- import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc.js'
- import { CONSTRAINTS_FIELDS, USER_EXPORT_MAX_ITEMS } from '../../initializers/constants.js'
- import {
- MComment,
- MCommentAP,
- MCommentAdminOrUserFormattable,
- MCommentExport,
- MCommentFormattable,
- MCommentId,
- MCommentOwner,
- MCommentOwnerReplyVideoImmutable, MCommentOwnerVideoFeed,
- MCommentOwnerVideoReply,
- MVideo,
- MVideoImmutable
- } from '../../types/models/video/index.js'
- import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse.js'
- import { AccountModel } from '../account/account.js'
- import { ActorModel } from '../actor/actor.js'
- import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js'
- import { SequelizeModel, buildLocalAccountIdsIn, buildSQLAttributes, throwIfNotValid } from '../shared/index.js'
- import { ListVideoCommentsOptions, VideoCommentListQueryBuilder } from './sql/comment/video-comment-list-query-builder.js'
- import { VideoChannelModel } from './video-channel.js'
- import { VideoModel } from './video.js'
-
- export enum ScopeNames {
- WITH_ACCOUNT = 'WITH_ACCOUNT',
- WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
- WITH_VIDEO = 'WITH_VIDEO'
- }
-
- @Scopes(() => ({
- [ScopeNames.WITH_ACCOUNT]: {
- include: [
- {
- model: AccountModel
- }
- ]
- },
- [ScopeNames.WITH_IN_REPLY_TO]: {
- include: [
- {
- model: VideoCommentModel,
- as: 'InReplyToVideoComment'
- }
- ]
- },
- [ScopeNames.WITH_VIDEO]: {
- include: [
- {
- model: VideoModel,
- required: true,
- include: [
- {
- model: VideoChannelModel.unscoped(),
- attributes: [ 'id', 'accountId' ],
- required: true,
- include: [
- {
- attributes: [ 'id', 'url' ],
- model: ActorModel.unscoped(),
- required: true
- },
- {
- attributes: [ 'id' ],
- model: AccountModel.unscoped(),
- required: true,
- include: [
- {
- attributes: [ 'id', 'url' ],
- model: ActorModel.unscoped(),
- 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 SequelizeModel<VideoCommentModel> {
- @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
-
- @AllowNull(false)
- @Column
- heldForReview: boolean
-
- @AllowNull(true)
- @Column
- replyApproval: string
-
- @ForeignKey(() => VideoCommentModel)
- @Column
- originCommentId: number
-
- @BelongsTo(() => VideoCommentModel, {
- foreignKey: {
- name: 'originCommentId',
- allowNull: true
- },
- as: 'OriginVideoComment',
- onDelete: 'CASCADE'
- })
- OriginVideoComment: Awaited<VideoCommentModel>
-
- @ForeignKey(() => VideoCommentModel)
- @Column
- inReplyToCommentId: number
-
- @BelongsTo(() => VideoCommentModel, {
- foreignKey: {
- name: 'inReplyToCommentId',
- allowNull: true
- },
- as: 'InReplyToVideoComment',
- onDelete: 'CASCADE'
- })
- InReplyToVideoComment: Awaited<VideoCommentModel> | null
-
- @ForeignKey(() => VideoModel)
- @Column
- videoId: number
-
- @BelongsTo(() => VideoModel, {
- foreignKey: {
- allowNull: false
- },
- onDelete: 'CASCADE'
- })
- Video: Awaited<VideoModel>
-
- @ForeignKey(() => AccountModel)
- @Column
- accountId: number
-
- @BelongsTo(() => AccountModel, {
- foreignKey: {
- allowNull: true
- },
- onDelete: 'CASCADE'
- })
- Account: Awaited<AccountModel>
-
- @HasMany(() => VideoCommentAbuseModel, {
- foreignKey: {
- name: 'videoCommentId',
- allowNull: true
- },
- onDelete: 'set null'
- })
- CommentAbuses: Awaited<VideoCommentAbuseModel>[]
-
- @HasMany(() => CommentAutomaticTagModel, {
- foreignKey: 'commentId',
- onDelete: 'CASCADE'
- })
- CommentAutomaticTags: Awaited<CommentAutomaticTagModel>[]
-
- // ---------------------------------------------------------------------------
-
- static getSQLAttributes (tableName: string, aliasPrefix = '') {
- return buildSQLAttributes({
- model: this,
- tableName,
- aliasPrefix
- })
- }
-
- // ---------------------------------------------------------------------------
-
- static loadById (id: number, transaction?: Transaction): Promise<MComment> {
- const query = {
- where: {
- id
- },
- transaction
- }
-
- return VideoCommentModel.findOne(query)
- }
-
- static loadByIdAndPopulateVideoAndAccountAndReply (id: number, transaction?: Transaction): Promise<MCommentOwnerVideoReply> {
- const query = {
- where: {
- id
- },
- transaction
- }
-
- return VideoCommentModel
- .scope([ ScopeNames.WITH_VIDEO, ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_IN_REPLY_TO ])
- .findOne(query)
- }
-
- // ---------------------------------------------------------------------------
-
- static loadByUrlAndPopulateAccountAndVideoAndReply (url: string, transaction?: Transaction): Promise<MCommentOwnerVideoReply> {
- const query = {
- where: {
- url
- },
- transaction
- }
-
- return VideoCommentModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_VIDEO, ScopeNames.WITH_IN_REPLY_TO ]).findOne(query)
- }
-
- static loadByUrlAndPopulateReplyAndVideoImmutableAndAccount (
- url: string,
- transaction?: Transaction
- ): Promise<MCommentOwnerReplyVideoImmutable> {
- const query = {
- where: {
- url
- },
- include: [
- {
- attributes: [ 'id', 'uuid', 'url', 'remote' ],
- model: VideoModel.unscoped()
- }
- ],
- transaction
- }
-
- return VideoCommentModel.scope([ ScopeNames.WITH_IN_REPLY_TO, ScopeNames.WITH_ACCOUNT ]).findOne(query)
- }
-
- // ---------------------------------------------------------------------------
-
- static listCommentsForApi (parameters: {
- start: number
- count: number
- sort: string
-
- autoTagOfAccountId: number
-
- videoAccountOwnerId?: number
- videoChannelOwnerId?: number
-
- onLocalVideo?: boolean
- isLocal?: boolean
-
- search?: string
- searchAccount?: string
- searchVideo?: string
-
- heldForReview: boolean
-
- videoId?: number
- videoChannelId?: number
- autoTagOneOf?: string[]
- }) {
- const queryOptions: ListVideoCommentsOptions = {
- ...pick(parameters, [
- 'start',
- 'count',
- 'sort',
- 'isLocal',
- 'search',
- 'searchVideo',
- 'searchAccount',
- 'onLocalVideo',
- 'videoId',
- 'videoChannelId',
- 'autoTagOneOf',
- 'autoTagOfAccountId',
- 'videoAccountOwnerId',
- 'videoChannelOwnerId',
- 'heldForReview'
- ]),
-
- selectType: 'api',
- notDeleted: true
- }
-
- return Promise.all([
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminOrUserFormattable>(),
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
- ]).then(([ rows, count ]) => {
- return { total: count, data: rows }
- })
- }
-
- static async listThreadsForApi (parameters: {
- video: MVideo
- start: number
- count: number
- sort: string
- user?: MUserAccountId
- }) {
- const { video, user } = parameters
-
- const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video })
-
- const commonOptions: ListVideoCommentsOptions = {
- selectType: 'api',
- videoId: video.id,
- blockerAccountIds,
-
- heldForReview: canSeeHeldForReview
- ? undefined // Display all comments for video owner or moderator
- : false,
- heldForReviewAccountIdException: user?.Account?.id
- }
-
- const listOptions: ListVideoCommentsOptions = {
- ...commonOptions,
- ...pick(parameters, [ 'sort', 'start', 'count' ]),
-
- isThread: true,
- includeReplyCounters: true
- }
-
- const countOptions: ListVideoCommentsOptions = {
- ...commonOptions,
-
- isThread: true
- }
-
- const notDeletedCountOptions: ListVideoCommentsOptions = {
- ...commonOptions,
-
- notDeleted: true
- }
-
- return Promise.all([
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, listOptions).listComments<MCommentAdminOrUserFormattable>(),
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, countOptions).countComments(),
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, notDeletedCountOptions).countComments()
- ]).then(([ rows, count, totalNotDeletedComments ]) => {
- return { total: count, data: rows, totalNotDeletedComments }
- })
- }
-
- static async listThreadCommentsForApi (parameters: {
- video: MVideo
- threadId: number
- user?: MUserAccountId
- }) {
- const { user, video, threadId } = parameters
-
- const { blockerAccountIds, canSeeHeldForReview } = await VideoCommentModel.buildBlockerAccountIdsAndCanSeeHeldForReview({ user, video })
-
- const queryOptions: ListVideoCommentsOptions = {
- threadId,
-
- videoId: video.id,
- selectType: 'api',
- sort: 'createdAt',
-
- blockerAccountIds,
- includeReplyCounters: true,
-
- heldForReview: canSeeHeldForReview
- ? undefined // Display all comments for video owner or moderator
- : false,
- heldForReviewAccountIdException: user?.Account?.id
- }
-
- return Promise.all([
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentAdminOrUserFormattable>(),
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
- ]).then(([ rows, count ]) => {
- return { total: count, data: rows }
- })
- }
-
- static listThreadParentComments (options: {
- comment: MCommentId
- transaction?: Transaction
- order?: 'ASC' | 'DESC'
- }): Promise<MCommentOwner[]> {
- const { comment, transaction, order = 'ASC' } = options
-
- 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
- }
-
- return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
- .findAll(query)
- }
-
- static async listAndCountByVideoForAP (parameters: {
- video: MVideoImmutable
- start: number
- count: number
- }) {
- const { video } = parameters
-
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
-
- const queryOptions: ListVideoCommentsOptions = {
- ...pick(parameters, [ 'start', 'count' ]),
-
- selectType: 'comment-only',
- videoId: video.id,
- sort: 'createdAt',
-
- heldForReview: false,
-
- blockerAccountIds
- }
-
- return Promise.all([
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>(),
- new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).countComments()
- ]).then(([ rows, count ]) => {
- return { total: count, data: rows }
- })
- }
-
- static async listForFeed (parameters: {
- start: number
- count: number
- videoId?: number
- videoAccountOwnerId?: number
- videoChannelOwnerId?: number
- }) {
- const blockerAccountIds = await VideoCommentModel.buildBlockerAccountIds({ user: null })
-
- const queryOptions: ListVideoCommentsOptions = {
- ...pick(parameters, [ 'start', 'count', 'videoAccountOwnerId', 'videoId', 'videoChannelOwnerId' ]),
-
- selectType: 'feed',
-
- sort: '-createdAt',
- onPublicVideo: true,
-
- notDeleted: true,
- heldForReview: false,
-
- blockerAccountIds
- }
-
- return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MCommentOwnerVideoFeed>()
- }
-
- static listForBulkDelete (ofAccount: MAccount, filter: { onVideosOfAccount?: MAccountId } = {}) {
- const queryOptions: ListVideoCommentsOptions = {
- selectType: 'comment-only',
-
- accountId: ofAccount.id,
- videoAccountOwnerId: filter.onVideosOfAccount?.id,
-
- heldForReview: undefined,
-
- notDeleted: true,
- count: 5000
- }
-
- return new VideoCommentListQueryBuilder(VideoCommentModel.sequelize, queryOptions).listComments<MComment>()
- }
-
- static listForExport (ofAccountId: number): Promise<MCommentExport[]> {
- return VideoCommentModel.findAll({
- attributes: [ 'id', 'url', 'text', 'createdAt' ],
- where: {
- accountId: ofAccountId,
- deletedAt: null
- },
- include: [
- {
- attributes: [ 'id', 'uuid', 'url' ],
- required: true,
- model: VideoModel.unscoped()
- },
- {
- attributes: [ 'url' ],
- required: false,
- model: VideoCommentModel,
- as: 'InReplyToVideoComment'
- }
- ],
- limit: USER_EXPORT_MAX_ITEMS
- })
- }
-
- // ---------------------------------------------------------------------------
-
- static async getStats () {
- const where = {
- deletedAt: null,
- heldForReview: false
- }
-
- const totalLocalVideoComments = await VideoCommentModel.count({
- where,
- include: [
- {
- model: AccountModel.unscoped(),
- required: true,
- include: [
- {
- model: ActorModel.unscoped(),
- required: true,
- where: {
- serverId: null
- }
- }
- ]
- }
- ]
- })
- const totalVideoComments = await VideoCommentModel.count({ where })
-
- return {
- totalLocalVideoComments,
- totalVideoComments
- }
- }
-
- // ---------------------------------------------------------------------------
-
- static listRemoteCommentUrlsOfLocalVideos () {
- const query = `SELECT "videoComment".url FROM "videoComment" ` +
- `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
- `INNER JOIN actor ON actor.id = "account"."actorId" AND actor."serverId" IS NOT NULL ` +
- `INNER JOIN video ON video.id = "videoComment"."videoId" AND video.remote IS FALSE`
-
- return VideoCommentModel.sequelize.query<{ url: string }>(query, {
- type: QueryTypes.SELECT,
- raw: true
- }).then(rows => rows.map(r => r.url))
- }
-
- 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()
- }
-
- getCommentUserReviewPath () {
- return '/my-account/videos/comments?search=heldForReview:true'
- }
-
- getThreadId (): number {
- return this.originCommentId || this.id
- }
-
- isOwned () {
- if (!this.Account) return false
-
- return this.Account.isOwned()
- }
-
- markAsDeleted () {
- this.text = ''
- this.deletedAt = new Date()
- this.accountId = null
- }
-
- isDeleted () {
- return this.deletedAt !== null
- }
-
- extractMentions () {
- return extractMentions(this.text, this.isOwned())
- }
-
- 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,
-
- heldForReview: this.heldForReview,
-
- isDeleted: this.isDeleted(),
-
- totalRepliesFromVideoAuthor: this.get('totalRepliesFromVideoAuthor') || 0,
- totalReplies: this.get('totalReplies') || 0,
-
- account: this.Account
- ? this.Account.toFormattedJSON()
- : null
- } as VideoComment
- }
-
- toFormattedForAdminOrUserJSON (this: MCommentAdminOrUserFormattable) {
- 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,
-
- heldForReview: this.heldForReview,
- automaticTags: (this.CommentAutomaticTags || []).map(m => m.AutomaticTag.name),
-
- video: {
- id: this.Video.id,
- uuid: this.Video.uuid,
- name: this.Video.name
- },
-
- account: this.Account
- ? this.Account.toFormattedJSON()
- : null
- } as VideoCommentForAdminOrUser
- }
-
- toActivityPubObject (this: MCommentAP, threadParentComments: MCommentOwner[]): VideoCommentObject | ActivityTombstoneObject {
- const inReplyTo = this.inReplyToCommentId === null
- ? this.Video.url // New thread, so we reply to the video
- : 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()}`
- })
- }
-
- let replyApproval = this.replyApproval
- if (this.Video.isOwned() && !this.heldForReview) {
- replyApproval = getLocalApproveReplyActivityPubUrl(this.Video, this)
- }
-
- return {
- type: 'Note' as 'Note',
- id: this.url,
-
- content: this.text,
- mediaType: 'text/markdown',
-
- inReplyTo,
- updated: this.updatedAt.toISOString(),
- published: this.createdAt.toISOString(),
- url: this.url,
- attributedTo: this.Account.Actor.url,
- replyApproval,
- tag
- }
- }
-
- private static async buildBlockerAccountIds (options: {
- user: MUserAccountId
- }): Promise<number[]> {
- const { user } = options
-
- const serverActor = await getServerActor()
- const blockerAccountIds = [ serverActor.Account.id ]
-
- if (user) blockerAccountIds.push(user.Account.id)
-
- return blockerAccountIds
- }
-
- private static buildBlockerAccountIdsAndCanSeeHeldForReview (options: {
- video: MVideo
- user: MUserAccountId
- }) {
- const { video, user } = options
- const blockerAccountIdsPromise = this.buildBlockerAccountIds(options)
-
- let canSeeHeldForReviewPromise: Promise<boolean>
- if (user) {
- if (user.hasRight(UserRight.SEE_ALL_COMMENTS)) {
- canSeeHeldForReviewPromise = Promise.resolve(true)
- } else {
- canSeeHeldForReviewPromise = VideoChannelModel.loadAndPopulateAccount(video.channelId)
- .then(c => c.accountId === user.Account.id)
- }
- } else {
- canSeeHeldForReviewPromise = Promise.resolve(false)
- }
-
- return Promise.all([ blockerAccountIdsPromise, canSeeHeldForReviewPromise ])
- .then(([ blockerAccountIds, canSeeHeldForReview ]) => ({ blockerAccountIds, canSeeHeldForReview }))
- }
- }
|