Init commit

This commit is contained in:
ShreejitPanchal
2026-05-19 19:56:02 +08:00
commit 6601501eff
4047 changed files with 2185372 additions and 0 deletions

View File

@@ -0,0 +1,271 @@
import { ActorImageType, UserNotificationType_Type } from '@peertube/peertube-models'
import { AbstractListQuery, AbstractListQueryOptions } from '@server/models/shared/abstract-list-query.js'
import { Sequelize } from 'sequelize'
export interface ListNotificationsOptions extends AbstractListQueryOptions {
userId: number
unread?: boolean
typeOneOf?: UserNotificationType_Type[]
}
export class UserNotificationListQueryBuilder extends AbstractListQuery {
constructor (
protected readonly sequelize: Sequelize,
protected readonly options: ListNotificationsOptions
) {
super(sequelize, { modelName: 'UserNotificationModel', tableName: 'userNotification' }, options)
}
protected buildSubQueryWhere () {
this.subQueryWhere = 'WHERE "UserNotificationModel"."userId" = :userId '
this.replacements.userId = this.options.userId
if (this.options.unread === true) {
this.subQueryWhere += 'AND "UserNotificationModel"."read" IS FALSE '
} else if (this.options.unread === false) {
this.subQueryWhere += 'AND "UserNotificationModel"."read" IS TRUE '
}
if (this.options.typeOneOf) {
this.subQueryWhere += 'AND "UserNotificationModel"."type" IN (:typeOneOf) '
this.replacements.typeOneOf = this.options.typeOneOf
}
}
protected buildSubQueryJoin () {
}
protected buildSubQueryAttributes () {
this.subQueryAttributes = [
...this.subQueryAttributes,
`*`
]
}
protected buildQueryAttributes () {
this.attributes = [
...this.attributes,
`"UserNotificationModel"."id"`,
`"UserNotificationModel"."type"`,
`"UserNotificationModel"."read"`,
`"UserNotificationModel"."data"`,
`"UserNotificationModel"."createdAt"`,
`"UserNotificationModel"."updatedAt"`,
`"Video"."id" AS "Video.id"`,
`"Video"."uuid" AS "Video.uuid"`,
`"Video"."name" AS "Video.name"`,
`"Video"."state" AS "Video.state"`,
...this.getAccountOrChannelAttributes('Video->VideoChannel', 'Video.VideoChannel'),
`"VideoComment"."id" AS "VideoComment.id"`,
`"VideoComment"."originCommentId" AS "VideoComment.originCommentId"`,
`"VideoComment"."heldForReview" AS "VideoComment.heldForReview"`,
`"VideoComment->Video"."id" AS "VideoComment.Video.id"`,
`"VideoComment->Video"."uuid" AS "VideoComment.Video.uuid"`,
`"VideoComment->Video"."name" AS "VideoComment.Video.name"`,
`"VideoComment->Video"."state" AS "VideoComment.Video.state"`,
...this.getAccountOrChannelAttributes('VideoComment->Account', 'VideoComment.Account'),
`"Abuse"."id" AS "Abuse.id"`,
`"Abuse"."state" AS "Abuse.state"`,
`"Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id"`,
`"Abuse->VideoAbuse->Video"."id" AS "Abuse.VideoAbuse.Video.id"`,
`"Abuse->VideoAbuse->Video"."uuid" AS "Abuse.VideoAbuse.Video.uuid"`,
`"Abuse->VideoAbuse->Video"."name" AS "Abuse.VideoAbuse.Video.name"`,
`"Abuse->VideoAbuse->Video"."state" AS "Abuse.VideoAbuse.Video.state"`,
`"Abuse->VideoCommentAbuse"."id" AS "Abuse.VideoCommentAbuse.id"`,
`"Abuse->VideoCommentAbuse->VideoComment"."id" AS "Abuse.VideoCommentAbuse.VideoComment.id"`,
`"Abuse->VideoCommentAbuse->VideoComment"."originCommentId" AS "Abuse.VideoCommentAbuse.VideoComment.originCommentId"`,
`"Abuse->VideoCommentAbuse->VideoComment->Video"."id" AS "Abuse.VideoCommentAbuse.VideoComment.Video.id"`,
`"Abuse->VideoCommentAbuse->VideoComment->Video"."name" AS "Abuse.VideoCommentAbuse.VideoComment.Video.name"`,
`"Abuse->VideoCommentAbuse->VideoComment->Video"."uuid" AS "Abuse.VideoCommentAbuse.VideoComment.Video.uuid"`,
`"Abuse->VideoCommentAbuse->VideoComment->Video"."state" AS "Abuse.VideoCommentAbuse.VideoComment.Video.state"`,
...this.getAccountOrChannelAttributes('Abuse->FlaggedAccount', 'Abuse.FlaggedAccount'),
`"VideoBlacklist"."id" AS "VideoBlacklist.id"`,
`"VideoBlacklist->Video"."id" AS "VideoBlacklist.Video.id"`,
`"VideoBlacklist->Video"."uuid" AS "VideoBlacklist.Video.uuid"`,
`"VideoBlacklist->Video"."name" AS "VideoBlacklist.Video.name"`,
`"VideoBlacklist->Video"."state" AS "VideoBlacklist.Video.state"`,
`"VideoImport"."id" AS "VideoImport.id"`,
`"VideoImport"."magnetUri" AS "VideoImport.magnetUri"`,
`"VideoImport"."targetUrl" AS "VideoImport.targetUrl"`,
`"VideoImport"."torrentName" AS "VideoImport.torrentName"`,
`"VideoImport->Video"."id" AS "VideoImport.Video.id"`,
`"VideoImport->Video"."uuid" AS "VideoImport.Video.uuid"`,
`"VideoImport->Video"."name" AS "VideoImport.Video.name"`,
`"VideoImport->Video"."state" AS "VideoImport.Video.state"`,
`"Plugin"."id" AS "Plugin.id"`,
`"Plugin"."name" AS "Plugin.name"`,
`"Plugin"."type" AS "Plugin.type"`,
`"Plugin"."latestVersion" AS "Plugin.latestVersion"`,
`"Application"."id" AS "Application.id"`,
`"Application"."latestPeerTubeVersion" AS "Application.latestPeerTubeVersion"`,
`"ActorFollow"."id" AS "ActorFollow.id"`,
`"ActorFollow"."state" AS "ActorFollow.state"`,
`"ActorFollow->ActorFollower"."id" AS "ActorFollow.ActorFollower.id"`,
`"ActorFollow->ActorFollower"."preferredUsername" AS "ActorFollow.ActorFollower.preferredUsername"`,
`"ActorFollow->ActorFollower->Account"."id" AS "ActorFollow.ActorFollower.Account.id"`,
`"ActorFollow->ActorFollower->Account"."name" AS "ActorFollow.ActorFollower.Account.name"`,
`"ActorFollow->ActorFollower->Avatars"."id" AS "ActorFollow.ActorFollower.Avatars.id"`,
`"ActorFollow->ActorFollower->Avatars"."width" AS "ActorFollow.ActorFollower.Avatars.width"`,
`"ActorFollow->ActorFollower->Avatars"."type" AS "ActorFollow.ActorFollower.Avatars.type"`,
`"ActorFollow->ActorFollower->Avatars"."filename" AS "ActorFollow.ActorFollower.Avatars.filename"`,
`"ActorFollow->ActorFollower->Server"."id" AS "ActorFollow.ActorFollower.Server.id"`,
`"ActorFollow->ActorFollower->Server"."host" AS "ActorFollow.ActorFollower.Server.host"`,
`"ActorFollow->ActorFollowing"."id" AS "ActorFollow.ActorFollowing.id"`,
`"ActorFollow->ActorFollowing"."preferredUsername" AS "ActorFollow.ActorFollowing.preferredUsername"`,
`"ActorFollow->ActorFollowing"."type" AS "ActorFollow.ActorFollowing.type"`,
`"ActorFollow->ActorFollowing->VideoChannel"."id" AS "ActorFollow.ActorFollowing.VideoChannel.id"`,
`"ActorFollow->ActorFollowing->VideoChannel"."name" AS "ActorFollow.ActorFollowing.VideoChannel.name"`,
`"ActorFollow->ActorFollowing->Account"."id" AS "ActorFollow.ActorFollowing.Account.id"`,
`"ActorFollow->ActorFollowing->Account"."name" AS "ActorFollow.ActorFollowing.Account.name"`,
`"ActorFollow->ActorFollowing->Server"."id" AS "ActorFollow.ActorFollowing.Server.id"`,
`"ActorFollow->ActorFollowing->Server"."host" AS "ActorFollow.ActorFollowing.Server.host"`,
...this.getAccountOrChannelAttributes('Account', 'Account'),
`"UserRegistration"."id" AS "UserRegistration.id"`,
`"UserRegistration"."username" AS "UserRegistration.username"`,
`"VideoCaption"."id" AS "VideoCaption.id"`,
`"VideoCaption"."language" AS "VideoCaption.language"`,
`"VideoCaption->Video"."id" AS "VideoCaption.Video.id"`,
`"VideoCaption->Video"."uuid" AS "VideoCaption.Video.uuid"`,
`"VideoCaption->Video"."name" AS "VideoCaption.Video.name"`,
`"VideoCaption->Video"."state" AS "VideoCaption.Video.state"`,
`"ChannelCollab"."id" AS "ChannelCollab.id"`,
`"ChannelCollab"."state" AS "ChannelCollab.state"`,
...this.getAccountOrChannelAttributes('ChannelCollab->Account', 'ChannelCollab.Account'),
...this.getAccountOrChannelAttributes('ChannelCollab->Channel->Account', 'ChannelCollab.Channel.Account'),
...this.getAccountOrChannelAttributes('ChannelCollab->Channel', 'ChannelCollab.Channel')
]
}
protected buildQueryJoin () {
this.join = `
LEFT JOIN (
"video" AS "Video"
${this.getChannelJoin('Video', 'channelId')}
) ON "UserNotificationModel"."videoId" = "Video"."id"
LEFT JOIN (
"videoComment" AS "VideoComment"
${this.getAccountJoin('VideoComment', 'accountId')}
INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id"
) ON "UserNotificationModel"."commentId" = "VideoComment"."id"
LEFT JOIN "abuse" AS "Abuse" ON "UserNotificationModel"."abuseId" = "Abuse"."id"
LEFT JOIN "videoAbuse" AS "Abuse->VideoAbuse" ON "Abuse"."id" = "Abuse->VideoAbuse"."abuseId"
LEFT JOIN "video" AS "Abuse->VideoAbuse->Video" ON "Abuse->VideoAbuse"."videoId" = "Abuse->VideoAbuse->Video"."id"
LEFT JOIN "commentAbuse" AS "Abuse->VideoCommentAbuse" ON "Abuse"."id" = "Abuse->VideoCommentAbuse"."abuseId"
LEFT JOIN "videoComment" AS "Abuse->VideoCommentAbuse->VideoComment"
ON "Abuse->VideoCommentAbuse"."videoCommentId" = "Abuse->VideoCommentAbuse->VideoComment"."id"
LEFT JOIN "video" AS "Abuse->VideoCommentAbuse->VideoComment->Video"
ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id"
LEFT JOIN (
"account" AS "Abuse->FlaggedAccount"
${this.getActorJoin('Abuse->FlaggedAccount', 'accountId')}
) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id"
LEFT JOIN (
"videoBlacklist" AS "VideoBlacklist"
INNER JOIN "video" AS "VideoBlacklist->Video" ON "VideoBlacklist"."videoId" = "VideoBlacklist->Video"."id"
) ON "UserNotificationModel"."videoBlacklistId" = "VideoBlacklist"."id"
LEFT JOIN "videoImport" AS "VideoImport" ON "UserNotificationModel"."videoImportId" = "VideoImport"."id"
LEFT JOIN "video" AS "VideoImport->Video" ON "VideoImport"."videoId" = "VideoImport->Video"."id"
LEFT JOIN "plugin" AS "Plugin" ON "UserNotificationModel"."pluginId" = "Plugin"."id"
LEFT JOIN "application" AS "Application" ON "UserNotificationModel"."applicationId" = "Application"."id"
LEFT JOIN (
"actorFollow" AS "ActorFollow"
INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id"
INNER JOIN "account" AS "ActorFollow->ActorFollower->Account"
ON "ActorFollow->ActorFollower"."accountId" = "ActorFollow->ActorFollower->Account"."id"
${this.getActorImageJoin('ActorFollow->ActorFollower')}
${this.getActorServerJoin('ActorFollow->ActorFollower')}
INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id"
LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel"
ON "ActorFollow->ActorFollowing"."videoChannelId" = "ActorFollow->ActorFollowing->VideoChannel"."id"
LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account"
ON "ActorFollow->ActorFollowing"."accountId" = "ActorFollow->ActorFollowing->Account"."id"
${this.getActorServerJoin('ActorFollow->ActorFollowing')}
) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id"
LEFT JOIN (
"account" AS "Account"
${this.getActorJoin('Account', 'accountId')}
) ON "UserNotificationModel"."accountId" = "Account"."id"
LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id"
LEFT JOIN (
"videoCaption" AS "VideoCaption"
INNER JOIN "video" AS "VideoCaption->Video" ON "VideoCaption"."videoId" = "VideoCaption->Video"."id"
) ON "UserNotificationModel"."videoCaptionId" = "VideoCaption"."id"
LEFT JOIN (
"videoChannelCollaborator" AS "ChannelCollab"
${this.getAccountJoin('ChannelCollab', 'accountId')}
${this.getChannelJoin('ChannelCollab', 'channelId', 'Channel')}
${this.getAccountJoin('ChannelCollab->Channel', 'accountId')}
) ON "UserNotificationModel"."channelCollaboratorId" = "ChannelCollab"."id"`
}
// ---------------------------------------------------------------------------
private getAccountOrChannelAttributes (tableName: string, alias: string) {
return [
`"${tableName}"."id" AS "${alias}.id"`,
`"${tableName}"."name" AS "${alias}.name"`,
`"${tableName}->Actor"."id" AS "${alias}.Actor.id"`,
`"${tableName}->Actor"."preferredUsername" AS "${alias}.Actor.preferredUsername"`,
`"${tableName}->Actor->Avatars"."id" AS "${alias}.Actor.Avatars.id"`,
`"${tableName}->Actor->Avatars"."width" AS "${alias}.Actor.Avatars.width"`,
`"${tableName}->Actor->Avatars"."type" AS "${alias}.Actor.Avatars.type"`,
`"${tableName}->Actor->Avatars"."filename" AS "${alias}.Actor.Avatars.filename"`,
`"${tableName}->Actor->Server"."id" AS "${alias}.Actor.Server.id"`,
`"${tableName}->Actor->Server"."host" AS "${alias}.Actor.Server.host"`
]
}
private getAccountJoin (tableName: string, columnJoin: string) {
return `INNER JOIN "account" AS "${tableName}->Account" ON "${tableName}"."${columnJoin}" = "${tableName}->Account"."id" ` +
this.getActorJoin(`${tableName}->Account`, 'accountId')
}
private getChannelJoin (tableName: string, columnJoin: string, aliasTableName = 'VideoChannel') {
// eslint-disable-next-line max-len
return `INNER JOIN "videoChannel" AS "${tableName}->${aliasTableName}" ON "${tableName}"."${columnJoin}" = "${tableName}->${aliasTableName}".id ` +
this.getActorJoin(`${tableName}->${aliasTableName}`, 'videoChannelId')
}
private getActorJoin (tableName: string, column: string) {
return `INNER JOIN "actor" AS "${tableName}->Actor" ON "${tableName}"."id" = "${tableName}->Actor"."${column}" ` +
this.getActorImageJoin(`${tableName}->Actor`) +
this.getActorServerJoin(`${tableName}->Actor`)
}
private getActorImageJoin (tableName: string) {
return `LEFT JOIN "actorImage" AS "${tableName}->Avatars"
ON "${tableName}"."id" = "${tableName}->Avatars"."actorId"
AND "${tableName}->Avatars"."type" = ${ActorImageType.AVATAR} `
}
private getActorServerJoin (tableName: string) {
return `LEFT JOIN "server" AS "${tableName}->Server"
ON "${tableName}"."serverId" = "${tableName}->Server"."id" `
}
}

View File

@@ -0,0 +1,155 @@
import { ActorImageType, VideoChannelCollaboratorState, VideoPlaylistType } from '@peertube/peertube-models'
import { AbstractListQuery, AbstractListQueryOptions } from '@server/models/shared/abstract-list-query.js'
import { Sequelize } from 'sequelize'
import { UserTableAttributes } from './user-table-attributes.js'
export interface ListUserOptions extends AbstractListQueryOptions {
userId: number
}
export class UserListQueryBuilder extends AbstractListQuery {
private readonly tableAttributes = new UserTableAttributes()
private builtAccountJoin = false
private builtChannelsJoin = false
private builtChannelCollabsJoin = false
private builtPlaylistsJoin = false
private builtNotificationSettingsJoin = false
constructor (
protected readonly sequelize: Sequelize,
protected readonly options: ListUserOptions
) {
super(sequelize, { modelName: 'UserModel', tableName: 'user' }, options)
}
protected buildSubQueryWhere () {
const where: string[] = []
if (this.options.userId) {
where.push('"UserModel"."id" = :userId')
this.replacements.userId = this.options.userId
}
if (where.length !== 0) {
this.subQueryWhere = `WHERE ${where.join(' AND ')}`
}
}
private buildAccountJoin () {
if (this.builtAccountJoin) return
this.join += ' INNER JOIN "account" "Account" ON "Account"."userId" = "UserModel"."id" ' +
'INNER JOIN "actor" "Account->Actor" ON "Account->Actor"."accountId" = "Account"."id" ' +
'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" ' +
'LEFT JOIN "actorImage" "Account->Actor->Avatars" ON "Account->Actor->Avatars"."actorId" = "Account->Actor"."id" ' +
` AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} `
this.builtAccountJoin = true
}
private buildChannelsJoin () {
if (this.builtChannelsJoin) return
this.join += ' LEFT JOIN "videoChannel" "Account->VideoChannels" ON "Account"."id" = "Account->VideoChannels"."accountId" ' +
'LEFT JOIN "actor" "Account->VideoChannels->Actor" ' +
' ON "Account->VideoChannels->Actor"."videoChannelId" = "Account->VideoChannels"."id" ' +
'LEFT JOIN "server" "Account->VideoChannels->Actor->Server" ' +
' ON "Account->VideoChannels->Actor->Server"."id" = "Account->VideoChannels->Actor"."serverId" ' +
'LEFT JOIN "actorImage" "Account->VideoChannels->Actor->Avatars" ' +
' ON "Account->VideoChannels->Actor->Avatars"."actorId" = "Account->VideoChannels->Actor"."id" ' +
` AND "Account->VideoChannels->Actor->Avatars"."type" = ${ActorImageType.AVATAR} ` +
'LEFT JOIN "actorImage" "Account->VideoChannels->Actor->Banners" ' +
' ON "Account->VideoChannels->Actor->Banners"."actorId" = "Account->VideoChannels->Actor"."id" ' +
` AND "Account->VideoChannels->Actor->Banners"."type" = ${ActorImageType.BANNER} `
this.builtChannelsJoin = true
}
private buildChannelCollabs () {
if (this.builtChannelCollabsJoin) return
this.join += 'LEFT JOIN "videoChannelCollaborator" AS "Account->Collabs" ' +
`ON "Account"."id" = "Account->Collabs"."accountId" AND "Account->Collabs"."state" = ${VideoChannelCollaboratorState.ACCEPTED} ` +
'LEFT JOIN (' +
' "videoChannel" "Account->Collabs->Channel" ' +
' INNER JOIN "actor" AS "Account->Collabs->Channel->Actor" ' +
' ON "Account->Collabs->Channel"."id" = "Account->Collabs->Channel->Actor"."videoChannelId" ' +
' LEFT JOIN "server" AS "Account->Collabs->Channel->Actor->Server" ' +
' ON "Account->Collabs->Channel->Actor"."serverId" = "Account->Collabs->Channel->Actor->Server"."id"' +
' LEFT JOIN "actorImage" AS "Account->Collabs->Channel->Actor->Avatars" ' +
' ON "Account->Collabs->Channel->Actor"."id" = "Account->Collabs->Channel->Actor->Avatars"."actorId"' +
` AND "Account->Collabs->Channel->Actor->Avatars"."type" = ${ActorImageType.AVATAR}` +
' LEFT JOIN "actorImage" AS "Account->Collabs->Channel->Actor->Banners" ' +
' ON "Account->Collabs->Channel->Actor"."id" = "Account->Collabs->Channel->Actor->Banners"."actorId" ' +
` AND "Account->Collabs->Channel->Actor->Banners"."type" = ${ActorImageType.BANNER} ` +
') ON "Account->Collabs"."channelId" = "Account->Collabs->Channel"."id"'
this.builtChannelCollabsJoin = true
}
private buildPlaylistsJoin () {
if (this.builtPlaylistsJoin) return
this.join += 'INNER JOIN "videoPlaylist" AS "Account->VideoPlaylists" ' +
'ON "Account"."id" = "Account->VideoPlaylists"."ownerAccountId" ' +
`AND "Account->VideoPlaylists"."type" = ${VideoPlaylistType.WATCH_LATER} `
this.builtPlaylistsJoin = true
}
private buildNotificationSettingsJoin () {
if (this.builtNotificationSettingsJoin) return
this.join += 'INNER JOIN "userNotificationSetting" AS "NotificationSetting" ON "UserModel"."id" = "NotificationSetting"."userId"'
this.builtNotificationSettingsJoin = true
}
// ---------------------------------------------------------------------------
protected buildQueryJoin () {
this.buildAccountJoin()
this.buildChannelsJoin()
this.buildChannelCollabs()
this.buildPlaylistsJoin()
this.buildNotificationSettingsJoin()
}
protected buildQueryAttributes () {
this.attributes = [
...this.attributes,
this.tableAttributes.getAccountAttributes(),
this.tableAttributes.getAccountActorAttributes(),
this.tableAttributes.getAccountServerAttributes(),
this.tableAttributes.getAccountAvatarAttributes(),
this.tableAttributes.getChannelAttributes(),
this.tableAttributes.getChannelActorAttributes(),
this.tableAttributes.getChannelServerAttributes(),
this.tableAttributes.getChannelAvatarAttributes(),
this.tableAttributes.getChannelBannerAttributes(),
this.tableAttributes.getPlaylistAttributes(),
this.tableAttributes.getNotificationSettingAttributes(),
this.tableAttributes.getCollabAttributes(),
this.tableAttributes.getCollabChannelAttributes(),
this.tableAttributes.getCollabChannelActorAttributes(),
this.tableAttributes.getCollabChannelActorServerAttributes(),
this.tableAttributes.getCollabChannelActorAvatarAttributes(),
this.tableAttributes.getCollabChannelActorBannerAttributes()
]
}
protected buildSubQueryJoin () {
// empty
}
protected buildSubQueryAttributes () {
this.subQueryAttributes = [
...this.subQueryAttributes,
this.tableAttributes.getUserAttributes()
]
}
}

View File

@@ -0,0 +1,120 @@
import { Memoize } from '@server/helpers/memoize.js'
import { AccountModel } from '@server/models/account/account.js'
import { ActorImageModel } from '@server/models/actor/actor-image.js'
import { ActorModel } from '@server/models/actor/actor.js'
import { ServerModel } from '@server/models/server/server.js'
import { UserModel } from '../../user.js'
import { VideoChannelModel } from '@server/models/video/video-channel.js'
import { VideoPlaylistModel } from '@server/models/video/video-playlist.js'
import { UserNotificationSettingModel } from '../../user-notification-setting.js'
import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js'
export class UserTableAttributes {
@Memoize()
getUserAttributes () {
return UserModel.getSQLAttributes('UserModel').join(', ')
}
// ---------------------------------------------------------------------------
@Memoize()
getAccountAttributes () {
return AccountModel.getSQLAttributes('Account', 'Account.').join(', ')
}
@Memoize()
getAccountActorAttributes () {
return ActorModel.getSQLAPIAttributes('Account->Actor', `Account.Actor.`).join(', ')
}
@Memoize()
getAccountServerAttributes () {
return ServerModel.getSQLAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ')
}
@Memoize()
getAccountAvatarAttributes () {
return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ')
}
// ---------------------------------------------------------------------------
@Memoize()
getChannelAttributes () {
return VideoChannelModel.getSQLAttributes('Account->VideoChannels', 'Account.VideoChannels.').join(', ')
}
@Memoize()
getChannelActorAttributes () {
return ActorModel.getSQLAPIAttributes('Account->VideoChannels->Actor', `Account.VideoChannels.Actor.`).join(', ')
}
@Memoize()
getChannelServerAttributes () {
return ServerModel.getSQLSummaryAttributes('Account->VideoChannels->Actor->Server', `Account.VideoChannels.Actor.Server.`).join(', ')
}
@Memoize()
getChannelAvatarAttributes () {
return ActorImageModel.getSQLAttributes('Account->VideoChannels->Actor->Avatars', 'Account.VideoChannels.Actor.Avatars.').join(', ')
}
@Memoize()
getChannelBannerAttributes () {
return ActorImageModel.getSQLAttributes('Account->VideoChannels->Actor->Banners', 'Account.VideoChannels.Actor.Banners.').join(', ')
}
// ---------------------------------------------------------------------------
@Memoize()
getPlaylistAttributes () {
return VideoPlaylistModel.getSQLSummaryAttributes('Account->VideoPlaylists', 'Account.VideoPlaylists.').join(', ')
}
// ---------------------------------------------------------------------------
@Memoize()
getNotificationSettingAttributes () {
return UserNotificationSettingModel.getSQLAttributes('NotificationSetting', 'NotificationSetting.').join(', ')
}
// ---------------------------------------------------------------------------
@Memoize()
getCollabAttributes () {
return VideoChannelCollaboratorModel.getSQLAttributes('Account->Collabs', 'Account.Collabs.').join(', ')
}
@Memoize()
getCollabChannelAttributes () {
return VideoChannelModel.getSQLAttributes('Account->Collabs->Channel', 'Account.Collabs.Channel.').join(', ')
}
@Memoize()
getCollabChannelActorAttributes () {
return ActorModel.getSQLAPIAttributes('Account->Collabs->Channel->Actor', 'Account.Collabs.Channel.Actor.').join(', ')
}
@Memoize()
getCollabChannelActorServerAttributes () {
return ServerModel.getSQLAttributes(
'Account->Collabs->Channel->Actor->Server',
'Account.Collabs.Channel.Actor.Server.'
).join(', ')
}
@Memoize()
getCollabChannelActorAvatarAttributes () {
return ActorImageModel.getSQLAttributes(
'Account->Collabs->Channel->Actor->Avatars',
'Account.Collabs.Channel.Actor.Avatars.'
).join(', ')
}
@Memoize()
getCollabChannelActorBannerAttributes () {
return ActorImageModel.getSQLAttributes(
'Account->Collabs->Channel->Actor->Banners',
'Account.Collabs.Channel.Actor.Banners.'
).join(', ')
}
}

View File

@@ -0,0 +1,228 @@
import { FileStorage, UserExportState, type FileStorageType, type UserExport, type UserExportStateType } from '@peertube/peertube-models'
import { logger } from '@server/helpers/logger.js'
import { CONFIG } from '@server/initializers/config.js'
import {
JWT_TOKEN_USER_EXPORT_FILE_LIFETIME,
DOWNLOAD_PATHS,
USER_EXPORT_FILE_PREFIX,
USER_EXPORT_STATES,
WEBSERVER
} from '@server/initializers/constants.js'
import { removeUserExportObjectStorage } from '@server/lib/object-storage/user-export.js'
import { getFSUserExportFilePath } from '@server/lib/paths.js'
import { MUserAccountId, MUserExport } from '@server/types/models/index.js'
import { remove } from 'fs-extra/esm'
import jwt from 'jsonwebtoken'
import { join } from 'path'
import { FindOptions, Op } from 'sequelize'
import { AllowNull, BeforeDestroy, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
import { doesExist } from '../shared/query.js'
import { SequelizeModel } from '../shared/sequelize-type.js'
import { getSort } from '../shared/sort.js'
import { UserModel } from './user.js'
@Table({
tableName: 'userExport',
indexes: [
{
fields: [ 'userId' ]
},
{
fields: [ 'filename' ],
unique: true
}
]
})
export class UserExportModel extends SequelizeModel<UserExportModel> {
@CreatedAt
declare createdAt: Date
@UpdatedAt
declare updatedAt: Date
@AllowNull(true)
@Column
declare filename: string
@AllowNull(false)
@Column
declare withVideoFiles: boolean
@AllowNull(false)
@Column
declare state: UserExportStateType
@AllowNull(true)
@Column(DataType.TEXT)
declare error: string
@AllowNull(true)
@Column(DataType.BIGINT)
declare size: number
@AllowNull(false)
@Column
declare storage: FileStorageType
@AllowNull(true)
@Column
declare fileUrl: string
@ForeignKey(() => UserModel)
@Column
declare userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
declare User: Awaited<UserModel>
@BeforeDestroy
static removeFile (instance: UserExportModel) {
logger.info('Removing user export file %s.', instance.filename)
if (instance.storage === FileStorage.FILE_SYSTEM) {
remove(getFSUserExportFilePath(instance))
.catch(err => logger.error('Cannot delete user export archive %s from filesystem.', instance.filename, { err }))
} else {
removeUserExportObjectStorage(instance)
.catch(err => logger.error('Cannot delete user export archive %s from object storage.', instance.filename, { err }))
}
return undefined
}
static listByUser (user: MUserAccountId) {
const query: FindOptions = {
where: {
userId: user.id
}
}
return UserExportModel.findAll<MUserExport>(query)
}
static listExpired (expirationTimeMS: number) {
const query: FindOptions = {
where: {
createdAt: {
[Op.lt]: new Date(new Date().getTime() - expirationTimeMS)
}
}
}
return UserExportModel.findAll<MUserExport>(query)
}
static listForApi (options: {
user: MUserAccountId
start: number
count: number
}) {
const { count, start, user } = options
const query: FindOptions = {
offset: start,
limit: count,
order: getSort('createdAt'),
where: {
userId: user.id
}
}
return Promise.all([
UserExportModel.count(query),
UserExportModel.findAll<MUserExport>(query)
]).then(([ total, data ]) => ({ total, data }))
}
static load (id: number | string) {
return UserExportModel.findByPk<MUserExport>(id)
}
static loadByFilename (filename: string) {
return UserExportModel.findOne<MUserExport>({ where: { filename } })
}
// ---------------------------------------------------------------------------
static async doesOwnedFileExist (filename: string, storage: FileStorageType) {
const query = 'SELECT 1 FROM "userExport" ' +
`WHERE "filename" = $filename AND "storage" = $storage LIMIT 1`
return doesExist({ sequelize: this.sequelize, query, bind: { filename, storage } })
}
// ---------------------------------------------------------------------------
generateAndSetFilename () {
if (!this.userId) throw new Error('Cannot generate filename without userId')
if (!this.createdAt) throw new Error('Cannot generate filename without createdAt')
this.filename = `${USER_EXPORT_FILE_PREFIX}${this.userId}-${this.createdAt.toISOString()}.zip`
}
canBeSafelyRemoved () {
const supportedStates = new Set<UserExportStateType>([ UserExportState.COMPLETED, UserExportState.ERRORED, UserExportState.PENDING ])
return supportedStates.has(this.state)
}
generateJWT () {
return jwt.sign(
{
userExportId: this.id
},
CONFIG.SECRETS.PEERTUBE,
{
expiresIn: JWT_TOKEN_USER_EXPORT_FILE_LIFETIME,
audience: this.filename,
issuer: WEBSERVER.URL
}
)
}
isJWTValid (jwtToken: string) {
try {
const payload = jwt.verify(jwtToken, CONFIG.SECRETS.PEERTUBE, {
audience: this.filename,
issuer: WEBSERVER.URL
})
if ((payload as any).userExportId !== this.id) return false
return true
} catch {
return false
}
}
getFileDownloadUrl () {
if (this.state !== UserExportState.COMPLETED) return null
return WEBSERVER.URL + join(DOWNLOAD_PATHS.USER_EXPORTS, this.filename) + '?jwt=' + this.generateJWT()
}
// ---------------------------------------------------------------------------
toFormattedJSON (this: MUserExport): UserExport {
return {
id: this.id,
state: {
id: this.state,
label: USER_EXPORT_STATES[this.state]
},
size: this.size,
fileUrl: this.fileUrl,
privateDownloadUrl: this.getFileDownloadUrl(),
createdAt: this.createdAt.toISOString(),
expiresOn: new Date(this.createdAt.getTime() + CONFIG.EXPORT.USERS.EXPORT_EXPIRATION).toISOString()
}
}
}

View File

@@ -0,0 +1,88 @@
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Table, UpdatedAt } from 'sequelize-typescript'
import { MUserImport } from '@server/types/models/index.js'
import { SequelizeModel } from '../shared/index.js'
import { UserModel } from './user.js'
import type { UserImportResultSummary, UserImportStateType } from '@peertube/peertube-models'
import { getSort } from '../shared/sort.js'
import { USER_IMPORT_STATES } from '@server/initializers/constants.js'
@Table({
tableName: 'userImport',
indexes: [
{
fields: [ 'userId' ]
},
{
fields: [ 'filename' ],
unique: true
}
]
})
export class UserImportModel extends SequelizeModel<UserImportModel> {
@CreatedAt
declare createdAt: Date
@UpdatedAt
declare updatedAt: Date
@AllowNull(true)
@Column
declare filename: string
@AllowNull(false)
@Column
declare state: UserImportStateType
@AllowNull(true)
@Column(DataType.TEXT)
declare error: string
@AllowNull(true)
@Column(DataType.JSONB)
declare resultSummary: UserImportResultSummary
@ForeignKey(() => UserModel)
@Column
declare userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
declare User: Awaited<UserModel>
static load (id: number | string) {
return UserImportModel.findByPk<MUserImport>(id)
}
static loadLatestByUserId (userId: number) {
return UserImportModel.findOne<MUserImport>({
where: {
userId
},
order: getSort('-createdAt')
})
}
// ---------------------------------------------------------------------------
generateAndSetFilename () {
if (!this.userId) throw new Error('Cannot generate filename without userId')
if (!this.createdAt) throw new Error('Cannot generate filename without createdAt')
this.filename = `user-import-${this.userId}-${this.createdAt.toISOString()}.zip`
}
toFormattedJSON () {
return {
id: this.id,
state: {
id: this.state,
label: USER_IMPORT_STATES[this.state]
},
createdAt: this.createdAt.toISOString()
}
}
}

View File

@@ -0,0 +1,261 @@
import { type UserNotificationSetting, type UserNotificationSettingValueType } from '@peertube/peertube-models'
import { TokensCache } from '@server/lib/auth/tokens-cache.js'
import { MNotificationSettingFormattable } from '@server/types/models/index.js'
import {
AfterDestroy,
AfterUpdate,
AllowNull,
BelongsTo,
Column,
CreatedAt,
Default,
ForeignKey,
Is,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications.js'
import { buildSQLAttributes, SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { UserModel } from './user.js'
@Table({
tableName: 'userNotificationSetting',
indexes: [
{
fields: [ 'userId' ],
unique: true
}
]
})
export class UserNotificationSettingModel extends SequelizeModel<UserNotificationSettingModel> {
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewVideoFromSubscription',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newVideoFromSubscription')
)
@Column
declare newVideoFromSubscription: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewCommentOnMyVideo',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newCommentOnMyVideo')
)
@Column
declare newCommentOnMyVideo: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingAbuseAsModerator',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseAsModerator')
)
@Column
declare abuseAsModerator: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingVideoAutoBlacklistAsModerator',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'videoAutoBlacklistAsModerator')
)
@Column
declare videoAutoBlacklistAsModerator: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingBlacklistOnMyVideo',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'blacklistOnMyVideo')
)
@Column
declare blacklistOnMyVideo: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingMyVideoPublished',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoPublished')
)
@Column
declare myVideoPublished: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingMyVideoImportFinished',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoImportFinished')
)
@Column
declare myVideoImportFinished: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewUserRegistration',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newUserRegistration')
)
@Column
declare newUserRegistration: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewInstanceFollower',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newInstanceFollower')
)
@Column
declare newInstanceFollower: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewInstanceFollower',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'autoInstanceFollowing')
)
@Column
declare autoInstanceFollowing: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewFollow',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newFollow')
)
@Column
declare newFollow: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingCommentMention',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'commentMention')
)
@Column
declare commentMention: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingAbuseStateChange',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseStateChange')
)
@Column
declare abuseStateChange: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingAbuseNewMessage',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'abuseNewMessage')
)
@Column
declare abuseNewMessage: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewPeerTubeVersion',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newPeerTubeVersion')
)
@Column
declare newPeerTubeVersion: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingNewPeerPluginVersion',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'newPluginVersion')
)
@Column
declare newPluginVersion: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingMyVideoStudioEditionFinished',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoStudioEditionFinished')
)
@Column
declare myVideoStudioEditionFinished: UserNotificationSettingValueType
@AllowNull(false)
@Default(null)
@Is(
'UserNotificationSettingTranscriptionGeneratedForOwner',
value => throwIfNotValid(value, isUserNotificationSettingValid, 'myVideoTranscriptionGenerated')
)
@Column
declare myVideoTranscriptionGenerated: UserNotificationSettingValueType
@ForeignKey(() => UserModel)
@Column
declare userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade'
})
declare User: Awaited<UserModel>
@CreatedAt
declare createdAt: Date
@UpdatedAt
declare updatedAt: Date
@AfterUpdate
@AfterDestroy
static removeTokenCache (instance: UserNotificationSettingModel) {
return TokensCache.Instance.clearCacheByUserId(instance.userId)
}
// ---------------------------------------------------------------------------
static getSQLAttributes (tableName: string, aliasPrefix = '') {
return buildSQLAttributes({
model: this,
tableName,
aliasPrefix
})
}
// ---------------------------------------------------------------------------
static updateUserSettings (settings: UserNotificationSetting, userId: number) {
const query = {
where: {
userId
}
}
return UserNotificationSettingModel.update(settings, query)
}
toFormattedJSON (this: MNotificationSettingFormattable): UserNotificationSetting {
return {
newCommentOnMyVideo: this.newCommentOnMyVideo,
newVideoFromSubscription: this.newVideoFromSubscription,
abuseAsModerator: this.abuseAsModerator,
videoAutoBlacklistAsModerator: this.videoAutoBlacklistAsModerator,
blacklistOnMyVideo: this.blacklistOnMyVideo,
myVideoPublished: this.myVideoPublished,
myVideoImportFinished: this.myVideoImportFinished,
newUserRegistration: this.newUserRegistration,
commentMention: this.commentMention,
newFollow: this.newFollow,
newInstanceFollower: this.newInstanceFollower,
autoInstanceFollowing: this.autoInstanceFollowing,
abuseNewMessage: this.abuseNewMessage,
abuseStateChange: this.abuseStateChange,
newPeerTubeVersion: this.newPeerTubeVersion,
myVideoStudioEditionFinished: this.myVideoStudioEditionFinished,
myVideoTranscriptionGenerated: this.myVideoTranscriptionGenerated,
newPluginVersion: this.newPluginVersion
}
}
}

View File

@@ -0,0 +1,626 @@
import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
import type { UserNotification, UserNotificationData, UserNotificationType_Type } from '@peertube/peertube-models'
import { uuidToShort } from '@peertube/peertube-node-utils'
import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user/index.js'
import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Table, UpdatedAt } from 'sequelize-typescript'
import { isBooleanValid } from '../../helpers/custom-validators/misc.js'
import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications.js'
import { AbuseModel } from '../abuse/abuse.js'
import { AccountModel } from '../account/account.js'
import { ActorFollowModel } from '../actor/actor-follow.js'
import { ActorImageModel } from '../actor/actor-image.js'
import { ApplicationModel } from '../application/application.js'
import { PluginModel } from '../server/plugin.js'
import { SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { getStateLabel } from '../video/formatter/video-api-format.js'
import { VideoBlacklistModel } from '../video/video-blacklist.js'
import { VideoCaptionModel } from '../video/video-caption.js'
import { VideoChannelCollaboratorModel } from '../video/video-channel-collaborator.js'
import { VideoCommentModel } from '../video/video-comment.js'
import { VideoImportModel } from '../video/video-import.js'
import { VideoModel } from '../video/video.js'
import { UserNotificationListQueryBuilder } from './sql/user-notification/user-notification-list-query-builder.js'
import { UserRegistrationModel } from './user-registration.js'
import { UserModel } from './user.js'
@Table({
tableName: 'userNotification',
indexes: [
{
fields: [ 'userId' ]
},
{
fields: [ 'videoId' ],
where: {
videoId: {
[Op.ne]: null
}
}
},
{
fields: [ 'commentId' ],
where: {
commentId: {
[Op.ne]: null
}
}
},
{
fields: [ 'abuseId' ],
where: {
abuseId: {
[Op.ne]: null
}
}
},
{
fields: [ 'videoBlacklistId' ],
where: {
videoBlacklistId: {
[Op.ne]: null
}
}
},
{
fields: [ 'videoImportId' ],
where: {
videoImportId: {
[Op.ne]: null
}
}
},
{
fields: [ 'accountId' ],
where: {
accountId: {
[Op.ne]: null
}
}
},
{
fields: [ 'actorFollowId' ],
where: {
actorFollowId: {
[Op.ne]: null
}
}
},
{
fields: [ 'pluginId' ],
where: {
pluginId: {
[Op.ne]: null
}
}
},
{
fields: [ 'applicationId' ],
where: {
applicationId: {
[Op.ne]: null
}
}
},
{
fields: [ 'userRegistrationId' ],
where: {
userRegistrationId: {
[Op.ne]: null
}
}
},
{
fields: [ 'channelCollaboratorId' ],
where: {
channelCollaboratorId: {
[Op.ne]: null
}
}
}
] as (ModelIndexesOptions & { where?: WhereOptions })[]
})
export class UserNotificationModel extends SequelizeModel<UserNotificationModel> {
@AllowNull(false)
@Default(null)
@Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
@Column
declare type: UserNotificationType_Type
@AllowNull(false)
@Default(false)
@Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
@Column
declare read: boolean
@AllowNull(true)
@Column(DataType.JSONB)
declare data: UserNotificationData
@CreatedAt
declare createdAt: Date
@UpdatedAt
declare updatedAt: Date
@ForeignKey(() => UserModel)
@Column
declare userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: false
},
onDelete: 'cascade'
})
declare User: Awaited<UserModel>
@ForeignKey(() => VideoModel)
@Column
declare videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare Video: Awaited<VideoModel>
@ForeignKey(() => VideoCommentModel)
@Column
declare commentId: number
@BelongsTo(() => VideoCommentModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare VideoComment: Awaited<VideoCommentModel>
@ForeignKey(() => AbuseModel)
@Column
declare abuseId: number
@BelongsTo(() => AbuseModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare Abuse: Awaited<AbuseModel>
@ForeignKey(() => VideoBlacklistModel)
@Column
declare videoBlacklistId: number
@BelongsTo(() => VideoBlacklistModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare VideoBlacklist: Awaited<VideoBlacklistModel>
@ForeignKey(() => VideoImportModel)
@Column
declare videoImportId: number
@BelongsTo(() => VideoImportModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare VideoImport: Awaited<VideoImportModel>
@ForeignKey(() => AccountModel)
@Column
declare accountId: number
@BelongsTo(() => AccountModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare Account: Awaited<AccountModel>
@ForeignKey(() => ActorFollowModel)
@Column
declare actorFollowId: number
@BelongsTo(() => ActorFollowModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare ActorFollow: Awaited<ActorFollowModel>
@ForeignKey(() => PluginModel)
@Column
declare pluginId: number
@BelongsTo(() => PluginModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare Plugin: Awaited<PluginModel>
@ForeignKey(() => ApplicationModel)
@Column
declare applicationId: number
@BelongsTo(() => ApplicationModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare Application: Awaited<ApplicationModel>
@ForeignKey(() => UserRegistrationModel)
@Column
declare userRegistrationId: number
@BelongsTo(() => UserRegistrationModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare UserRegistration: Awaited<UserRegistrationModel>
@ForeignKey(() => VideoCaptionModel)
@Column
declare videoCaptionId: number
@BelongsTo(() => VideoCaptionModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare VideoCaption: Awaited<VideoCaptionModel>
@ForeignKey(() => VideoChannelCollaboratorModel)
@Column
declare channelCollaboratorId: number
@BelongsTo(() => VideoChannelCollaboratorModel, {
foreignKey: {
allowNull: true
},
onDelete: 'cascade'
})
declare VideoChannelCollaborator: Awaited<VideoChannelCollaboratorModel>
static listForApi (options: {
userId: number
start: number
count: number
sort: string
unread?: boolean
typeOneOf?: UserNotificationType_Type[]
}) {
const { userId, start, count, sort, unread, typeOneOf } = options
const countWhere = { userId }
const query = {
start,
count,
sort,
userId,
unread,
typeOneOf
}
if (unread !== undefined) countWhere['read'] = !unread
if (typeOneOf !== undefined) countWhere['type'] = { [Op.in]: typeOneOf }
return Promise.all([
UserNotificationModel.count({ where: countWhere })
.then(count => count || 0),
count === 0
? [] as UserNotificationModelForApi[]
: new UserNotificationListQueryBuilder(this.sequelize, query).list<UserNotificationModelForApi>()
]).then(([ total, data ]) => ({ total, data }))
}
static markAsRead (userId: number, notificationIds: number[]) {
const query = {
where: {
userId,
id: {
[Op.in]: notificationIds
},
read: false
}
}
return UserNotificationModel.update({ read: true }, query)
}
static markAllAsRead (userId: number) {
const query = { where: { userId, read: false } }
return UserNotificationModel.update({ read: true }, query)
}
static removeNotificationsOf (options: {
id: number
type: 'account' | 'server'
forUserId?: number
}) {
const id = forceNumber(options.id)
function buildAccountWhereQuery (base: string) {
const whereSuffix = options.forUserId
? ` AND "userNotification"."userId" = ${options.forUserId}`
: ''
if (options.type === 'account') {
return base +
` WHERE "account"."id" = ${id} ${whereSuffix}`
}
return base +
` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
}
const queries = [
// Remove notifications from muted accounts
buildAccountWhereQuery(
`SELECT "userNotification"."id" FROM "userNotification" ` +
`INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
`INNER JOIN actor ON "actor"."accountId" = "account"."id" `
),
// Remove notifications from muted accounts that followed ours
buildAccountWhereQuery(
`SELECT "userNotification"."id" FROM "userNotification" ` +
`INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
`INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
`INNER JOIN account ON account."id" = actor."accountId" `
),
// Remove notifications from muted accounts that commented something
buildAccountWhereQuery(
`SELECT "userNotification"."id" FROM "userNotification" ` +
`INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
`INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
`INNER JOIN account ON account."id" = actor."accountId" `
),
// Remove notifications of comments from muted accounts
buildAccountWhereQuery(
`SELECT "userNotification"."id" FROM "userNotification" ` +
`INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
`INNER JOIN account ON account.id = "videoComment"."accountId" ` +
`INNER JOIN actor ON "actor"."accountId" = "account"."id" `
),
// Remove notifications from muted accounts that invited us to collaborate to a channel
buildAccountWhereQuery(
`SELECT "userNotification"."id" FROM "userNotification" ` +
`INNER JOIN "videoChannelCollaborator" ON "videoChannelCollaborator".id = "userNotification"."channelCollaboratorId" ` +
`INNER JOIN "videoChannel" ON "videoChannel".id = "videoChannelCollaborator"."channelId" ` +
`INNER JOIN "account" ON "videoChannel"."accountId" = "account"."id" ` +
`INNER JOIN actor ON "actor"."accountId" = "account"."id" `
)
]
const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
return UserNotificationModel.sequelize.query(query)
}
toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
const video = this.Video
? {
...this.formatVideo(this.Video),
channel: this.formatActor(this.Video.VideoChannel)
}
: undefined
const videoImport = this.VideoImport
? {
id: this.VideoImport.id,
video: this.VideoImport.Video
? this.formatVideo(this.VideoImport.Video)
: undefined,
torrentName: this.VideoImport.torrentName,
magnetUri: this.VideoImport.magnetUri,
targetUrl: this.VideoImport.targetUrl
}
: undefined
const comment = this.VideoComment
? {
id: this.VideoComment.id,
threadId: this.VideoComment.getThreadId(),
account: this.formatActor(this.VideoComment.Account),
video: this.formatVideo(this.VideoComment.Video),
heldForReview: this.VideoComment.heldForReview
}
: undefined
const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
const videoBlacklist = this.VideoBlacklist
? {
id: this.VideoBlacklist.id,
video: this.formatVideo(this.VideoBlacklist.Video)
}
: undefined
const account = this.Account ? this.formatActor(this.Account) : undefined
const actorFollowingType = {
Application: 'instance' as 'instance',
Group: 'channel' as 'channel',
Person: 'account' as 'account'
}
const actorFollow = this.ActorFollow
? {
id: this.ActorFollow.id,
state: this.ActorFollow.state,
follower: {
id: this.ActorFollow.ActorFollower.Account.id,
displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
name: this.ActorFollow.ActorFollower.preferredUsername,
host: this.ActorFollow.ActorFollower.getHost(),
...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
},
following: {
type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
name: this.ActorFollow.ActorFollowing.preferredUsername,
host: this.ActorFollow.ActorFollowing.getHost()
}
}
: undefined
const plugin = this.Plugin
? {
name: this.Plugin.name,
type: this.Plugin.type,
latestVersion: this.Plugin.latestVersion
}
: undefined
const peertube = this.Application
? { latestVersion: this.Application.latestPeerTubeVersion }
: undefined
const registration = this.UserRegistration
? { id: this.UserRegistration.id, username: this.UserRegistration.username }
: undefined
const videoCaption = this.VideoCaption
? {
id: this.VideoCaption.id,
language: {
id: this.VideoCaption.language,
label: VideoCaptionModel.getLanguageLabel(this.VideoCaption.language)
},
video: this.formatVideo(this.VideoCaption.Video)
}
: undefined
const videoChannelCollaborator = this.VideoChannelCollaborator
? {
id: this.VideoChannelCollaborator.id,
channelOwner: this.formatActor(this.VideoChannelCollaborator.Channel.Account),
channel: this.formatActor(this.VideoChannelCollaborator.Channel),
account: this.formatActor(this.VideoChannelCollaborator.Account),
state: {
id: this.VideoChannelCollaborator.state,
label: VideoChannelCollaboratorModel.getStateLabel(this.VideoChannelCollaborator.state)
}
}
: undefined
return {
id: this.id,
type: this.type,
read: this.read,
data: this.data,
video,
videoImport,
comment,
abuse,
videoBlacklist,
account,
actorFollow,
plugin,
peertube,
registration,
videoCaption,
videoChannelCollaborator,
createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString()
}
}
formatVideo (video: UserNotificationIncludes.VideoInclude) {
return {
id: video.id,
uuid: video.uuid,
shortUUID: uuidToShort(video.uuid),
name: video.name,
state: {
id: video.state,
label: getStateLabel(video.state)
}
}
}
formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
? {
threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
video: abuse.VideoCommentAbuse.VideoComment.Video
? this.formatVideo(abuse.VideoCommentAbuse.VideoComment.Video)
: undefined
}
: undefined
const videoAbuse = abuse.VideoAbuse?.Video
? this.formatVideo(abuse.VideoAbuse.Video)
: undefined
const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
? this.formatActor(abuse.FlaggedAccount)
: undefined
return {
id: abuse.id,
state: abuse.state,
video: videoAbuse,
comment: commentAbuse,
account: accountAbuse
}
}
formatActor (
accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
) {
return {
id: accountOrChannel.id,
displayName: accountOrChannel.getDisplayName(),
name: accountOrChannel.Actor.preferredUsername,
host: accountOrChannel.Actor.getHost(),
...this.formatAvatars(accountOrChannel.Actor.Avatars)
}
}
formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
return {
avatar: this.formatAvatar(maxBy(avatars, 'width')),
avatars: avatars.map(a => this.formatAvatar(a))
}
}
formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
return {
fileUrl: ActorImageModel.getImageUrl(a),
path: a.getStaticPath(),
width: a.width
}
}
}

View File

@@ -0,0 +1,301 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { UserRegistration, UserRegistrationState, type UserRegistrationStateType } from '@peertube/peertube-models'
import {
isRegistrationModerationResponseValid,
isRegistrationReasonValid,
isRegistrationStateValid
} from '@server/helpers/custom-validators/user-registration.js'
import { isVideoChannelDisplayNameValid } from '@server/helpers/custom-validators/video-channels.js'
import { cryptPassword } from '@server/helpers/peertube-crypto.js'
import { USER_REGISTRATION_STATES } from '@server/initializers/constants.js'
import { MRegistration, MRegistrationFormattable } from '@server/types/models/index.js'
import { col, FindOptions, fn, Op, QueryTypes, where, WhereOptions } from 'sequelize'
import {
AllowNull,
BeforeCreate,
BelongsTo,
Column,
CreatedAt,
DataType,
ForeignKey,
Is,
IsEmail,
Table,
UpdatedAt
} from 'sequelize-typescript'
import { isUserDisplayNameValid, isUserEmailVerifiedValid } from '../../helpers/custom-validators/users.js'
import { getSort, parseAggregateResult, SequelizeModel, throwIfNotValid } from '../shared/index.js'
import { UserModel } from './user.js'
@Table({
tableName: 'userRegistration',
indexes: [
{
fields: [ 'username' ],
unique: true
},
{
fields: [ 'email' ],
unique: true
},
{
fields: [ 'channelHandle' ],
unique: true
},
{
fields: [ 'userId' ],
unique: true
}
]
})
export class UserRegistrationModel extends SequelizeModel<UserRegistrationModel> {
@AllowNull(false)
@Is('RegistrationState', value => throwIfNotValid(value, isRegistrationStateValid, 'state'))
@Column
declare state: UserRegistrationStateType
@AllowNull(false)
@Is('RegistrationReason', value => throwIfNotValid(value, isRegistrationReasonValid, 'registration reason'))
@Column(DataType.TEXT)
declare registrationReason: string
@AllowNull(true)
@Is('RegistrationModerationResponse', value => throwIfNotValid(value, isRegistrationModerationResponseValid, 'moderation response', true))
@Column(DataType.TEXT)
declare moderationResponse: string
@AllowNull(true)
@Column
declare password: string
@AllowNull(false)
@Column
declare username: string
@AllowNull(false)
@IsEmail
@Column(DataType.STRING(400))
declare email: string
@AllowNull(true)
@Is('RegistrationEmailVerified', value => throwIfNotValid(value, isUserEmailVerifiedValid, 'email verified boolean', true))
@Column
declare emailVerified: boolean
@AllowNull(true)
@Is('RegistrationAccountDisplayName', value => throwIfNotValid(value, isUserDisplayNameValid, 'account display name', true))
@Column
declare accountDisplayName: string
@AllowNull(true)
@Is('ChannelHandle', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel handle', true))
@Column
declare channelHandle: string
@AllowNull(true)
@Is('ChannelDisplayName', value => throwIfNotValid(value, isVideoChannelDisplayNameValid, 'channel display name', true))
@Column
declare channelDisplayName: string
@AllowNull(true)
@Column
declare processedAt: Date
@CreatedAt
declare createdAt: Date
@UpdatedAt
declare updatedAt: Date
@ForeignKey(() => UserModel)
@Column
declare userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: true
},
onDelete: 'SET NULL'
})
declare User: Awaited<UserModel>
@BeforeCreate
static async cryptPasswordIfNeeded (instance: UserRegistrationModel) {
instance.password = await cryptPassword(instance.password)
}
static load (id: number): Promise<MRegistration> {
return UserRegistrationModel.findByPk(id)
}
static listByEmailCaseInsensitive (email: string): Promise<MRegistration[]> {
const query = {
where: where(
fn('LOWER', col('email')),
'=',
email.toLowerCase()
)
}
return UserRegistrationModel.findAll(query)
}
static listByEmailCaseInsensitiveOrUsername (emailOrUsername: string): Promise<MRegistration[]> {
const query = {
where: {
[Op.or]: [
where(
fn('LOWER', col('email')),
'=',
emailOrUsername.toLowerCase()
),
{ username: emailOrUsername }
]
}
}
return UserRegistrationModel.findAll(query)
}
static listByEmailCaseInsensitiveOrHandle (options: {
email: string
username: string
channelHandle?: string
}): Promise<MRegistration[]> {
const { email, username, channelHandle } = options
let or: WhereOptions = [
where(
fn('LOWER', col('email')),
'=',
email.toLowerCase()
),
{ channelHandle: username },
{ username }
]
if (channelHandle) {
or = or.concat([
{ username: channelHandle },
{ channelHandle }
])
}
const query = {
where: {
[Op.or]: or
}
}
return UserRegistrationModel.findAll(query)
}
// ---------------------------------------------------------------------------
static listForApi (options: {
start: number
count: number
sort: string
search?: string
}) {
const { start, count, sort, search } = options
const where: WhereOptions = {}
if (search) {
Object.assign(where, {
[Op.or]: [
{
email: {
[Op.iLike]: '%' + search + '%'
}
},
{
username: {
[Op.iLike]: '%' + search + '%'
}
}
]
})
}
const query: FindOptions = {
offset: start,
limit: count,
order: getSort(sort),
where,
include: [
{
model: UserModel.unscoped(),
required: false
}
]
}
return Promise.all([
UserRegistrationModel.count(query),
UserRegistrationModel.findAll<MRegistrationFormattable>(query)
]).then(([ total, data ]) => ({ total, data }))
}
// ---------------------------------------------------------------------------
static getStats () {
const query = `SELECT ` +
`AVG(EXTRACT(EPOCH FROM ("processedAt" - "createdAt") * 1000)) ` +
`FILTER (WHERE "processedAt" IS NOT NULL AND "createdAt" > CURRENT_DATE - INTERVAL '3 months')` +
`AS "avgResponseTime", ` +
// "processedAt" has been introduced in PeerTube 6.1 so also check the abuse state to check processed abuses
`COUNT(*) FILTER (WHERE "processedAt" IS NOT NULL OR "state" != ${UserRegistrationState.PENDING}) AS "processedRequests", ` +
`COUNT(*) AS "totalRequests" ` +
`FROM "userRegistration"`
return UserRegistrationModel.sequelize.query<any>(query, {
type: QueryTypes.SELECT,
raw: true
}).then(([ row ]) => {
return {
totalRegistrationRequests: parseAggregateResult(row.totalRequests),
totalRegistrationRequestsProcessed: parseAggregateResult(row.processedRequests),
averageRegistrationRequestResponseTimeMs: row?.avgResponseTime
? forceNumber(row.avgResponseTime)
: null
}
})
}
// ---------------------------------------------------------------------------
toFormattedJSON (this: MRegistrationFormattable): UserRegistration {
return {
id: this.id,
state: {
id: this.state,
label: USER_REGISTRATION_STATES[this.state]
},
registrationReason: this.registrationReason,
moderationResponse: this.moderationResponse,
username: this.username,
email: this.email,
emailVerified: this.emailVerified,
accountDisplayName: this.accountDisplayName,
channelHandle: this.channelHandle,
channelDisplayName: this.channelDisplayName,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
user: this.User
? { id: this.User.id }
: null
}
}
}

View File

@@ -0,0 +1,135 @@
import { DestroyOptions, Op, Transaction } from 'sequelize'
import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Table, UpdatedAt } from 'sequelize-typescript'
import { ResultList } from '@peertube/peertube-models'
import { MUserAccountId, MUserId } from '@server/types/models/index.js'
import { VideoModel } from '../video/video.js'
import { UserModel } from './user.js'
import { SequelizeModel } from '../shared/sequelize-type.js'
import { USER_EXPORT_MAX_ITEMS } from '@server/initializers/constants.js'
import { getSort } from '../shared/sort.js'
@Table({
tableName: 'userVideoHistory',
indexes: [
{
fields: [ 'userId', 'videoId' ],
unique: true
},
{
fields: [ 'userId' ]
},
{
fields: [ 'videoId' ]
}
]
})
export class UserVideoHistoryModel extends SequelizeModel<UserVideoHistoryModel> {
@CreatedAt
declare createdAt: Date
@UpdatedAt
declare updatedAt: Date
@AllowNull(false)
@IsInt
@Column
declare currentTime: number
@ForeignKey(() => VideoModel)
@Column
declare videoId: number
@BelongsTo(() => VideoModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
declare Video: Awaited<VideoModel>
@ForeignKey(() => UserModel)
@Column
declare userId: number
@BelongsTo(() => UserModel, {
foreignKey: {
allowNull: false
},
onDelete: 'CASCADE'
})
declare User: Awaited<UserModel>
// FIXME: have to specify the result type to not break peertube typings generation
static listForApi (user: MUserAccountId, start: number, count: number, search?: string): Promise<ResultList<VideoModel>> {
return VideoModel.listForApi({
start,
count,
search,
sort: '-"userVideoHistory"."updatedAt"',
nsfw: null, // All
displayOnlyForFollower: null,
user,
historyOfUser: user
})
}
static async listForExport (user: MUserId) {
const rows = await UserVideoHistoryModel.findAll({
attributes: [ 'createdAt', 'updatedAt', 'currentTime' ],
where: {
userId: user.id
},
limit: USER_EXPORT_MAX_ITEMS,
include: [
{
attributes: [ 'url' ],
model: VideoModel.unscoped(),
required: true
}
],
order: getSort('updatedAt')
})
return rows.map(r => ({ createdAt: r.createdAt, updatedAt: r.updatedAt, currentTime: r.currentTime, videoUrl: r.Video.url }))
}
static removeUserHistoryElement (user: MUserId, videoId: number) {
const query: DestroyOptions = {
where: {
userId: user.id,
videoId
}
}
return UserVideoHistoryModel.destroy(query)
}
static removeUserHistoryBefore (user: MUserId, beforeDate: string, t: Transaction) {
const query: DestroyOptions = {
where: {
userId: user.id
},
transaction: t
}
if (beforeDate) {
query.where['updatedAt'] = {
[Op.lt]: beforeDate
}
}
return UserVideoHistoryModel.destroy(query)
}
static removeOldHistory (beforeDate: string) {
const query: DestroyOptions = {
where: {
updatedAt: {
[Op.lt]: beforeDate
}
}
}
return UserVideoHistoryModel.destroy(query)
}
}

File diff suppressed because it is too large Load Diff