diff --git a/apps/peertube-runner/src/server/process/shared/process-live.ts b/apps/peertube-runner/src/server/process/shared/process-live.ts index 3c94e8702..3b054b2cb 100644 --- a/apps/peertube-runner/src/server/process/shared/process-live.ts +++ b/apps/peertube-runner/src/server/process/shared/process-live.ts @@ -23,11 +23,11 @@ import { ConfigManager } from '../../../shared/config-manager.js' import { logger } from '../../../shared/index.js' import { buildFFmpegLive, ProcessOptions } from './common.js' -type CustomLiveRTMPHLSTranscodingUpdatePayload = - Omit & { resolutionPlaylistFile?: [ Buffer, string ] | Blob | string } +type CustomLiveRTMPHLSTranscodingUpdatePayload = Omit & { + resolutionPlaylistFile?: [Buffer, string] | Blob | string +} export class ProcessLiveRTMPHLSTranscoding { - private readonly outputPath: string private readonly fsWatchers: FSWatcher[] = [] @@ -326,7 +326,7 @@ export class ProcessLiveRTMPHLSTranscoding { const p = payloadBuilder().then(p => this.updateWithRetry(p)) - if (!sequentialPromises) sequentialPromises = p + if (sequentialPromises === undefined) sequentialPromises = p else sequentialPromises = sequentialPromises.then(() => p) } @@ -388,7 +388,7 @@ export class ProcessLiveRTMPHLSTranscoding { return [ Buffer.from(this.latestFilteredPlaylistContent[playlistName], 'utf-8'), join(this.outputPath, 'master.m3u8') - ] as [ Buffer, string ] + ] as [Buffer, string] } // --------------------------------------------------------------------------- diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts index e2f05315e..e416cf2bb 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts @@ -140,33 +140,37 @@ export class MyVideoChannelsComponent { })), switchMap(options => this.videoChannelService.listAccountVideoChannels(options)) ) - .subscribe(res => { - this.videoChannels = this.videoChannels.concat(res.data) - this.pagination.totalItems = res.total + .subscribe({ + next: res => { + this.videoChannels = this.videoChannels.concat(res.data) + this.pagination.totalItems = res.total - // chart data - this.videoChannelsChartData = this.videoChannels.map(v => ({ - labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()), - datasets: [ - { - label: $localize`Views for the day`, - data: v.viewsPerDay.map(day => day.views), - fill: false, - borderColor: '#c6c6c6' - } - ], + // chart data + this.videoChannelsChartData = this.videoChannels.map(v => ({ + labels: v.viewsPerDay.map(day => day.date.toLocaleDateString()), + datasets: [ + { + label: $localize`Views for the day`, + data: v.viewsPerDay.map(day => day.views), + fill: false, + borderColor: '#c6c6c6' + } + ], - total: v.viewsPerDay.map(day => day.views) - .reduce((p, c) => p + c, 0), + total: v.viewsPerDay.map(day => day.views) + .reduce((p, c) => p + c, 0), - startDate: v.viewsPerDay.length !== 0 - ? v.viewsPerDay[0].date.toLocaleDateString() - : '' - })) + startDate: v.viewsPerDay.length !== 0 + ? v.viewsPerDay[0].date.toLocaleDateString() + : '' + })) - this.buildChartOptions() + this.buildChartOptions() - this.onChannelDataSubject.next(res.data) + this.onChannelDataSubject.next(res.data) + }, + + error: err => this.notifier.error(err.message) }) } diff --git a/eslint.config.mjs b/eslint.config.mjs index ed4d3a200..9e6e8cc44 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -85,7 +85,11 @@ export default defineConfig([ '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', '@typescript-eslint/strict-boolean-expressions': 'off', '@typescript-eslint/consistent-type-definitions': 'off', - '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-misused-promises': [ 'error', { + checksConditionals: true, + checksSpreads: true, + checksVoidReturn: false + } ], '@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-extraneous-class': 'off', diff --git a/packages/models/src/users/index.ts b/packages/models/src/users/index.ts index 5c7be1121..601e475f5 100644 --- a/packages/models/src/users/index.ts +++ b/packages/models/src/users/index.ts @@ -4,6 +4,7 @@ export * from './user-create-result.model.js' export * from './user-create.model.js' export * from './user-flag.model.js' export * from './user-login.model.js' +export * from './user-notification-data.model.js' export * from './user-notification-list-query.model.js' export * from './user-notification-setting.model.js' export * from './user-notification.model.js' diff --git a/packages/models/src/users/user-notification-data.model.ts b/packages/models/src/users/user-notification-data.model.ts new file mode 100644 index 000000000..393a4fa03 --- /dev/null +++ b/packages/models/src/users/user-notification-data.model.ts @@ -0,0 +1,12 @@ +export type UserNotificationData = UserNotificationDataCollaborationRejected + +export interface UserNotificationDataCollaborationRejected { + channelDisplayName: string + channelHandle: string + + channelOwnerDisplayName: string + channelOwnerHandle: string + + collaboratorDisplayName: string + collaboratorHandle: string +} diff --git a/packages/models/src/users/user-notification.model.ts b/packages/models/src/users/user-notification.model.ts index cc4623255..fa4c7d5e0 100644 --- a/packages/models/src/users/user-notification.model.ts +++ b/packages/models/src/users/user-notification.model.ts @@ -1,8 +1,10 @@ import { FollowState } from '../actors/index.js' import { AbuseStateType } from '../moderation/index.js' import { PluginType_Type } from '../plugins/index.js' +import { VideoChannelCollaboratorStateType } from '../videos/index.js' import { VideoConstant } from '../videos/video-constant.model.js' import { VideoStateType } from '../videos/video-state.enum.js' +import { UserNotificationData } from './user-notification-data.model.js' export const UserNotificationType = { NEW_VIDEO_FROM_SUBSCRIPTION: 1, @@ -40,7 +42,11 @@ export const UserNotificationType = { NEW_LIVE_FROM_SUBSCRIPTION: 21, - MY_VIDEO_TRANSCRIPTION_GENERATED: 22 + MY_VIDEO_TRANSCRIPTION_GENERATED: 22, + + INVITED_TO_COLLABORATE_TO_CHANNEL: 23, + ACCEPTED_TO_COLLABORATE_TO_CHANNEL: 24, + REFUSED_TO_COLLABORATE_TO_CHANNEL: 25 } as const export type UserNotificationType_Type = typeof UserNotificationType[keyof typeof UserNotificationType] @@ -79,6 +85,7 @@ export interface UserNotification { id: number type: UserNotificationType_Type read: boolean + data: UserNotificationData video?: VideoInfo & { channel: ActorInfo @@ -156,6 +163,15 @@ export interface UserNotification { video: VideoInfo } + videoChannelCollaborator?: { + id: number + + state: VideoConstant + + channel: ActorInfo + account: ActorInfo + } + createdAt: string updatedAt: string } diff --git a/packages/models/src/videos/channel/index.ts b/packages/models/src/videos/channel/index.ts index 3c96e80f0..7d7e2cb25 100644 --- a/packages/models/src/videos/channel/index.ts +++ b/packages/models/src/videos/channel/index.ts @@ -1,3 +1,4 @@ +export * from './video-channel-collaborator.model.js' export * from './video-channel-create-result.model.js' export * from './video-channel-create.model.js' export * from './video-channel-update.model.js' diff --git a/packages/models/src/videos/channel/video-channel-collaborator.model.ts b/packages/models/src/videos/channel/video-channel-collaborator.model.ts new file mode 100644 index 000000000..f914b66f9 --- /dev/null +++ b/packages/models/src/videos/channel/video-channel-collaborator.model.ts @@ -0,0 +1,21 @@ +import { AccountSummary } from '../../actors/index.js' + +export const VideoChannelCollaboratorState = { + PENDING: 'PENDING', + ACCEPTED: 'ACCEPTED' +} as const + +export type VideoChannelCollaboratorStateType = typeof VideoChannelCollaboratorState[keyof typeof VideoChannelCollaboratorState] + +export interface VideoChannelCollaborator { + id: number + account: AccountSummary + + state: { + id: VideoChannelCollaboratorStateType + label: string + } + + createdAt: string + updatedAt: string +} diff --git a/packages/server-commands/src/server/server.ts b/packages/server-commands/src/server/server.ts index c3dec3490..49d32f1cd 100644 --- a/packages/server-commands/src/server/server.ts +++ b/packages/server-commands/src/server/server.ts @@ -31,6 +31,7 @@ import { BlacklistCommand, CaptionsCommand, ChangeOwnershipCommand, + ChannelCollaboratorsCommand, ChannelSyncsCommand, ChannelsCommand, ChaptersCommand, @@ -82,6 +83,7 @@ export class PeerTubeServer { parallel?: boolean internalServerNumber: number + adminEmail: string serverNumber?: number customConfigFile?: string @@ -170,6 +172,8 @@ export class PeerTubeServer { watchedWordsLists?: WatchedWordsCommand autoTags?: AutomaticTagsCommand + channelCollaborators?: ChannelCollaboratorsCommand + constructor (options: { serverNumber: number } | { url: string }) { if ((options as any).url) { this.setUrl((options as any).url) @@ -188,6 +192,7 @@ export class PeerTubeServer { } } + this.adminEmail = this.buildEmail() this.assignCommands() } @@ -406,7 +411,7 @@ export class PeerTubeServer { well_known: this.getDirectoryPath('well-known') + '/' }, admin: { - email: `admin${this.internalServerNumber}@example.com` + email: this.buildEmail() }, live: { rtmp: { @@ -477,5 +482,11 @@ export class PeerTubeServer { this.watchedWordsLists = new WatchedWordsCommand(this) this.autoTags = new AutomaticTagsCommand(this) + + this.channelCollaborators = new ChannelCollaboratorsCommand(this) + } + + private buildEmail () { + return `admin${this.internalServerNumber}@example.com` } } diff --git a/packages/server-commands/src/users/users-command.ts b/packages/server-commands/src/users/users-command.ts index a690ed623..73bab7c9d 100644 --- a/packages/server-commands/src/users/users-command.ts +++ b/packages/server-commands/src/users/users-command.ts @@ -12,7 +12,8 @@ import { UserUpdate, UserUpdateMe, UserVideoQuota, - UserVideoRate + UserVideoRate, + VideoChannel } from '@peertube/peertube-models' import { unwrapBody } from '../requests/index.js' import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' @@ -282,6 +283,20 @@ export class UsersCommand extends AbstractCommand { }) } + listMyChannels (options: OverrideCommandOptions = {}) { + const path = '/api/v1/users/me/video-channels' + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + // --------------------------------------------------------------------------- + deleteMe (options: OverrideCommandOptions = {}) { const path = '/api/v1/users/me' diff --git a/packages/server-commands/src/videos/channel-collaborators-command.ts b/packages/server-commands/src/videos/channel-collaborators-command.ts new file mode 100644 index 000000000..e402a3834 --- /dev/null +++ b/packages/server-commands/src/videos/channel-collaborators-command.ts @@ -0,0 +1,99 @@ +import { HttpStatusCode, ResultList, VideoChannelCollaborator } from '@peertube/peertube-models' +import { unwrapBody } from '../requests/index.js' +import { AbstractCommand, OverrideCommandOptions } from '../shared/index.js' + +export class ChannelCollaboratorsCommand extends AbstractCommand { + list ( + options: OverrideCommandOptions & { + channel: string + } + ) { + const { channel } = options + const path = '/api/v1/video-channels/' + channel + '/collaborators' + + return this.getRequestBody>({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + }) + } + + async invite ( + options: OverrideCommandOptions & { + channel: string + target: string + } + ) { + const { channel, target } = options + const path = '/api/v1/video-channels/' + channel + '/collaborators/invite' + + const body = await unwrapBody<{ collaborator: VideoChannelCollaborator }>(this.postBodyRequest({ + ...options, + + path, + fields: { + accountHandle: target + }, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.OK_200 + })) + + return body.collaborator + } + + accept ( + options: OverrideCommandOptions & { + channel: string + id: number + } + ) { + const { id, channel } = options + const path = '/api/v1/video-channels/' + channel + '/collaborators/' + id + '/accept' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + reject ( + options: OverrideCommandOptions & { + channel: string + id: number + } + ) { + const { id, channel } = options + const path = '/api/v1/video-channels/' + channel + '/collaborators/' + id + '/reject' + + return this.postBodyRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } + + remove ( + options: OverrideCommandOptions & { + channel: string + id: number + } + ) { + const { id, channel } = options + const path = '/api/v1/video-channels/' + channel + '/collaborators/' + id + + return this.deleteRequest({ + ...options, + + path, + implicitToken: true, + defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 + }) + } +} diff --git a/packages/server-commands/src/videos/channels-command.ts b/packages/server-commands/src/videos/channels-command.ts index 14b6d8d16..4432abea8 100644 --- a/packages/server-commands/src/videos/channels-command.ts +++ b/packages/server-commands/src/videos/channels-command.ts @@ -39,6 +39,7 @@ export class ChannelsCommand extends AbstractCommand { sort?: string withStats?: boolean search?: string + includeCollaborations?: boolean } ) { const { accountName, sort = 'createdAt' } = options @@ -48,7 +49,7 @@ export class ChannelsCommand extends AbstractCommand { ...options, path, - query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search' ]) }, + query: { sort, ...pick(options, [ 'start', 'count', 'withStats', 'search', 'includeCollaborations' ]) }, implicitToken: false, defaultExpectedStatus: HttpStatusCode.OK_200 }) diff --git a/packages/server-commands/src/videos/comments-command.ts b/packages/server-commands/src/videos/comments-command.ts index 86ea6332b..55c8af996 100644 --- a/packages/server-commands/src/videos/comments-command.ts +++ b/packages/server-commands/src/videos/comments-command.ts @@ -23,7 +23,6 @@ type ListForAdminOrAccountCommonOptions = { } export class CommentsCommand extends AbstractCommand { - private lastVideoId: number | string private lastThreadId: number private lastReplyId: number @@ -51,6 +50,7 @@ export class CommentsCommand extends AbstractCommand { listCommentsOnMyVideos (options: OverrideCommandOptions & ListForAdminOrAccountCommonOptions & { isHeldForReview?: boolean + includeCollaborations?: boolean } = {}) { const path = '/api/v1/users/me/videos/comments' @@ -61,7 +61,7 @@ export class CommentsCommand extends AbstractCommand { query: { ...this.buildListForAdminOrAccountQuery(options), - isHeldForReview: options.isHeldForReview + ...pick(options, [ 'isHeldForReview', 'includeCollaborations' ]) }, implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 @@ -78,13 +78,15 @@ export class CommentsCommand extends AbstractCommand { // --------------------------------------------------------------------------- - listThreads (options: OverrideCommandOptions & { - videoId: number | string - videoPassword?: string - start?: number - count?: number - sort?: string - }) { + listThreads ( + options: OverrideCommandOptions & { + videoId: number | string + videoPassword?: string + start?: number + count?: number + sort?: string + } + ) { const { start, count, sort, videoId, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/comment-threads' @@ -99,10 +101,12 @@ export class CommentsCommand extends AbstractCommand { }) } - getThread (options: OverrideCommandOptions & { - videoId: number | string - threadId: number - }) { + getThread ( + options: OverrideCommandOptions & { + videoId: number | string + threadId: number + } + ) { const { videoId, threadId } = options const path = '/api/v1/videos/' + videoId + '/comment-threads/' + threadId @@ -115,21 +119,25 @@ export class CommentsCommand extends AbstractCommand { }) } - async getThreadOf (options: OverrideCommandOptions & { - videoId: number | string - text: string - }) { + async getThreadOf ( + options: OverrideCommandOptions & { + videoId: number | string + text: string + } + ) { const { videoId, text } = options const threadId = await this.findCommentId({ videoId, text }) return this.getThread({ ...options, videoId, threadId }) } - async createThread (options: OverrideCommandOptions & { - videoId: number | string - text: string - videoPassword?: string - }) { + async createThread ( + options: OverrideCommandOptions & { + videoId: number | string + text: string + videoPassword?: string + } + ) { const { videoId, text, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/comment-threads' @@ -149,12 +157,14 @@ export class CommentsCommand extends AbstractCommand { return body.comment } - async addReply (options: OverrideCommandOptions & { - videoId: number | string - toCommentId: number - text: string - videoPassword?: string - }) { + async addReply ( + options: OverrideCommandOptions & { + videoId: number | string + toCommentId: number + text: string + videoPassword?: string + } + ) { const { videoId, toCommentId, text, videoPassword } = options const path = '/api/v1/videos/' + videoId + '/comments/' + toCommentId @@ -173,22 +183,28 @@ export class CommentsCommand extends AbstractCommand { return body.comment } - async addReplyToLastReply (options: OverrideCommandOptions & { - text: string - }) { + async addReplyToLastReply ( + options: OverrideCommandOptions & { + text: string + } + ) { return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastReplyId }) } - async addReplyToLastThread (options: OverrideCommandOptions & { - text: string - }) { + async addReplyToLastThread ( + options: OverrideCommandOptions & { + text: string + } + ) { return this.addReply({ ...options, videoId: this.lastVideoId, toCommentId: this.lastThreadId }) } - async findCommentId (options: OverrideCommandOptions & { - videoId: number | string - text: string - }) { + async findCommentId ( + options: OverrideCommandOptions & { + videoId: number | string + text: string + } + ) { const { videoId, text } = options const { data } = await this.listForAdmin({ videoId, count: 25, sort: '-createdAt' }) @@ -197,10 +213,12 @@ export class CommentsCommand extends AbstractCommand { // --------------------------------------------------------------------------- - delete (options: OverrideCommandOptions & { - videoId: number | string - commentId: number - }) { + delete ( + options: OverrideCommandOptions & { + videoId: number | string + commentId: number + } + ) { const { videoId, commentId } = options const path = '/api/v1/videos/' + videoId + '/comments/' + commentId @@ -213,9 +231,11 @@ export class CommentsCommand extends AbstractCommand { }) } - async deleteAllComments (options: OverrideCommandOptions & { - videoUUID: string - }) { + async deleteAllComments ( + options: OverrideCommandOptions & { + videoUUID: string + } + ) { const { data } = await this.listForAdmin({ ...options, start: 0, count: 20 }) for (const comment of data) { @@ -227,10 +247,12 @@ export class CommentsCommand extends AbstractCommand { // --------------------------------------------------------------------------- - approve (options: OverrideCommandOptions & { - videoId: number | string - commentId: number - }) { + approve ( + options: OverrideCommandOptions & { + videoId: number | string + commentId: number + } + ) { const { videoId, commentId } = options const path = '/api/v1/videos/' + videoId + '/comments/' + commentId + '/approve' diff --git a/packages/server-commands/src/videos/index.ts b/packages/server-commands/src/videos/index.ts index d704b0446..ffb8a884e 100644 --- a/packages/server-commands/src/videos/index.ts +++ b/packages/server-commands/src/videos/index.ts @@ -1,5 +1,6 @@ export * from './blacklist-command.js' export * from './captions-command.js' +export * from './channel-collaborators-command.js' export * from './change-ownership-command.js' export * from './channels.js' export * from './channels-command.js' diff --git a/packages/server-commands/src/videos/playlists-command.ts b/packages/server-commands/src/videos/playlists-command.ts index 3fca7e03f..bf5fa4373 100644 --- a/packages/server-commands/src/videos/playlists-command.ts +++ b/packages/server-commands/src/videos/playlists-command.ts @@ -71,10 +71,11 @@ export class PlaylistsCommand extends AbstractCommand { sort?: string search?: string playlistType?: VideoPlaylistType_Type + includeCollaborations?: boolean } ) { const path = '/api/v1/accounts/' + options.handle + '/video-playlists' - const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType' ]) + const query = pick(options, [ 'start', 'count', 'sort', 'search', 'playlistType', 'includeCollaborations' ]) return this.getRequestBody>({ ...options, diff --git a/packages/server-commands/src/videos/videos-command.ts b/packages/server-commands/src/videos/videos-command.ts index 4e626dfa9..905387b63 100644 --- a/packages/server-commands/src/videos/videos-command.ts +++ b/packages/server-commands/src/videos/videos-command.ts @@ -229,14 +229,18 @@ export class VideosCommand extends AbstractCommand { // --------------------------------------------------------------------------- - listMyVideos (options: OverrideCommandOptions & VideosCommonQuery & { channelId?: number, channelNameOneOf?: string[] } = {}) { + listMyVideos (options: OverrideCommandOptions & VideosCommonQuery & { + channelId?: number + channelNameOneOf?: string[] + includeCollaborations?: boolean + } = {}) { const path = '/api/v1/users/me/videos' return this.getRequestBody>({ ...options, path, - query: { ...this.buildListQuery(options), ...pick(options, [ 'channelId', 'channelNameOneOf' ]) }, + query: { ...this.buildListQuery(options), ...pick(options, [ 'channelId', 'channelNameOneOf', 'includeCollaborations' ]) }, implicitToken: true, defaultExpectedStatus: HttpStatusCode.OK_200 }) diff --git a/packages/tests/src/api/check-params/channel-collaborators.ts b/packages/tests/src/api/check-params/channel-collaborators.ts new file mode 100644 index 000000000..6c076ba39 --- /dev/null +++ b/packages/tests/src/api/check-params/channel-collaborators.ts @@ -0,0 +1,445 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' + +describe('Test video channel collaborators API validators', function () { + let server: PeerTubeServer + let remoteServer: PeerTubeServer + + let collaboratorToken: string + let collaboratorId: number + + let collaboratorId2: number + + let unrelatedCollaboratorId: number + + let userToken: string + + before(async function () { + this.timeout(60000) + + const servers = await createMultipleServers(2) + + server = servers[0] + remoteServer = servers[1] + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + await servers[1].videos.quickUpload({ name: 'remote video' }) + + collaboratorToken = await server.users.generateUserAndToken('collaborator') + await server.users.generateUserAndToken('collaborator2') + userToken = await server.users.generateUserAndToken('user1') + + await server.users.generateUserAndToken('user2') + + const { id } = await server.channelCollaborators.invite({ channel: 'user1_channel', target: 'user2' }) + unrelatedCollaboratorId = id + + await waitJobs(servers) + }) + + describe('Invite', function () { + it('Should fail when not authenticated', async function () { + await server.channelCollaborators.invite({ + token: null, + channel: 'root_channel', + target: 'collaborator', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad channel handle', async function () { + await server.channelCollaborators.invite({ + channel: 'bad handle', + target: 'collaborator', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await server.channelCollaborators.invite({ + channel: 'root_channel@' + remoteServer.host, + target: 'collaborator', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a non owned channel', async function () { + await server.channelCollaborators.invite({ + token: userToken, + channel: 'root_channel', + target: 'collaborator', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with a bad account handle', async function () { + await server.channelCollaborators.invite({ + channel: 'root_channel', + target: 'bad handle', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await server.channelCollaborators.invite({ + channel: 'root_channel', + target: 'root@' + remoteServer.host, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with myself', async function () { + await server.channelCollaborators.invite({ + channel: 'root_channel', + target: 'root', + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should succeed with the correct parameters', async function () { + { + const { id } = await server.channelCollaborators.invite({ channel: 'root_channel', target: 'collaborator' }) + collaboratorId = id + } + + { + const { id } = await server.channelCollaborators.invite({ channel: 'root_channel', target: 'collaborator2' }) + collaboratorId2 = id + } + }) + + it('Should fail to re-invite the user', async function () { + await server.channelCollaborators.invite({ + channel: 'root_channel', + target: 'collaborator', + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + }) + + describe('Common Accept/Reject', function () { + it('Should fail when not authenticated', async function () { + const options = { + token: null, + channel: 'root_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + } + + await server.channelCollaborators.accept(options) + }) + + it('Should fail to accept the collaborator with another user', async function () { + const options = { + token: userToken, + channel: 'root_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + } + + await server.channelCollaborators.accept(options) + }) + + it('Should fail with an invalid collaborator id', async function () { + { + const options = { + token: collaboratorToken, + channel: 'root_channel', + id: 'toto' as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + } + + await server.channelCollaborators.accept(options) + + { + const options = { + token: collaboratorToken, + channel: 'root_channel', + id: 42, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + } + + await server.channelCollaborators.accept(options) + } + } + }) + + it('Should fail with a bad channel handle', async function () { + { + const options = { + token: collaboratorToken, + channel: 'bad handle', + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + } + + await server.channelCollaborators.accept(options) + } + + { + const options = { + token: collaboratorToken, + channel: 'root_channel@' + remoteServer.host, + id: collaboratorId, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + } + + await server.channelCollaborators.accept(options) + } + }) + + it('Should fail with another channel than the collaborator id', async function () { + const options = { + token: collaboratorToken, + channel: 'user1_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + } + + await server.channelCollaborators.accept(options) + }) + + it('Should fail to accept another collaborator invitation', async function () { + const options = { + token: collaboratorToken, + channel: 'root_channel', + id: collaboratorId2, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + } + + await server.channelCollaborators.accept(options) + }) + + it('Should fail with a channel not related to the collaborator id', async function () { + { + const options = { + token: collaboratorToken, + channel: 'root_channel', + id: unrelatedCollaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + } + + await server.channelCollaborators.accept(options) + } + + { + const options = { + token: collaboratorToken, + channel: 'user1_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + } + + await server.channelCollaborators.accept(options) + } + }) + }) + + describe('Accept', function () { + it('Should succeed with the correct params', async function () { + await server.channelCollaborators.accept({ + token: collaboratorToken, + channel: 'root_channel', + id: collaboratorId + }) + }) + + it('Should fail to re-accept the same collaborator', async function () { + await server.channelCollaborators.accept({ + token: collaboratorToken, + channel: 'root_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + }) + + it('Should fail to invite an accepted collaborator', async function () { + await server.channelCollaborators.invite({ + channel: 'root_channel', + target: 'collaborator', + expectedStatus: HttpStatusCode.CONFLICT_409 + }) + }) + + it('Should fail to invite another collaborator with an existing collaborator token', async function () { + await server.channelCollaborators.invite({ + token: collaboratorToken, + channel: 'root_channel', + target: 'collaborator2', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + }) + + describe('Remove', function () { + it('Should fail when not authenticated', async function () { + await server.channelCollaborators.remove({ + token: null, + channel: 'root_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail to remove the collaborator with another user', async function () { + await server.channelCollaborators.remove({ + token: userToken, + channel: 'root_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail with an invalid collaborator id', async function () { + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'root_channel', + id: 'toto' as any, + expectedStatus: HttpStatusCode.BAD_REQUEST_400 + }) + + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'root_channel', + id: 42, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a bad channel handle', async function () { + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'bad handle', + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'root@' + remoteServer.host, + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a channel not related to the collaborator id', async function () { + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'root_channel', + id: unrelatedCollaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'user1_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should succeed with the correct params', async function () { + await server.channelCollaborators.remove({ + token: collaboratorToken, + channel: 'root_channel', + id: collaboratorId + }) + + await server.channelCollaborators.remove({ + token: server.accessToken, + channel: 'root_channel', + id: collaboratorId2 + }) + }) + }) + + describe('Reject', function () { + before(async function () { + const { id } = await server.channelCollaborators.invite({ channel: 'root_channel', target: 'collaborator' }) + collaboratorId = id + }) + + it('Should succeed with the correct params', async function () { + await server.channelCollaborators.reject({ + token: collaboratorToken, + channel: 'root_channel', + id: collaboratorId + }) + }) + + it('Should fail to reject the same collaborator', async function () { + await server.channelCollaborators.reject({ + token: collaboratorToken, + channel: 'root_channel', + id: collaboratorId, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + }) + + describe('List', function () { + before(async function () { + const { id } = await server.channelCollaborators.invite({ channel: 'root_channel', target: 'collaborator' }) + collaboratorId = id + }) + + it('Should fail when not authenticated', async function () { + await server.channelCollaborators.list({ + token: null, + channel: 'root_channel', + expectedStatus: HttpStatusCode.UNAUTHORIZED_401 + }) + }) + + it('Should fail with a bad channel handle', async function () { + await server.channelCollaborators.list({ + token: userToken, + channel: 'bad handle', + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + + await server.channelCollaborators.list({ + token: userToken, + channel: 'root@' + remoteServer.host, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should fail with a non owned channel', async function () { + await server.channelCollaborators.list({ + token: userToken, + channel: 'root_channel', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + }) + + it('Should fail to list if the collaborator is not accepted yet', async function () { + await server.channelCollaborators.list({ + token: collaboratorToken, + channel: 'root_channel', + expectedStatus: HttpStatusCode.FORBIDDEN_403 + }) + + await server.channelCollaborators.accept({ token: collaboratorToken, channel: 'root_channel', id: collaboratorId }) + + await server.channelCollaborators.list({ + token: collaboratorToken, + channel: 'root_channel' + }) + }) + + it('Should succeed with the correct parameters', async function () { + await server.channelCollaborators.list({ channel: 'root_channel' }) + }) + }) + + after(async function () { + await cleanupTests([ server, remoteServer ]) + }) +}) diff --git a/packages/tests/src/api/check-params/channel-import-videos.ts b/packages/tests/src/api/check-params/channel-import-videos.ts index 0250c8782..28cdef16f 100644 --- a/packages/tests/src/api/check-params/channel-import-videos.ts +++ b/packages/tests/src/api/check-params/channel-import-videos.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' -import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' import { HttpStatusCode } from '@peertube/peertube-models' +import { areHttpImportTestsDisabled } from '@peertube/peertube-node-utils' import { ChannelsCommand, cleanupTests, @@ -11,6 +10,7 @@ import { setAccessTokensToServers, setDefaultVideoChannel } from '@peertube/peertube-server-commands' +import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' describe('Test videos import in a channel API validator', function () { let server: PeerTubeServer diff --git a/packages/tests/src/api/check-params/video-channels.ts b/packages/tests/src/api/check-params/video-channels.ts index 84b962b19..db365467a 100644 --- a/packages/tests/src/api/check-params/video-channels.ts +++ b/packages/tests/src/api/check-params/video-channels.ts @@ -1,9 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { omit } from '@peertube/peertube-core-utils' import { HttpStatusCode, VideoChannelUpdate } from '@peertube/peertube-models' -import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' import { buildAbsoluteFixturePath } from '@peertube/peertube-node-utils' import { ChannelsCommand, @@ -16,6 +14,8 @@ import { PeerTubeServer, setAccessTokensToServers } from '@peertube/peertube-server-commands' +import { checkBadCountPagination, checkBadSortPagination, checkBadStartPagination } from '@tests/shared/checks.js' +import { expect } from 'chai' describe('Test video channels API validator', function () { const videoChannelPath = '/api/v1/video-channels' diff --git a/packages/tests/src/api/check-params/video-playlists.ts b/packages/tests/src/api/check-params/video-playlists.ts index 46581bb59..721006909 100644 --- a/packages/tests/src/api/check-params/video-playlists.ts +++ b/packages/tests/src/api/check-params/video-playlists.ts @@ -260,7 +260,7 @@ describe('Test video playlists API validator', function () { }) it('Should fail with an unknown video channel id', async function () { - const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.NOT_FOUND_404 }) await command.create(params) await command.update(getUpdate(params, playlist.shortUUID)) @@ -307,7 +307,7 @@ describe('Test video playlists API validator', function () { }) it('Should fail to set a playlist to a channel owned by another user', async function () { - const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + const params = getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }) await command.create(params) await command.update(getUpdate(params, userPlaylist.shortUUID)) diff --git a/packages/tests/src/api/notifications/admin-notifications.ts b/packages/tests/src/api/notifications/admin-notifications.ts index b9d35b280..71db9e2e4 100644 --- a/packages/tests/src/api/notifications/admin-notifications.ts +++ b/packages/tests/src/api/notifications/admin-notifications.ts @@ -1,13 +1,15 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { wait } from '@peertube/peertube-core-utils' import { PluginType, UserNotification, UserNotificationType } from '@peertube/peertube-models' import { cleanupTests, PeerTubeServer } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' import { MockJoinPeerTubeVersions } from '@tests/shared/mock-servers/mock-joinpeertube-versions.js' -import { CheckerBaseParams, prepareNotificationsTest, checkNewPeerTubeVersion, checkNewPluginVersion } from '@tests/shared/notifications.js' +import { checkNewPeerTubeVersion, checkNewPluginVersion } from '@tests/shared/notifications/check-admin-notifications.js' +import { prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' import { SQLCommand } from '@tests/shared/sql-command.js' +import { expect } from 'chai' describe('Test admin notifications', function () { let server: PeerTubeServer diff --git a/packages/tests/src/api/notifications/caption-notifications.ts b/packages/tests/src/api/notifications/caption-notifications.ts index 56c6f99d0..27449f823 100644 --- a/packages/tests/src/api/notifications/caption-notifications.ts +++ b/packages/tests/src/api/notifications/caption-notifications.ts @@ -3,7 +3,10 @@ import { UserNotification } from '@peertube/peertube-models' import { PeerTubeServer, cleanupTests, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' -import { CheckerBaseParams, checkMyVideoTranscriptionGenerated, prepareNotificationsTest } from '@tests/shared/notifications.js' +import { checkMyVideoTranscriptionGenerated } from '@tests/shared/notifications/check-video-notifications.js' +import { prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' + import { join } from 'path' describe('Test caption notifications', function () { diff --git a/packages/tests/src/api/notifications/channel-collaborators-notification.ts b/packages/tests/src/api/notifications/channel-collaborators-notification.ts new file mode 100644 index 000000000..cee0c3499 --- /dev/null +++ b/packages/tests/src/api/notifications/channel-collaborators-notification.ts @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { UserNotification } from '@peertube/peertube-models' +import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { + checkAcceptedToCollaborateToChannel, + CheckChannelCollaboratorOptions, + checkInvitedToCollaborateToChannel, + checkRefusedToCollaborateToChannel +} from '@tests/shared/notifications/check-channel-notifications.js' +import { prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' + +type BaseParam = Omit + +describe('Test channel collaborators notifications', function () { + let server: PeerTubeServer + + let userNotifications: UserNotification[] = [] + let adminNotifications: UserNotification[] = [] + let emails: object[] = [] + + let baseAdminParams: BaseParam + let baseUserParams: BaseParam + + let userAccessToken: string + let collaboratorId: number + + const userEmail = 'user_1@example.com' + + before(async function () { + this.timeout(120000) + + const res = await prepareNotificationsTest(1) + emails = res.emails + userAccessToken = res.userAccessToken + server = res.servers[0] + userNotifications = res.userNotifications + adminNotifications = res.adminNotifications + + baseAdminParams = { + server, + emails, + to: server.adminEmail, + token: server.accessToken, + socketNotifications: adminNotifications, + channelDisplayName: 'Main root channel', + targetDisplayName: 'User 1', + sourceDisplayName: 'root' + } + baseUserParams = { + server, + emails, + to: userEmail, + token: userAccessToken, + socketNotifications: userNotifications, + channelDisplayName: 'Main root channel', + targetDisplayName: 'User 1', + sourceDisplayName: 'root' + } + }) + + it('Should send a notification when a user is invited to collaborate to a channel', async function () { + const res = await server.channelCollaborators.invite({ channel: 'root_channel', target: 'user_1' }) + collaboratorId = res.id + + await waitJobs([ server ]) + + await checkInvitedToCollaborateToChannel({ ...baseUserParams, checkType: 'presence' }) + await checkInvitedToCollaborateToChannel({ ...baseAdminParams, checkType: 'absence' }) + }) + + it('Should send a notification when a user accepts to collaborate to a channel', async function () { + await server.channelCollaborators.accept({ id: collaboratorId, token: userAccessToken, channel: 'root_channel' }) + await waitJobs([ server ]) + + await checkAcceptedToCollaborateToChannel({ ...baseUserParams, checkType: 'absence' }) + await checkAcceptedToCollaborateToChannel({ ...baseAdminParams, checkType: 'presence' }) + }) + + it('Should send a notification when a user refuses to collaborate to a channel', async function () { + // Re-invite the user + { + await server.channelCollaborators.remove({ channel: 'root_channel', id: collaboratorId }) + const res = await server.channelCollaborators.invite({ channel: 'root_channel', target: 'user_1' }) + collaboratorId = res.id + } + + await server.channelCollaborators.reject({ id: collaboratorId, channel: 'root_channel', token: userAccessToken }) + await waitJobs([ server ]) + + await checkRefusedToCollaborateToChannel({ ...baseUserParams, checkType: 'absence' }) + await checkRefusedToCollaborateToChannel({ ...baseAdminParams, checkType: 'presence' }) + }) + + after(async function () { + await MockSmtpServer.Instance.kill() + + await cleanupTests([ server ]) + }) +}) diff --git a/packages/tests/src/api/notifications/comments-notifications.ts b/packages/tests/src/api/notifications/comments-notifications.ts index a1976fe53..dfdfa991f 100644 --- a/packages/tests/src/api/notifications/comments-notifications.ts +++ b/packages/tests/src/api/notifications/comments-notifications.ts @@ -3,7 +3,9 @@ import { UserNotification, UserNotificationType, VideoCommentPolicy } from '@peertube/peertube-models' import { PeerTubeServer, cleanupTests, setDefaultAccountAvatar, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' -import { CheckerBaseParams, checkCommentMention, checkNewCommentOnMyVideo, prepareNotificationsTest } from '@tests/shared/notifications.js' +import { checkCommentMention, checkNewCommentOnMyVideo } from '@tests/shared/notifications/check-comment-notifications.js' +import { prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' import { expect } from 'chai' describe('Test comments notifications', function () { diff --git a/packages/tests/src/api/notifications/index.ts b/packages/tests/src/api/notifications/index.ts index 3975eb317..35b0066f9 100644 --- a/packages/tests/src/api/notifications/index.ts +++ b/packages/tests/src/api/notifications/index.ts @@ -1,5 +1,6 @@ import './admin-notifications.js' import './caption-notifications.js' +import './channel-collaborators-notification.js' import './comments-notifications.js' import './moderation-notifications.js' import './notifications-api.js' diff --git a/packages/tests/src/api/notifications/moderation-notifications.ts b/packages/tests/src/api/notifications/moderation-notifications.ts index c16f394f2..1a5fb6959 100644 --- a/packages/tests/src/api/notifications/moderation-notifications.ts +++ b/packages/tests/src/api/notifications/moderation-notifications.ts @@ -6,21 +6,19 @@ import { buildUUID } from '@peertube/peertube-node-utils' import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' import { MockInstancesIndex } from '@tests/shared/mock-servers/mock-instances-index.js' +import { checkAutoInstanceFollowing, checkNewInstanceFollower } from '@tests/shared/notifications/check-follow-notifications.js' import { - prepareNotificationsTest, - CheckerBaseParams, - checkNewVideoAbuseForModerators, - checkNewCommentAbuseForModerators, - checkNewAccountAbuseForModerators, checkAbuseStateChange, checkNewAbuseMessage, + checkNewAccountAbuseForModerators, checkNewBlacklistOnMyVideo, - checkNewInstanceFollower, - checkAutoInstanceFollowing, - checkVideoAutoBlacklistForModerators, - checkMyVideoIsPublished, - checkNewVideoFromSubscription -} from '@tests/shared/notifications.js' + checkNewCommentAbuseForModerators, + checkNewVideoAbuseForModerators, + checkVideoAutoBlacklistForModerators +} from '@tests/shared/notifications/check-moderation-notifications.js' +import { checkMyVideoIsPublished, checkNewVideoFromSubscription } from '@tests/shared/notifications/check-video-notifications.js' +import { prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' describe('Test moderation notifications', function () { let servers: PeerTubeServer[] = [] diff --git a/packages/tests/src/api/notifications/notifications-api.ts b/packages/tests/src/api/notifications/notifications-api.ts index 9530d1fe7..44a914596 100644 --- a/packages/tests/src/api/notifications/notifications-api.ts +++ b/packages/tests/src/api/notifications/notifications-api.ts @@ -1,15 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ -import { expect } from 'chai' import { UserNotification, UserNotificationSettingValue, UserNotificationType } from '@peertube/peertube-models' import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' -import { - prepareNotificationsTest, - CheckerBaseParams, - getAllNotificationsSettings, - checkNewVideoFromSubscription -} from '@tests/shared/notifications.js' +import { checkNewVideoFromSubscription } from '@tests/shared/notifications/check-video-notifications.js' +import { getAllNotificationsSettings, prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' +import { expect } from 'chai' describe('Test notifications API', function () { let server: PeerTubeServer diff --git a/packages/tests/src/api/notifications/registrations-notifications.ts b/packages/tests/src/api/notifications/registrations-notifications.ts index f96c197e5..a15272fa2 100644 --- a/packages/tests/src/api/notifications/registrations-notifications.ts +++ b/packages/tests/src/api/notifications/registrations-notifications.ts @@ -3,7 +3,9 @@ import { UserNotification } from '@peertube/peertube-models' import { cleanupTests, PeerTubeServer, waitJobs } from '@peertube/peertube-server-commands' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' -import { CheckerBaseParams, prepareNotificationsTest, checkUserRegistered, checkRegistrationRequest } from '@tests/shared/notifications.js' +import { checkRegistrationRequest, checkUserRegistered } from '@tests/shared/notifications/check-moderation-notifications.js' +import { prepareNotificationsTest } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' describe('Test registrations notifications', function () { let server: PeerTubeServer diff --git a/packages/tests/src/api/notifications/user-notifications.ts b/packages/tests/src/api/notifications/user-notifications.ts index 1be70802d..82f80cf0d 100644 --- a/packages/tests/src/api/notifications/user-notifications.ts +++ b/packages/tests/src/api/notifications/user-notifications.ts @@ -6,17 +6,16 @@ import { buildUUID } from '@peertube/peertube-node-utils' import { cleanupTests, findExternalSavedVideo, PeerTubeServer, stopFfmpeg, waitJobs } from '@peertube/peertube-server-commands' import { FIXTURE_URLS } from '@tests/shared/fixture-urls.js' import { MockSmtpServer } from '@tests/shared/mock-servers/mock-email.js' +import { checkNewActorFollow } from '@tests/shared/notifications/check-follow-notifications.js' import { - CheckerBaseParams, checkMyVideoImportIsFinished, checkMyVideoIsPublished, - checkNewActorFollow, checkNewLiveFromSubscription, checkNewVideoFromSubscription, - checkVideoStudioEditionIsFinished, - prepareNotificationsTest, - waitUntilNotification -} from '@tests/shared/notifications.js' + checkVideoStudioEditionIsFinished +} from '@tests/shared/notifications/check-video-notifications.js' +import { prepareNotificationsTest, waitUntilNotification } from '@tests/shared/notifications/notifications-common.js' +import { CheckerBaseParams } from '@tests/shared/notifications/shared/notification-checker.js' import { uploadRandomVideoOnServers } from '@tests/shared/videos.js' import { expect } from 'chai' diff --git a/packages/tests/src/api/videos/channel-collaborators.ts b/packages/tests/src/api/videos/channel-collaborators.ts new file mode 100644 index 000000000..b01b42689 --- /dev/null +++ b/packages/tests/src/api/videos/channel-collaborators.ts @@ -0,0 +1,305 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { HttpStatusCode, VideoChannelCollaboratorState, VideoPrivacy } from '@peertube/peertube-models' +import { + cleanupTests, + createMultipleServers, + doubleFollow, + makeRawRequest, + PeerTubeServer, + setAccessTokensToServers, + waitJobs +} from '@peertube/peertube-server-commands' +import { checkActorImage } from '@tests/shared/actors.js' +import { expect } from 'chai' + +describe('Test channel collaborators', function () { + let servers: PeerTubeServer[] + + let collaborator1: string + let collaborator2: string + + let collaboratorId1: number + let collaboratorId2: number + + let channelCollab: number + + async function expectMyChannels (token: string, names: string[]) { + const me = await servers[0].users.getMyInfo({ token }) + + const { total, data } = await servers[0].channels.listByAccount({ accountName: me.username, token, includeCollaborations: true }) + expect(total).to.equal(names.length) + expect(data).to.have.lengthOf(names.length) + + expect(data.map(c => c.name)).to.deep.equal(names) + } + + before(async function () { + this.timeout(60000) + + servers = await createMultipleServers(2) + + await setAccessTokensToServers(servers) + await doubleFollow(servers[0], servers[1]) + + await waitJobs(servers) + + collaborator1 = await servers[0].users.generateUserAndToken('collaborator1') + collaborator2 = await servers[0].users.generateUserAndToken('collaborator2') + + await servers[0].users.updateMyAvatar({ fixture: 'avatar.png', token: collaborator1 }) + + await waitJobs(servers) + }) + + describe('Manage collaborators', function () { + it('Should not have collaborators by default', async function () { + const collaborators = await servers[0].channelCollaborators.list({ channel: 'root_channel' }) + expect(collaborators.total).to.equal(0) + expect(collaborators.data).to.have.lengthOf(0) + }) + + it('Should create a channel and invite a collaborator', async function () { + const channel = await servers[0].channels.create({ attributes: { name: 'channel_collaboration1' } }) + channelCollab = channel.id + + await servers[0].channels.updateImage({ channelName: 'channel_collaboration1', fixture: 'avatar.png', type: 'avatar' }) + + const { id } = await servers[0].channelCollaborators.invite({ channel: 'channel_collaboration1', target: 'collaborator1' }) + collaboratorId1 = id + + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1' }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + const collab = data[0] + expect(collab.id).to.exist + expect(collab.createdAt).to.exist + expect(collab.updatedAt).to.exist + expect(collab.state.id).to.equal(VideoChannelCollaboratorState.PENDING) + expect(collab.state.label).to.equal('Pending') + + expect(collab.account.displayName).to.equal('collaborator1') + expect(collab.account.host).to.equal(servers[0].host) + expect(collab.account.id).to.exist + expect(collab.account.name).to.equal('collaborator1') + await makeRawRequest({ url: collab.account.url, expectedStatus: HttpStatusCode.OK_200 }) + await checkActorImage(collab.account) + }) + + it('Should invite another collaborator', async function () { + const { id } = await servers[0].channelCollaborators.invite({ channel: 'channel_collaboration1', target: 'collaborator2' }) + collaboratorId2 = id + + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1' }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + expect(data[0].account.name).to.equal('collaborator2') + expect(data[1].account.name).to.equal('collaborator1') + }) + + it('Should not list channels when collaboration is not yet accepted', async function () { + await expectMyChannels(servers[0].accessToken, [ 'root_channel', 'channel_collaboration1' ]) + await expectMyChannels(collaborator1, [ 'collaborator1_channel' ]) + await expectMyChannels(collaborator2, [ 'collaborator2_channel' ]) + }) + + it('Should accept an invitation', async function () { + await servers[0].channelCollaborators.accept({ channel: 'channel_collaboration1', id: collaboratorId1, token: collaborator1 }) + + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1', token: collaborator1 }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + + expect(data[0].account.name).to.equal('collaborator2') + expect(data[0].state.id).to.equal(VideoChannelCollaboratorState.PENDING) + expect(data[1].account.name).to.equal('collaborator1') + expect(data[1].state.id).to.equal(VideoChannelCollaboratorState.ACCEPTED) + expect(data[1].state.label).to.equal('Accepted') + }) + + it('Should list channel collaborations after having accepted an invitation', async function () { + await expectMyChannels(servers[0].accessToken, [ 'root_channel', 'channel_collaboration1' ]) + await expectMyChannels(collaborator1, [ 'collaborator1_channel', 'channel_collaboration1' ]) + await expectMyChannels(collaborator2, [ 'collaborator2_channel' ]) + }) + + it('Should reject an invitation', async function () { + await servers[0].channelCollaborators.reject({ channel: 'channel_collaboration1', id: collaboratorId2, token: collaborator2 }) + + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1' }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + + expect(data[0].account.name).to.equal('collaborator1') + expect(data[0].state.id).to.equal(VideoChannelCollaboratorState.ACCEPTED) + }) + + it('Should list channel collaborations after having rejected an invitation', async function () { + await expectMyChannels(servers[0].accessToken, [ 'root_channel', 'channel_collaboration1' ]) + await expectMyChannels(collaborator1, [ 'collaborator1_channel', 'channel_collaboration1' ]) + await expectMyChannels(collaborator2, [ 'collaborator2_channel' ]) + }) + + it('Should delete a pending invitation', async function () { + const { id } = await servers[0].channelCollaborators.invite({ channel: 'channel_collaboration1', target: 'collaborator2' }) + collaboratorId2 = id + + { + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1' }) + expect(total).to.equal(2) + expect(data).to.have.lengthOf(2) + } + + await servers[0].channelCollaborators.remove({ channel: 'channel_collaboration1', id: collaboratorId2 }) + + { + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1' }) + expect(total).to.equal(1) + expect(data).to.have.lengthOf(1) + } + + await servers[0].channelCollaborators.accept({ + channel: 'channel_collaboration1', + id: collaboratorId2, + expectedStatus: HttpStatusCode.NOT_FOUND_404 + }) + }) + + it('Should delete an accepted invitation', async function () { + await servers[0].channelCollaborators.remove({ channel: 'channel_collaboration1', id: collaboratorId1 }) + + const { total, data } = await servers[0].channelCollaborators.list({ channel: 'channel_collaboration1' }) + expect(total).to.equal(0) + expect(data).to.have.lengthOf(0) + }) + + it('Should not list collab channels anymore', async function () { + await expectMyChannels(servers[0].accessToken, [ 'root_channel', 'channel_collaboration1' ]) + await expectMyChannels(collaborator1, [ 'collaborator1_channel' ]) + await expectMyChannels(collaborator2, [ 'collaborator2_channel' ]) + }) + }) + + describe('With a collaborator', function () { + let user1: string + let external: string + let videoId: string + let playlistId: string + + before(async function () { + user1 = await servers[0].users.generateUserAndToken('user1') + external = await servers[0].users.generateUserAndToken('external') + + { + const { id } = await servers[0].channelCollaborators.invite({ channel: 'channel_collaboration1', target: 'collaborator1' }) + await servers[0].channelCollaborators.accept({ channel: 'channel_collaboration1', id, token: collaborator1 }) + } + + { + const { id } = await servers[0].channelCollaborators.invite({ channel: 'channel_collaboration1', target: 'collaborator2' }) + await servers[0].channelCollaborators.accept({ channel: 'channel_collaboration1', id, token: collaborator2 }) + } + + { + const { id } = await servers[0].channelCollaborators.invite({ channel: 'user1_channel', target: 'collaborator1', token: user1 }) + await servers[0].channelCollaborators.accept({ channel: 'user1_channel', id, token: collaborator1 }) + } + }) + + it('Should list videos from collab channels', async function () { + const { uuid } = await servers[0].videos.upload({ + attributes: { name: 'video collab 1', channelId: channelCollab }, + token: collaborator1 + }) + videoId = uuid + + await servers[0].videos.quickUpload({ name: 'video collab 2', channelId: channelCollab }) + + for (const token of [ collaborator1, collaborator2 ]) { + const videos = await servers[0].videos.listMyVideos({ token, includeCollaborations: true }) + + expect(videos.total).to.equal(2) + expect(videos.data).to.have.lengthOf(2) + expect(videos.data[0].name).to.equal('video collab 2') + expect(videos.data[1].name).to.equal('video collab 1') + } + + for (const token of [ external, user1 ]) { + const videos = await servers[0].videos.listMyVideos({ token, includeCollaborations: true }) + expect(videos.total).to.equal(0) + expect(videos.data).to.have.lengthOf(0) + } + }) + + it('Should list comments from collab channels', async function () { + await servers[0].comments.createThread({ token: external, videoId, text: 'A thread from collab channel' }) + await servers[0].comments.addReplyToLastThread({ token: external, text: 'A reply from collab channel' }) + + for (const token of [ collaborator1, collaborator2 ]) { + const comments = await servers[0].comments.listCommentsOnMyVideos({ token, includeCollaborations: true }) + expect(comments.total).to.equal(2) + expect(comments.data).to.have.lengthOf(2) + expect(comments.data[0].text).to.equal('A reply from collab channel') + expect(comments.data[1].text).to.equal('A thread from collab channel') + } + + for (const token of [ external, user1 ]) { + const comments = await servers[0].comments.listCommentsOnMyVideos({ token, includeCollaborations: true }) + expect(comments.total).to.equal(0) + expect(comments.data).to.have.lengthOf(0) + } + }) + + it('Should list playlists from collab channels', async function () { + for (const displayName of [ 'playlist1', 'playlist2' ]) { + const playlist = await servers[0].playlists.create({ + token: collaborator1, + attributes: { displayName, privacy: VideoPrivacy.PUBLIC, videoChannelId: channelCollab } + }) + playlistId = playlist.uuid + } + + await servers[0].playlists.addElement({ playlistId, attributes: { videoId }, token: collaborator2 }) + + for (const token of [ collaborator1, collaborator2 ]) { + const me = await servers[0].users.getMyInfo({ token }) + + const playlists = await servers[0].playlists.listByAccount({ token, handle: me.username, includeCollaborations: true }) + expect(playlists.total).to.equal(3) + expect(playlists.data).to.have.lengthOf(3) + expect(playlists.data[0].displayName).to.equal('playlist2') + expect(playlists.data[1].displayName).to.equal('playlist1') + expect(playlists.data[2].displayName).to.equal('Watch later') + } + + for (const token of [ external, user1 ]) { + const me = await servers[0].users.getMyInfo({ token }) + + const playlists = await servers[0].playlists.listByAccount({ token, handle: me.username, includeCollaborations: true }) + expect(playlists.total).to.equal(1) + expect(playlists.data).to.have.lengthOf(1) + expect(playlists.data[0].displayName).to.equal('Watch later') + } + }) + + it('Should list imports from collab channels', async function () { + // TODO + }) + + it('Should have federated objects created by collaborators', async function () { + await waitJobs(servers) + + const video = await servers[1].videos.get({ id: videoId }) + expect(video.name).to.equal('video collab 1') + + const playlist = await servers[1].playlists.get({ playlistId }) + expect(playlist.displayName).to.equal('playlist2') + }) + }) + + after(async function () { + await cleanupTests(servers) + }) +}) diff --git a/packages/tests/src/api/videos/index.ts b/packages/tests/src/api/videos/index.ts index 4b542619f..ead5abe2f 100644 --- a/packages/tests/src/api/videos/index.ts +++ b/packages/tests/src/api/videos/index.ts @@ -21,8 +21,8 @@ import './video-schedule-update.js' import './video-source.js' import './video-static-file-privacy.js' import './video-storyboard.js' -import './video-storyboard-remote-runner.js' import './video-transcription.js' import './videos-common-filters.js' import './videos-history.js' import './videos-overview.js' +import './channel-collaborators.js' diff --git a/packages/tests/src/server-helpers/mentions.ts b/packages/tests/src/server-helpers/mentions.ts index 11cb7b6fe..e95b93b7b 100644 --- a/packages/tests/src/server-helpers/mentions.ts +++ b/packages/tests/src/server-helpers/mentions.ts @@ -8,9 +8,9 @@ describe('Comment model', function () { const text = '@florian @jean@localhost:9000 @flo @another@localhost:9000 @flo2@jean.com hello ' + 'email@localhost:9000 coucou.com no? @chocobozzz @chocobozzz @end' - const isOwned = true + const isLocal = true - const result = extractMentions(text, isOwned).sort((a, b) => a.localeCompare(b)) + const result = extractMentions(text, isLocal).sort((a, b) => a.localeCompare(b)) expect(result).to.deep.equal([ 'another', 'chocobozzz', 'end', 'flo', 'florian', 'jean' ]) }) diff --git a/packages/tests/src/shared/actors.ts b/packages/tests/src/shared/actors.ts index 02d507a49..f1f102be4 100644 --- a/packages/tests/src/shared/actors.ts +++ b/packages/tests/src/shared/actors.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ +import { Account, AccountSummary, HttpStatusCode, VideoChannel, VideoChannelSummary } from '@peertube/peertube-models' +import { makeRawRequest, PeerTubeServer } from '@peertube/peertube-server-commands' import { expect } from 'chai' import { pathExists } from 'fs-extra/esm' import { readdir } from 'fs/promises' -import { Account, VideoChannel } from '@peertube/peertube-models' -import { PeerTubeServer } from '@peertube/peertube-server-commands' -async function expectChannelsFollows (options: { +export async function expectChannelsFollows (options: { server: PeerTubeServer handle: string followers: number @@ -18,7 +18,7 @@ async function expectChannelsFollows (options: { return expectActorFollow({ ...options, data }) } -async function expectAccountFollows (options: { +export async function expectAccountFollows (options: { server: PeerTubeServer handle: string followers: number @@ -30,7 +30,7 @@ async function expectAccountFollows (options: { return expectActorFollow({ ...options, data }) } -async function checkActorFilesWereRemoved (filename: string, server: PeerTubeServer) { +export async function checkActorFilesWereRemoved (filename: string, server: PeerTubeServer) { for (const directory of [ 'avatars' ]) { const directoryPath = server.getDirectoryPath(directory) @@ -44,12 +44,22 @@ async function checkActorFilesWereRemoved (filename: string, server: PeerTubeSer } } -export { - expectAccountFollows, - expectChannelsFollows, - checkActorFilesWereRemoved +export async function checkActorImage (actor: AccountSummary | VideoChannelSummary) { + expect(actor.avatars).to.have.lengthOf(4) + + for (const avatar of actor.avatars) { + expect(avatar.createdAt).to.exist + expect(avatar.fileUrl).to.exist + expect(avatar.height).to.be.greaterThan(0) + expect(avatar.width).to.be.greaterThan(0) + expect(avatar.updatedAt).to.exist + + await makeRawRequest({ url: avatar.fileUrl, expectedStatus: HttpStatusCode.OK_200 }) + } } +// --------------------------------------------------------------------------- +// Private // --------------------------------------------------------------------------- function expectActorFollow (options: { diff --git a/packages/tests/src/shared/import-export.ts b/packages/tests/src/shared/import-export.ts index c6ab6e97a..7e9872151 100644 --- a/packages/tests/src/shared/import-export.ts +++ b/packages/tests/src/shared/import-export.ts @@ -33,7 +33,7 @@ import { tmpdir } from 'os' import { basename, join, resolve } from 'path' import { testFileExistsOnFSOrNot } from './checks.js' import { MockSmtpServer } from './mock-servers/mock-email.js' -import { getAllNotificationsSettings } from './notifications.js' +import { getAllNotificationsSettings } from './notifications/notifications-common.js' type ExportOutbox = ActivityPubOrderedCollection> diff --git a/packages/tests/src/shared/mock-servers/mock-email.ts b/packages/tests/src/shared/mock-servers/mock-email.ts index 4093637b9..766b011ed 100644 --- a/packages/tests/src/shared/mock-servers/mock-email.ts +++ b/packages/tests/src/shared/mock-servers/mock-email.ts @@ -66,7 +66,7 @@ class MockSmtpServer { async kill () { if (!this.maildev) return - if (this.relayingEmail) { + if (this.relayingEmail !== undefined) { await this.relayingEmail } diff --git a/packages/tests/src/shared/notifications.ts b/packages/tests/src/shared/notifications.ts deleted file mode 100644 index 39fa4169c..000000000 --- a/packages/tests/src/shared/notifications.ts +++ /dev/null @@ -1,1036 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ - -import { - AbuseState, - AbuseStateType, - PluginType_Type, - UserNotification, - UserNotificationSetting, - UserNotificationSettingValue, - UserNotificationType, - UserNotificationType_Type -} from '@peertube/peertube-models' -import { - ConfigCommand, - PeerTubeServer, - createMultipleServers, - doubleFollow, - setAccessTokensToServers, - setDefaultAccountAvatar, - setDefaultChannelAvatar, - setDefaultVideoChannel, - waitJobs -} from '@peertube/peertube-server-commands' -import { expect } from 'chai' -import { inspect } from 'util' -import { MockSmtpServer } from './mock-servers/index.js' -import { wait } from '@peertube/peertube-core-utils' - -type CheckerBaseParams = { - server: PeerTubeServer - emails: any[] - socketNotifications: UserNotification[] - token: string - check?: { web: boolean, mail: boolean } -} - -type CheckerType = 'presence' | 'absence' - -function getAllNotificationsSettings (): UserNotificationSetting { - return { - newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - myVideoStudioEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - myVideoTranscriptionGenerated: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, - newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL - } -} - -async function waitUntilNotification (options: { - server: PeerTubeServer - notificationType: UserNotificationType_Type - token: string - fromDate: Date -}) { - const { server, fromDate, notificationType, token } = options - - do { - const { data } = await server.notifications.list({ start: 0, count: 5, token }) - if (data.some(n => n.type === notificationType && new Date(n.createdAt) >= fromDate)) break - - await wait(500) - } while (true) - - await waitJobs([ server ]) -} - -async function checkNewVideoFromSubscription ( - options: CheckerBaseParams & { - videoName: string - shortUUID: string - checkType: CheckerType - } -) { - const { videoName, shortUUID } = options - const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkVideo(notification.video, videoName, shortUUID) - checkActor(notification.video.channel) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n === undefined || n.type !== UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION || n.video.name !== videoName - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewLiveFromSubscription ( - options: CheckerBaseParams & { - videoName: string - shortUUID: string - checkType: CheckerType - } -) { - const { videoName, shortUUID } = options - const notificationType = UserNotificationType.NEW_LIVE_FROM_SUBSCRIPTION - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkVideo(notification.video, videoName, shortUUID) - checkActor(notification.video.channel) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n === undefined || n.type !== UserNotificationType.NEW_LIVE_FROM_SUBSCRIPTION || n.video.name !== videoName - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkMyVideoIsPublished ( - options: CheckerBaseParams & { - videoName: string - shortUUID: string - checkType: CheckerType - } -) { - const { videoName, shortUUID } = options - const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkVideo(notification.video, videoName, shortUUID) - checkActor(notification.video.channel) - } else { - expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - return text.includes(shortUUID) && text.includes('Your video') - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkVideoStudioEditionIsFinished ( - options: CheckerBaseParams & { - videoName: string - shortUUID: string - checkType: CheckerType - } -) { - const { videoName, shortUUID } = options - const notificationType = UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkVideo(notification.video, videoName, shortUUID) - checkActor(notification.video.channel) - } else { - expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - return text.includes(shortUUID) && text.includes('Edition of your video') - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkMyVideoImportIsFinished ( - options: CheckerBaseParams & { - videoName: string - shortUUID: string - url: string - success: boolean - checkType: CheckerType - } -) { - const { videoName, shortUUID, url, success } = options - - const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.videoImport.targetUrl).to.equal(url) - - if (success) checkVideo(notification.videoImport.video, videoName, shortUUID) - } else { - expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - const toFind = success - ? /\bfinished\b/ - : /\berror\b/ - - return text.includes(url) && !!text.match(toFind) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -// --------------------------------------------------------------------------- - -async function checkUserRegistered ( - options: CheckerBaseParams & { - username: string - checkType: CheckerType - } -) { - const { username } = options - const notificationType = UserNotificationType.NEW_USER_REGISTRATION - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkActor(notification.account, { withAvatar: false }) - expect(notification.account.name).to.equal(username) - } else { - expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - return text.includes(' registered.') && text.includes(username) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkRegistrationRequest ( - options: CheckerBaseParams & { - username: string - registrationReason: string - checkType: CheckerType - } -) { - const { username, registrationReason } = options - const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.registration.username).to.equal(username) - } else { - expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -// --------------------------------------------------------------------------- - -async function checkNewActorFollow ( - options: CheckerBaseParams & { - followType: 'channel' | 'account' - followerName: string - followerDisplayName: string - followingDisplayName: string - checkType: CheckerType - } -) { - const { followType, followerName, followerDisplayName, followingDisplayName } = options - const notificationType = UserNotificationType.NEW_FOLLOW - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkActor(notification.actorFollow.follower) - expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName) - expect(notification.actorFollow.follower.name).to.equal(followerName) - expect(notification.actorFollow.follower.host).to.not.be.undefined - - const following = notification.actorFollow.following - expect(following.displayName).to.equal(followingDisplayName) - expect(following.type).to.equal(followType) - } else { - expect(notification).to.satisfy(n => { - return n.type !== notificationType || - (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName) - }) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewInstanceFollower ( - options: CheckerBaseParams & { - followerHost: string - checkType: CheckerType - } -) { - const { followerHost } = options - const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkActor(notification.actorFollow.follower, { withAvatar: false }) - expect(notification.actorFollow.follower.name).to.equal('peertube') - expect(notification.actorFollow.follower.host).to.equal(followerHost) - - expect(notification.actorFollow.following.name).to.equal('peertube') - } else { - expect(notification).to.satisfy(n => { - return n.type !== notificationType || n.actorFollow.follower.host !== followerHost - }) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - return text.includes('PeerTube has a new follower') && text.includes(followerHost) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkAutoInstanceFollowing ( - options: CheckerBaseParams & { - followerHost: string - followingHost: string - checkType: CheckerType - } -) { - const { followerHost, followingHost } = options - const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - const following = notification.actorFollow.following - - checkActor(following, { withAvatar: false }) - expect(following.name).to.equal('peertube') - expect(following.host).to.equal(followingHost) - - expect(notification.actorFollow.follower.name).to.equal('peertube') - expect(notification.actorFollow.follower.host).to.equal(followerHost) - } else { - expect(notification).to.satisfy(n => { - return n.type !== notificationType || n.actorFollow.following.host !== followingHost - }) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - return text.match(/\bautomatically followed\b/) && text.includes(followingHost) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkCommentMention ( - options: CheckerBaseParams & { - shortUUID: string - commentId: number - threadId: number - byAccountDisplayName: string - checkType: CheckerType - } -) { - const { shortUUID, commentId, threadId, byAccountDisplayName } = options - const notificationType = UserNotificationType.COMMENT_MENTION - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkComment(notification.comment, commentId, threadId) - checkActor(notification.comment.account) - expect(notification.comment.account.displayName).to.equal(byAccountDisplayName) - - checkVideo(notification.comment.video, undefined, shortUUID) - } else { - expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - return text.match(/\bmentioned\b/) && text.includes(shortUUID) && text.includes(byAccountDisplayName) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -let lastEmailCount = 0 - -async function checkNewCommentOnMyVideo ( - options: CheckerBaseParams & { - shortUUID: string - commentId: number - threadId: number - checkType: CheckerType - approval?: boolean // default false - } -) { - const { server, shortUUID, commentId, threadId, checkType, emails, approval = false } = options - const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - checkComment(notification.comment, commentId, threadId) - checkActor(notification.comment.account) - checkVideo(notification.comment.video, undefined, shortUUID) - - expect(notification.comment.heldForReview).to.equal(approval) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.comment === undefined || n.comment.id !== commentId - }) - } - } - - const commentUrl = approval - ? `${server.url}/my-account/videos/comments?search=heldForReview:true` - : `${server.url}/w/${shortUUID};threadId=${threadId}` - - function emailNotificationFinder (email: object) { - const text = email['text'] - - return text.includes(commentUrl) && - (approval && text.includes('requires approval')) || - (!approval && !text.includes('requires approval')) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) - - if (checkType === 'presence') { - // We cannot detect email duplicates, so check we received another email - expect(emails).to.have.length.above(lastEmailCount) - lastEmailCount = emails.length - } -} - -async function checkNewVideoAbuseForModerators ( - options: CheckerBaseParams & { - shortUUID: string - videoName: string - checkType: CheckerType - } -) { - const { shortUUID, videoName } = options - const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.abuse.id).to.be.a('number') - checkVideo(notification.abuse.video, videoName, shortUUID) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.abuse === undefined || n.abuse.video.shortUUID !== shortUUID - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewAbuseMessage ( - options: CheckerBaseParams & { - abuseId: number - message: string - toEmail: string - checkType: CheckerType - } -) { - const { abuseId, message, toEmail } = options - const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.abuse.id).to.equal(abuseId) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n === undefined || n.type !== notificationType || n.abuse === undefined || n.abuse.id !== abuseId - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - const to = email['to'].filter(t => t.address === toEmail) - - return text.indexOf(message) !== -1 && to.length !== 0 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkAbuseStateChange ( - options: CheckerBaseParams & { - abuseId: number - state: AbuseStateType - checkType: CheckerType - } -) { - const { abuseId, state } = options - const notificationType = UserNotificationType.ABUSE_STATE_CHANGE - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.abuse.id).to.equal(abuseId) - expect(notification.abuse.state).to.equal(state) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.abuse === undefined || n.abuse.id !== abuseId - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - - const contains = state === AbuseState.ACCEPTED - ? ' accepted' - : ' rejected' - - return text.indexOf(contains) !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewCommentAbuseForModerators ( - options: CheckerBaseParams & { - shortUUID: string - videoName: string - checkType: CheckerType - } -) { - const { shortUUID, videoName } = options - const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.abuse.id).to.be.a('number') - checkVideo(notification.abuse.comment.video, videoName, shortUUID) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.abuse === undefined || n.abuse.comment.video.shortUUID !== shortUUID - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewAccountAbuseForModerators ( - options: CheckerBaseParams & { - displayName: string - checkType: CheckerType - } -) { - const { displayName } = options - const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.abuse.id).to.be.a('number') - expect(notification.abuse.account.displayName).to.equal(displayName) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.abuse === undefined || n.abuse.account.displayName !== displayName - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkVideoAutoBlacklistForModerators ( - options: CheckerBaseParams & { - shortUUID: string - videoName: string - checkType: CheckerType - } -) { - const { shortUUID, videoName } = options - const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.videoBlacklist.video.id).to.be.a('number') - checkVideo(notification.videoBlacklist.video, videoName, shortUUID) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.video === undefined || n.video.shortUUID !== shortUUID - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - return text.indexOf(shortUUID) !== -1 && email['text'].indexOf('moderation/video-blocks/list') !== -1 - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewBlacklistOnMyVideo ( - options: CheckerBaseParams & { - shortUUID: string - videoName: string - blacklistType: 'blacklist' | 'unblacklist' - } -) { - const { videoName, shortUUID, blacklistType } = options - const notificationType = blacklistType === 'blacklist' - ? UserNotificationType.BLACKLIST_ON_MY_VIDEO - : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO - - function notificationChecker (notification: UserNotification) { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video - - checkVideo(video, videoName, shortUUID) - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - - const blacklistReg = blacklistType === 'blacklist' - ? /\bblocked\b/ - : /\bunblocked\b/ - - return text.includes(shortUUID) && !!text.match(blacklistReg) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' }) -} - -async function checkNewPeerTubeVersion ( - options: CheckerBaseParams & { - latestVersion: string - checkType: CheckerType - } -) { - const { latestVersion } = options - const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.peertube).to.exist - expect(notification.peertube.latestVersion).to.equal(latestVersion) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.peertube === undefined || n.peertube.latestVersion !== latestVersion - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - - return text.includes(latestVersion) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkNewPluginVersion ( - options: CheckerBaseParams & { - pluginType: PluginType_Type - pluginName: string - checkType: CheckerType - } -) { - const { pluginName, pluginType } = options - const notificationType = UserNotificationType.NEW_PLUGIN_VERSION - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.plugin.name).to.equal(pluginName) - expect(notification.plugin.type).to.equal(pluginType) - } else { - expect(notification).to.satisfy((n: UserNotification) => { - return n?.plugin === undefined || n.plugin.name !== pluginName - }) - } - } - - function emailNotificationFinder (email: object) { - const text = email['text'] - - return text.includes(pluginName) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function checkMyVideoTranscriptionGenerated ( - options: CheckerBaseParams & { - videoName: string - shortUUID: string - language: { - id: string - label: string - } - checkType: CheckerType - } -) { - const { videoName, shortUUID, language } = options - const notificationType = UserNotificationType.MY_VIDEO_TRANSCRIPTION_GENERATED - - function notificationChecker (notification: UserNotification, checkType: CheckerType) { - if (checkType === 'presence') { - expect(notification).to.not.be.undefined - expect(notification.type).to.equal(notificationType) - - expect(notification.videoCaption).to.exist - expect(notification.videoCaption.language.id).to.equal(language.id) - expect(notification.videoCaption.language.label).to.equal(language.label) - checkVideo(notification.videoCaption.video, videoName, shortUUID) - } else { - expect(notification.videoCaption).to.satisfy(c => c === undefined || c.Video.shortUUID !== shortUUID) - } - } - - function emailNotificationFinder (email: object) { - const text: string = email['text'] - return text.includes(shortUUID) && text.includes('Transcription in ' + language.label) - } - - await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) -} - -async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: any = {}) { - const userNotifications: UserNotification[] = [] - const adminNotifications: UserNotification[] = [] - const adminNotificationsServer2: UserNotification[] = [] - const emails: object[] = [] - - const port = await MockSmtpServer.Instance.collectEmails(emails) - - const overrideConfig = { - ...ConfigCommand.getEmailOverrideConfig(port), - - signup: { - limit: 20 - } - } - const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) - - await setAccessTokensToServers(servers) - await setDefaultVideoChannel(servers) - await setDefaultChannelAvatar(servers) - await setDefaultAccountAvatar(servers) - - if (servers[1]) { - await servers[1].config.enableStudio() - await servers[1].config.enableLive({ allowReplay: true, transcoding: false }) - } - - if (serversCount > 1) { - await doubleFollow(servers[0], servers[1]) - } - - const user = { username: 'user_1', password: 'super password' } - await servers[0].users.create({ ...user, videoQuota: 10 * 1000 * 1000 }) - const userAccessToken = await servers[0].login.getAccessToken(user) - - await servers[0].notifications.updateMySettings({ token: userAccessToken, settings: getAllNotificationsSettings() }) - await servers[0].users.updateMyAvatar({ token: userAccessToken, fixture: 'avatar.png' }) - await servers[0].channels.updateImage({ channelName: 'user_1_channel', token: userAccessToken, fixture: 'avatar.png', type: 'avatar' }) - - await servers[0].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) - - if (serversCount > 1) { - await servers[1].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) - } - - { - const socket = servers[0].socketIO.getUserNotificationSocket({ token: userAccessToken }) - socket.on('new-notification', n => userNotifications.push(n)) - } - { - const socket = servers[0].socketIO.getUserNotificationSocket() - socket.on('new-notification', n => adminNotifications.push(n)) - } - - if (serversCount > 1) { - const socket = servers[1].socketIO.getUserNotificationSocket() - socket.on('new-notification', n => adminNotificationsServer2.push(n)) - } - - const { videoChannels } = await servers[0].users.getMyInfo() - const channelId = videoChannels[0].id - - return { - userNotifications, - adminNotifications, - adminNotificationsServer2, - userAccessToken, - emails, - servers, - channelId, - baseOverrideConfig: overrideConfig - } -} - -// --------------------------------------------------------------------------- - -export { - type CheckerType, - type CheckerBaseParams, - getAllNotificationsSettings, - waitUntilNotification, - checkMyVideoImportIsFinished, - checkUserRegistered, - checkAutoInstanceFollowing, - checkMyVideoIsPublished, - checkNewLiveFromSubscription, - checkNewVideoFromSubscription, - checkNewActorFollow, - checkNewCommentOnMyVideo, - checkNewBlacklistOnMyVideo, - checkCommentMention, - checkNewVideoAbuseForModerators, - checkVideoAutoBlacklistForModerators, - checkNewAbuseMessage, - checkAbuseStateChange, - checkNewInstanceFollower, - prepareNotificationsTest, - checkNewCommentAbuseForModerators, - checkNewAccountAbuseForModerators, - checkNewPeerTubeVersion, - checkNewPluginVersion, - checkVideoStudioEditionIsFinished, - checkRegistrationRequest, - checkMyVideoTranscriptionGenerated -} - -// --------------------------------------------------------------------------- - -async function checkNotification ( - options: CheckerBaseParams & { - notificationChecker: (notification: UserNotification, checkType: CheckerType) => void - emailNotificationFinder: (email: object) => boolean - checkType: CheckerType - } -) { - const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options - - const check = options.check || { web: true, mail: true } - - if (check.web) { - const notification = await server.notifications.getLatest({ token }) - - if (notification || checkType !== 'absence') { - notificationChecker(notification, checkType) - } - - const socketNotification = socketNotifications.find(n => { - try { - notificationChecker(n, 'presence') - return true - } catch { - return false - } - }) - - if (checkType === 'presence') { - const obj = inspect(socketNotifications, { depth: 5 }) - expect(socketNotification, 'The socket notification is absent when it should be present. ' + obj).to.not.be.undefined - } else { - const obj = inspect(socketNotification, { depth: 5 }) - expect(socketNotification, 'The socket notification is present when it should not be present. ' + obj).to.be.undefined - } - } - - if (check.mail) { - // Last email - const email = emails.slice() - .reverse() - .find(e => emailNotificationFinder(e)) - - if (checkType === 'presence') { - const texts = emails.map(e => e.text) - expect(email, 'The email is absent when is should be present. ' + inspect(texts)).to.not.be.undefined - } else { - expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined - } - } -} - -function checkVideo (video: any, videoName?: string, shortUUID?: string) { - if (videoName) { - expect(video.name).to.be.a('string') - expect(video.name).to.not.be.empty - expect(video.name).to.equal(videoName) - } - - if (shortUUID) { - expect(video.shortUUID).to.be.a('string') - expect(video.shortUUID).to.not.be.empty - expect(video.shortUUID).to.equal(shortUUID) - } - - expect(video.state).to.exist - expect(video.id).to.be.a('number') -} - -function checkActor (actor: any, options: { withAvatar?: boolean } = {}) { - const { withAvatar = true } = options - - expect(actor.displayName).to.be.a('string') - expect(actor.displayName).to.not.be.empty - expect(actor.host).to.not.be.undefined - - if (withAvatar) { - expect(actor.avatars).to.be.an('array') - expect(actor.avatars).to.have.lengthOf(4) - expect(actor.avatars[0].path).to.exist.and.not.empty - } -} - -function checkComment (comment: any, commentId: number, threadId: number) { - expect(comment.id).to.equal(commentId) - expect(comment.threadId).to.equal(threadId) -} diff --git a/packages/tests/src/shared/notifications/check-admin-notifications.ts b/packages/tests/src/shared/notifications/check-admin-notifications.ts new file mode 100644 index 000000000..8ff828f88 --- /dev/null +++ b/packages/tests/src/shared/notifications/check-admin-notifications.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { PluginType_Type, UserNotification, UserNotificationType } from '@peertube/peertube-models' +import { expect } from 'chai' +import { CheckerBaseParams, CheckerType, checkNotification } from './shared/notification-checker.js' + +export async function checkNewPeerTubeVersion ( + options: CheckerBaseParams & { + latestVersion: string + checkType: CheckerType + } +) { + const { latestVersion } = options + const notificationType = UserNotificationType.NEW_PEERTUBE_VERSION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.peertube).to.exist + expect(notification.peertube.latestVersion).to.equal(latestVersion) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.peertube === undefined || n.peertube.latestVersion !== latestVersion + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(latestVersion) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewPluginVersion ( + options: CheckerBaseParams & { + pluginType: PluginType_Type + pluginName: string + checkType: CheckerType + } +) { + const { pluginName, pluginType } = options + const notificationType = UserNotificationType.NEW_PLUGIN_VERSION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.plugin.name).to.equal(pluginName) + expect(notification.plugin.type).to.equal(pluginType) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.plugin === undefined || n.plugin.name !== pluginName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(pluginName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} diff --git a/packages/tests/src/shared/notifications/check-channel-notifications.ts b/packages/tests/src/shared/notifications/check-channel-notifications.ts new file mode 100644 index 000000000..47561bddd --- /dev/null +++ b/packages/tests/src/shared/notifications/check-channel-notifications.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { + UserNotification, + UserNotificationType, + UserNotificationType_Type, + VideoChannelCollaboratorState, + VideoChannelCollaboratorStateType +} from '@peertube/peertube-models' +import { expect } from 'chai' +import { checkActor, CheckerBaseParams, CheckerType, checkNotification } from './shared/notification-checker.js' + +export type CheckChannelCollaboratorOptions = CheckerBaseParams & { + channelDisplayName: string + targetDisplayName: string + sourceDisplayName: string + checkType: CheckerType + to: string +} + +export async function checkInvitedToCollaborateToChannel (options: CheckChannelCollaboratorOptions) { + const { channelDisplayName, targetDisplayName, sourceDisplayName, to } = options + const notificationType = UserNotificationType.INVITED_TO_COLLABORATE_TO_CHANNEL + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + checkCollaboratorNotification({ + notification, + notificationType, + channelDisplayName, + targetDisplayName, + sourceDisplayName, + state: VideoChannelCollaboratorState.PENDING + }) + } else { + expect(notification).to.satisfy(c => isNotificationAbsent(c)) + } + } + + function emailNotificationFinder (email: object) { + if (email['to'][0]['address'] !== to) return false + + const text: string = email['text'] + return text.includes(`${sourceDisplayName} invited you`) && + text.includes(`of channel ${channelDisplayName}`) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkAcceptedToCollaborateToChannel (options: CheckChannelCollaboratorOptions) { + const { channelDisplayName, targetDisplayName, sourceDisplayName, to } = options + const notificationType = UserNotificationType.ACCEPTED_TO_COLLABORATE_TO_CHANNEL + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + checkCollaboratorNotification({ + notification, + notificationType, + channelDisplayName, + targetDisplayName, + sourceDisplayName, + state: VideoChannelCollaboratorState.ACCEPTED + }) + } else { + expect(notification).to.satisfy(c => isNotificationAbsent(c)) + } + } + + function emailNotificationFinder (email: object) { + if (email['to'][0]['address'] !== to) return false + + const text: string = email['text'] + return text.includes(`${targetDisplayName} accepted`) && + text.includes(`of ${channelDisplayName}`) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkRefusedToCollaborateToChannel (options: CheckChannelCollaboratorOptions) { + const { channelDisplayName, targetDisplayName, sourceDisplayName, to } = options + const notificationType = UserNotificationType.REFUSED_TO_COLLABORATE_TO_CHANNEL + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + checkCollaboratorNotification({ notification, notificationType, channelDisplayName, targetDisplayName, sourceDisplayName }) + } else { + expect(notification).to.satisfy(c => isNotificationAbsent(c)) + } + } + + function emailNotificationFinder (email: object) { + if (email['to'][0]['address'] !== to) return false + + const text: string = email['text'] + return text.includes(`${targetDisplayName} refused`) && + text.includes(`of ${channelDisplayName}`) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +function checkCollaboratorNotification (options: { + notification: UserNotification + notificationType: UserNotificationType_Type + channelDisplayName: string + targetDisplayName: string + sourceDisplayName: string + state?: VideoChannelCollaboratorStateType +}) { + const { channelDisplayName, targetDisplayName, notificationType, notification, state, sourceDisplayName } = options + + expect(notification).to.exist + expect(notification.type).to.equal(notificationType) + + const collaborator = notification.videoChannelCollaborator + + if (collaborator) { + expect(collaborator.channel.avatars).to.have.lengthOf(4) + expect(collaborator.account.avatars).to.have.lengthOf(4) + expect(collaborator.id).to.exist + expect(collaborator.state.id).to.equal(state) + expect(collaborator.account.displayName).to.equal(targetDisplayName) + expect(collaborator.channel.displayName).to.equal(channelDisplayName) + + checkActor(collaborator.account) + checkActor(collaborator.channel) + } else { + expect(notification.data.channelDisplayName).to.equal(channelDisplayName) + expect(notification.data.collaboratorDisplayName).to.equal(targetDisplayName) + expect(notification.data.channelOwnerDisplayName).to.equal(sourceDisplayName) + + expect(notification.data.channelHandle).to.exist + expect(notification.data.collaboratorHandle).to.exist + expect(notification.data.channelOwnerHandle).to.exist + } +} + +function isNotificationAbsent (options: { + notification: UserNotification + notificationType: UserNotificationType_Type + channelDisplayName: string + targetDisplayName: string +}) { + const { notification: n, notificationType, channelDisplayName, targetDisplayName } = options + + if (!n) return true + if (!n.videoChannelCollaborator) return true + if (n.type !== notificationType) return true + + if ( + n.videoChannelCollaborator.account.displayName !== targetDisplayName && + n.videoChannelCollaborator.channel.displayName !== channelDisplayName + ) return true + + return false +} diff --git a/packages/tests/src/shared/notifications/check-comment-notifications.ts b/packages/tests/src/shared/notifications/check-comment-notifications.ts new file mode 100644 index 000000000..0bfe82742 --- /dev/null +++ b/packages/tests/src/shared/notifications/check-comment-notifications.ts @@ -0,0 +1,93 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { UserNotification, UserNotificationType } from '@peertube/peertube-models' +import { expect } from 'chai' +import { checkActor, checkComment, CheckerBaseParams, CheckerType, checkNotification, checkVideo } from './shared/notification-checker.js' + +export async function checkCommentMention ( + options: CheckerBaseParams & { + shortUUID: string + commentId: number + threadId: number + byAccountDisplayName: string + checkType: CheckerType + } +) { + const { shortUUID, commentId, threadId, byAccountDisplayName } = options + const notificationType = UserNotificationType.COMMENT_MENTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + expect(notification.comment.account.displayName).to.equal(byAccountDisplayName) + + checkVideo(notification.comment.video, undefined, shortUUID) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.comment.id !== commentId) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.match(/\bmentioned\b/) && text.includes(shortUUID) && text.includes(byAccountDisplayName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +let lastEmailCount = 0 + +export async function checkNewCommentOnMyVideo ( + options: CheckerBaseParams & { + shortUUID: string + commentId: number + threadId: number + checkType: CheckerType + approval?: boolean // default false + } +) { + const { server, shortUUID, commentId, threadId, checkType, emails, approval = false } = options + const notificationType = UserNotificationType.NEW_COMMENT_ON_MY_VIDEO + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkComment(notification.comment, commentId, threadId) + checkActor(notification.comment.account) + checkVideo(notification.comment.video, undefined, shortUUID) + + expect(notification.comment.heldForReview).to.equal(approval) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.comment === undefined || n.comment.id !== commentId + }) + } + } + + const commentUrl = approval + ? `${server.url}/my-account/videos/comments?search=heldForReview:true` + : `${server.url}/w/${shortUUID};threadId=${threadId}` + + function emailNotificationFinder (email: object) { + const text = email['text'] + + return text.includes(commentUrl) && + (approval && text.includes('requires approval')) || + (!approval && !text.includes('requires approval')) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) + + if (checkType === 'presence') { + // We cannot detect email duplicates, so check we received another email + expect(emails).to.have.length.above(lastEmailCount) + lastEmailCount = emails.length + } +} diff --git a/packages/tests/src/shared/notifications/check-follow-notifications.ts b/packages/tests/src/shared/notifications/check-follow-notifications.ts new file mode 100644 index 000000000..fffe236c4 --- /dev/null +++ b/packages/tests/src/shared/notifications/check-follow-notifications.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { UserNotification, UserNotificationType } from '@peertube/peertube-models' +import { expect } from 'chai' +import { checkActor, CheckerBaseParams, CheckerType, checkNotification } from './shared/notification-checker.js' + +export async function checkNewActorFollow ( + options: CheckerBaseParams & { + followType: 'channel' | 'account' + followerName: string + followerDisplayName: string + followingDisplayName: string + checkType: CheckerType + } +) { + const { followType, followerName, followerDisplayName, followingDisplayName } = options + const notificationType = UserNotificationType.NEW_FOLLOW + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.actorFollow.follower) + expect(notification.actorFollow.follower.displayName).to.equal(followerDisplayName) + expect(notification.actorFollow.follower.name).to.equal(followerName) + expect(notification.actorFollow.follower.host).to.not.be.undefined + + const following = notification.actorFollow.following + expect(following.displayName).to.equal(followingDisplayName) + expect(following.type).to.equal(followType) + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || + (n.actorFollow.follower.name !== followerName && n.actorFollow.following !== followingDisplayName) + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(followType) && text.includes(followingDisplayName) && text.includes(followerDisplayName) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewInstanceFollower ( + options: CheckerBaseParams & { + followerHost: string + checkType: CheckerType + } +) { + const { followerHost } = options + const notificationType = UserNotificationType.NEW_INSTANCE_FOLLOWER + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.actorFollow.follower, { withAvatar: false }) + expect(notification.actorFollow.follower.name).to.equal('peertube') + expect(notification.actorFollow.follower.host).to.equal(followerHost) + + expect(notification.actorFollow.following.name).to.equal('peertube') + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || n.actorFollow.follower.host !== followerHost + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes('PeerTube has a new follower') && text.includes(followerHost) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkAutoInstanceFollowing ( + options: CheckerBaseParams & { + followerHost: string + followingHost: string + checkType: CheckerType + } +) { + const { followerHost, followingHost } = options + const notificationType = UserNotificationType.AUTO_INSTANCE_FOLLOWING + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + const following = notification.actorFollow.following + + checkActor(following, { withAvatar: false }) + expect(following.name).to.equal('peertube') + expect(following.host).to.equal(followingHost) + + expect(notification.actorFollow.follower.name).to.equal('peertube') + expect(notification.actorFollow.follower.host).to.equal(followerHost) + } else { + expect(notification).to.satisfy(n => { + return n.type !== notificationType || n.actorFollow.following.host !== followingHost + }) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.match(/\bautomatically followed\b/) && text.includes(followingHost) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} diff --git a/packages/tests/src/shared/notifications/check-moderation-notifications.ts b/packages/tests/src/shared/notifications/check-moderation-notifications.ts new file mode 100644 index 000000000..a75788a7e --- /dev/null +++ b/packages/tests/src/shared/notifications/check-moderation-notifications.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { AbuseState, AbuseStateType, UserNotification, UserNotificationType } from '@peertube/peertube-models' +import { expect } from 'chai' +import { checkActor, CheckerBaseParams, CheckerType, checkNotification, checkVideo } from './shared/notification-checker.js' + +export async function checkNewVideoAbuseForModerators ( + options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType + } +) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + checkVideo(notification.abuse.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.abuse === undefined || n.abuse.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewAbuseMessage ( + options: CheckerBaseParams & { + abuseId: number + message: string + toEmail: string + checkType: CheckerType + } +) { + const { abuseId, message, toEmail } = options + const notificationType = UserNotificationType.ABUSE_NEW_MESSAGE + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.equal(abuseId) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== notificationType || n.abuse === undefined || n.abuse.id !== abuseId + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + const to = email['to'].filter(t => t.address === toEmail) + + return text.indexOf(message) !== -1 && to.length !== 0 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkAbuseStateChange ( + options: CheckerBaseParams & { + abuseId: number + state: AbuseStateType + checkType: CheckerType + } +) { + const { abuseId, state } = options + const notificationType = UserNotificationType.ABUSE_STATE_CHANGE + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.equal(abuseId) + expect(notification.abuse.state).to.equal(state) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.abuse === undefined || n.abuse.id !== abuseId + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + + const contains = state === AbuseState.ACCEPTED + ? ' accepted' + : ' rejected' + + return text.indexOf(contains) !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewCommentAbuseForModerators ( + options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType + } +) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + checkVideo(notification.abuse.comment.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.abuse === undefined || n.abuse.comment.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewAccountAbuseForModerators ( + options: CheckerBaseParams & { + displayName: string + checkType: CheckerType + } +) { + const { displayName } = options + const notificationType = UserNotificationType.NEW_ABUSE_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.abuse.id).to.be.a('number') + expect(notification.abuse.account.displayName).to.equal(displayName) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.abuse === undefined || n.abuse.account.displayName !== displayName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(displayName) !== -1 && text.indexOf('abuse') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkVideoAutoBlacklistForModerators ( + options: CheckerBaseParams & { + shortUUID: string + videoName: string + checkType: CheckerType + } +) { + const { shortUUID, videoName } = options + const notificationType = UserNotificationType.VIDEO_AUTO_BLACKLIST_FOR_MODERATORS + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoBlacklist.video.id).to.be.a('number') + checkVideo(notification.videoBlacklist.video, videoName, shortUUID) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n?.video === undefined || n.video.shortUUID !== shortUUID + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && email['text'].indexOf('moderation/video-blocks/list') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewBlacklistOnMyVideo ( + options: CheckerBaseParams & { + shortUUID: string + videoName: string + blacklistType: 'blacklist' | 'unblacklist' + } +) { + const { videoName, shortUUID, blacklistType } = options + const notificationType = blacklistType === 'blacklist' + ? UserNotificationType.BLACKLIST_ON_MY_VIDEO + : UserNotificationType.UNBLACKLIST_ON_MY_VIDEO + + function notificationChecker (notification: UserNotification) { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + const video = blacklistType === 'blacklist' ? notification.videoBlacklist.video : notification.video + + checkVideo(video, videoName, shortUUID) + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + const blacklistReg = blacklistType === 'blacklist' + ? /\bblocked\b/ + : /\bunblocked\b/ + + return text.includes(shortUUID) && !!text.match(blacklistReg) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder, checkType: 'presence' }) +} + +export async function checkUserRegistered ( + options: CheckerBaseParams & { + username: string + checkType: CheckerType + } +) { + const { username } = options + const notificationType = UserNotificationType.NEW_USER_REGISTRATION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkActor(notification.account, { withAvatar: false }) + expect(notification.account.name).to.equal(username) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.account.name !== username) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' registered.') && text.includes(username) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkRegistrationRequest ( + options: CheckerBaseParams & { + username: string + registrationReason: string + checkType: CheckerType + } +) { + const { username, registrationReason } = options + const notificationType = UserNotificationType.NEW_USER_REGISTRATION_REQUEST + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.registration.username).to.equal(username) + } else { + expect(notification).to.satisfy(n => n.type !== notificationType || n.registration.username !== username) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + + return text.includes(' wants to register ') && text.includes(username) && text.includes(registrationReason) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} diff --git a/packages/tests/src/shared/notifications/check-video-notifications.ts b/packages/tests/src/shared/notifications/check-video-notifications.ts new file mode 100644 index 000000000..06ddb0892 --- /dev/null +++ b/packages/tests/src/shared/notifications/check-video-notifications.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { UserNotification, UserNotificationType } from '@peertube/peertube-models' +import { expect } from 'chai' +import { checkActor, CheckerBaseParams, CheckerType, checkNotification, checkVideo } from './shared/notification-checker.js' + +export async function checkNewVideoFromSubscription ( + options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType + } +) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== UserNotificationType.NEW_VIDEO_FROM_SUBSCRIPTION || n.video.name !== videoName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkNewLiveFromSubscription ( + options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType + } +) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.NEW_LIVE_FROM_SUBSCRIPTION + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification).to.satisfy((n: UserNotification) => { + return n === undefined || n.type !== UserNotificationType.NEW_LIVE_FROM_SUBSCRIPTION || n.video.name !== videoName + }) + } + } + + function emailNotificationFinder (email: object) { + const text = email['text'] + return text.indexOf(shortUUID) !== -1 && text.indexOf('Your subscription') !== -1 + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkMyVideoIsPublished ( + options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType + } +) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.MY_VIDEO_PUBLISHED + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + return text.includes(shortUUID) && text.includes('Your video') + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkVideoStudioEditionIsFinished ( + options: CheckerBaseParams & { + videoName: string + shortUUID: string + checkType: CheckerType + } +) { + const { videoName, shortUUID } = options + const notificationType = UserNotificationType.MY_VIDEO_STUDIO_EDITION_FINISHED + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + checkVideo(notification.video, videoName, shortUUID) + checkActor(notification.video.channel) + } else { + expect(notification.video).to.satisfy(v => v === undefined || v.name !== videoName) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + return text.includes(shortUUID) && text.includes('Edition of your video') + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkMyVideoImportIsFinished ( + options: CheckerBaseParams & { + videoName: string + shortUUID: string + url: string + success: boolean + checkType: CheckerType + } +) { + const { videoName, shortUUID, url, success } = options + + const notificationType = success ? UserNotificationType.MY_VIDEO_IMPORT_SUCCESS : UserNotificationType.MY_VIDEO_IMPORT_ERROR + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoImport.targetUrl).to.equal(url) + + if (success) checkVideo(notification.videoImport.video, videoName, shortUUID) + } else { + expect(notification.videoImport).to.satisfy(i => i === undefined || i.targetUrl !== url) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + const toFind = success + ? /\bfinished\b/ + : /\berror\b/ + + return text.includes(url) && !!text.match(toFind) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} + +export async function checkMyVideoTranscriptionGenerated ( + options: CheckerBaseParams & { + videoName: string + shortUUID: string + language: { + id: string + label: string + } + checkType: CheckerType + } +) { + const { videoName, shortUUID, language } = options + const notificationType = UserNotificationType.MY_VIDEO_TRANSCRIPTION_GENERATED + + function notificationChecker (notification: UserNotification, checkType: CheckerType) { + if (checkType === 'presence') { + expect(notification).to.not.be.undefined + expect(notification.type).to.equal(notificationType) + + expect(notification.videoCaption).to.exist + expect(notification.videoCaption.language.id).to.equal(language.id) + expect(notification.videoCaption.language.label).to.equal(language.label) + checkVideo(notification.videoCaption.video, videoName, shortUUID) + } else { + expect(notification.videoCaption).to.satisfy(c => c === undefined || c.Video.shortUUID !== shortUUID) + } + } + + function emailNotificationFinder (email: object) { + const text: string = email['text'] + return text.includes(shortUUID) && text.includes('Transcription in ' + language.label) + } + + await checkNotification({ ...options, notificationChecker, emailNotificationFinder }) +} diff --git a/packages/tests/src/shared/notifications/notifications-common.ts b/packages/tests/src/shared/notifications/notifications-common.ts new file mode 100644 index 000000000..7901351d4 --- /dev/null +++ b/packages/tests/src/shared/notifications/notifications-common.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { wait } from '@peertube/peertube-core-utils' +import { + UserNotification, + UserNotificationSetting, + UserNotificationSettingValue, + UserNotificationType_Type +} from '@peertube/peertube-models' +import { + ConfigCommand, + createMultipleServers, + doubleFollow, + PeerTubeServer, + setAccessTokensToServers, + setDefaultAccountAvatar, + setDefaultChannelAvatar, + setDefaultVideoChannel, + waitJobs +} from '@peertube/peertube-server-commands' +import { MockSmtpServer } from '../mock-servers/mock-email.js' + +export function getAllNotificationsSettings (): UserNotificationSetting { + return { + newVideoFromSubscription: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newCommentOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + videoAutoBlacklistAsModerator: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + blacklistOnMyVideo: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoImportFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoPublished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + commentMention: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newFollow: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newUserRegistration: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newInstanceFollower: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseNewMessage: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + abuseStateChange: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + autoInstanceFollowing: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newPeerTubeVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoStudioEditionFinished: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + myVideoTranscriptionGenerated: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL, + newPluginVersion: UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } +} + +export async function waitUntilNotification (options: { + server: PeerTubeServer + notificationType: UserNotificationType_Type + token: string + fromDate: Date +}) { + const { server, fromDate, notificationType, token } = options + + do { + const { data } = await server.notifications.list({ start: 0, count: 5, token }) + if (data.some(n => n.type === notificationType && new Date(n.createdAt) >= fromDate)) break + + await wait(500) + } while (true) + + await waitJobs([ server ]) +} + +export async function prepareNotificationsTest (serversCount = 3, overrideConfigArg: any = {}) { + const userNotifications: UserNotification[] = [] + const adminNotifications: UserNotification[] = [] + const adminNotificationsServer2: UserNotification[] = [] + const emails: object[] = [] + + const port = await MockSmtpServer.Instance.collectEmails(emails) + + const overrideConfig = { + ...ConfigCommand.getEmailOverrideConfig(port), + + signup: { + limit: 20 + } + } + const servers = await createMultipleServers(serversCount, Object.assign(overrideConfig, overrideConfigArg)) + + await setAccessTokensToServers(servers) + await setDefaultVideoChannel(servers) + await setDefaultChannelAvatar(servers) + await setDefaultAccountAvatar(servers) + + if (servers[1]) { + await servers[1].config.enableStudio() + await servers[1].config.enableLive({ allowReplay: true, transcoding: false }) + } + + if (serversCount > 1) { + await doubleFollow(servers[0], servers[1]) + } + + const user = { username: 'user_1', password: 'super password' } + await servers[0].users.create({ ...user, videoQuota: 10 * 1000 * 1000 }) + const userAccessToken = await servers[0].login.getAccessToken(user) + await servers[0].users.updateMe({ token: userAccessToken, displayName: 'User 1' }) + + await servers[0].notifications.updateMySettings({ token: userAccessToken, settings: getAllNotificationsSettings() }) + await servers[0].users.updateMyAvatar({ token: userAccessToken, fixture: 'avatar.png' }) + await servers[0].channels.updateImage({ channelName: 'user_1_channel', token: userAccessToken, fixture: 'avatar.png', type: 'avatar' }) + + await servers[0].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) + + if (serversCount > 1) { + await servers[1].notifications.updateMySettings({ settings: getAllNotificationsSettings() }) + } + + { + const socket = servers[0].socketIO.getUserNotificationSocket({ token: userAccessToken }) + socket.on('new-notification', n => userNotifications.push(n)) + } + { + const socket = servers[0].socketIO.getUserNotificationSocket() + socket.on('new-notification', n => adminNotifications.push(n)) + } + + if (serversCount > 1) { + const socket = servers[1].socketIO.getUserNotificationSocket() + socket.on('new-notification', n => adminNotificationsServer2.push(n)) + } + + const { videoChannels } = await servers[0].users.getMyInfo() + const channelId = videoChannels[0].id + + return { + userNotifications, + adminNotifications, + adminNotificationsServer2, + userAccessToken, + emails, + servers, + channelId, + baseOverrideConfig: overrideConfig + } +} diff --git a/packages/tests/src/shared/notifications/shared/notification-checker.ts b/packages/tests/src/shared/notifications/shared/notification-checker.ts new file mode 100644 index 000000000..a1def1745 --- /dev/null +++ b/packages/tests/src/shared/notifications/shared/notification-checker.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { UserNotification } from '@peertube/peertube-models' +import { PeerTubeServer } from '@peertube/peertube-server-commands' +import { expect } from 'chai' +import { inspect } from 'util' + +export type CheckerBaseParams = { + server: PeerTubeServer + emails: any[] + socketNotifications: UserNotification[] + token: string + check?: { web: boolean, mail: boolean } +} + +export type CheckerType = 'presence' | 'absence' + +export async function checkNotification ( + options: CheckerBaseParams & { + notificationChecker: (notification: UserNotification, checkType: CheckerType) => void + emailNotificationFinder: (email: object) => boolean + checkType: CheckerType + } +) { + const { server, token, checkType, notificationChecker, emailNotificationFinder, socketNotifications, emails } = options + + const check = options.check || { web: true, mail: true } + + if (check.web) { + const notification = await server.notifications.getLatest({ token }) + + if (notification || checkType !== 'absence') { + notificationChecker(notification, checkType) + } + + const socketNotification = socketNotifications.find(n => { + try { + notificationChecker(n, 'presence') + return true + } catch { + return false + } + }) + + if (checkType === 'presence') { + const obj = inspect(socketNotifications, { depth: 5 }) + expect(socketNotification, 'The socket notification is absent when it should be present. ' + obj).to.not.be.undefined + } else { + const obj = inspect(socketNotification, { depth: 5 }) + expect(socketNotification, 'The socket notification is present when it should not be present. ' + obj).to.be.undefined + } + } + + if (check.mail) { + // Last email + const email = emails.slice() + .reverse() + .find(e => emailNotificationFinder(e)) + + if (checkType === 'presence') { + const texts = emails.map(e => e.text) + expect(email, 'The email is absent when is should be present. ' + inspect(texts)).to.not.be.undefined + } else { + expect(email, 'The email is present when is should not be present. ' + inspect(email)).to.be.undefined + } + } +} + +export function checkVideo (video: any, videoName?: string, shortUUID?: string) { + if (videoName) { + expect(video.name).to.be.a('string') + expect(video.name).to.not.be.empty + expect(video.name).to.equal(videoName) + } + + if (shortUUID) { + expect(video.shortUUID).to.be.a('string') + expect(video.shortUUID).to.not.be.empty + expect(video.shortUUID).to.equal(shortUUID) + } + + expect(video.state).to.exist + expect(video.id).to.be.a('number') +} + +export function checkActor (actor: any, options: { withAvatar?: boolean } = {}) { + const { withAvatar = true } = options + + expect(actor.displayName).to.be.a('string') + expect(actor.displayName).to.not.be.empty + expect(actor.host).to.not.be.undefined + + if (withAvatar) { + expect(actor.avatars).to.be.an('array') + expect(actor.avatars).to.have.lengthOf(4) + expect(actor.avatars[0].path).to.exist.and.not.empty + } +} + +export function checkComment (comment: any, commentId: number, threadId: number) { + expect(comment.id).to.equal(commentId) + expect(comment.threadId).to.equal(threadId) +} diff --git a/server/core/controllers/activitypub/client.ts b/server/core/controllers/activitypub/client.ts index 3391f96ed..98a902fcd 100644 --- a/server/core/controllers/activitypub/client.ts +++ b/server/core/controllers/activitypub/client.ts @@ -61,28 +61,28 @@ activityPubClientRouter.get( [ '/accounts?/:handle', '/accounts?/:handle/video-channels', '/a/:handle', '/a/:handle/video-channels' ], executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })), asyncMiddleware(accountController) ) activityPubClientRouter.get( '/accounts?/:handle/followers', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })), asyncMiddleware(accountFollowersController) ) activityPubClientRouter.get( '/accounts?/:handle/following', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })), asyncMiddleware(accountFollowingController) ) activityPubClientRouter.get( '/accounts?/:handle/playlists', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })), asyncMiddleware(accountPlaylistsController) ) activityPubClientRouter.get( @@ -212,35 +212,35 @@ activityPubClientRouter.get( [ '/video-channels/:handle', '/video-channels/:handle/videos', '/c/:handle', '/c/:handle/videos' ], executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(videoChannelController) ) activityPubClientRouter.get( '/video-channels/:handle/followers', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(videoChannelFollowersController) ) activityPubClientRouter.get( '/video-channels/:handle/following', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(videoChannelFollowingController) ) activityPubClientRouter.get( '/video-channels/:handle/playlists', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(videoChannelPlaylistsController) ) activityPubClientRouter.get( '/video-channels/:handle/player-settings', executeIfActivityPub, activityPubRateLimiter, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(channelPlayerSettingsController) ) @@ -462,7 +462,7 @@ async function videoCommentController (req: express.Request, res: express.Respon const videoComment = res.locals.videoCommentFull if (redirectIfNotOwned(videoComment.url, res)) return - if (videoComment.Video.isOwned() && videoComment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) + if (videoComment.Video.isLocal() && videoComment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) const threadParentComments = await VideoCommentModel.listThreadParentComments({ comment: videoComment }) @@ -484,7 +484,7 @@ async function videoCommentController (req: express.Request, res: express.Respon async function videoCommentApprovedController (req: express.Request, res: express.Response) { const comment = res.locals.videoCommentFull - if (!comment.Video.isOwned() || comment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) + if (!comment.Video.isLocal() || comment.heldForReview === true) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) const activity = buildApprovalActivity({ comment, type: 'ApproveReply' }) diff --git a/server/core/controllers/activitypub/inbox.ts b/server/core/controllers/activitypub/inbox.ts index cfbf2d2fa..6d9777bdb 100644 --- a/server/core/controllers/activitypub/inbox.ts +++ b/server/core/controllers/activitypub/inbox.ts @@ -29,7 +29,7 @@ inboxRouter.post( activityPubRateLimiter, signatureValidator, asyncMiddleware(checkSignature), - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false })), asyncMiddleware(activityPubValidator), inboxController ) @@ -39,7 +39,7 @@ inboxRouter.post( activityPubRateLimiter, signatureValidator, asyncMiddleware(checkSignature), - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(activityPubValidator), inboxController ) diff --git a/server/core/controllers/activitypub/outbox.ts b/server/core/controllers/activitypub/outbox.ts index 40f36151d..525f9214b 100644 --- a/server/core/controllers/activitypub/outbox.ts +++ b/server/core/controllers/activitypub/outbox.ts @@ -23,7 +23,7 @@ outboxRouter.get( '/accounts/:handle/outbox', activityPubRateLimiter, apPaginationValidator, - accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: false }), + accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: false }), asyncMiddleware(outboxController) ) @@ -31,7 +31,7 @@ outboxRouter.get( '/video-channels/:handle/outbox', activityPubRateLimiter, apPaginationValidator, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(outboxController) ) diff --git a/server/core/controllers/api/accounts.ts b/server/core/controllers/api/accounts.ts index 7aafc6e08..44edc8dcc 100644 --- a/server/core/controllers/api/accounts.ts +++ b/server/core/controllers/api/accounts.ts @@ -26,12 +26,16 @@ import { accountHandleGetValidatorFactory, accountsFollowersSortValidator, accountsSortValidator, + listAccountChannelsValidator, videoChannelsSortValidator, - videoChannelStatsValidator, videoChannelSyncsSortValidator, videosSortValidator } from '../../middlewares/validators/index.js' -import { commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator } from '../../middlewares/validators/videos/video-playlists.js' +import { + commonVideoPlaylistFiltersValidator, + videoPlaylistsAccountValidator, + videoPlaylistsSearchValidator +} from '../../middlewares/validators/videos/video-playlists.js' import { AccountVideoRateModel } from '../../models/account/account-video-rate.js' import { AccountModel } from '../../models/account/account.js' import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter/index.js' @@ -54,13 +58,13 @@ accountsRouter.get( accountsRouter.get( '/:handle', - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })), getAccount ) accountsRouter.get( '/:handle/videos', - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })), paginationValidator, videosSortValidator, setDefaultVideosSort, @@ -72,8 +76,8 @@ accountsRouter.get( accountsRouter.get( '/:handle/video-channels', - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkManage: false })), - videoChannelStatsValidator, + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })), + listAccountChannelsValidator, paginationValidator, videoChannelsSortValidator, setDefaultSort, @@ -84,20 +88,21 @@ accountsRouter.get( accountsRouter.get( '/:handle/video-playlists', optionalAuthenticate, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })), paginationValidator, videoPlaylistsSortValidator, setDefaultSort, setDefaultPagination, commonVideoPlaylistFiltersValidator, videoPlaylistsSearchValidator, + videoPlaylistsAccountValidator, asyncMiddleware(listAccountPlaylists) ) accountsRouter.get( '/:handle/video-channel-syncs', authenticate, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: true })), paginationValidator, videoChannelSyncsSortValidator, setDefaultSort, @@ -108,7 +113,7 @@ accountsRouter.get( accountsRouter.get( '/:handle/ratings', authenticate, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: true })), paginationValidator, videoRatesSortValidator, setDefaultSort, @@ -120,7 +125,7 @@ accountsRouter.get( accountsRouter.get( '/:handle/followers', authenticate, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: true, checkCanManage: true })), paginationValidator, accountsFollowersSortValidator, setDefaultSort, @@ -153,16 +158,15 @@ async function listAccounts (req: express.Request, res: express.Response) { } async function listAccountChannels (req: express.Request, res: express.Response) { - const options = { + const resultList = await VideoChannelModel.listByAccountForAPI({ accountId: res.locals.account.id, start: req.query.start, count: req.query.count, sort: req.query.sort, withStats: req.query.withStats, + includeCollaborations: req.query.includeCollaborations, search: req.query.search - } - - const resultList = await VideoChannelModel.listByAccountForAPI(options) + }) return res.json(getFormattedObjects(resultList.data, resultList.total)) } @@ -183,7 +187,7 @@ async function listAccountChannelsSync (req: express.Request, res: express.Respo async function listAccountPlaylists (req: express.Request, res: express.Response) { const serverActor = await getServerActor() - const query = req.query as VideoPlaylistsListQuery + const query = req.query as VideoPlaylistsListQuery & { includeCollaborations?: boolean } // Allow users to see their private/unlisted video playlists let listMyPlaylists = false @@ -204,7 +208,9 @@ async function listAccountPlaylists (req: express.Request, res: express.Response sort: query.sort, search: query.search, - type: query.playlistType + type: query.playlistType, + + includeCollaborations: query.includeCollaborations }) return res.json(getFormattedObjects(resultList.data, resultList.total)) @@ -260,7 +266,7 @@ async function listAccountRatings (req: express.Request, res: express.Response) async function listAccountFollowers (req: express.Request, res: express.Response) { const account = res.locals.account - const channels = await VideoChannelModel.listAllByAccount(account.id) + const channels = await VideoChannelModel.listAllOwnedByAccount(account.id) const actorIds = [ account.actorId ].concat(channels.map(c => c.actorId)) const resultList = await ActorFollowModel.listFollowersForApi({ diff --git a/server/core/controllers/api/index.ts b/server/core/controllers/api/index.ts index a0a35b951..8c541f77f 100644 --- a/server/core/controllers/api/index.ts +++ b/server/core/controllers/api/index.ts @@ -21,7 +21,7 @@ import { searchRouter } from './search/index.js' import { serverRouter } from './server/index.js' import { usersRouter } from './users/index.js' import { videoChannelSyncRouter } from './video-channel-sync.js' -import { videoChannelRouter } from './video-channel.js' +import { videoChannelRouter } from './video-channels/index.js' import { videoPlaylistRouter } from './video-playlist.js' import { videosRouter } from './videos/index.js' import { watchedWordsRouter } from './watched-words.js' diff --git a/server/core/controllers/api/player-settings.ts b/server/core/controllers/api/player-settings.ts index 1475dfef2..f159006e7 100644 --- a/server/core/controllers/api/player-settings.ts +++ b/server/core/controllers/api/player-settings.ts @@ -1,4 +1,5 @@ import { PlayerChannelSettingsUpdate, PlayerVideoSettingsUpdate } from '@peertube/peertube-models' +import { sendUpdateChannelPlayerSettings, sendUpdateVideoPlayerSettings } from '@server/lib/activitypub/send/send-update.js' import { upsertPlayerSettings } from '@server/lib/player-settings.js' import { getChannelPlayerSettingsValidator, @@ -15,7 +16,6 @@ import { optionalAuthenticate, videoChannelsHandleValidatorFactory } from '../../middlewares/index.js' -import { sendUpdateChannelPlayerSettings, sendUpdateVideoPlayerSettings } from '@server/lib/activitypub/send/send-update.js' const playerSettingsRouter = express.Router() @@ -39,7 +39,7 @@ playerSettingsRouter.put( playerSettingsRouter.get( '/video-channels/:handle', optionalAuthenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })), getChannelPlayerSettingsValidator, asyncMiddleware(getChannelPlayerSettings) ) @@ -47,7 +47,7 @@ playerSettingsRouter.get( playerSettingsRouter.put( '/video-channels/:handle', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), updatePlayerSettingsValidatorFactory('channel'), asyncMiddleware(updateChannelPlayerSettings) ) diff --git a/server/core/controllers/api/search/search-video-channels.ts b/server/core/controllers/api/search/search-video-channels.ts index 920b1a07a..e263eb552 100644 --- a/server/core/controllers/api/search/search-video-channels.ts +++ b/server/core/controllers/api/search/search-video-channels.ts @@ -29,7 +29,8 @@ import { searchLocalUrl } from './shared/index.js' const searchChannelsRouter = express.Router() -searchChannelsRouter.get('/video-channels', +searchChannelsRouter.get( + '/video-channels', openapiOperationDoc({ operationId: 'searchChannels' }), paginationValidator, setDefaultPagination, @@ -102,7 +103,7 @@ async function searchVideoChannelsDB (query: VideoChannelsSearchQueryAfterSaniti }, 'filter:api.search.video-channels.local.list.params') const resultList = await Hooks.wrapPromiseFun( - VideoChannelModel.searchForApi.bind(VideoChannelModel), + VideoChannelModel.listForApi.bind(VideoChannelModel), apiOptions, 'filter:api.search.video-channels.local.list.result' ) diff --git a/server/core/controllers/api/users/me.ts b/server/core/controllers/api/users/me.ts index 4493e0cd1..b4efbd258 100644 --- a/server/core/controllers/api/users/me.ts +++ b/server/core/controllers/api/users/me.ts @@ -56,10 +56,10 @@ const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_ const meRouter = express.Router() -meRouter.get('/me', authenticate, asyncMiddleware(getUserInformation)) +meRouter.get('/me', authenticate, asyncMiddleware(getMyInformation)) meRouter.delete('/me', authenticate, deleteMeValidator, asyncMiddleware(deleteMe)) -meRouter.get('/me/video-quota-used', authenticate, asyncMiddleware(getUserVideoQuotaUsed)) +meRouter.get('/me/video-quota-used', authenticate, asyncMiddleware(getMyVideoQuotaUsed)) meRouter.get( '/me/videos/imports', @@ -69,7 +69,7 @@ meRouter.get( setDefaultSort, setDefaultPagination, getMyVideoImportsValidator, - asyncMiddleware(getUserVideoImports) + asyncMiddleware(listMyVideoImports) ) meRouter.get( @@ -92,14 +92,14 @@ meRouter.get( setDefaultPagination, commonVideosFiltersValidator, asyncMiddleware(usersVideosValidator), - asyncMiddleware(listUserVideos) + asyncMiddleware(listMyVideos) ) meRouter.get( '/me/videos/:videoId/rating', authenticate, asyncMiddleware(usersVideoRatingValidator), - asyncMiddleware(getUserVideoRating) + asyncMiddleware(getMyVideoRating) ) meRouter.put( @@ -131,32 +131,36 @@ export { // --------------------------------------------------------------------------- -async function listUserVideos (req: express.Request, res: express.Response) { +async function listMyVideos (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User const countVideos = getCountVideos(req) const query = pickCommonVideoQuery(req.query) const include = (query.include || VideoInclude.NONE) | VideoInclude.BLACKLISTED | VideoInclude.NOT_PUBLISHED_STATE - const apiOptions = await Hooks.wrapObject({ - privacyOneOf: getAllPrivacies(), + const apiOptions = await Hooks.wrapObject( + { + privacyOneOf: getAllPrivacies(), - ...query, + ...query, - // Display all - nsfw: null, + // Display all + nsfw: null, - user, - accountId: user.Account.id, - displayOnlyForFollower: null, + user, + accountId: user.Account.id, + displayOnlyForFollower: null, - videoChannelId: res.locals.videoChannel?.id, - channelNameOneOf: req.query.channelNameOneOf, + videoChannelId: res.locals.videoChannel?.id, + channelNameOneOf: req.query.channelNameOneOf, + includeCollaborations: req.query.includeCollaborations || false, - countVideos, + countVideos, - include - }, 'filter:api.user.me.videos.list.params') + include + } satisfies Parameters[0], + 'filter:api.user.me.videos.list.params' + ) const resultList = await Hooks.wrapPromiseFun( VideoModel.listForApi.bind(VideoModel), @@ -170,7 +174,7 @@ async function listUserVideos (req: express.Request, res: express.Response) { async function listCommentsOnUserVideos (req: express.Request, res: express.Response) { const userAccount = res.locals.oauth.token.User.Account - const options = { + const resultList = await VideoCommentModel.listCommentsForApi({ ...pick(req.query, [ 'start', 'count', @@ -182,14 +186,15 @@ async function listCommentsOnUserVideos (req: express.Request, res: express.Resp ]), autoTagOfAccountId: userAccount.id, + videoAccountOwnerId: userAccount.id, + videoAccountOwnerIncludeCollaborations: req.query.includeCollaborations || false, + heldForReview: req.query.isHeldForReview, videoChannelOwnerId: res.locals.videoChannel?.id, videoId: res.locals.videoAll?.id - } - - const resultList = await VideoCommentModel.listCommentsForApi(options) + }) return res.json({ total: resultList.total, @@ -197,7 +202,7 @@ async function listCommentsOnUserVideos (req: express.Request, res: express.Resp }) } -async function getUserVideoImports (req: express.Request, res: express.Response) { +async function listMyVideoImports (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.User const resultList = await VideoImportModel.listUserVideoImportsForApi({ userId: user.id, @@ -208,7 +213,7 @@ async function getUserVideoImports (req: express.Request, res: express.Response) return res.json(getFormattedObjects(resultList.data, resultList.total)) } -async function getUserInformation (req: express.Request, res: express.Response) { +async function getMyInformation (req: express.Request, res: express.Response) { // We did not load channels in res.locals.user const user = await UserModel.loadForMeAPI(res.locals.oauth.token.user.id) @@ -221,7 +226,7 @@ async function getUserInformation (req: express.Request, res: express.Response) return res.json(result) } -async function getUserVideoQuotaUsed (req: express.Request, res: express.Response) { +async function getMyVideoQuotaUsed (req: express.Request, res: express.Response) { const user = res.locals.oauth.token.user const videoQuotaUsed = await getOriginalVideoFileTotalFromUser(user) const videoQuotaUsedDaily = await getOriginalVideoFileTotalDailyFromUser(user) @@ -233,7 +238,7 @@ async function getUserVideoQuotaUsed (req: express.Request, res: express.Respons return res.json(data) } -async function getUserVideoRating (req: express.Request, res: express.Response) { +async function getMyVideoRating (req: express.Request, res: express.Response) { const videoId = res.locals.videoId.id const accountId = +res.locals.oauth.token.User.Account.id diff --git a/server/core/controllers/api/video-channel.ts b/server/core/controllers/api/video-channels/index.ts similarity index 88% rename from server/core/controllers/api/video-channel.ts rename to server/core/controllers/api/video-channels/index.ts index 979182087..b839fc63e 100644 --- a/server/core/controllers/api/video-channel.ts +++ b/server/core/controllers/api/video-channels/index.ts @@ -14,17 +14,17 @@ import { ActorFollowModel } from '@server/models/actor/actor-follow.js' import { getServerActor } from '@server/models/application/application.js' import { MChannelBannerAccountDefault } from '@server/types/models/index.js' import express from 'express' -import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../helpers/audit-logger.js' -import { resetSequelizeInstance } from '../../helpers/database-utils.js' -import { buildNSFWFilters, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils.js' -import { logger } from '../../helpers/logger.js' -import { getFormattedObjects } from '../../helpers/utils.js' -import { MIMETYPES } from '../../initializers/constants.js' -import { sequelizeTypescript } from '../../initializers/database.js' -import { sendUpdateActor } from '../../lib/activitypub/send/index.js' -import { JobQueue } from '../../lib/job-queue/index.js' -import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../lib/local-actor.js' -import { createLocalVideoChannelWithoutKeys, federateAllVideosOfChannel } from '../../lib/video-channel.js' +import { auditLoggerFactory, getAuditIdFromRes, VideoChannelAuditView } from '../../../helpers/audit-logger.js' +import { resetSequelizeInstance } from '../../../helpers/database-utils.js' +import { buildNSFWFilters, createReqFiles, getCountVideos, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils.js' +import { logger } from '../../../helpers/logger.js' +import { getFormattedObjects } from '../../../helpers/utils.js' +import { MIMETYPES } from '../../../initializers/constants.js' +import { sequelizeTypescript } from '../../../initializers/database.js' +import { sendUpdateActor } from '../../../lib/activitypub/send/index.js' +import { JobQueue } from '../../../lib/job-queue/index.js' +import { deleteLocalActorImageFile, updateLocalActorImageFiles } from '../../../lib/local-actor.js' +import { createLocalVideoChannelWithoutKeys, federateAllVideosOfChannel } from '../../../lib/video-channel.js' import { apiRateLimiter, asyncMiddleware, @@ -41,8 +41,8 @@ import { videoChannelsSortValidator, videoChannelsUpdateValidator, videoPlaylistsSortValidator -} from '../../middlewares/index.js' -import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image.js' +} from '../../../middlewares/index.js' +import { updateAvatarValidator, updateBannerValidator } from '../../../middlewares/validators/actor-image.js' import { ensureChannelOwnerCanUpload, videoChannelImportVideosValidator, @@ -50,16 +50,17 @@ import { videoChannelsHandleValidatorFactory, videoChannelsListValidator, videosSortValidator -} from '../../middlewares/validators/index.js' +} from '../../../middlewares/validators/index.js' import { commonVideoPlaylistFiltersValidator, videoPlaylistsReorderInChannelValidator -} from '../../middlewares/validators/videos/video-playlists.js' -import { AccountModel } from '../../models/account/account.js' -import { guessAdditionalAttributesFromQuery } from '../../models/video/formatter/index.js' -import { VideoChannelModel } from '../../models/video/video-channel.js' -import { VideoPlaylistModel } from '../../models/video/video-playlist.js' -import { VideoModel } from '../../models/video/video.js' +} from '../../../middlewares/validators/videos/video-playlists.js' +import { AccountModel } from '../../../models/account/account.js' +import { guessAdditionalAttributesFromQuery } from '../../../models/video/formatter/index.js' +import { VideoChannelModel } from '../../../models/video/video-channel.js' +import { VideoPlaylistModel } from '../../../models/video/video-playlist.js' +import { VideoModel } from '../../../models/video/video.js' +import { channelCollaborators } from './video-channel-collaborators.js' const auditLogger = auditLoggerFactory('channels') const reqAvatarFile = createReqFiles([ 'avatarfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT) @@ -68,6 +69,7 @@ const reqBannerFile = createReqFiles([ 'bannerfile' ], MIMETYPES.IMAGE.MIMETYPE_ const videoChannelRouter = express.Router() videoChannelRouter.use(apiRateLimiter) +videoChannelRouter.use(channelCollaborators) videoChannelRouter.get( '/', @@ -85,7 +87,7 @@ videoChannelRouter.post( '/:handle/avatar/pick', authenticate, reqAvatarFile, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), updateAvatarValidator, asyncMiddleware(updateVideoChannelAvatar) ) @@ -94,7 +96,7 @@ videoChannelRouter.post( '/:handle/banner/pick', authenticate, reqBannerFile, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), updateBannerValidator, asyncMiddleware(updateVideoChannelBanner) ) @@ -102,21 +104,21 @@ videoChannelRouter.post( videoChannelRouter.delete( '/:handle/avatar', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), asyncMiddleware(deleteVideoChannelAvatar) ) videoChannelRouter.delete( '/:handle/banner', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), asyncMiddleware(deleteVideoChannelBanner) ) videoChannelRouter.put( '/:handle', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), videoChannelsUpdateValidator, asyncRetryTransactionMiddleware(updateVideoChannel) ) @@ -124,14 +126,14 @@ videoChannelRouter.put( videoChannelRouter.delete( '/:handle', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: true })), asyncMiddleware(videoChannelsRemoveValidator), asyncRetryTransactionMiddleware(removeVideoChannel) ) videoChannelRouter.get( '/:handle', - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })), asyncMiddleware(getVideoChannel) ) @@ -140,7 +142,7 @@ videoChannelRouter.get( videoChannelRouter.get( '/:handle/video-playlists', optionalAuthenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })), paginationValidator, videoPlaylistsSortValidator, setDefaultSort, @@ -152,7 +154,7 @@ videoChannelRouter.get( videoChannelRouter.post( '/:handle/video-playlists/reorder', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), asyncMiddleware(videoPlaylistsReorderInChannelValidator), asyncRetryTransactionMiddleware(reorderPlaylistsInChannel) ) @@ -161,7 +163,7 @@ videoChannelRouter.post( videoChannelRouter.get( '/:handle/videos', - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: false, checkIsOwner: false })), paginationValidator, videosSortValidator, setDefaultVideosSort, @@ -174,7 +176,7 @@ videoChannelRouter.get( videoChannelRouter.get( '/:handle/followers', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: false, checkCanManage: true, checkIsOwner: false })), paginationValidator, videoChannelsFollowersSortValidator, setDefaultSort, @@ -185,7 +187,7 @@ videoChannelRouter.get( videoChannelRouter.post( '/:handle/import-videos', authenticate, - asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkManage: true })), + asyncMiddleware(videoChannelsHandleValidatorFactory({ checkIsLocal: true, checkCanManage: true, checkIsOwner: false })), asyncMiddleware(videoChannelImportVideosValidator), asyncMiddleware(ensureChannelOwnerCanUpload), asyncMiddleware(importVideosInChannel) diff --git a/server/core/controllers/api/video-channels/video-channel-collaborators.ts b/server/core/controllers/api/video-channels/video-channel-collaborators.ts new file mode 100644 index 000000000..37367097f --- /dev/null +++ b/server/core/controllers/api/video-channels/video-channel-collaborators.ts @@ -0,0 +1,113 @@ +import { HttpStatusCode, VideoChannelCollaboratorState } from '@peertube/peertube-models' +import { deleteInTransactionWithRetries, saveInTransactionWithRetries } from '@server/helpers/database-utils.js' +import { getFormattedObjects } from '@server/helpers/utils.js' +import { Notifier } from '@server/lib/notifier/notifier.js' +import { + channelAcceptOrRejectInviteCollaboratorsValidator, + channelDeleteCollaboratorsValidator, + channelInviteCollaboratorsValidator, + channelListCollaboratorsValidator +} from '@server/middlewares/validators/videos/video-channel-collaborators.js' +import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js' +import { MChannelCollaboratorAccount } from '@server/types/models/index.js' +import express from 'express' +import { asyncMiddleware, authenticate } from '../../../middlewares/index.js' + +const channelCollaborators = express.Router() + +channelCollaborators.get( + '/:handle/collaborators', + authenticate, + asyncMiddleware(channelListCollaboratorsValidator), + asyncMiddleware(listCollaborators) +) + +channelCollaborators.post( + '/:handle/collaborators/invite', + authenticate, + asyncMiddleware(channelInviteCollaboratorsValidator), + asyncMiddleware(inviteCollaborator) +) + +channelCollaborators.post( + '/:handle/collaborators/:collaboratorId/accept', + authenticate, + asyncMiddleware(channelAcceptOrRejectInviteCollaboratorsValidator), + asyncMiddleware(acceptCollaboratorInvite) +) + +channelCollaborators.post( + '/:handle/collaborators/:collaboratorId/reject', + authenticate, + asyncMiddleware(channelAcceptOrRejectInviteCollaboratorsValidator), + asyncMiddleware(rejectCollaboratorInvite) +) + +channelCollaborators.delete( + '/:handle/collaborators/:collaboratorId', + authenticate, + asyncMiddleware(channelDeleteCollaboratorsValidator), + asyncMiddleware(removeCollaborator) +) + +// --------------------------------------------------------------------------- + +export { + channelCollaborators +} + +// --------------------------------------------------------------------------- + +async function listCollaborators (req: express.Request, res: express.Response) { + const resultList = await VideoChannelCollaboratorModel.listForApi({ + channelId: res.locals.videoChannel.id, + start: 0, + count: 100, + sort: '-createdAt' + }) + + return res.json(getFormattedObjects(resultList.data, resultList.total)) +} + +async function inviteCollaborator (req: express.Request, res: express.Response) { + const collaborator = new VideoChannelCollaboratorModel({ + state: VideoChannelCollaboratorState.PENDING, + accountId: res.locals.account.id, + channelId: res.locals.videoChannel.id + }) as MChannelCollaboratorAccount + + await saveInTransactionWithRetries(collaborator) + collaborator.Account = res.locals.account + + Notifier.Instance.notifyOfChannelCollaboratorInvitation(collaborator, res.locals.videoChannel) + + return res.json({ collaborator: collaborator.toFormattedJSON() }) +} + +async function acceptCollaboratorInvite (req: express.Request, res: express.Response) { + const collaborator = res.locals.channelCollaborator + collaborator.state = VideoChannelCollaboratorState.ACCEPTED + + await saveInTransactionWithRetries(collaborator) + + Notifier.Instance.notifyOfAcceptedChannelCollaborator(collaborator, res.locals.videoChannel) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function rejectCollaboratorInvite (req: express.Request, res: express.Response) { + const collaborator = res.locals.channelCollaborator + await deleteInTransactionWithRetries(collaborator) + + Notifier.Instance.notifyOfRefusedChannelCollaborator(collaborator, res.locals.videoChannel) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} + +async function removeCollaborator (req: express.Request, res: express.Response) { + const collaborator = res.locals.channelCollaborator + + await deleteInTransactionWithRetries(collaborator) + + return res.sendStatus(HttpStatusCode.NO_CONTENT_204) +} diff --git a/server/core/controllers/api/video-playlist.ts b/server/core/controllers/api/video-playlist.ts index fc8a3176b..cbb6d027d 100644 --- a/server/core/controllers/api/video-playlist.ts +++ b/server/core/controllers/api/video-playlist.ts @@ -109,7 +109,7 @@ videoPlaylistRouter.get( paginationValidator, setDefaultPagination, optionalAuthenticate, - asyncMiddleware(getVideoPlaylistVideos) + asyncMiddleware(listVideosOfPlaylist) ) videoPlaylistRouter.post( @@ -517,7 +517,7 @@ async function reorderVideosOfPlaylist (req: express.Request, res: express.Respo return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end() } -async function getVideoPlaylistVideos (req: express.Request, res: express.Response) { +async function listVideosOfPlaylist (req: express.Request, res: express.Response) { const videoPlaylistInstance = res.locals.videoPlaylistSummary const user = res.locals.oauth ? res.locals.oauth.token.User : undefined const server = await getServerActor() diff --git a/server/core/controllers/api/videos/live.ts b/server/core/controllers/api/videos/live.ts index 04bd6101b..57859e50c 100644 --- a/server/core/controllers/api/videos/live.ts +++ b/server/core/controllers/api/videos/live.ts @@ -10,6 +10,7 @@ import { sequelizeTypescript } from '@server/initializers/database.js' import { federateVideoIfNeeded } from '@server/lib/activitypub/videos/index.js' import { LocalVideoCreator } from '@server/lib/local-video-creator.js' import { Hooks } from '@server/lib/plugins/hooks.js' +import { checkCanManageVideo } from '@server/middlewares/validators/shared/videos.js' import { videoLiveAddValidator, videoLiveFindReplaySessionValidator, @@ -44,21 +45,30 @@ liveRouter.get( '/live/:videoId/sessions', authenticate, asyncMiddleware(videoLiveGetValidator), - videoLiveListSessionsValidator, + asyncMiddleware(videoLiveListSessionsValidator), asyncMiddleware(getLiveVideoSessions) ) -liveRouter.get('/live/:videoId', optionalAuthenticate, asyncMiddleware(videoLiveGetValidator), getLiveVideo) +liveRouter.get( + '/live/:videoId', + optionalAuthenticate, + asyncMiddleware(videoLiveGetValidator), + asyncMiddleware(getLiveVideo) +) liveRouter.put( '/live/:videoId', authenticate, asyncMiddleware(videoLiveGetValidator), - videoLiveUpdateValidator, + asyncMiddleware(videoLiveUpdateValidator), asyncRetryTransactionMiddleware(updateLiveVideo) ) -liveRouter.get('/:videoId/live-session', asyncMiddleware(videoLiveFindReplaySessionValidator), getLiveReplaySession) +liveRouter.get( + '/:videoId/live-session', + asyncMiddleware(videoLiveFindReplaySessionValidator), + getLiveReplaySession +) // --------------------------------------------------------------------------- @@ -68,10 +78,10 @@ export { // --------------------------------------------------------------------------- -function getLiveVideo (req: express.Request, res: express.Response) { +async function getLiveVideo (req: express.Request, res: express.Response) { const videoLive = res.locals.videoLive - return res.json(videoLive.toFormattedJSON(canSeePrivateLiveInformation(res))) + return res.json(videoLive.toFormattedJSON(await canSeePrivateLiveInformation(req, res))) } function getLiveReplaySession (req: express.Request, res: express.Response) { @@ -88,14 +98,16 @@ async function getLiveVideoSessions (req: express.Request, res: express.Response return res.json(getFormattedObjects(data, data.length)) } -function canSeePrivateLiveInformation (res: express.Response) { - const user = res.locals.oauth?.token.User - if (!user) return false - - if (user.hasRight(UserRight.GET_ANY_LIVE)) return true - - const video = res.locals.videoAll - return video.VideoChannel.Account.userId === user.id +function canSeePrivateLiveInformation (req: express.Request, res: express.Response) { + return checkCanManageVideo({ + user: res.locals.oauth?.token.User, + video: res.locals.videoAll, + right: UserRight.GET_ANY_LIVE, + req, + res: null, + checkIsLocal: true, + checkIsOwner: false + }) } async function updateLiveVideo (req: express.Request, res: express.Response) { diff --git a/server/core/controllers/services.ts b/server/core/controllers/services.ts index 39593d083..c2a7b9a76 100644 --- a/server/core/controllers/services.ts +++ b/server/core/controllers/services.ts @@ -12,7 +12,7 @@ servicesRouter.use('/oembed', cors(), apiRateLimiter, asyncMiddleware(oembedVali servicesRouter.use( '/redirect/accounts/:handle', apiRateLimiter, - asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkManage: false })), + asyncMiddleware(accountHandleGetValidatorFactory({ checkIsLocal: false, checkCanManage: false })), redirectToAccountUrl ) diff --git a/server/core/helpers/custom-validators/video-ownership.ts b/server/core/helpers/custom-validators/video-ownership.ts index 5961ed97c..8a8ad2c9a 100644 --- a/server/core/helpers/custom-validators/video-ownership.ts +++ b/server/core/helpers/custom-validators/video-ownership.ts @@ -1,20 +1,25 @@ -import { Response } from 'express' -import { HttpStatusCode } from '@peertube/peertube-models' -import { MUserId } from '@server/types/models/index.js' +import { HttpStatusCode, UserRight } from '@peertube/peertube-models' +import { checkCanManageAccount } from '@server/middlewares/validators/shared/users.js' +import { MUserAccountId } from '@server/types/models/index.js' import { MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership.js' +import { Request, Response } from 'express' -function checkUserCanTerminateOwnershipChange (user: MUserId, videoChangeOwnership: MVideoChangeOwnershipFull, res: Response) { - if (videoChangeOwnership.NextOwner.userId === user.id) { - return true +export function checkCanTerminateOwnershipChange (options: { + user: MUserAccountId + videoChangeOwnership: MVideoChangeOwnershipFull + req: Request + res: Response +}) { + const { user, videoChangeOwnership, req, res } = options + + if (!checkCanManageAccount({ user, account: videoChangeOwnership.NextOwner, req, res: null, specialRight: UserRight.MANAGE_USERS })) { + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: req.t('Cannot terminate an ownership change of another user') + }) + + return false } - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot terminate an ownership change of another user' - }) - return false -} - -export { - checkUserCanTerminateOwnershipChange + return true } diff --git a/server/core/helpers/custom-validators/webfinger.ts b/server/core/helpers/custom-validators/webfinger.ts index c5f3544e9..10d0a0495 100644 --- a/server/core/helpers/custom-validators/webfinger.ts +++ b/server/core/helpers/custom-validators/webfinger.ts @@ -2,7 +2,7 @@ import { REMOTE_SCHEME, WEBSERVER } from '../../initializers/constants.js' import { sanitizeHost } from '../core-utils.js' import { exists } from './misc.js' -function isWebfingerLocalResourceValid (value: string) { +export function isWebfingerLocalResourceValid (value: string) { if (!exists(value)) return false if (value.startsWith('acct:') === false) return false @@ -13,9 +13,3 @@ function isWebfingerLocalResourceValid (value: string) { const host = actorParts[1] return sanitizeHost(host, REMOTE_SCHEME.HTTP) === WEBSERVER.HOST } - -// --------------------------------------------------------------------------- - -export { - isWebfingerLocalResourceValid -} diff --git a/server/core/helpers/database-utils.ts b/server/core/helpers/database-utils.ts index 94ab78e2f..28fd20246 100644 --- a/server/core/helpers/database-utils.ts +++ b/server/core/helpers/database-utils.ts @@ -5,7 +5,7 @@ import { Model } from 'sequelize-typescript' import { sequelizeTypescript } from '@server/initializers/database.js' import { logger } from './logger.js' -function retryTransactionWrapper ( +export function retryTransactionWrapper ( functionToRetry: (arg1: A, arg2: B, arg3: C, arg4: D) => Promise, arg1: A, arg2: B, @@ -13,29 +13,29 @@ function retryTransactionWrapper ( arg4: D ): Promise -function retryTransactionWrapper ( +export function retryTransactionWrapper ( functionToRetry: (arg1: A, arg2: B, arg3: C) => Promise, arg1: A, arg2: B, arg3: C ): Promise -function retryTransactionWrapper ( +export function retryTransactionWrapper ( functionToRetry: (arg1: A, arg2: B) => Promise, arg1: A, arg2: B ): Promise -function retryTransactionWrapper ( +export function retryTransactionWrapper ( functionToRetry: (arg1: A) => Promise, arg1: A ): Promise -function retryTransactionWrapper ( +export function retryTransactionWrapper ( functionToRetry: () => Promise | Bluebird ): Promise -function retryTransactionWrapper ( +export function retryTransactionWrapper ( functionToRetry: (...args: any[]) => Promise, ...args: any[] ): Promise { @@ -50,7 +50,7 @@ function retryTransactionWrapper ( }) } -function transactionRetryer (func: (err: any, data: T) => any) { +export function transactionRetryer (func: (err: any, data: T) => any) { return new Promise((res, rej) => { retry( { @@ -68,7 +68,7 @@ function transactionRetryer (func: (err: any, data: T) => any) { }) } -function saveInTransactionWithRetries> (model: T) { +export function saveInTransactionWithRetries> (model: T) { const changedKeys = model.changed() || [] return retryTransactionWrapper(() => { @@ -87,46 +87,41 @@ function saveInTransactionWithRetries> }) } +export function deleteInTransactionWithRetries> (model: T) { + return retryTransactionWrapper(() => { + return sequelizeTypescript.transaction(async transaction => { + await model.destroy({ transaction }) + }) + }) +} + // --------------------------------------------------------------------------- -function resetSequelizeInstance (instance: Model) { +export function resetSequelizeInstance (instance: Model) { return instance.reload() } -function filterNonExistingModels ( +export function filterNonExistingModels ( fromDatabase: T[], newModels: T[] ) { return fromDatabase.filter(f => !newModels.find(newModel => newModel.hasSameUniqueKeysThan(f))) } -function deleteAllModels> (models: T[], transaction: Transaction) { +export function deleteAllModels> (models: T[], transaction: Transaction) { return Promise.all(models.map(f => f.destroy({ transaction }))) } // --------------------------------------------------------------------------- -function runInReadCommittedTransaction (fn: (t: Transaction) => Promise) { +export function runInReadCommittedTransaction (fn: (t: Transaction) => Promise) { const options = { isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED } return sequelizeTypescript.transaction(options, t => fn(t)) } -function afterCommitIfTransaction (t: Transaction, fn: Function) { +export function afterCommitIfTransaction (t: Transaction, fn: Function) { if (t) return t.afterCommit(() => fn()) return fn() } - -// --------------------------------------------------------------------------- - -export { - resetSequelizeInstance, - retryTransactionWrapper, - transactionRetryer, - saveInTransactionWithRetries, - afterCommitIfTransaction, - filterNonExistingModels, - deleteAllModels, - runInReadCommittedTransaction -} diff --git a/server/core/helpers/geo-ip.ts b/server/core/helpers/geo-ip.ts index c6c7cbf54..d9833dbcb 100644 --- a/server/core/helpers/geo-ip.ts +++ b/server/core/helpers/geo-ip.ts @@ -30,7 +30,10 @@ export class GeoIP { if (CONFIG.GEO_IP.ENABLED === false) return emptyResult try { - if (!this.initReadersPromise) this.initReadersPromise = this.initReadersIfNeeded() + if (this.initReadersPromise === undefined) { + this.initReadersPromise = this.initReadersIfNeeded() + } + await this.initReadersPromise this.initReadersPromise = undefined diff --git a/server/core/helpers/mentions.ts b/server/core/helpers/mentions.ts index 238e468ea..829cf276f 100644 --- a/server/core/helpers/mentions.ts +++ b/server/core/helpers/mentions.ts @@ -3,13 +3,13 @@ import { WEBSERVER } from '@server/initializers/constants.js' import { actorNameAlphabet } from './custom-validators/activitypub/actor.js' import { regexpCapture } from './regexp.js' -export function extractMentions (text: string, isOwned: boolean) { +export function extractMentions (text: string, isLocal: boolean) { let result: string[] = [] const localMention = `@(${actorNameAlphabet}+)` const remoteMention = `${localMention}@${WEBSERVER.HOST}` - const mentionRegex = isOwned + const mentionRegex = isLocal ? '(?:(?:' + remoteMention + ')|(?:' + localMention + '))' // Include local mentions? : '(?:' + remoteMention + ')' @@ -20,16 +20,14 @@ export function extractMentions (text: string, isOwned: boolean) { result = result.concat( regexpCapture(text, firstMentionRegex) .map(([ , username1, username2 ]) => username1 || username2), - regexpCapture(text, endMentionRegex) .map(([ , username1, username2 ]) => username1 || username2), - regexpCapture(text, remoteMentionsRegex) .map(([ , username ]) => username) ) // Include local mentions - if (isOwned) { + if (isLocal) { const localMentionsRegex = new RegExp(' ' + localMention + ' ', 'g') result = result.concat( diff --git a/server/core/initializers/constants.ts b/server/core/initializers/constants.ts index faf76160e..5161a6af4 100644 --- a/server/core/initializers/constants.ts +++ b/server/core/initializers/constants.ts @@ -20,6 +20,8 @@ import { UserImportStateType, UserRegistrationState, UserRegistrationStateType, + VideoChannelCollaboratorState, + VideoChannelCollaboratorStateType, VideoChannelSyncState, VideoChannelSyncStateType, VideoCommentPolicy, @@ -690,6 +692,11 @@ export const VIDEO_COMMENTS_POLICY: { [id in VideoCommentPolicyType]: string } = [VideoCommentPolicy.REQUIRES_APPROVAL]: 'Requires approval' } +export const CHANNEL_COLLABORATOR_STATE: { [id in VideoChannelCollaboratorStateType]: string } = { + [VideoChannelCollaboratorState.ACCEPTED]: 'Accepted', + [VideoChannelCollaboratorState.PENDING]: 'Pending' +} + export const MIMETYPES = { AUDIO: { MIMETYPE_EXT: { diff --git a/server/core/initializers/database.ts b/server/core/initializers/database.ts index 8f1340097..3e2323dae 100644 --- a/server/core/initializers/database.ts +++ b/server/core/initializers/database.ts @@ -16,7 +16,9 @@ import { UserNotificationModel } from '@server/models/user/user-notification.js' import { UserRegistrationModel } from '@server/models/user/user-registration.js' import { UserVideoHistoryModel } from '@server/models/user/user-video-history.js' import { UserModel } from '@server/models/user/user.js' +import { PlayerSettingModel } from '@server/models/video/player-setting.js' import { StoryboardModel } from '@server/models/video/storyboard.js' +import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js' import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js' import { VideoChapterModel } from '@server/models/video/video-chapter.js' import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' @@ -69,7 +71,6 @@ import { VideoTagModel } from '../models/video/video-tag.js' import { VideoModel } from '../models/video/video.js' import { VideoViewModel } from '../models/view/video-view.js' import { CONFIG } from './config.js' -import { PlayerSettingModel } from '@server/models/video/player-setting.js' pg.defaults.parseInt8 = true // Avoid BIGINT to be converted to string @@ -191,7 +192,8 @@ export async function initDatabaseModels (silent: boolean) { AccountAutomaticTagPolicyModel, UploadImageModel, VideoLiveScheduleModel, - PlayerSettingModel + PlayerSettingModel, + VideoChannelCollaboratorModel ]) // Check extensions exist in the database diff --git a/server/core/lib/activitypub/playlists/create-update.ts b/server/core/lib/activitypub/playlists/create-update.ts index 746853f75..980b75fc2 100644 --- a/server/core/lib/activitypub/playlists/create-update.ts +++ b/server/core/lib/activitypub/playlists/create-update.ts @@ -9,13 +9,7 @@ import { updateRemotePlaylistMiniatureFromUrl } from '@server/lib/thumbnail.js' import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element.js' import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' import { FilteredModelAttributes } from '@server/types/index.js' -import { - MAccountHost, - MThumbnail, - MVideoPlaylist, - MVideoPlaylistFull, - MVideoPlaylistVideosLength -} from '@server/types/models/index.js' +import { MAccountHost, MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models/index.js' import Bluebird from 'bluebird' import { getAPId } from '../activity.js' import { getOrCreateAPActor } from '../actors/index.js' @@ -33,6 +27,11 @@ import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activit const lTags = loggerTagsFactory('ap', 'video-playlist') export async function createAccountPlaylists (playlistUrls: string[], account: MAccountHost) { + logger.info( + `Creating or updating ${playlistUrls.length} playlists for account ${account.Actor.preferredUsername}`, + lTags() + ) + await Bluebird.map(playlistUrls, async playlistUrl => { if (!checkUrlsSameHost(playlistUrl, account.Actor.url)) { logger.warn(`Playlist ${playlistUrl} is not on the same host as owner account ${account.Actor.url}`, lTags(playlistUrl)) @@ -69,6 +68,8 @@ export async function createOrUpdateVideoPlaylist (options: { throw new Error(`Playlist ${playlistObject.id} is not on the same host as context URL ${contextUrl}`) } + logger.debug(`Creating or updating playlist ${playlistObject.id}`, lTags(playlistObject.id)) + const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to) const channel = await getRemotePlaylistChannel(playlistObject) diff --git a/server/core/lib/activitypub/process/process-announce.ts b/server/core/lib/activitypub/process/process-announce.ts index 8500efd4d..a99201be5 100644 --- a/server/core/lib/activitypub/process/process-announce.ts +++ b/server/core/lib/activitypub/process/process-announce.ts @@ -51,7 +51,7 @@ async function processVideoShare (actorAnnouncer: MActorSignature, activity: Act transaction: t }) - if (video.isOwned() && created === true) { + if (video.isLocal() && created === true) { // Don't resend the activity to the sender const exceptions = [ actorAnnouncer ] diff --git a/server/core/lib/activitypub/process/process-create.ts b/server/core/lib/activitypub/process/process-create.ts index dba187f6c..870c32e46 100644 --- a/server/core/lib/activitypub/process/process-create.ts +++ b/server/core/lib/activitypub/process/process-create.ts @@ -91,7 +91,7 @@ async function processCreateCacheFile ( const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object }) - if (video.isOwned() && !canVideoBeFederated(video)) { + if (video.isLocal() && !canVideoBeFederated(video)) { logger.warn(`Do not process create cache file ${cacheFile.object} on a video that cannot be federated`) return } @@ -100,7 +100,7 @@ async function processCreateCacheFile ( return createOrUpdateCacheFile(cacheFile, video, byActor, t) }) - if (video.isOwned()) { + if (video.isLocal()) { // Don't resend the activity to the sender const exceptions = [ byActor ] await forwardVideoRelatedActivity(activity, undefined, exceptions, video) @@ -152,7 +152,7 @@ async function processCreateVideoComment ( } // Try to not forward unwanted comments on our videos - if (video.isOwned()) { + if (video.isLocal()) { if (!canVideoBeFederated(video)) { logger.info('Skip comment forward on non federated video' + video.url) return diff --git a/server/core/lib/activitypub/process/process-delete.ts b/server/core/lib/activitypub/process/process-delete.ts index e38e64513..c686a0f35 100644 --- a/server/core/lib/activitypub/process/process-delete.ts +++ b/server/core/lib/activitypub/process/process-delete.ts @@ -55,7 +55,7 @@ async function processDeleteActivity (options: APProcessorOptions { if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) @@ -92,7 +92,7 @@ async function processUndoDislike (byActor: MActorSignature, activity: ActivityU const dislikeActivity = activity.object const { video: onlyVideo } = await maybeGetOrCreateAPVideo({ videoObject: dislikeActivity.object }) - if (!onlyVideo?.isOwned()) return + if (!onlyVideo?.isLocal()) return return sequelizeTypescript.transaction(async t => { if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) @@ -132,7 +132,7 @@ async function processUndoCacheFile ( await cacheFile.destroy({ transaction: t }) - if (video.isOwned()) { + if (video.isLocal()) { // Don't resend the activity to the sender const exceptions = [ byActor ] @@ -153,7 +153,7 @@ function processUndoAnnounce (byActor: MActorSignature, announceActivity: Activi await share.destroy({ transaction: t }) - if (share.Video.isOwned()) { + if (share.Video.isLocal()) { // Don't resend the activity to the sender const exceptions = [ byActor ] diff --git a/server/core/lib/activitypub/process/process-update.ts b/server/core/lib/activitypub/process/process-update.ts index 857d47717..7f3c4aa1f 100644 --- a/server/core/lib/activitypub/process/process-update.ts +++ b/server/core/lib/activitypub/process/process-update.ts @@ -103,7 +103,7 @@ async function processUpdateCacheFile ( const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object }) - if (video.isOwned() && !canVideoBeFederated(video)) { + if (video.isLocal() && !canVideoBeFederated(video)) { logger.warn(`Do not process update cache file on video ${activity.object} that cannot be federated`) return } @@ -112,7 +112,7 @@ async function processUpdateCacheFile ( await createOrUpdateCacheFile(cacheFileObject, video, byActor, t) }) - if (video.isOwned()) { + if (video.isLocal()) { // Don't resend the activity to the sender const exceptions = [ byActor ] diff --git a/server/core/lib/activitypub/process/process-view.ts b/server/core/lib/activitypub/process/process-view.ts index 12e86d993..72a3fa8c6 100644 --- a/server/core/lib/activitypub/process/process-view.ts +++ b/server/core/lib/activitypub/process/process-view.ts @@ -38,7 +38,7 @@ async function processCreateView (activity: ActivityView, byActor: MActorSignatu viewerResultCounter: getViewerResultCounter(activity) }) - if (video.isOwned()) { + if (video.isLocal()) { // Forward the view but don't resend the activity to the sender const exceptions = [ byActor ] await forwardVideoRelatedActivity(activity, undefined, exceptions, video) diff --git a/server/core/lib/activitypub/send/send-create.ts b/server/core/lib/activitypub/send/send-create.ts index 381d20336..52655e8f6 100644 --- a/server/core/lib/activitypub/send/send-create.ts +++ b/server/core/lib/activitypub/send/send-create.ts @@ -110,7 +110,7 @@ export async function sendCreateVideoPlaylist (playlist: MVideoPlaylistFull, tra } export async function sendCreateVideoCommentIfNeeded (comment: MCommentOwnerVideoReply, transaction: Transaction) { - const isOrigin = comment.Video.isOwned() + const isOrigin = comment.Video.isLocal() if (isOrigin) { const videoWithBlacklist = await VideoModel.loadWithBlacklist(comment.Video.id) diff --git a/server/core/lib/activitypub/send/send-delete.ts b/server/core/lib/activitypub/send/send-delete.ts index 4c810bafe..8f05e1af6 100644 --- a/server/core/lib/activitypub/send/send-delete.ts +++ b/server/core/lib/activitypub/send/send-delete.ts @@ -53,13 +53,13 @@ async function sendDeleteActor (byActor: ActorModel, transaction: Transaction) { async function sendDeleteVideoComment (videoComment: MCommentOwnerVideo, transaction: Transaction) { logger.info('Creating job to send delete of comment %s.', videoComment.url) - const isVideoOrigin = videoComment.Video.isOwned() + const isVideoOrigin = videoComment.Video.isLocal() const url = getDeleteActivityPubUrl(videoComment.url) const videoAccount = await AccountModel.load(videoComment.Video.VideoChannel.Account.id, transaction) - const byActor = videoComment.isOwned() + const byActor = videoComment.isLocal() ? videoComment.Account.Actor : videoAccount.Actor diff --git a/server/core/lib/activitypub/send/shared/send-utils.ts b/server/core/lib/activitypub/send/shared/send-utils.ts index 1ad9f3fa1..ba17a528f 100644 --- a/server/core/lib/activitypub/send/shared/send-utils.ts +++ b/server/core/lib/activitypub/send/shared/send-utils.ts @@ -29,7 +29,7 @@ async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAud const { byActor, video, transaction, contextType, parallelizable } = options // Send to origin - if (video.isOwned() === false) { + if (video.isLocal() === false) { return sendVideoActivityToOrigin(activityBuilder, options) } @@ -62,7 +62,7 @@ async function sendVideoActivityToOrigin (activityBuilder: (audience: ActivityAu }) { const { byActor, video, actorsInvolvedInVideo, transaction, contextType } = options - if (video.isOwned()) throw new Error('Cannot send activity to owned video origin ' + video.url) + if (video.isLocal()) throw new Error('Cannot send activity to owned video origin ' + video.url) let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction) diff --git a/server/core/lib/activitypub/video-comments.ts b/server/core/lib/activitypub/video-comments.ts index 22c8be1c3..adf28637e 100644 --- a/server/core/lib/activitypub/video-comments.ts +++ b/server/core/lib/activitypub/video-comments.ts @@ -109,7 +109,7 @@ async function tryToResolveThreadFromVideo (params: ResolveThreadParams) { const syncParam = { rates: true, shares: true, comments: false, refreshVideo: false } const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam }) - if (video.isOwned() && !canVideoBeFederated(video)) { + if (video.isLocal() && !canVideoBeFederated(video)) { throw new Error('Cannot resolve thread of video that is not compatible with federation') } @@ -169,7 +169,7 @@ async function getAutomaticTagsAndAssignReview (comment: MComment, video: MVideo const automaticTags = await new AutomaticTagger().buildCommentsAutomaticTags({ ownerAccount, text: comment.text }) // Third parties rely on origin, so if origin has the comment it's not held for review - if (video.isOwned() || comment.isOwned()) { + if (video.isLocal() || comment.isLocal()) { comment.heldForReview = await shouldCommentBeHeldForReview({ user: null, video, automaticTags }) } else { comment.heldForReview = false diff --git a/server/core/lib/activitypub/video-rates.ts b/server/core/lib/activitypub/video-rates.ts index 1c0d5ec14..1e3a99c5c 100644 --- a/server/core/lib/activitypub/video-rates.ts +++ b/server/core/lib/activitypub/video-rates.ts @@ -13,7 +13,7 @@ async function sendVideoRateChange ( dislikes: number, t: Transaction ) { - if (video.isOwned()) return federateVideoIfNeeded(video, false, t) + if (video.isLocal()) return federateVideoIfNeeded(video, false, t) return sendVideoRateChangeToOrigin(account, video, likes, dislikes, t) } @@ -41,7 +41,7 @@ async function sendVideoRateChangeToOrigin ( t: Transaction ) { // Local video, we don't need to send like - if (video.isOwned()) return + if (video.isLocal()) return const actor = account.Actor diff --git a/server/core/lib/emailer.ts b/server/core/lib/emailer.ts index 1496469e4..6bdebcfa5 100644 --- a/server/core/lib/emailer.ts +++ b/server/core/lib/emailer.ts @@ -490,7 +490,7 @@ export class Emailer { } private initHandlebarsIfNeeded () { - if (this.registeringHandlebars) return this.registeringHandlebars + if (this.registeringHandlebars !== undefined) return this.registeringHandlebars this.registeringHandlebars = this._initHandlebarsIfNeeded() diff --git a/server/core/lib/files-cache/shared/abstract-permanent-file-cache.ts b/server/core/lib/files-cache/shared/abstract-permanent-file-cache.ts index f7df6e560..0aa4f6d0f 100644 --- a/server/core/lib/files-cache/shared/abstract-permanent-file-cache.ts +++ b/server/core/lib/files-cache/shared/abstract-permanent-file-cache.ts @@ -12,13 +12,13 @@ type ImageModel = { filename: string onDisk: boolean - isOwned (): boolean - getPath (): string + isLocal(): boolean + getPath(): string - save (): Promise + save(): Promise } -export abstract class AbstractPermanentFileCache { +export abstract class AbstractPermanentFileCache { // Unsafe because it can return paths that do not exist anymore private readonly filenameToPathUnsafeCache = new LRUCache({ max: LRU_CACHE.FILENAME_TO_PATH_PERMANENT_FILE_CACHE.MAX_SIZE @@ -28,7 +28,6 @@ export abstract class AbstractPermanentFileCache { protected abstract loadModel (filename: string): Promise constructor (private readonly directory: string) { - } async lazyServe (options: { @@ -102,7 +101,7 @@ export abstract class AbstractPermanentFileCache { const { err, image, filename, next } = options // It seems this actor image is not on the disk anymore - if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) { + if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isLocal()) { logger.error('Cannot lazy serve image %s.', filename, { err }) this.filenameToPathUnsafeCache.delete(filename) diff --git a/server/core/lib/files-cache/shared/abstract-simple-file-cache.ts b/server/core/lib/files-cache/shared/abstract-simple-file-cache.ts index 6e7d6867b..2febaa80a 100644 --- a/server/core/lib/files-cache/shared/abstract-simple-file-cache.ts +++ b/server/core/lib/files-cache/shared/abstract-simple-file-cache.ts @@ -2,10 +2,9 @@ import { remove } from 'fs-extra/esm' import { logger } from '../../../helpers/logger.js' import memoizee from 'memoizee' -type GetFilePathResult = { isOwned: boolean, path: string, downloadName?: string } | undefined - -export abstract class AbstractSimpleFileCache { +type GetFilePathResult = { isLocal: boolean, path: string, downloadName?: string } | undefined +export abstract class AbstractSimpleFileCache { getFilePath: (params: T) => Promise abstract getFilePathImpl (params: T): Promise @@ -19,7 +18,7 @@ export abstract class AbstractSimpleFileCache { max, promise: true, dispose: (result?: GetFilePathResult) => { - if (result && result.isOwned !== true) { + if (result && result.isLocal !== true) { remove(result.path) .then(() => logger.debug('%s removed from %s', result.path, this.constructor.name)) .catch(err => logger.error('Cannot remove %s from cache %s.', result.path, this.constructor.name, { err })) diff --git a/server/core/lib/files-cache/video-captions-simple-file-cache.ts b/server/core/lib/files-cache/video-captions-simple-file-cache.ts index 9f68dc511..6e0e7038f 100644 --- a/server/core/lib/files-cache/video-captions-simple-file-cache.ts +++ b/server/core/lib/files-cache/video-captions-simple-file-cache.ts @@ -21,8 +21,8 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache { const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(filename) if (!videoCaption) return undefined - if (videoCaption.isOwned()) { - return { isOwned: true, path: videoCaption.getFSFilePath() } + if (videoCaption.isLocal()) { + return { isLocal: true, path: videoCaption.getFSFilePath() } } return this.loadRemoteFile(filename) @@ -33,7 +33,7 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache { const videoCaption = await VideoCaptionModel.loadWithVideoByFilename(key) if (!videoCaption) return undefined - if (videoCaption.isOwned()) throw new Error('Cannot load remote caption of owned video.') + if (videoCaption.isLocal()) throw new Error('Cannot load remote caption of owned video.') // Used to fetch the path const video = await VideoModel.loadFull(videoCaption.videoId) @@ -45,7 +45,7 @@ class VideoCaptionsSimpleFileCache extends AbstractSimpleFileCache { try { await doRequestAndSaveToFile(remoteUrl, destPath) - return { isOwned: false, path: destPath } + return { isLocal: false, path: destPath } } catch (err) { logger.info('Cannot fetch remote caption file %s.', remoteUrl, { err }) diff --git a/server/core/lib/files-cache/video-previews-simple-file-cache.ts b/server/core/lib/files-cache/video-previews-simple-file-cache.ts index 4dd0fd148..262882f31 100644 --- a/server/core/lib/files-cache/video-previews-simple-file-cache.ts +++ b/server/core/lib/files-cache/video-previews-simple-file-cache.ts @@ -1,14 +1,13 @@ +import { ThumbnailType } from '@peertube/peertube-models' +import { logger } from '@server/helpers/logger.js' +import { doRequestAndSaveToFile } from '@server/helpers/requests.js' +import { ThumbnailModel } from '@server/models/video/thumbnail.js' import { join } from 'path' import { FILES_CACHE } from '../../initializers/constants.js' import { VideoModel } from '../../models/video/video.js' import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js' -import { doRequestAndSaveToFile } from '@server/helpers/requests.js' -import { ThumbnailModel } from '@server/models/video/thumbnail.js' -import { ThumbnailType } from '@peertube/peertube-models' -import { logger } from '@server/helpers/logger.js' - -class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache { +class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache { private static instance: VideoPreviewsSimpleFileCache private constructor () { @@ -23,7 +22,7 @@ class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache { const thumbnail = await ThumbnailModel.loadWithVideoByFilename(filename, ThumbnailType.PREVIEW) if (!thumbnail) return undefined - if (thumbnail.Video.isOwned()) return { isOwned: true, path: thumbnail.getPath() } + if (thumbnail.Video.isLocal()) return { isLocal: true, path: thumbnail.getPath() } return this.loadRemoteFile(thumbnail.Video.uuid) } @@ -33,7 +32,7 @@ class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache { const video = await VideoModel.loadFull(key) if (!video) return undefined - if (video.isOwned()) throw new Error('Cannot load remote preview of owned video.') + if (video.isLocal()) throw new Error('Cannot load remote preview of owned video.') const preview = video.getPreview() const destPath = join(FILES_CACHE.PREVIEWS.DIRECTORY, preview.filename) @@ -44,7 +43,7 @@ class VideoPreviewsSimpleFileCache extends AbstractSimpleFileCache { logger.debug('Fetched remote preview %s to %s.', remoteUrl, destPath) - return { isOwned: false, path: destPath } + return { isLocal: false, path: destPath } } catch (err) { logger.info('Cannot fetch remote preview file %s.', remoteUrl, { err }) diff --git a/server/core/lib/files-cache/video-storyboards-simple-file-cache.ts b/server/core/lib/files-cache/video-storyboards-simple-file-cache.ts index 7991b8d59..b0389ce63 100644 --- a/server/core/lib/files-cache/video-storyboards-simple-file-cache.ts +++ b/server/core/lib/files-cache/video-storyboards-simple-file-cache.ts @@ -1,12 +1,11 @@ -import { join } from 'path' import { logger } from '@server/helpers/logger.js' import { doRequestAndSaveToFile } from '@server/helpers/requests.js' import { StoryboardModel } from '@server/models/video/storyboard.js' +import { join } from 'path' import { FILES_CACHE } from '../../initializers/constants.js' import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js' -class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache { - +class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache { private static instance: VideoStoryboardsSimpleFileCache private constructor () { @@ -21,7 +20,7 @@ class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache { const storyboard = await StoryboardModel.loadWithVideoByFilename(filename) if (!storyboard) return undefined - if (storyboard.Video.isOwned()) return { isOwned: true, path: storyboard.getPath() } + if (storyboard.Video.isLocal()) return { isLocal: true, path: storyboard.getPath() } return this.loadRemoteFile(storyboard.filename) } @@ -39,7 +38,7 @@ class VideoStoryboardsSimpleFileCache extends AbstractSimpleFileCache { logger.debug('Fetched remote storyboard %s to %s.', remoteUrl, destPath) - return { isOwned: false, path: destPath } + return { isLocal: false, path: destPath } } catch (err) { logger.info('Cannot fetch remote storyboard file %s.', remoteUrl, { err }) diff --git a/server/core/lib/files-cache/video-torrents-simple-file-cache.ts b/server/core/lib/files-cache/video-torrents-simple-file-cache.ts index 5a1ef3467..3e09e1764 100644 --- a/server/core/lib/files-cache/video-torrents-simple-file-cache.ts +++ b/server/core/lib/files-cache/video-torrents-simple-file-cache.ts @@ -8,8 +8,7 @@ import { FILES_CACHE } from '../../initializers/constants.js' import { VideoModel } from '../../models/video/video.js' import { AbstractSimpleFileCache } from './shared/abstract-simple-file-cache.js' -class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache { - +class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache { private static instance: VideoTorrentsSimpleFileCache private constructor () { @@ -24,10 +23,10 @@ class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache { const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(filename) if (!file) return undefined - if (file.getVideo().isOwned()) { + if (file.getVideo().isLocal()) { const downloadName = this.buildDownloadName(file.getVideo(), file) - return { isOwned: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName } + return { isLocal: true, path: join(CONFIG.STORAGE.TORRENTS_DIR, file.torrentFilename), downloadName } } return this.loadRemoteFile(filename) @@ -38,7 +37,7 @@ class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache { const file = await VideoFileModel.loadWithVideoOrPlaylistByTorrentFilename(key) if (!file) return undefined - if (file.getVideo().isOwned()) throw new Error('Cannot load remote file of owned video.') + if (file.getVideo().isLocal()) throw new Error('Cannot load remote file of owned video.') // Used to fetch the path const video = await VideoModel.loadFull(file.getVideo().id) @@ -52,7 +51,7 @@ class VideoTorrentsSimpleFileCache extends AbstractSimpleFileCache { const downloadName = this.buildDownloadName(video, file) - return { isOwned: false, path: destPath, downloadName } + return { isLocal: false, path: destPath, downloadName } } catch (err) { logger.info('Cannot fetch remote torrent file %s.', remoteUrl, { err }) diff --git a/server/core/lib/html/shared/actor-html.ts b/server/core/lib/html/shared/actor-html.ts index 27a14d33f..24e548adc 100644 --- a/server/core/lib/html/shared/actor-html.ts +++ b/server/core/lib/html/shared/actor-html.ts @@ -108,7 +108,7 @@ export class ActorHtml { updatedAt: entity.updatedAt }, - forbidIndexation: !entity.Actor.isOwned(), + forbidIndexation: !entity.Actor.isLocal(), embedIndexation: false, rssFeeds: getRSSFeeds(entity) diff --git a/server/core/lib/html/shared/playlist-html.ts b/server/core/lib/html/shared/playlist-html.ts index 5adf72304..35698c822 100644 --- a/server/core/lib/html/shared/playlist-html.ts +++ b/server/core/lib/html/shared/playlist-html.ts @@ -113,7 +113,7 @@ export class PlaylistHtml { forbidIndexation: isEmbed ? playlist.privacy !== VideoPlaylistPrivacy.PUBLIC && playlist.privacy !== VideoPlaylistPrivacy.UNLISTED - : !playlist.isOwned() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC, + : !playlist.isLocal() || playlist.privacy !== VideoPlaylistPrivacy.PUBLIC, embedIndexation: isEmbed, diff --git a/server/core/lib/live/shared/muxing-session.ts b/server/core/lib/live/shared/muxing-session.ts index fcda0e93d..4513cf572 100644 --- a/server/core/lib/live/shared/muxing-session.ts +++ b/server/core/lib/live/shared/muxing-session.ts @@ -438,7 +438,10 @@ class MuxingSession extends EventEmitter implements MuxingSession { setTimeout(() => { // Wait latest segments generation, and close watchers - const promise = this.filesWatcher?.close() || Promise.resolve() + const promise = this.filesWatcher + ? this.filesWatcher.close() + : Promise.resolve() + promise .then(() => { // Process remaining segments hash diff --git a/server/core/lib/moderation.ts b/server/core/lib/moderation.ts index 0a5069f7b..861aebfa0 100644 --- a/server/core/lib/moderation.ts +++ b/server/core/lib/moderation.ts @@ -129,7 +129,7 @@ async function createVideoAbuse (options: { videoAbuseInstance.Video = videoInstance abuseInstance.VideoAbuse = videoAbuseInstance - return { isOwned: videoInstance.isOwned() } + return { isLocal: videoInstance.isLocal() } } return createAbuse({ @@ -160,7 +160,7 @@ function createVideoCommentAbuse (options: { commentAbuseInstance.VideoComment = commentInstance abuseInstance.VideoCommentAbuse = commentAbuseInstance - return { isOwned: commentInstance.isOwned() } + return { isLocal: commentInstance.isLocal() } } return createAbuse({ @@ -183,7 +183,7 @@ function createAccountAbuse (options: { const { baseAbuse, accountInstance, transaction, reporterAccount, skipNotification } = options const associateFun = () => { - return Promise.resolve({ isOwned: accountInstance.isOwned() }) + return Promise.resolve({ isLocal: accountInstance.isLocal() }) } return createAbuse({ @@ -217,7 +217,7 @@ async function createAbuse (options: { base: FilteredModelAttributes reporterAccount: MAccountDefault flaggedAccount: MAccountLight - associateFun: (abuseInstance: MAbuseFull) => Promise<{ isOwned: boolean }> + associateFun: (abuseInstance: MAbuseFull) => Promise<{ isLocal: boolean }> skipNotification: boolean transaction: Transaction }) { @@ -230,9 +230,9 @@ async function createAbuse (options: { abuseInstance.ReporterAccount = reporterAccount abuseInstance.FlaggedAccount = flaggedAccount - const { isOwned } = await associateFun(abuseInstance) + const { isLocal } = await associateFun(abuseInstance) - if (isOwned === false) { + if (isLocal === false) { sendAbuse(reporterAccount.Actor, abuseInstance, abuseInstance.FlaggedAccount, transaction) } diff --git a/server/core/lib/notifier/notifier.ts b/server/core/lib/notifier/notifier.ts index a2cf1f396..e03152130 100644 --- a/server/core/lib/notifier/notifier.ts +++ b/server/core/lib/notifier/notifier.ts @@ -8,6 +8,9 @@ import { MAbuseMessage, MActorFollowFull, MApplication, + MChannelAccountDefault, + MChannelCollaboratorAccount, + MChannelDefault, MCommentOwnerVideo, MPlugin, MVideoAccountLight, @@ -17,6 +20,9 @@ import { import { JobQueue } from '../job-queue/index.js' import { PeerTubeSocket } from '../peertube-socket.js' import { Hooks } from '../plugins/hooks.js' +import { AcceptedToCollaborateToChannel } from './shared/channel/accepted-to-collaborate-to-channel.js' +import { InvitedToCollaborateToChannel } from './shared/channel/invited-to-collaborate-to-channel.js' +import { RefusedToCollaborateToChannel } from './shared/channel/refused-to-collaborate-to-channel.js' import { AbstractNotification, AbuseStateChangeForReporter, @@ -71,7 +77,10 @@ class Notifier { newPeertubeVersion: [ NewPeerTubeVersionForAdmins ], newPluginVersion: [ NewPluginVersionForAdmins ], videoStudioEditionFinished: [ StudioEditionFinishedForOwner ], - videoTranscriptionGenerated: [ VideoTranscriptionGeneratedForOwner ] + videoTranscriptionGenerated: [ VideoTranscriptionGeneratedForOwner ], + channelCollaboratorInvitation: [ InvitedToCollaborateToChannel ], + channelCollaborationAccepted: [ AcceptedToCollaborateToChannel ], + channelCollaborationRefused: [ RefusedToCollaborateToChannel ] } private static instance: Notifier @@ -287,6 +296,44 @@ class Notifier { .catch(err => logger.error('Cannot notify on generated video transcription %s of video %s.', caption.language, video.url, { err })) } + notifyOfChannelCollaboratorInvitation (collaborator: MChannelCollaboratorAccount, channel: MChannelAccountDefault) { + const models = this.notificationModels.channelCollaboratorInvitation + + const channelName = channel.Actor.preferredUsername + const collaboratorName = collaborator.Account.Actor.preferredUsername + + logger.debug('Notify on channel collaborator invitation', { channelName, collaboratorName, ...lTags() }) + + this.sendNotifications(models, { channel, collaborator }) + .catch(err => logger.error(`Cannot notify ${collaboratorName} of invitation to collaborate to channel ${channelName}`, { err })) + } + + notifyOfAcceptedChannelCollaborator (collaborator: MChannelCollaboratorAccount, channel: MChannelDefault) { + const models = this.notificationModels.channelCollaborationAccepted + + const channelName = channel.Actor.preferredUsername + const channelOwner = collaborator.Account.Actor.preferredUsername + + logger.debug('Notify of accepted channel collaboration invitation', { channelName, channelOwner, ...lTags() }) + + this.sendNotifications(models, { channel, collaborator }) + .catch(err => logger.error(`Cannot notify ${channelOwner} of accepted invitation to collaborate to channel ${channelName}`, { err })) + } + + notifyOfRefusedChannelCollaborator (collaborator: MChannelCollaboratorAccount, channel: MChannelDefault) { + const models = this.notificationModels.channelCollaborationRefused + + const channelName = channel.Actor.preferredUsername + const channelOwner = collaborator.Account.Actor.preferredUsername + + logger.debug('Notify of refused channel collaboration invitation', { channelName, channelOwner, ...lTags() }) + + this.sendNotifications(models, { channel, collaborator }) + .catch(err => logger.error(`Cannot notify ${channelOwner} of refused invitation to collaborate to channel ${channelName}`, { err })) + } + + // --------------------------------------------------------------------------- + private async notify (object: AbstractNotification) { await object.prepare() diff --git a/server/core/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts b/server/core/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts index 867df4ca6..bad552c91 100644 --- a/server/core/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts +++ b/server/core/lib/notifier/shared/abuse/abuse-state-change-for-reporter.ts @@ -13,7 +13,7 @@ export class AbuseStateChangeForReporter extends AbstractNotification channelDisplayName: channel.getDisplayName(), channelUrl: channel.getClientUrl(), reporter: this.payload.reporter, - action: this.buildEmailAction() + action: this.buildEmailAction(to) } } } @@ -85,12 +85,12 @@ export class NewAbuseForModerators extends AbstractNotification locals: { commentUrl: WEBSERVER.URL + comment.getCommentStaticPath(), videoName: comment.Video.name, - isLocal: comment.isOwned(), + isLocal: comment.isLocal(), commentCreatedAt: new Date(comment.createdAt).toLocaleString(), reason: this.payload.abuse.reason, flaggedAccount: this.payload.abuseInstance.FlaggedAccount.getDisplayName(), reporter: this.payload.reporter, - action: this.buildEmailAction() + action: this.buildEmailAction(to) } } } @@ -106,17 +106,17 @@ export class NewAbuseForModerators extends AbstractNotification locals: { accountUrl, accountDisplayName: account.getDisplayName(), - isLocal: account.isOwned(), + isLocal: account.isLocal(), reason: this.payload.abuse.reason, reporter: this.payload.reporter, - action: this.buildEmailAction() + action: this.buildEmailAction(to) } } } - private buildEmailAction () { + private buildEmailAction (to: To) { return { - text: 'View report #' + this.payload.abuseInstance.id, + text: t('View report #' + this.payload.abuseInstance.id, to.language), url: getAdminAbuseUrl(this.payload.abuseInstance) } } diff --git a/server/core/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts b/server/core/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts index 2864a1cf0..7c598718b 100644 --- a/server/core/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts +++ b/server/core/lib/notifier/shared/abuse/new-abuse-message-for-reporter.ts @@ -9,7 +9,7 @@ export class NewAbuseMessageForReporter extends AbstractNewAbuseMessage { async prepare () { // Only notify our users - if (this.abuse.ReporterAccount.isOwned() !== true) return + if (this.abuse.ReporterAccount.isLocal() !== true) return await this.loadMessageAccount() diff --git a/server/core/lib/notifier/shared/channel/accepted-to-collaborate-to-channel.ts b/server/core/lib/notifier/shared/channel/accepted-to-collaborate-to-channel.ts new file mode 100644 index 000000000..cae3cbc7e --- /dev/null +++ b/server/core/lib/notifier/shared/channel/accepted-to-collaborate-to-channel.ts @@ -0,0 +1,68 @@ +import { UserNotificationSettingValue, UserNotificationType } from '@peertube/peertube-models' +import { t } from '@server/helpers/i18n.js' +import { logger } from '@server/helpers/logger.js' +import { UserModel } from '@server/models/user/user.js' +import { MUserDefault, MUserWithNotificationSetting } from '@server/types/models/index.js' +import { AbstractNotification } from '../common/abstract-notification.js' +import { buildCollaborateToChannelNotification, NotificationCollaboratePayload } from './collaborate-to-channel-utils.js' + +export class AcceptedToCollaborateToChannel extends AbstractNotification { + private user: MUserDefault + + async prepare () { + this.user = await UserModel.loadByAccountId(this.payload.channel.accountId) + } + + log () { + logger.info( + `Notifying user ${this.user.username} of accepted invitation to collaborate on channel ${this.payload.channel.Actor.getIdentifier()}` + ) + } + + isDisabled () { + return false + } + + getSetting (_user: MUserWithNotificationSetting) { + // Always notify + return UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } + + getTargetUsers () { + return [ this.user ] + } + + createNotification (user: MUserWithNotificationSetting) { + return buildCollaborateToChannelNotification({ + user, + payload: this.payload, + notificationType: UserNotificationType.ACCEPTED_TO_COLLABORATE_TO_CHANNEL + }) + } + + // --------------------------------------------------------------------------- + + createEmail (user: MUserWithNotificationSetting) { + const userLanguage = user.getLanguage() + const to = { email: user.email, language: userLanguage } + + const { channel, collaborator } = this.payload + + const text = t('{collaboratorName} accepted your invitation to become a collaborator of {channelName}', userLanguage, { + collaboratorName: collaborator.Account.getDisplayName(), + channelName: channel.getDisplayName() + }) + + return { + to, + subject: text, + text, + locals: { + action: { + text: t('Manage your channel', userLanguage), + url: channel.getClientManageUrl() + } + } + } + } +} diff --git a/server/core/lib/notifier/shared/channel/collaborate-to-channel-utils.ts b/server/core/lib/notifier/shared/channel/collaborate-to-channel-utils.ts new file mode 100644 index 000000000..0bdce827a --- /dev/null +++ b/server/core/lib/notifier/shared/channel/collaborate-to-channel-utils.ts @@ -0,0 +1,31 @@ +import { UserNotificationType_Type } from '@peertube/peertube-models' +import { UserNotificationModel } from '@server/models/user/user-notification.js' +import { MChannelAccountDefault, MChannelCollaboratorAccount } from '@server/types/models/index.js' +import { MUserId, UserNotificationModelForApi } from '@server/types/models/user/index.js' + +export type NotificationCollaboratePayload = { + collaborator: MChannelCollaboratorAccount + channel: MChannelAccountDefault +} + +export function buildCollaborateToChannelNotification (options: { + user: MUserId + payload: NotificationCollaboratePayload + notificationType: UserNotificationType_Type +}): UserNotificationModelForApi { + const { user, payload, notificationType } = options + + const notification = UserNotificationModel.build({ + type: notificationType, + + userId: user.id, + channelCollaboratorId: payload.collaborator.id + }) + + notification.VideoChannelCollaborator = Object.assign(payload.collaborator, { + Account: payload.collaborator.Account, + Channel: payload.channel + }) + + return notification +} diff --git a/server/core/lib/notifier/shared/channel/invited-to-collaborate-to-channel.ts b/server/core/lib/notifier/shared/channel/invited-to-collaborate-to-channel.ts new file mode 100644 index 000000000..b24765b5c --- /dev/null +++ b/server/core/lib/notifier/shared/channel/invited-to-collaborate-to-channel.ts @@ -0,0 +1,69 @@ +import { UserNotificationSettingValue, UserNotificationType } from '@peertube/peertube-models' +import { t } from '@server/helpers/i18n.js' +import { logger } from '@server/helpers/logger.js' +import { WEBSERVER } from '@server/initializers/constants.js' +import { UserModel } from '@server/models/user/user.js' +import { MUserDefault, MUserWithNotificationSetting } from '@server/types/models/index.js' +import { AbstractNotification } from '../common/abstract-notification.js' +import { buildCollaborateToChannelNotification, NotificationCollaboratePayload } from './collaborate-to-channel-utils.js' + +export class InvitedToCollaborateToChannel extends AbstractNotification { + private user: MUserDefault + + async prepare () { + this.user = await UserModel.loadByAccountId(this.payload.collaborator.accountId) + } + + log () { + logger.info( + `Notifying user ${this.user.username} of invitation to collaborate on channel ${this.payload.channel.Actor.getIdentifier()}` + ) + } + + isDisabled () { + return false + } + + getSetting (_user: MUserWithNotificationSetting) { + // Always notify + return UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } + + getTargetUsers () { + return [ this.user ] + } + + createNotification (user: MUserWithNotificationSetting) { + return buildCollaborateToChannelNotification({ + user, + payload: this.payload, + notificationType: UserNotificationType.INVITED_TO_COLLABORATE_TO_CHANNEL + }) + } + + // --------------------------------------------------------------------------- + + createEmail (user: MUserWithNotificationSetting) { + const userLanguage = user.getLanguage() + const to = { email: user.email, language: userLanguage } + + const { channel } = this.payload + + const text = t('{channelOwner} invited you to become a collaborator of channel {channelName}', userLanguage, { + channelOwner: channel.Account.getDisplayName(), + channelName: channel.getDisplayName() + }) + + return { + to, + subject: text, + text, + locals: { + action: { + text: t('Review the invitation', userLanguage), + url: WEBSERVER.URL + '/my-account/notifications' + } + } + } + } +} diff --git a/server/core/lib/notifier/shared/channel/refused-to-collaborate-to-channel.ts b/server/core/lib/notifier/shared/channel/refused-to-collaborate-to-channel.ts new file mode 100644 index 000000000..bf000fdfc --- /dev/null +++ b/server/core/lib/notifier/shared/channel/refused-to-collaborate-to-channel.ts @@ -0,0 +1,81 @@ +import { UserNotificationDataCollaborationRejected, UserNotificationSettingValue, UserNotificationType } from '@peertube/peertube-models' +import { t } from '@server/helpers/i18n.js' +import { logger } from '@server/helpers/logger.js' +import { UserNotificationModel } from '@server/models/user/user-notification.js' +import { UserModel } from '@server/models/user/user.js' +import { MUserDefault, MUserWithNotificationSetting, UserNotificationModelForApi } from '@server/types/models/index.js' +import { AbstractNotification } from '../common/abstract-notification.js' +import { NotificationCollaboratePayload } from './collaborate-to-channel-utils.js' + +export class RefusedToCollaborateToChannel extends AbstractNotification { + private user: MUserDefault + + async prepare () { + this.user = await UserModel.loadByAccountId(this.payload.channel.accountId) + } + + log () { + logger.info( + `Notifying user ${this.user.username} of refused invitation to collaborate on channel ${this.payload.channel.Actor.getIdentifier()}` + ) + } + + isDisabled () { + return false + } + + getSetting (_user: MUserWithNotificationSetting) { + // Always notify + return UserNotificationSettingValue.WEB | UserNotificationSettingValue.EMAIL + } + + getTargetUsers () { + return [ this.user ] + } + + createNotification (user: MUserWithNotificationSetting) { + const { channel, collaborator } = this.payload + + return UserNotificationModel.build({ + type: UserNotificationType.REFUSED_TO_COLLABORATE_TO_CHANNEL, + userId: user.id, + + data: { + channelDisplayName: channel.getDisplayName(), + channelHandle: channel.Actor.getIdentifier(), + + channelOwnerDisplayName: this.user.Account.getDisplayName(), + channelOwnerHandle: this.user.Account.Actor.getIdentifier(), + + collaboratorDisplayName: collaborator.Account.getDisplayName(), + collaboratorHandle: collaborator.Account.Actor.getIdentifier() + } satisfies UserNotificationDataCollaborationRejected + }) + } + + // --------------------------------------------------------------------------- + + createEmail (user: MUserWithNotificationSetting) { + const userLanguage = user.getLanguage() + const to = { email: user.email, language: userLanguage } + + const { channel, collaborator } = this.payload + + const text = t('{collaboratorName} refused your invitation to become a collaborator of {channelName}', userLanguage, { + collaboratorName: collaborator.Account.getDisplayName(), + channelName: channel.getDisplayName() + }) + + return { + to, + subject: text, + text, + locals: { + action: { + text: t('Manage your channel', userLanguage), + url: channel.getClientManageUrl() + } + } + } + } +} diff --git a/server/core/lib/notifier/shared/comment/comment-mention.ts b/server/core/lib/notifier/shared/comment/comment-mention.ts index b9f4d9eac..62164a4ae 100644 --- a/server/core/lib/notifier/shared/comment/comment-mention.ts +++ b/server/core/lib/notifier/shared/comment/comment-mention.ts @@ -40,7 +40,7 @@ export class CommentMention extends AbstractNotification u.id !== userException.id) } diff --git a/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts b/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts index 7a33b1918..4c946e643 100644 --- a/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts +++ b/server/core/lib/notifier/shared/comment/new-comment-for-video-owner.ts @@ -21,7 +21,7 @@ export class NewCommentForVideoOwner extends AbstractNotification { } async isDisabled () { - if (this.payload.ActorFollowing.isOwned() === false) return true + if (this.payload.ActorFollowing.isLocal() === false) return true const followerAccount = this.actorFollow.ActorFollower.Account const followerAccountWithActor = Object.assign(followerAccount, { Actor: this.actorFollow.ActorFollower }) diff --git a/server/core/lib/notifier/shared/video-publication/new-video-or-live-for-subscribers.ts b/server/core/lib/notifier/shared/video-publication/new-video-or-live-for-subscribers.ts index 1b5905330..db4978768 100644 --- a/server/core/lib/notifier/shared/video-publication/new-video-or-live-for-subscribers.ts +++ b/server/core/lib/notifier/shared/video-publication/new-video-or-live-for-subscribers.ts @@ -65,12 +65,15 @@ export class NewVideoOrLiveForSubscribers extends AbstractNotification function getClient () { - if (s3ClientPromise) return s3ClientPromise + if (s3ClientPromise !== undefined) return s3ClientPromise s3ClientPromise = (async () => { const OBJECT_STORAGE = CONFIG.OBJECT_STORAGE diff --git a/server/core/lib/thumbnail.ts b/server/core/lib/thumbnail.ts index 2a26c1629..cc8649086 100644 --- a/server/core/lib/thumbnail.ts +++ b/server/core/lib/thumbnail.ts @@ -56,7 +56,7 @@ export function updateRemotePlaylistMiniatureFromUrl (options: { const type = ThumbnailType.MINIATURE // Only save the file URL if it is a remote playlist - const fileUrl = playlist.isOwned() + const fileUrl = playlist.isLocal() ? null : downloadUrl @@ -124,27 +124,30 @@ export function generateLocalVideoMiniature (options: { let thumbnailCreator: () => Promise if (videoFile.isAudio()) { - thumbnailCreator = () => processImageFromWorker({ - path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, - destination: outputPath, - newSize: { width, height }, - keepOriginal: true - }) + thumbnailCreator = () => + processImageFromWorker({ + path: ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, + destination: outputPath, + newSize: { width, height }, + keepOriginal: true + }) } else if (biggestImagePath) { - thumbnailCreator = () => processImageFromWorker({ - path: biggestImagePath, - destination: outputPath, - newSize: { width, height }, - keepOriginal: true - }) + thumbnailCreator = () => + processImageFromWorker({ + path: biggestImagePath, + destination: outputPath, + newSize: { width, height }, + keepOriginal: true + }) } else { - thumbnailCreator = () => generateImageFromVideoFile({ - fromPath: input, - folder: basePath, - imageName: filename, - size: { height, width }, - ffprobe - }) + thumbnailCreator = () => + generateImageFromVideoFile({ + fromPath: input, + folder: basePath, + imageName: filename, + size: { height, width }, + ffprobe + }) } if (!biggestImagePath) biggestImagePath = outputPath @@ -175,7 +178,7 @@ export function updateLocalVideoMiniatureFromUrl (options: { const { filename: updatedFilename, basePath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) // Only save the file URL if it is a remote video - const fileUrl = video.isOwned() + const fileUrl = video.isLocal() ? null : downloadUrl diff --git a/server/core/lib/user-import-export/exporters/channels-exporter.ts b/server/core/lib/user-import-export/exporters/channels-exporter.ts index af416a2a6..78854613d 100644 --- a/server/core/lib/user-import-export/exporters/channels-exporter.ts +++ b/server/core/lib/user-import-export/exporters/channels-exporter.ts @@ -12,7 +12,7 @@ export class ChannelsExporter extends ActorExporter { const channelsJSON: ChannelExportJSON['channels'] = [] let staticFiles: ExportResult['staticFiles'] = [] - const channels = await VideoChannelModel.listAllByAccount(this.user.Account.id) + const channels = await VideoChannelModel.listAllOwnedByAccount(this.user.Account.id) for (const channel of channels) { try { diff --git a/server/core/lib/user-import-export/exporters/followers-exporter.ts b/server/core/lib/user-import-export/exporters/followers-exporter.ts index 0692ed4f2..02147266a 100644 --- a/server/core/lib/user-import-export/exporters/followers-exporter.ts +++ b/server/core/lib/user-import-export/exporters/followers-exporter.ts @@ -3,15 +3,14 @@ import { FollowersExportJSON } from '@peertube/peertube-models' import { ActorFollowModel } from '@server/models/actor/actor-follow.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' -export class FollowersExporter extends AbstractUserExporter { - +export class FollowersExporter extends AbstractUserExporter { async export () { let followersJSON = this.formatFollowersJSON( await ActorFollowModel.listAcceptedFollowersForExport(this.user.Account.actorId), this.user.Account.Actor.getFullIdentifier() ) - const channels = await VideoChannelModel.listAllByAccount(this.user.Account.id) + const channels = await VideoChannelModel.listAllOwnedByAccount(this.user.Account.id) for (const channel of channels) { followersJSON = followersJSON.concat( diff --git a/server/core/lib/user-import-export/exporters/videos-exporter.ts b/server/core/lib/user-import-export/exporters/videos-exporter.ts index b4394cb4d..f1b8b4b1b 100644 --- a/server/core/lib/user-import-export/exporters/videos-exporter.ts +++ b/server/core/lib/user-import-export/exporters/videos-exporter.ts @@ -56,7 +56,7 @@ export class VideosExporter extends AbstractUserExporter { const activityPubOutbox: ActivityCreate[] = [] let staticFiles: ExportResult['staticFiles'] = [] - const channels = await VideoChannelModel.listAllByAccount(this.user.Account.id) + const channels = await VideoChannelModel.listAllOwnedByAccount(this.user.Account.id) for (const channel of channels) { const videoIds = await VideoModel.getAllIdsFromChannel(channel, USER_EXPORT_MAX_ITEMS) diff --git a/server/core/lib/video-comment.ts b/server/core/lib/video-comment.ts index 2c65b387e..83c396064 100644 --- a/server/core/lib/video-comment.ts +++ b/server/core/lib/video-comment.ts @@ -11,7 +11,9 @@ import { MComment, MCommentFormattable, MCommentOwnerVideo, - MCommentOwnerVideoReply, MUserAccountId, MVideoAccountLight, + MCommentOwnerVideoReply, + MUserAccountId, + MVideoAccountLight, MVideoFullLight } from '../types/models/index.js' import { sendCreateVideoCommentIfNeeded, sendDeleteVideoComment, sendReplyApproval } from './activitypub/send/index.js' @@ -29,7 +31,7 @@ export async function removeComment (commentArg: MComment, req: express.Request, videoCommentInstanceBefore = cloneDeep(comment) - if (comment.isOwned() || comment.Video.isOwned()) { + if (comment.isLocal() || comment.Video.isLocal()) { await sendDeleteVideoComment(comment, t) } @@ -52,7 +54,7 @@ export async function approveComment (commentArg: MComment) { comment.heldForReview = false await comment.save({ transaction: t }) - if (comment.isOwned()) { + if (comment.isLocal()) { await sendCreateVideoCommentIfNeeded(comment, t) } else { sendReplyApproval(comment, 'ApproveReply') @@ -159,13 +161,13 @@ export async function shouldCommentBeHeldForReview (options: { }) { const { user, video, transaction, automaticTags } = options - if (video.isOwned() && user) { + if (video.isLocal() && user) { if (user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) return false if (user.Account.id === video.VideoChannel.accountId) return false } if (video.commentsPolicy === VideoCommentPolicy.REQUIRES_APPROVAL) return true - if (video.isOwned() !== true) return false + if (video.isLocal() !== true) return false const ownerAccountTags = automaticTags .filter(t => t.accountId === video.VideoChannel.accountId) diff --git a/server/core/lib/video-download.ts b/server/core/lib/video-download.ts index f6f14f492..4be5a9c4e 100644 --- a/server/core/lib/video-download.ts +++ b/server/core/lib/video-download.ts @@ -217,7 +217,7 @@ export class VideoDownload { private async buildCoverInput () { const preview = this.video.getPreview() - if (this.video.isOwned()) return { coverPath: preview?.getPath() } + if (this.video.isLocal()) return { coverPath: preview?.getPath() } if (preview.fileUrl) { const destination = VideoPathManager.Instance.buildTMPDestination(preview.filename) diff --git a/server/core/lib/video-playlist.ts b/server/core/lib/video-playlist.ts index 32b7e7da0..8ced4eab1 100644 --- a/server/core/lib/video-playlist.ts +++ b/server/core/lib/video-playlist.ts @@ -39,7 +39,7 @@ export async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylis // Ensure the file is on disk const videoMiniaturePermanentFileCache = new VideoMiniaturePermanentFileCache() - const inputPath = videoMiniature.isOwned() + const inputPath = videoMiniature.isLocal() ? videoMiniature.getPath() : await videoMiniaturePermanentFileCache.downloadRemoteFile(videoMiniature) diff --git a/server/core/lib/views/shared/video-views.ts b/server/core/lib/views/shared/video-views.ts index 078aca836..b8a0a2c54 100644 --- a/server/core/lib/views/shared/video-views.ts +++ b/server/core/lib/views/shared/video-views.ts @@ -12,7 +12,6 @@ import { CONFIG } from '@server/initializers/config.js' const lTags = loggerTagsFactory('views') export class VideoViews { - private readonly viewsCache = new LRUCache({ max: 10_000, ttl: VIEW_LIFETIME.VIEW @@ -58,7 +57,7 @@ export class VideoViews { private async addView (video: MVideoImmutable) { const promises: Promise[] = [] - if (video.isOwned()) { + if (video.isLocal()) { promises.push(Redis.Instance.addLocalVideoView(video.id)) } diff --git a/server/core/middlewares/validators/abuse.ts b/server/core/middlewares/validators/abuse.ts index 12c316ef8..a3b188d4c 100644 --- a/server/core/middlewares/validators/abuse.ts +++ b/server/core/middlewares/validators/abuse.ts @@ -57,7 +57,7 @@ const abuseReportValidator = [ const body: AbuseCreate = req.body if (body.video?.id && !await doesVideoExist(body.video.id, res)) return - if (body.account?.id && !await doesAccountIdExist({ id: body.account.id, req, res, checkIsLocal: false, checkManage: false })) return + if (body.account?.id && !await doesAccountIdExist({ id: body.account.id, req, res, checkIsLocal: false, checkCanManage: false })) return if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return if (!body.video?.id && !body.account?.id && !body.comment?.id) { @@ -187,7 +187,7 @@ const getAbuseValidator = [ const checkAbuseValidForMessagesValidator = [ (req: express.Request, res: express.Response, next: express.NextFunction) => { const abuse = res.locals.abuse - if (abuse.ReporterAccount.isOwned() === false) { + if (abuse.ReporterAccount.isLocal() === false) { return res.fail({ message: 'This abuse was created by a user of your instance.' }) } diff --git a/server/core/middlewares/validators/account.ts b/server/core/middlewares/validators/account.ts index 9458bd7b2..86da6857e 100644 --- a/server/core/middlewares/validators/account.ts +++ b/server/core/middlewares/validators/account.ts @@ -1,13 +1,13 @@ import { UserRight } from '@peertube/peertube-models' import express from 'express' import { param } from 'express-validator' -import { areValidationErrors, checkUserCanManageAccount, doesAccountHandleExist } from './shared/index.js' +import { areValidationErrors, checkCanManageAccount, doesAccountHandleExist } from './shared/index.js' export const accountHandleGetValidatorFactory = (options: { - checkManage: boolean + checkCanManage: boolean checkIsLocal: boolean }) => { - const { checkManage, checkIsLocal } = options + const { checkCanManage, checkIsLocal } = options return [ param('handle') @@ -15,12 +15,12 @@ export const accountHandleGetValidatorFactory = (options: { async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.handle, req, res, checkIsLocal, checkManage })) return + if (!await doesAccountHandleExist({ handle: req.params.handle, req, res, checkIsLocal, checkCanManage })) return - if (options.checkManage) { + if (checkCanManage) { const user = res.locals.oauth.token.User - if (!checkUserCanManageAccount({ account: res.locals.account, user, req, res, specialRight: UserRight.MANAGE_USERS })) { + if (!checkCanManageAccount({ account: res.locals.account, user, req, res, specialRight: UserRight.MANAGE_USERS })) { return false } } diff --git a/server/core/middlewares/validators/automatic-tags.ts b/server/core/middlewares/validators/automatic-tags.ts index fdd8fa371..a60d84c24 100644 --- a/server/core/middlewares/validators/automatic-tags.ts +++ b/server/core/middlewares/validators/automatic-tags.ts @@ -12,7 +12,7 @@ export const manageAccountAutomaticTagsValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkManage: true })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkCanManage: true })) return return next() } diff --git a/server/core/middlewares/validators/blocklist.ts b/server/core/middlewares/validators/blocklist.ts index 01e9712ef..1c92255be 100644 --- a/server/core/middlewares/validators/blocklist.ts +++ b/server/core/middlewares/validators/blocklist.ts @@ -17,7 +17,7 @@ const blockAccountValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return const user = res.locals.oauth.token.User const accountToBlock = res.locals.account @@ -40,7 +40,7 @@ const unblockAccountByAccountValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return const user = res.locals.oauth.token.User const targetAccount = res.locals.account @@ -56,7 +56,7 @@ const unblockAccountByServerValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return const serverActor = await getServerActor() const targetAccount = res.locals.account diff --git a/server/core/middlewares/validators/bulk.ts b/server/core/middlewares/validators/bulk.ts index c9162ebd5..c99c9f3cb 100644 --- a/server/core/middlewares/validators/bulk.ts +++ b/server/core/middlewares/validators/bulk.ts @@ -12,7 +12,7 @@ export const bulkRemoveCommentsOfValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkManage: false })) return + if (!await doesAccountHandleExist({ handle: req.body.accountName, req, res, checkIsLocal: false, checkCanManage: false })) return const user = res.locals.oauth.token.User const body = req.body as BulkRemoveCommentsOfBody diff --git a/server/core/middlewares/validators/feeds.ts b/server/core/middlewares/validators/feeds.ts index 9b260fafa..bff1e21fa 100644 --- a/server/core/middlewares/validators/feeds.ts +++ b/server/core/middlewares/validators/feeds.ts @@ -14,7 +14,7 @@ import { doesVideoExist } from './shared/index.js' -const feedsFormatValidator = [ +export const feedsFormatValidator = [ param('format') .optional() .custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'), @@ -29,7 +29,7 @@ const feedsFormatValidator = [ } ] -function setFeedFormatContentType (req: express.Request, res: express.Response, next: express.NextFunction) { +export function setFeedFormatContentType (req: express.Request, res: express.Response, next: express.NextFunction) { const format = req.query.format || req.params.format || 'rss' let acceptableContentTypes: string[] @@ -46,13 +46,13 @@ function setFeedFormatContentType (req: express.Request, res: express.Response, return feedContentTypeResponse(req, res, next, acceptableContentTypes) } -function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) { +export function setFeedPodcastContentType (req: express.Request, res: express.Response, next: express.NextFunction) { const acceptableContentTypes = [ 'application/rss+xml', 'application/xml', 'text/xml' ] return feedContentTypeResponse(req, res, next, acceptableContentTypes) } -function feedContentTypeResponse ( +export function feedContentTypeResponse ( req: express.Request, res: express.Response, next: express.NextFunction, @@ -72,7 +72,7 @@ function feedContentTypeResponse ( // --------------------------------------------------------------------------- -const feedsAccountOrChannelFiltersValidator = [ +export const feedsAccountOrChannelFiltersValidator = [ query('accountId') .optional() .custom(isIdValid), @@ -91,7 +91,7 @@ const feedsAccountOrChannelFiltersValidator = [ if (areValidationErrors(req, res)) return const { accountId, videoChannelId, accountName, videoChannelName } = req.query - const commonOptions = { req, res, checkManage: false, checkIsLocal: false } + const commonOptions = { req, res, checkCanManage: false, checkIsLocal: false, checkIsOwner: false } if (accountId && !await doesAccountIdExist({ id: accountId, ...commonOptions })) return if (videoChannelId && !await doesChannelIdExist({ id: videoChannelId, ...commonOptions })) return @@ -105,13 +105,23 @@ const feedsAccountOrChannelFiltersValidator = [ // --------------------------------------------------------------------------- -const videoFeedsPodcastValidator = [ +export const videoFeedsPodcastValidator = [ query('videoChannelId') .custom(isIdValid), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: false, checkIsLocal: false, req, res })) return + + if ( + !await doesChannelIdExist({ + id: req.query.videoChannelId, + checkCanManage: false, + checkIsLocal: false, + checkIsOwner: false, + req, + res + }) + ) return return next() } @@ -119,7 +129,7 @@ const videoFeedsPodcastValidator = [ // --------------------------------------------------------------------------- -const videoSubscriptionFeedsValidator = [ +export const videoSubscriptionFeedsValidator = [ query('accountId') .custom(isIdValid), @@ -129,14 +139,14 @@ const videoSubscriptionFeedsValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountIdExist({ id: req.query.accountId, req, res, checkIsLocal: true, checkManage: false })) return + if (!await doesAccountIdExist({ id: req.query.accountId, req, res, checkIsLocal: true, checkCanManage: false })) return if (!await doesUserFeedTokenCorrespond(res.locals.account.userId, req.query.token, res)) return return next() } ] -const videoCommentsFeedsValidator = [ +export const videoCommentsFeedsValidator = [ query('videoId') .optional() .customSanitizer(toCompleteUUID) @@ -157,15 +167,3 @@ const videoCommentsFeedsValidator = [ return next() } ] - -// --------------------------------------------------------------------------- - -export { - feedsAccountOrChannelFiltersValidator, - feedsFormatValidator, - setFeedFormatContentType, - setFeedPodcastContentType, - videoCommentsFeedsValidator, - videoFeedsPodcastValidator, - videoSubscriptionFeedsValidator -} diff --git a/server/core/middlewares/validators/player-settings.ts b/server/core/middlewares/validators/player-settings.ts index e39cdd8a7..1e1893ef3 100644 --- a/server/core/middlewares/validators/player-settings.ts +++ b/server/core/middlewares/validators/player-settings.ts @@ -3,9 +3,9 @@ import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validato import { isPlayerChannelThemeSettingValid, isPlayerVideoThemeSettingValid } from '@server/helpers/custom-validators/player-settings.js' import express from 'express' import { body, query } from 'express-validator' -import { checkUserCanManageAccount } from './shared/users.js' +import { checkCanManageAccount } from './shared/users.js' import { areValidationErrors, isValidVideoIdParam } from './shared/utils.js' -import { checkCanSeeVideo, checkUserCanManageVideo, doesVideoExist } from './shared/videos.js' +import { checkCanSeeVideo, checkCanManageVideo, doesVideoExist } from './shared/videos.js' export const getVideoPlayerSettingsValidator = [ isValidVideoIdParam('videoId'), @@ -28,7 +28,17 @@ export const getVideoPlayerSettingsValidator = [ if (raw === true) { const user = res.locals.oauth?.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return } return next() @@ -48,7 +58,7 @@ export const getChannelPlayerSettingsValidator = [ const account = res.locals.videoChannel.Account const user = res.locals.oauth?.token.User - if (!checkUserCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) return false + if (!checkCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) return false } return next() @@ -64,7 +74,17 @@ export const updateVideoPlayerSettingsValidator = [ if (!await doesVideoExist(req.params.videoId, res, 'all')) return const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } diff --git a/server/core/middlewares/validators/shared/accounts.ts b/server/core/middlewares/validators/shared/accounts.ts index 14f0503e6..67837045a 100644 --- a/server/core/middlewares/validators/shared/accounts.ts +++ b/server/core/middlewares/validators/shared/accounts.ts @@ -3,34 +3,34 @@ import { HttpStatusCode, UserRight } from '@peertube/peertube-models' import { AccountModel } from '@server/models/account/account.js' import { MAccountDefault } from '@server/types/models/index.js' import { Request, Response } from 'express' -import { checkUserCanManageAccount } from './users.js' +import { checkCanManageAccount } from './users.js' export async function doesAccountIdExist (options: { id: string | number req: Request res: Response - checkManage: boolean // Also check the user can manage the account + checkCanManage: boolean // Also check the user can manage the account checkIsLocal: boolean // Also check this is a local channel }) { - const { id, req, res, checkIsLocal, checkManage } = options + const { id, req, res, checkIsLocal, checkCanManage } = options const account = await AccountModel.load(forceNumber(id)) - return doesAccountExist({ account, req, res, checkIsLocal, checkManage }) + return doesAccountExist({ account, req, res, checkIsLocal, checkCanManage }) } export async function doesAccountHandleExist (options: { handle: string req: Request res: Response - checkManage: boolean // Also check the user can manage the account + checkCanManage: boolean // Also check the user can manage the account checkIsLocal: boolean // Also check this is a local channel }) { - const { handle, req, res, checkIsLocal, checkManage } = options + const { handle, req, res, checkIsLocal, checkCanManage } = options const account = await AccountModel.loadByHandle(handle) - return doesAccountExist({ account, req, res, checkIsLocal, checkManage }) + return doesAccountExist({ account, req, res, checkIsLocal, checkCanManage }) } // --------------------------------------------------------------------------- @@ -41,10 +41,10 @@ function doesAccountExist (options: { account: MAccountDefault req: Request res: Response - checkManage: boolean + checkCanManage: boolean checkIsLocal: boolean }) { - const { account, req, res, checkIsLocal, checkManage } = options + const { account, req, res, checkIsLocal, checkCanManage } = options if (!account) { res.fail({ @@ -54,15 +54,15 @@ function doesAccountExist (options: { return false } - if (checkManage) { + if (checkCanManage) { const user = res.locals.oauth.token.User - if (!checkUserCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_USERS })) { + if (!checkCanManageAccount({ account, user, req, res, specialRight: UserRight.MANAGE_USERS })) { return false } } - if (checkIsLocal && account.Actor.isOwned() === false) { + if (checkIsLocal && account.Actor.isLocal() === false) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, message: 'This account is not owned.' diff --git a/server/core/middlewares/validators/shared/users.ts b/server/core/middlewares/validators/shared/users.ts index 20c1ff9d2..02f1cbdcc 100644 --- a/server/core/middlewares/validators/shared/users.ts +++ b/server/core/middlewares/validators/shared/users.ts @@ -91,17 +91,17 @@ export async function checkUserExist (finder: () => Promise, res: return true } -export function checkUserCanManageAccount (options: { +export function checkCanManageAccount (options: { user: MUserAccountId account: MAccountId specialRight: UserRightType req: express.Request - res: express.Response + res: express.Response | null }) { const { user, account, specialRight, res, req } = options if (!user) { - res.fail({ + res?.fail({ status: HttpStatusCode.UNAUTHORIZED_401, message: req.t('Authentication is required') }) @@ -111,18 +111,9 @@ export function checkUserCanManageAccount (options: { if (account.id === user.Account.id) return true if (specialRight && user.hasRight(specialRight) === true) return true - if (!specialRight) { - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: req.t('Only the owner of this account can manage this account resource.') - }) - - return false - } - - res.fail({ + res?.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: req.t('Only a user with sufficient right can access this account resource.') + message: req.t('Only a user with sufficient right can manage this account resource.') }) return false diff --git a/server/core/middlewares/validators/shared/video-channels.ts b/server/core/middlewares/validators/shared/video-channels.ts index c5e5d3b48..6baf13ab3 100644 --- a/server/core/middlewares/validators/shared/video-channels.ts +++ b/server/core/middlewares/validators/shared/video-channels.ts @@ -1,70 +1,119 @@ -import { HttpStatusCode, UserRight } from '@peertube/peertube-models' +import { HttpStatusCode, UserRight, UserRightType } from '@peertube/peertube-models' +import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js' import { VideoChannelModel } from '@server/models/video/video-channel.js' -import { MChannelBannerAccountDefault } from '@server/types/models/index.js' +import { MChannelBannerAccountDefault, MChannelUserId, MUserAccountId } from '@server/types/models/index.js' import express from 'express' -import { checkUserCanManageAccount } from './users.js' +import { checkCanManageAccount } from './users.js' -export async function doesChannelIdExist (options: { - id: number - checkManage: boolean // Also check the user can manage the account - checkIsLocal: boolean // Also check this is a local channel +type CommonOptions = { + checkCanManage: boolean // Also check the user can manage the account + checkIsOwner: boolean // Also check this is the owner of the channel req: express.Request res: express.Response -}) { - const { id, checkManage, checkIsLocal, req, res } = options + specialRight?: UserRightType +} + +export async function doesChannelIdExist ( + options: CommonOptions & { + id: number + checkIsLocal: boolean // Also check this is a local channel + } +) { + const { id, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight } = options const channel = await VideoChannelModel.loadAndPopulateAccount(+id) - return processVideoChannelExist({ channel, checkManage, checkIsLocal, req, res }) + return processVideoChannelExist({ channel, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight }) } -export async function doesChannelHandleExist (options: { - handle: string - checkManage: boolean // Also check the user can manage the account - checkIsLocal: boolean // Also check this is a local channel - req: express.Request - res: express.Response -}) { - const { handle, checkManage, checkIsLocal, req, res } = options +export async function doesChannelHandleExist ( + options: CommonOptions & { + handle: string + checkIsLocal: boolean // Also check this is a local channel + } +) { + const { handle, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight } = options const channel = await VideoChannelModel.loadByHandleAndPopulateAccount(handle) - return processVideoChannelExist({ channel, checkManage, checkIsLocal, req, res }) + return processVideoChannelExist({ channel, checkCanManage, checkIsLocal, checkIsOwner, req, res, specialRight }) } -// --------------------------------------------------------------------------- -// Private -// --------------------------------------------------------------------------- - -function processVideoChannelExist (options: { - channel: MChannelBannerAccountDefault - req: express.Request - res: express.Response - checkManage: boolean - checkIsLocal: boolean -}) { - const { channel, req, res, checkManage, checkIsLocal } = options +export async function canManageChannel ( + options: CommonOptions & { + user: MUserAccountId + channel: MChannelUserId + } +) { + const { channel, user, req, res, checkCanManage, checkIsOwner, specialRight = UserRight.MANAGE_ANY_VIDEO_CHANNEL } = options if (!channel) { - res.fail({ + res?.fail({ status: HttpStatusCode.NOT_FOUND_404, message: req.t('Video channel not found') }) return false } - if (checkManage) { - const user = res.locals.oauth.token.User - - if (!checkUserCanManageAccount({ account: channel.Account, user, req, res, specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL })) { + if (checkIsOwner || checkCanManage) { + if (!user) { + res?.fail({ + status: HttpStatusCode.UNAUTHORIZED_401, + message: req.t('Authentication is required') + }) return false } + + const isOwner = checkCanManageAccount({ + account: channel.Account, + user, + req, + res: null, + specialRight + }) + + if (!isOwner) { + if (checkIsOwner) { + res?.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: req.t('This user has not owner rights on this channel') + }) + + return false + } + + if (checkCanManage && !await VideoChannelCollaboratorModel.isCollaborator({ user, channel })) { + res?.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: req.t('This user cannot manage this channel') + }) + return false + } + } } - if (checkIsLocal && channel.Actor.isOwned() === false) { + return true +} + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function processVideoChannelExist ( + options: CommonOptions & { + channel: MChannelBannerAccountDefault + checkIsLocal: boolean // Also check this is a local channel + } +) { + const { channel, req, res, checkCanManage, checkIsLocal, checkIsOwner, specialRight = UserRight.MANAGE_ANY_VIDEO_CHANNEL } = options + + const user = res.locals.oauth?.token.User + if (!await canManageChannel({ channel, user, req, res, checkCanManage, checkIsOwner, specialRight })) return false + + if (checkIsLocal && channel.Actor.isLocal() === false) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: req.t('This channel is not owned.') + message: req.t('The channel must be local.') }) return false diff --git a/server/core/middlewares/validators/shared/video-passwords.ts b/server/core/middlewares/validators/shared/video-passwords.ts index dc82be572..4d250e8e4 100644 --- a/server/core/middlewares/validators/shared/video-passwords.ts +++ b/server/core/middlewares/validators/shared/video-passwords.ts @@ -1,22 +1,24 @@ -import express from 'express' -import { HttpStatusCode, UserRight, VideoPrivacy } from '@peertube/peertube-models' import { forceNumber } from '@peertube/peertube-core-utils' -import { VideoPasswordModel } from '@server/models/video/video-password.js' -import { header } from 'express-validator' +import { HttpStatusCode, UserRight, VideoPrivacy } from '@peertube/peertube-models' import { getVideoWithAttributes } from '@server/helpers/video.js' +import { VideoPasswordModel } from '@server/models/video/video-password.js' +import { MUserAccountId, MVideoAccountLight } from '@server/types/models/index.js' +import express from 'express' +import { header } from 'express-validator' +import { checkCanManageVideo } from './videos.js' -function isValidVideoPasswordHeader () { +export function isValidVideoPasswordHeader () { return header('x-peertube-video-password') .optional() .isString() } -function checkVideoIsPasswordProtected (res: express.Response) { +export function checkVideoIsPasswordProtected (req: express.Request, res: express.Response) { const video = getVideoWithAttributes(res) if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED) { res.fail({ status: HttpStatusCode.BAD_REQUEST_400, - message: 'Video is not password protected' + message: req.t('Video is not password protected') }) return false } @@ -24,15 +26,21 @@ function checkVideoIsPasswordProtected (res: express.Response) { return true } -async function doesVideoPasswordExist (idArg: number | string, res: express.Response) { +export async function doesVideoPasswordExist (options: { + id: number | string + req: express.Request + res: express.Response +}) { + const { req, res } = options + const video = getVideoWithAttributes(res) - const id = forceNumber(idArg) + const id = forceNumber(options.id) const videoPassword = await VideoPasswordModel.loadByIdAndVideo({ id, videoId: video.id }) if (!videoPassword) { res.fail({ status: HttpStatusCode.NOT_FOUND_404, - message: 'Video password not found' + message: req.t('Video password not found') }) return false } @@ -42,39 +50,23 @@ async function doesVideoPasswordExist (idArg: number | string, res: express.Resp return true } -async function isVideoPasswordDeletable (res: express.Response) { - const user = res.locals.oauth.token.User - const userAccount = user.Account - const video = res.locals.videoAll - - // Check if the user who did the request is able to delete the video passwords - if ( - user.hasRight(UserRight.UPDATE_ANY_VIDEO) === false && // Not a moderator - video.VideoChannel.accountId !== userAccount.id // Not the video owner - ) { - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot remove passwords of another user\'s video' - }) - return false - } +export async function checkCanDeleteVideoPassword (options: { + user: MUserAccountId + video: MVideoAccountLight + req: express.Request + res: express.Response +}) { + const { user, video, req, res } = options const passwordCount = await VideoPasswordModel.countByVideoId(video.id) if (passwordCount <= 1) { res.fail({ status: HttpStatusCode.BAD_REQUEST_400, - message: 'Cannot delete the last password of the protected video' + message: req.t('Cannot delete the last password of the protected video') }) return false } - return true -} - -export { - isValidVideoPasswordHeader, - checkVideoIsPasswordProtected as isVideoPasswordProtected, - doesVideoPasswordExist, - isVideoPasswordDeletable + return checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false }) } diff --git a/server/core/middlewares/validators/shared/videos.ts b/server/core/middlewares/validators/shared/videos.ts index 4f6af5e4f..53981eb55 100644 --- a/server/core/middlewares/validators/shared/videos.ts +++ b/server/core/middlewares/validators/shared/videos.ts @@ -4,12 +4,10 @@ import { VideoLoadType, loadVideo } from '@server/lib/model-loaders/index.js' import { isUserQuotaValid } from '@server/lib/user.js' import { VideoTokensManager } from '@server/lib/video-tokens-manager.js' import { authenticatePromise } from '@server/middlewares/auth.js' -import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoFileModel } from '@server/models/video/video-file.js' import { VideoPasswordModel } from '@server/models/video/video-password.js' import { VideoModel } from '@server/models/video/video.js' import { - MUser, MUserAccountId, MUserId, MVideo, @@ -23,6 +21,7 @@ import { MVideoWithRights } from '@server/types/models/index.js' import { Request, Response } from 'express' +import { canManageChannel } from './video-channels.js' export async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') { const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined @@ -79,31 +78,6 @@ export async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: numb // --------------------------------------------------------------------------- -export async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAccountId, res: Response) { - const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) - - if (videoChannel === null) { - res.fail({ message: `Unknown ${channelId} on this instance.` }) - return false - } - - // Don't check account id if the user can update any video - if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) === true) { - res.locals.videoChannel = videoChannel - return true - } - - if (videoChannel.Account.id !== user.Account.id) { - res.fail({ message: `Unknown channel ${channelId} for this account.` }) - return false - } - - res.locals.videoChannel = videoChannel - return true -} - -// --------------------------------------------------------------------------- - export async function checkCanSeeVideo (options: { req: Request res: Response @@ -137,7 +111,7 @@ async function checkCanSeeUserAuthVideo (options: { const fail = () => { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot fetch information of private/internal/blocked video' + message: req.t('Cannot fetch information of private/internal/blocked video') }) return false @@ -158,13 +132,13 @@ async function checkCanSeeUserAuthVideo (options: { } if (videoWithRights.isBlacklisted()) { - if (canUserAccessVideo(user, videoWithRights, UserRight.MANAGE_VIDEO_BLACKLIST)) return true + if (await canUserAccessVideo({ user, req, video: videoWithRights, right: UserRight.MANAGE_VIDEO_BLACKLIST })) return true return fail() } if (privacy === VideoPrivacy.PRIVATE || privacy === VideoPrivacy.UNLISTED) { - if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true + if (await canUserAccessVideo({ user, req, video: videoWithRights, right: UserRight.SEE_ALL_VIDEOS })) return true return fail() } @@ -192,7 +166,7 @@ async function checkCanSeePasswordProtectedVideo (options: { await authenticatePromise({ req, res, errorMessage, errorStatus: HttpStatusCode.FORBIDDEN_403, errorType }) const user = res.locals.oauth?.token.User - if (canUserAccessVideo(user, videoWithRights, UserRight.SEE_ALL_VIDEOS)) return true + if (await canUserAccessVideo({ user, req, video: videoWithRights, right: UserRight.SEE_ALL_VIDEOS })) return true } res.fail({ @@ -214,16 +188,31 @@ async function checkCanSeePasswordProtectedVideo (options: { return false } -function canUserAccessVideo (user: MUser, video: MVideoWithRights | MVideoAccountLight, right: UserRightType) { - const isOwnedByUser = video.VideoChannel.Account.userId === user.id +async function canUserAccessVideo (options: { + req: Request + user: MUserAccountId + video: MVideoWithRights | MVideoAccountLight + right: UserRightType +}) { + const { user, video, right, req } = options - return isOwnedByUser || user.hasRight(right) + return canManageChannel({ + channel: video.VideoChannel, + user, + req, + res: null, + checkCanManage: true, + checkIsOwner: false, + specialRight: right + }) } async function getVideoWithRights (video: MVideoWithRights): Promise { - return video.VideoChannel?.Account?.userId - ? video - : VideoModel.loadFull(video.id) + const channel = video.VideoChannel + + if (channel?.id && channel?.Account?.userId && channel?.Account?.id) return video + + return VideoModel.loadFull(video.id) } // --------------------------------------------------------------------------- @@ -259,7 +248,7 @@ export async function checkCanAccessVideoSourceFile (options: { const video = await VideoModel.loadFull(videoId) if (res.locals.oauth?.token.User) { - if (canUserAccessVideo(res.locals.oauth.token.User, video, UserRight.SEE_ALL_VIDEOS) === true) return true + if (await canUserAccessVideo({ user: res.locals.oauth.token.User, req, video, right: UserRight.SEE_ALL_VIDEOS }) === true) return true res.sendStatus(HttpStatusCode.FORBIDDEN_403) return false @@ -284,34 +273,45 @@ function assignVideoTokenIfNeeded (req: Request, res: Response, video: MVideoUUI // --------------------------------------------------------------------------- -export function checkUserCanManageVideo (options: { - user: MUser +export async function checkCanManageVideo (options: { + user: MUserAccountId video: MVideoAccountLight right: UserRightType req: Request - res: Response - onlyOwned?: boolean + res: Response | null + checkIsLocal: boolean + checkIsOwner: boolean }) { - const { user, video, right, req, res, onlyOwned = true } = options + const { user, video, right, req, res, checkIsLocal, checkIsOwner } = options if (!user) { - res.fail({ + res?.fail({ status: HttpStatusCode.UNAUTHORIZED_401, message: req.t('Authentication is required.') }) + return false } - if (onlyOwned && video.isOwned() === false) { - res.fail({ + if (checkIsLocal && video.isLocal() === false) { + res?.fail({ status: HttpStatusCode.FORBIDDEN_403, message: req.t('Cannot manage a video of another server.') }) return false } - const account = video.VideoChannel.Account - if (user.hasRight(right) === false && account.userId !== user.id) { - res.fail({ + if ( + !await canManageChannel({ + channel: video.VideoChannel, + user, + req, + res: null, + checkCanManage: true, + checkIsOwner, + specialRight: right + }) + ) { + res?.fail({ status: HttpStatusCode.FORBIDDEN_403, message: req.t('Cannot manage a video of another user.') }) diff --git a/server/core/middlewares/validators/token.ts b/server/core/middlewares/validators/token.ts index 02b0fe732..c03bbfe20 100644 --- a/server/core/middlewares/validators/token.ts +++ b/server/core/middlewares/validators/token.ts @@ -3,7 +3,7 @@ import { isIdValid } from '@server/helpers/custom-validators/misc.js' import { OAuthTokenModel } from '@server/models/oauth/oauth-token.js' import express from 'express' import { param } from 'express-validator' -import { checkUserCanManageAccount, checkUserIdExist } from './shared/users.js' +import { checkCanManageAccount, checkUserIdExist } from './shared/users.js' import { areValidationErrors } from './shared/utils.js' export const manageTokenSessionsValidator = [ @@ -17,7 +17,7 @@ export const manageTokenSessionsValidator = [ const authUser = res.locals.oauth.token.User const targetUser = res.locals.user - if (!checkUserCanManageAccount({ account: targetUser.Account, user: authUser, req, res, specialRight: UserRight.MANAGE_USERS })) return + if (!checkCanManageAccount({ account: targetUser.Account, user: authUser, req, res, specialRight: UserRight.MANAGE_USERS })) return return next() } diff --git a/server/core/middlewares/validators/users/users.ts b/server/core/middlewares/validators/users/users.ts index d0145d58a..d22ec55d4 100644 --- a/server/core/middlewares/validators/users/users.ts +++ b/server/core/middlewares/validators/users/users.ts @@ -130,7 +130,7 @@ export const usersRemoveValidator = [ return res.fail({ message: 'Cannot remove the root user' }) } - if (!checkUserCanModerate(user, res)) return + if (!checkCanModerate(user, res)) return return next() } @@ -152,7 +152,7 @@ export const usersBlockToggleValidator = [ return res.fail({ message: 'Cannot block the root user' }) } - if (!checkUserCanModerate(user, res)) return + if (!checkCanModerate(user, res)) return return next() } @@ -207,7 +207,7 @@ export const usersUpdateValidator = [ return res.fail({ message: 'Cannot change root role.' }) } - if (!checkUserCanModerate(user, res)) return + if (!checkCanModerate(user, res)) return if (req.body.email && req.body.email !== user.email && !await checkEmailDoesNotAlreadyExist(req.body.email, res)) return @@ -366,10 +366,17 @@ export const usersVideosValidator = [ .customSanitizer(arrayify) .custom(isStringArray).withMessage('Should have a valid channelNameOneOf array'), + query('includeCollaborations') + .optional() + .customSanitizer(toBooleanOrNull), + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (req.query.channelId && !await doesChannelIdExist({ id: req.query.channelId, checkManage: true, checkIsLocal: true, req, res })) { + if ( + req.query.channelId && + !await doesChannelIdExist({ id: req.query.channelId, checkCanManage: true, checkIsLocal: true, checkIsOwner: false, req, res }) + ) { return } @@ -478,7 +485,7 @@ export const userAutocompleteValidator = [ // Private // --------------------------------------------------------------------------- -function checkUserCanModerate (onUser: MUser, res: express.Response) { +function checkCanModerate (onUser: MUser, res: express.Response) { const authUser = res.locals.oauth.token.User if (authUser.role === UserRole.ADMINISTRATOR) return true diff --git a/server/core/middlewares/validators/videos/shared/video-validators.ts b/server/core/middlewares/validators/videos/shared/video-validators.ts index 7bff15251..6b1738f11 100644 --- a/server/core/middlewares/validators/videos/shared/video-validators.ts +++ b/server/core/middlewares/validators/videos/shared/video-validators.ts @@ -98,7 +98,7 @@ export function checkVideoFileCanBeEdited (video: MVideo, res: express.Response) return true } -export function checkVideoCanBeTranscribedOrTranscripted (video: MVideo, res: express.Response) { +export function checkVideoCanBeTranscribed (video: MVideo, res: express.Response) { if (video.remote) { res.fail({ status: HttpStatusCode.BAD_REQUEST_400, diff --git a/server/core/middlewares/validators/videos/video-captions.ts b/server/core/middlewares/validators/videos/video-captions.ts index 10f27397a..9297b14e9 100644 --- a/server/core/middlewares/validators/videos/video-captions.ts +++ b/server/core/middlewares/validators/videos/video-captions.ts @@ -11,13 +11,13 @@ import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.j import { areValidationErrors, checkCanSeeVideo, - checkUserCanManageVideo, + checkCanManageVideo, doesVideoCaptionExist, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared/index.js' -import { checkVideoCanBeTranscribedOrTranscripted } from './shared/video-validators.js' +import { checkVideoCanBeTranscribed } from './shared/video-validators.js' export const addVideoCaptionValidator = [ isValidVideoIdParam('videoId'), @@ -40,7 +40,17 @@ export const addVideoCaptionValidator = [ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) { + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) { return cleanUpReqFiles(req) } @@ -70,11 +80,13 @@ export const generateVideoCaptionValidator = [ const video = res.locals.videoAll - if (!checkVideoCanBeTranscribedOrTranscripted(video, res)) return + if (!checkVideoCanBeTranscribed(video, res)) return // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) { + return + } // Check the video has not already a caption const captions = await VideoCaptionModel.listVideoCaptions(video.id) @@ -125,7 +137,17 @@ export const deleteVideoCaptionValidator = [ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } diff --git a/server/core/middlewares/validators/videos/video-channel-collaborators.ts b/server/core/middlewares/validators/videos/video-channel-collaborators.ts new file mode 100644 index 000000000..b073db3aa --- /dev/null +++ b/server/core/middlewares/validators/videos/video-channel-collaborators.ts @@ -0,0 +1,157 @@ +import { HttpStatusCode, UserRight, VideoChannelCollaboratorState } from '@peertube/peertube-models' +import { isIdValid } from '@server/helpers/custom-validators/misc.js' +import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js' +import express from 'express' +import { body, param } from 'express-validator' +import { areValidationErrors, checkCanManageAccount, doesAccountHandleExist, doesChannelHandleExist } from '../shared/index.js' + +export const channelListCollaboratorsValidator = [ + param('handle').exists(), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if ( + !await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: true, checkIsLocal: true, checkIsOwner: false, req, res }) + ) return + + return next() + } +] + +export const channelInviteCollaboratorsValidator = [ + param('handle').exists(), + + body('accountHandle').exists(), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if ( + !await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: true, checkIsLocal: true, checkIsOwner: true, req, res }) + ) { + return + } + + if (!await doesAccountHandleExist({ handle: req.body.accountHandle, req, res, checkIsLocal: true, checkCanManage: false })) return + + const user = res.locals.oauth.token.User + if (user.Account.id === res.locals.account.id) { + res.fail({ + message: req.t('Cannot invite the account owner of the channel to collaborate'), + status: HttpStatusCode.BAD_REQUEST_400 + }) + return + } + + const collaborator = await VideoChannelCollaboratorModel.loadByCollaboratorAccountName({ + accountName: req.body.accountHandle, + channelId: res.locals.videoChannel.id + }) + + if (collaborator) { + res.fail({ + message: req.t('This account is already a collaborator or has a pending invitation for this channel'), + status: HttpStatusCode.CONFLICT_409 + }) + return + } + + return next() + } +] + +export const channelAcceptOrRejectInviteCollaboratorsValidator = [ + param('handle').exists(), + param('collaboratorId').custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if ( + !await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: false, checkIsLocal: true, checkIsOwner: false, req, res }) + ) { + return + } + + if (!await doesChannelCollaboratorExist({ collaboratorId: +req.params.collaboratorId, channelHandle: req.params.handle, req, res })) { + return + } + + const channelCollaborator = res.locals.channelCollaborator + + if (channelCollaborator.state !== VideoChannelCollaboratorState.PENDING) { + res.fail({ message: req.t('Collaborator is not in pending state') }) + return + } + + const user = res.locals.oauth.token.User + if (channelCollaborator.accountId !== user.Account.id) { + res.fail({ message: req.t('Collaborator is not the current user') }) + return + } + + return next() + } +] + +export const channelDeleteCollaboratorsValidator = [ + param('handle').exists(), + param('collaboratorId').custom(isIdValid), + + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + if (!await doesChannelCollaboratorExist({ collaboratorId: +req.params.collaboratorId, channelHandle: req.params.handle, req, res })) { + return + } + + const user = res.locals.oauth.token.User + + const canManageCollaboratorAccount = checkCanManageAccount({ + user, + account: res.locals.channelCollaborator.Account, + req, + res, + specialRight: UserRight.MANAGE_ANY_VIDEO_CHANNEL + }) + + // Check this is the owner of the channel if the user is not the collaborator account + // Only the owner and the collaborator can delete the collaboration + const checkIsOwner = !canManageCollaboratorAccount + + if ( + !await doesChannelHandleExist({ handle: req.params.handle, checkCanManage: true, checkIsLocal: true, checkIsOwner, req, res }) + ) { + return + } + + return next() + } +] + +// --------------------------------------------------------------------------- +// Private +// --------------------------------------------------------------------------- + +async function doesChannelCollaboratorExist (options: { + collaboratorId: number + channelHandle: string + req: express.Request + res: express.Response +}) { + const { collaboratorId, channelHandle, req, res } = options + + const channelCollaborator = await VideoChannelCollaboratorModel.loadByChannelHandle(collaboratorId, channelHandle) + if (!channelCollaborator) { + res.fail({ + message: req.t('Channel collaborator does not exist'), + status: HttpStatusCode.NOT_FOUND_404 + }) + return false + } + + res.locals.channelCollaborator = channelCollaborator + + return true +} diff --git a/server/core/middlewares/validators/videos/video-channel-sync.ts b/server/core/middlewares/validators/videos/video-channel-sync.ts index 8aa461985..986d0d05c 100644 --- a/server/core/middlewares/validators/videos/video-channel-sync.ts +++ b/server/core/middlewares/validators/videos/video-channel-sync.ts @@ -29,7 +29,9 @@ export const videoChannelSyncValidator = [ if (areValidationErrors(req, res)) return const body: VideoChannelSyncCreate = req.body - if (!await doesChannelIdExist({ id: body.videoChannelId, checkManage: true, checkIsLocal: true, req, res })) return + if (!await doesChannelIdExist({ id: body.videoChannelId, checkCanManage: true, checkIsOwner: false, checkIsLocal: true, req, res })) { + return + } const count = await VideoChannelSyncModel.countByAccount(res.locals.videoChannel.accountId) if (count >= CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER) { @@ -49,7 +51,16 @@ export const ensureSyncExists = [ if (areValidationErrors(req, res)) return if (!await doesVideoChannelSyncIdExist(+req.params.id, res)) return - if (!await doesChannelIdExist({ id: res.locals.videoChannelSync.videoChannelId, checkManage: true, checkIsLocal: true, req, res })) { + if ( + !await doesChannelIdExist({ + id: res.locals.videoChannelSync.videoChannelId, + checkCanManage: true, + checkIsOwner: false, + checkIsLocal: true, + req, + res + }) + ) { return } diff --git a/server/core/middlewares/validators/videos/video-channels.ts b/server/core/middlewares/validators/videos/video-channels.ts index 43bbb7d96..42252a2ac 100644 --- a/server/core/middlewares/validators/videos/video-channels.ts +++ b/server/core/middlewares/validators/videos/video-channels.ts @@ -81,9 +81,10 @@ export const videoChannelsRemoveValidator = [ export const videoChannelsHandleValidatorFactory = (options: { checkIsLocal: boolean - checkManage: boolean + checkCanManage: boolean + checkIsOwner: boolean }) => { - const { checkIsLocal, checkManage } = options + const { checkIsLocal, checkCanManage, checkIsOwner } = options return [ param('handle') @@ -92,7 +93,7 @@ export const videoChannelsHandleValidatorFactory = (options: { async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesChannelHandleExist({ handle: req.params.handle, checkManage, checkIsLocal, req, res })) return + if (!await doesChannelHandleExist({ handle: req.params.handle, checkCanManage, checkIsLocal, checkIsOwner, req, res })) return return next() } @@ -110,14 +111,18 @@ export const ensureChannelOwnerCanUpload = [ } ] -export const videoChannelStatsValidator = [ +export const listAccountChannelsValidator = [ query('withStats') .optional() - .customSanitizer(toBooleanOrNull) - .custom(isBooleanValid).withMessage('Should have a valid stats flag boolean'), + .customSanitizer(toBooleanOrNull), + + query('includeCollaborations') + .optional() + .customSanitizer(toBooleanOrNull), (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return + return next() } ] diff --git a/server/core/middlewares/validators/videos/video-chapters.ts b/server/core/middlewares/validators/videos/video-chapters.ts index d6e8acf62..e7a0912af 100644 --- a/server/core/middlewares/validators/videos/video-chapters.ts +++ b/server/core/middlewares/validators/videos/video-chapters.ts @@ -1,7 +1,7 @@ import express from 'express' import { body } from 'express-validator' import { HttpStatusCode, UserRight } from '@peertube/peertube-models' -import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' +import { areValidationErrors, checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js' export const updateVideoChaptersValidator = [ @@ -24,7 +24,17 @@ export const updateVideoChaptersValidator = [ // Check if the user who did the request is able to update video chapters (same right as updating the video) const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } diff --git a/server/core/middlewares/validators/videos/video-comments.ts b/server/core/middlewares/validators/videos/video-comments.ts index a74b81aa6..226e77e1d 100644 --- a/server/core/middlewares/validators/videos/video-comments.ts +++ b/server/core/middlewares/validators/videos/video-comments.ts @@ -2,6 +2,7 @@ import { arrayify } from '@peertube/peertube-core-utils' import { HttpStatusCode, UserRight, VideoCommentPolicy } from '@peertube/peertube-models' import { isStringArray } from '@server/helpers/custom-validators/search.js' import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js' +import { VideoChannelModel } from '@server/models/video/video-channel.js' import { MUserAccountUrl } from '@server/types/models/index.js' import express from 'express' import { body, param, query } from 'express-validator' @@ -21,9 +22,10 @@ import { Hooks } from '../../../lib/plugins/hooks.js' import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video/index.js' import { areValidationErrors, + canManageChannel, + checkCanManageAccount, + checkCanManageVideo, checkCanSeeVideo, - checkUserCanManageAccount, - checkUserCanManageVideo, doesChannelIdExist, doesVideoCommentExist, doesVideoCommentThreadExist, @@ -53,7 +55,7 @@ export const listAllVideoCommentsForAdminValidator = [ if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'unsafe-only-immutable-attributes')) return if ( req.query.videoChannelId && - !await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: true, checkIsLocal: true, req, res }) + !await doesChannelIdExist({ id: req.query.videoChannelId, checkCanManage: true, checkIsOwner: false, checkIsLocal: true, req, res }) ) return return next() @@ -65,9 +67,11 @@ export const listCommentsOnUserVideosValidator = [ query('isHeldForReview') .optional() - .customSanitizer(toBooleanOrNull) - .custom(isBooleanValid) - .withMessage('Should have a valid isHeldForReview boolean'), + .customSanitizer(toBooleanOrNull), + + query('includeCollaborations') + .optional() + .customSanitizer(toBooleanOrNull), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return @@ -75,16 +79,19 @@ export const listCommentsOnUserVideosValidator = [ if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'all')) return if ( req.query.videoChannelId && - !await doesChannelIdExist({ id: req.query.videoChannelId, checkManage: true, checkIsLocal: true, req, res }) + !await doesChannelIdExist({ id: req.query.videoChannelId, checkCanManage: true, checkIsLocal: true, checkIsOwner: false, req, res }) ) return const user = res.locals.oauth.token.User const video = res.locals.videoAll - if (video && !checkUserCanManageVideo({ user, video, right: UserRight.SEE_ALL_COMMENTS, req, res })) return + if ( + video && + !await checkCanManageVideo({ user, video, right: UserRight.SEE_ALL_COMMENTS, req, res, checkIsLocal: true, checkIsOwner: false }) + ) return const channel = res.locals.videoChannel - if (channel && !checkUserCanManageAccount({ account: channel.Account, user, req, res, specialRight: UserRight.SEE_ALL_COMMENTS })) { + if (channel && !checkCanManageAccount({ account: channel.Account, user, req, res, specialRight: UserRight.SEE_ALL_COMMENTS })) { return } @@ -197,7 +204,9 @@ export const removeVideoCommentValidator = [ if (!await doesVideoExist(req.params.videoId, res)) return if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return - if (!checkUserCanDeleteVideoComment(res.locals.oauth.token.User, res.locals.videoCommentFull, res)) return + if (!await checkCanDeleteVideoComment({ user: res.locals.oauth.token.User, videoComment: res.locals.videoCommentFull, req, res })) { + return + } return next() } @@ -214,7 +223,9 @@ export const approveVideoCommentValidator = [ if (!await doesVideoExist(req.params.videoId, res)) return if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return - if (!checkUserCanApproveVideoComment(res.locals.oauth.token.User, res.locals.videoCommentFull, res)) return + if (!await checkCanApproveVideoComment({ user: res.locals.oauth.token.User, videoComment: res.locals.videoCommentFull, req, res })) { + return + } return next() } @@ -236,63 +247,76 @@ function isVideoCommentsEnabled (video: MVideo, res: express.Response) { return true } -function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) { +function checkCanDeleteVideoComment (options: { + user: MUserAccountUrl + videoComment: MCommentOwnerVideoReply + req: express.Request + res: express.Response +}): Promise { + const { user, videoComment, req, res } = options + if (videoComment.isDeleted()) { res.fail({ status: HttpStatusCode.CONFLICT_409, - message: 'This comment is already deleted' + message: req.t('This comment is already deleted') }) - return false + return Promise.resolve(false) } - const userAccount = user.Account - - if ( - user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) === false && // Not a moderator - videoComment.accountId !== userAccount.id && // Not the comment owner - videoComment.Video.VideoChannel.accountId !== userAccount.id // Not the video owner - ) { - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot remove video comment of another user' - }) - return false + // Owner of the comment + if (videoComment.accountId === user.Account.id) { + return Promise.resolve(true) } - return true + return checkCanManageCommentsOfVideo(options) } -function checkUserCanApproveVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) { +function checkCanApproveVideoComment (options: { + user: MUserAccountUrl + videoComment: MCommentOwnerVideoReply + req: express.Request + res: express.Response +}): Promise { + const { user, videoComment, req, res } = options + if (videoComment.isDeleted()) { res.fail({ status: HttpStatusCode.CONFLICT_409, - message: 'This comment is deleted' + message: req.t('This comment is deleted') }) - return false + return Promise.resolve(false) } if (videoComment.heldForReview !== true) { res.fail({ status: HttpStatusCode.BAD_REQUEST_400, - message: 'This comment is not held for review' + message: req.t('This comment is not held for review') }) - return false + return Promise.resolve(false) } - const userAccount = user.Account + return checkCanManageCommentsOfVideo({ user, videoComment, req, res }) +} - if ( - user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT) === false && // Not a moderator - videoComment.Video.VideoChannel.accountId !== userAccount.id // Not the video owner - ) { - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot approve video comment of another user' - }) - return false - } +async function checkCanManageCommentsOfVideo (options: { + user: MUserAccountUrl + videoComment: MCommentOwnerVideoReply + req: express.Request + res: express.Response +}) { + const { user, videoComment, req, res } = options - return true + if (user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) return true + + const channel = await VideoChannelModel.loadAndPopulateAccount(videoComment.Video.VideoChannel.id) + if (await canManageChannel({ channel, user, req, res: null, checkCanManage: true, checkIsOwner: false })) return true + + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: req.t('User does not have the permission to delete this comment') + }) + + return false } async function isVideoCommentAccepted (req: express.Request, res: express.Response, video: MVideoFullLight, isReply: boolean) { diff --git a/server/core/middlewares/validators/videos/video-imports.ts b/server/core/middlewares/validators/videos/video-imports.ts index d1829d4a7..a2220dc47 100644 --- a/server/core/middlewares/validators/videos/video-imports.ts +++ b/server/core/middlewares/validators/videos/video-imports.ts @@ -3,7 +3,7 @@ import { HttpStatusCode, UserRight, VideoImportCreate, VideoImportState } from ' import { isResolvingToUnicastOnly } from '@server/helpers/dns.js' import { isPreImportVideoAccepted } from '@server/lib/moderation.js' import { Hooks } from '@server/lib/plugins/hooks.js' -import { MUserAccountId, MVideoImport } from '@server/types/models/index.js' +import { MUserAccountId, MVideoImportDefault } from '@server/types/models/index.js' import express from 'express' import { body, param, query } from 'express-validator' import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc.js' @@ -13,10 +13,10 @@ import { cleanUpReqFiles } from '../../../helpers/express-utils.js' import { logger } from '../../../helpers/logger.js' import { CONFIG } from '../../../initializers/config.js' import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js' -import { areValidationErrors, doesVideoChannelOfAccountExist, doesVideoImportExist } from '../shared/index.js' +import { areValidationErrors, checkCanManageVideo, doesChannelIdExist, doesVideoImportExist } from '../shared/index.js' import { areErrorsInNSFW, getCommonVideoEditAttributes } from './videos.js' -const videoImportAddValidator = getCommonVideoEditAttributes().concat([ +export const videoImportAddValidator = getCommonVideoEditAttributes().concat([ body('channelId') .customSanitizer(toIntOrNull) .custom(isIdValid), @@ -43,7 +43,6 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ .withMessage('Video passwords should be an array.'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { - const user = res.locals.oauth.token.User const torrentFile = req.files?.['torrentfile'] ? req.files['torrentfile'][0] : undefined if (areValidationErrors(req, res)) return cleanUpReqFiles(req) @@ -69,7 +68,9 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ }) } - if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) + if (!await doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })) { + return cleanUpReqFiles(req) + } // Check we have at least 1 required param if (!req.body.targetUrl && !req.body.magnetUri && !torrentFile) { @@ -97,7 +98,7 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([ } ]) -const getMyVideoImportsValidator = [ +export const getMyVideoImportsValidator = [ query('videoChannelSyncId') .optional() .custom(isIdValid), @@ -109,7 +110,7 @@ const getMyVideoImportsValidator = [ } ] -const videoImportDeleteValidator = [ +export const videoImportDeleteValidator = [ param('id') .custom(isIdValid), @@ -117,7 +118,7 @@ const videoImportDeleteValidator = [ if (areValidationErrors(req, res)) return if (!await doesVideoImportExist(parseInt(req.params.id), res)) return - if (!checkUserCanManageImport(res.locals.oauth.token.user, res.locals.videoImport, res)) return + if (!await checkCanManageImport({ user: res.locals.oauth.token.User, videoImport: res.locals.videoImport, req, res })) return if (res.locals.videoImport.state === VideoImportState.PENDING) { return res.fail({ @@ -130,7 +131,7 @@ const videoImportDeleteValidator = [ } ] -const videoImportCancelValidator = [ +export const videoImportCancelValidator = [ param('id') .custom(isIdValid), @@ -138,7 +139,7 @@ const videoImportCancelValidator = [ if (areValidationErrors(req, res)) return if (!await doesVideoImportExist(forceNumber(req.params.id), res)) return - if (!checkUserCanManageImport(res.locals.oauth.token.user, res.locals.videoImport, res)) return + if (!await checkCanManageImport({ user: res.locals.oauth.token.User, videoImport: res.locals.videoImport, req, res })) return if (res.locals.videoImport.state !== VideoImportState.PENDING) { return res.fail({ @@ -152,14 +153,7 @@ const videoImportCancelValidator = [ ] // --------------------------------------------------------------------------- - -export { - getMyVideoImportsValidator, - videoImportAddValidator, - videoImportCancelValidator, - videoImportDeleteValidator -} - +// Private // --------------------------------------------------------------------------- async function isImportAccepted (req: express.Request, res: express.Response) { @@ -192,14 +186,34 @@ async function isImportAccepted (req: express.Request, res: express.Response) { return true } -function checkUserCanManageImport (user: MUserAccountId, videoImport: MVideoImport, res: express.Response) { - if (user.hasRight(UserRight.MANAGE_VIDEO_IMPORTS) === false && videoImport.userId !== user.id) { - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot manage video import of another user' +async function checkCanManageImport (options: { + user: MUserAccountId + videoImport: MVideoImportDefault + req: express.Request + res: express.Response +}) { + const { user, videoImport, req, res } = options + + if (user.hasRight(UserRight.MANAGE_VIDEO_IMPORTS) === false) return true + if (videoImport.userId === user.id) return true + if ( + videoImport.Video && + await checkCanManageVideo({ + user, + video: videoImport.Video, + req, + res, + right: UserRight.MANAGE_VIDEO_IMPORTS, + checkIsLocal: true, + checkIsOwner: false }) - return false + ) { + return true } - return true + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: req.t('Cannot manage video import of another user') + }) + return false } diff --git a/server/core/middlewares/validators/videos/video-live.ts b/server/core/middlewares/validators/videos/video-live.ts index 633ea392e..1bf8b8cb8 100644 --- a/server/core/middlewares/validators/videos/video-live.ts +++ b/server/core/middlewares/validators/videos/video-live.ts @@ -7,7 +7,7 @@ import { UserRight, VideoState } from '@peertube/peertube-models' -import { isLiveLatencyModeValid, areLiveSchedulesValid } from '@server/helpers/custom-validators/video-lives.js' +import { areLiveSchedulesValid, isLiveLatencyModeValid } from '@server/helpers/custom-validators/video-lives.js' import { CONSTRAINTS_FIELDS } from '@server/initializers/constants.js' import { isLocalLiveVideoAccepted } from '@server/lib/moderation.js' import { Hooks } from '@server/lib/plugins/hooks.js' @@ -21,16 +21,10 @@ import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacy import { cleanUpReqFiles } from '../../../helpers/express-utils.js' import { logger } from '../../../helpers/logger.js' import { CONFIG } from '../../../initializers/config.js' -import { - areValidationErrors, - checkUserCanManageVideo, - doesVideoChannelOfAccountExist, - doesVideoExist, - isValidVideoIdParam -} from '../shared/index.js' +import { areValidationErrors, checkCanManageVideo, doesChannelIdExist, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' import { areErrorsInNSFW, getCommonVideoEditAttributes } from './videos.js' -const videoLiveGetValidator = [ +export const videoLiveGetValidator = [ isValidVideoIdParam('videoId'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -51,7 +45,7 @@ const videoLiveGetValidator = [ } ] -const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ +export const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ body('channelId') .customSanitizer(toIntOrNull) .custom(isIdValid), @@ -127,8 +121,9 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ }) } - const user = res.locals.oauth.token.User - if (!await doesVideoChannelOfAccountExist(body.channelId, user, res)) return cleanUpReqFiles(req) + if (!await doesChannelIdExist({ id: body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })) { + return cleanUpReqFiles(req) + } if (CONFIG.LIVE.MAX_INSTANCE_LIVES !== -1) { const totalInstanceLives = await VideoModel.countLives({ remote: false, mode: 'not-ended' }) @@ -145,6 +140,8 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ } if (CONFIG.LIVE.MAX_USER_LIVES !== -1) { + const user = res.locals.oauth.token.User + const totalUserLives = await VideoModel.countLivesOfAccount(user.Account.id) if (totalUserLives >= CONFIG.LIVE.MAX_USER_LIVES) { @@ -164,7 +161,7 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([ } ]) -const videoLiveUpdateValidator = [ +export const videoLiveUpdateValidator = [ body('saveReplay') .optional() .customSanitizer(toBooleanOrNull) @@ -184,7 +181,7 @@ const videoLiveUpdateValidator = [ .optional() .custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'), - (req: express.Request, res: express.Response, next: express.NextFunction) => { + async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return const body: LiveVideoUpdate = req.body @@ -211,23 +208,43 @@ const videoLiveUpdateValidator = [ // Check the user can manage the live const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.GET_ANY_LIVE, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.GET_ANY_LIVE, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } ] -const videoLiveListSessionsValidator = [ - (req: express.Request, res: express.Response, next: express.NextFunction) => { +export const videoLiveListSessionsValidator = [ + async (req: express.Request, res: express.Response, next: express.NextFunction) => { // Check the user can manage the live const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.GET_ANY_LIVE, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.GET_ANY_LIVE, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } ] -const videoLiveFindReplaySessionValidator = [ +export const videoLiveFindReplaySessionValidator = [ isValidVideoIdParam('videoId'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { @@ -249,15 +266,7 @@ const videoLiveFindReplaySessionValidator = [ ] // --------------------------------------------------------------------------- - -export { - videoLiveAddValidator, - videoLiveFindReplaySessionValidator, - videoLiveGetValidator, - videoLiveListSessionsValidator, - videoLiveUpdateValidator -} - +// Private // --------------------------------------------------------------------------- async function isLiveVideoAccepted (req: express.Request, res: express.Response) { diff --git a/server/core/middlewares/validators/videos/video-ownership-changes.ts b/server/core/middlewares/validators/videos/video-ownership-changes.ts index 8c9e1d857..9e7e8df9e 100644 --- a/server/core/middlewares/validators/videos/video-ownership-changes.ts +++ b/server/core/middlewares/validators/videos/video-ownership-changes.ts @@ -1,16 +1,16 @@ import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models' import { isIdValid } from '@server/helpers/custom-validators/misc.js' -import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership.js' +import { checkCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership.js' import { AccountModel } from '@server/models/account/account.js' import { MVideoWithAllFiles } from '@server/types/models/index.js' import express from 'express' import { param } from 'express-validator' import { areValidationErrors, - checkUserCanManageVideo, + checkCanManageVideo, checkUserQuota, doesChangeVideoOwnershipExist, - doesVideoChannelOfAccountExist, + doesChannelIdExist, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' @@ -24,10 +24,12 @@ export const videosChangeOwnershipValidator = [ // Check if the user who did the request is able to change the ownership of the video if ( - !checkUserCanManageVideo({ + !await checkCanManageVideo({ user: res.locals.oauth.token.User, video: res.locals.videoAll, right: UserRight.CHANGE_VIDEO_OWNERSHIP, + checkIsOwner: true, + checkIsLocal: true, req, res }) @@ -53,7 +55,14 @@ export const videosTerminateChangeOwnershipValidator = [ if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return // Check if the user who did the request is able to change the ownership of the video - if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return + if ( + !checkCanTerminateOwnershipChange({ + user: res.locals.oauth.token.User, + videoChangeOwnership: res.locals.videoChangeOwnership, + req, + res + }) + ) return const videoChangeOwnership = res.locals.videoChangeOwnership @@ -72,7 +81,7 @@ export const videosTerminateChangeOwnershipValidator = [ export const videosAcceptChangeOwnershipValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { const body = req.body as VideoChangeOwnershipAccept - if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return + if (!await doesChannelIdExist({ id: body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: true })) return const videoChangeOwnership = res.locals.videoChangeOwnership diff --git a/server/core/middlewares/validators/videos/video-passwords.ts b/server/core/middlewares/validators/videos/video-passwords.ts index 8d7e56629..e403631ca 100644 --- a/server/core/middlewares/validators/videos/video-passwords.ts +++ b/server/core/middlewares/validators/videos/video-passwords.ts @@ -1,36 +1,46 @@ -import express from 'express' -import { - areValidationErrors, - doesVideoExist, - isVideoPasswordProtected, - isValidVideoIdParam, - doesVideoPasswordExist, - isVideoPasswordDeletable, - checkUserCanManageVideo -} from '../shared/index.js' -import { body, param } from 'express-validator' +import { UserRight } from '@peertube/peertube-models' import { isIdValid } from '@server/helpers/custom-validators/misc.js' import { isValidPasswordProtectedPrivacy } from '@server/helpers/custom-validators/videos.js' -import { UserRight } from '@peertube/peertube-models' +import express from 'express' +import { body, param } from 'express-validator' +import { + areValidationErrors, + checkCanDeleteVideoPassword, + checkCanManageVideo, + doesVideoExist, + doesVideoPasswordExist, + isValidVideoIdParam, + checkVideoIsPasswordProtected +} from '../shared/index.js' -const listVideoPasswordValidator = [ +export const listVideoPasswordValidator = [ isValidVideoIdParam('videoId'), async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return if (!await doesVideoExist(req.params.videoId, res)) return - if (!isVideoPasswordProtected(res)) return + if (!checkVideoIsPasswordProtected(req, res)) return // Check if the user who did the request is able to access video password list const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.SEE_ALL_VIDEOS, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.SEE_ALL_VIDEOS, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } ] -const updateVideoPasswordListValidator = [ +export const updateVideoPasswordListValidator = [ body('passwords') .optional() .isArray() @@ -44,13 +54,23 @@ const updateVideoPasswordListValidator = [ // Check if the user who did the request is able to update video passwords const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) return return next() } ] -const removeVideoPasswordValidator = [ +export const removeVideoPasswordValidator = [ isValidVideoIdParam('videoId'), param('passwordId') @@ -60,18 +80,11 @@ const removeVideoPasswordValidator = [ if (areValidationErrors(req, res)) return if (!await doesVideoExist(req.params.videoId, res)) return - if (!isVideoPasswordProtected(res)) return - if (!await doesVideoPasswordExist(req.params.passwordId, res)) return - if (!await isVideoPasswordDeletable(res)) return + if (!checkVideoIsPasswordProtected(req, res)) return + if (!await doesVideoPasswordExist({ id: req.params.passwordId, req, res })) return + + if (!await checkCanDeleteVideoPassword({ user: res.locals.oauth.token.User, video: res.locals.videoAll, req, res })) return return next() } ] - -// --------------------------------------------------------------------------- - -export { - listVideoPasswordValidator, - updateVideoPasswordListValidator, - removeVideoPasswordValidator -} diff --git a/server/core/middlewares/validators/videos/video-playlists.ts b/server/core/middlewares/validators/videos/video-playlists.ts index 3549e2466..3f549bba6 100644 --- a/server/core/middlewares/validators/videos/video-playlists.ts +++ b/server/core/middlewares/validators/videos/video-playlists.ts @@ -8,6 +8,7 @@ import { VideoPlaylistType, VideoPlaylistUpdate } from '@peertube/peertube-models' +import { VideoChannelModel } from '@server/models/video/video-channel.js' import { VideoPlaylistModel } from '@server/models/video/video-playlist.js' import { ExpressPromiseHandler } from '@server/types/express-handler.js' import { MUserAccountId } from '@server/types/models/index.js' @@ -18,6 +19,7 @@ import { isIdOrUUIDValid, isIdValid, isUUIDValid, + toBooleanOrNull, toCompleteUUID, toIntArray, toIntOrNull, @@ -34,11 +36,13 @@ import { isVideoImageValid } from '../../../helpers/custom-validators/videos.js' import { cleanUpReqFiles } from '../../../helpers/express-utils.js' import { CONSTRAINTS_FIELDS } from '../../../initializers/constants.js' import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element.js' -import { MVideoPlaylist } from '../../../types/models/video/video-playlist.js' +import { MVideoPlaylistFullSummary } from '../../../types/models/video/video-playlist.js' import { authenticatePromise } from '../../auth.js' import { areValidationErrors, - doesVideoChannelOfAccountExist, + canManageChannel, + checkCanManageAccount, + doesChannelIdExist, doesVideoExist, doesVideoPlaylistExist, isValidPlaylistIdParam, @@ -53,7 +57,10 @@ export const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().conc if (areValidationErrors(req, res)) return cleanUpReqFiles(req) const body: VideoPlaylistCreate = req.body - if (body.videoChannelId && !await doesVideoChannelOfAccountExist(body.videoChannelId, res.locals.oauth.token.User, res)) { + if ( + body.videoChannelId && + !await doesChannelIdExist({ id: body.videoChannelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false }) + ) { return cleanUpReqFiles(req) } @@ -84,7 +91,15 @@ export const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().c const videoPlaylist = getPlaylist(res) - if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) { + if ( + !await checkCanManagePlaylist({ + user: res.locals.oauth.token.User, + videoPlaylist, + right: UserRight.REMOVE_ANY_VIDEO_PLAYLIST, + req, + res + }) + ) { return cleanUpReqFiles(req) } @@ -109,7 +124,10 @@ export const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().c return res.fail({ message: 'Cannot update a watch later playlist.' }) } - if (body.videoChannelId && !await doesVideoChannelOfAccountExist(body.videoChannelId, res.locals.oauth.token.User, res)) { + if ( + body.videoChannelId && + !await doesChannelIdExist({ id: body.videoChannelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false }) + ) { return cleanUpReqFiles(req) } @@ -130,7 +148,15 @@ export const videoPlaylistsDeleteValidator = [ return res.fail({ message: 'Cannot delete a watch later playlist.' }) } - if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) { + if ( + !await checkCanManagePlaylist({ + user: res.locals.oauth.token.User, + videoPlaylist, + right: UserRight.REMOVE_ANY_VIDEO_PLAYLIST, + req, + res + }) + ) { return } @@ -194,6 +220,18 @@ export const videoPlaylistsSearchValidator = [ } ] +export const videoPlaylistsAccountValidator = [ + query('includeCollaborations') + .optional() + .customSanitizer(toBooleanOrNull), + + (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (areValidationErrors(req, res)) return + + return next() + } +] + export const videoPlaylistsAddVideoValidator = [ isValidPlaylistIdParam('playlistId'), @@ -215,7 +253,15 @@ export const videoPlaylistsAddVideoValidator = [ const videoPlaylist = getPlaylist(res) - if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) { + if ( + !await checkCanManagePlaylist({ + user: res.locals.oauth.token.User, + videoPlaylist, + right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST, + req, + res + }) + ) { return } @@ -225,9 +271,11 @@ export const videoPlaylistsAddVideoValidator = [ export const videoPlaylistsUpdateOrRemoveVideoValidator = [ isValidPlaylistIdParam('playlistId'), + param('playlistElementId') .customSanitizer(toCompleteUUID) .custom(isIdValid).withMessage('Should have an element id/uuid/short uuid'), + body('startTimestamp') .optional() .custom(isVideoPlaylistTimestampValid), @@ -252,7 +300,15 @@ export const videoPlaylistsUpdateOrRemoveVideoValidator = [ } res.locals.videoPlaylistElement = videoPlaylistElement - if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return + if ( + !await checkCanManagePlaylist({ + user: res.locals.oauth.token.User, + videoPlaylist, + right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST, + req, + res + }) + ) return return next() } @@ -339,7 +395,15 @@ export const videoPlaylistsReorderVideosValidator = [ if (!await doesVideoPlaylistExist(req.params.playlistId, res, 'all')) return const videoPlaylist = getPlaylist(res) - if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.UPDATE_ANY_VIDEO_PLAYLIST, res)) return + if ( + !await checkCanManagePlaylist({ + user: res.locals.oauth.token.User, + videoPlaylist, + right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST, + req, + res + }) + ) return const nextPosition = await VideoPlaylistElementModel.getNextPositionOf(videoPlaylist.id) const startPosition: number = req.body.startPosition @@ -384,6 +448,8 @@ export const doVideosInPlaylistExistValidator = [ } ] +// --------------------------------------------------------------------------- +// Private // --------------------------------------------------------------------------- function getCommonPlaylistEditAttributes () { @@ -409,32 +475,49 @@ function getCommonPlaylistEditAttributes () { ] as (ValidationChain | ExpressPromiseHandler)[] } -function checkUserCanManageVideoPlaylist ( - user: MUserAccountId, - videoPlaylist: MVideoPlaylist, - right: UserRightType, +async function checkCanManagePlaylist (options: { + user: MUserAccountId + videoPlaylist: MVideoPlaylistFullSummary + right: UserRightType + req: express.Request res: express.Response -) { - if (videoPlaylist.isOwned() === false) { +}) { + const { user, videoPlaylist, right, res, req } = options + + if (videoPlaylist.isLocal() === false) { res.fail({ status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot manage video playlist of another server.' + message: req.t('Cannot manage video playlist of another server.') }) return false } - // Check if the user can manage the video playlist - // The user can delete it if s/he is an admin - // Or if s/he is the video playlist's owner - if (user.hasRight(right) === false && videoPlaylist.ownerAccountId !== user.Account.id) { - res.fail({ - status: HttpStatusCode.FORBIDDEN_403, - message: 'Cannot manage video playlist of another user' - }) - return false + if (checkCanManageAccount({ user, account: videoPlaylist.OwnerAccount, specialRight: right, req, res: null })) return true + + if (videoPlaylist.videoChannelId) { + const channel = await VideoChannelModel.loadAndPopulateAccount(videoPlaylist.videoChannelId) + + if ( + await canManageChannel({ + channel, + user, + req, + res: null, + checkCanManage: true, + checkIsOwner: false, + specialRight: right + }) + ) { + return true + } } - return true + res.fail({ + status: HttpStatusCode.FORBIDDEN_403, + message: req.t('Cannot manage video playlist of another user') + }) + + return false } function getPlaylist (res: express.Response) { diff --git a/server/core/middlewares/validators/videos/video-source.ts b/server/core/middlewares/validators/videos/video-source.ts index 6d958ab58..fd0861c89 100644 --- a/server/core/middlewares/validators/videos/video-source.ts +++ b/server/core/middlewares/validators/videos/video-source.ts @@ -10,7 +10,7 @@ import { param } from 'express-validator' import { areValidationErrors, checkCanAccessVideoSourceFile, - checkUserCanManageVideo, + checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' @@ -26,7 +26,9 @@ export const videoSourceGetLatestValidator = [ const video = getVideoWithAttributes(res) as MVideoFullLight const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return + if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) { + return + } res.locals.videoSource = await VideoSourceModel.loadLatest(video.id) @@ -123,7 +125,9 @@ async function checkCanUpdateVideoFile (options: { const user = res.locals.oauth.token.User const video = res.locals.videoAll - if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return false + if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) { + return false + } if (!checkVideoFileCanBeEdited(video, res)) return false diff --git a/server/core/middlewares/validators/videos/video-stats.ts b/server/core/middlewares/validators/videos/video-stats.ts index d9b3d9ccc..1d2d4f26e 100644 --- a/server/core/middlewares/validators/videos/video-stats.ts +++ b/server/core/middlewares/validators/videos/video-stats.ts @@ -4,7 +4,7 @@ import { isDateValid } from '@server/helpers/custom-validators/misc.js' import { isValidStatTimeserieMetric } from '@server/helpers/custom-validators/video-stats.js' import { STATS_TIMESERIE } from '@server/initializers/constants.js' import { HttpStatusCode, UserRight, VideoStatsTimeserieQuery } from '@peertube/peertube-models' -import { areValidationErrors, checkUserCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' +import { areValidationErrors, checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' export const videoOverallOrUserAgentStatsValidator = [ isValidVideoIdParam('videoId'), @@ -89,8 +89,17 @@ export const videoTimeseriesStatsValidator = [ async function commonStatsCheck (req: express.Request, res: express.Response) { if (!await doesVideoExist(req.params.videoId, res, 'all')) return false + if ( - !checkUserCanManageVideo({ user: res.locals.oauth.token.User, video: res.locals.videoAll, right: UserRight.SEE_ALL_VIDEOS, req, res }) + !await checkCanManageVideo({ + user: res.locals.oauth.token.User, + video: res.locals.videoAll, + right: UserRight.SEE_ALL_VIDEOS, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) ) { return false } diff --git a/server/core/middlewares/validators/videos/video-studio.ts b/server/core/middlewares/validators/videos/video-studio.ts index 91ee10b51..2c1ab4472 100644 --- a/server/core/middlewares/validators/videos/video-studio.ts +++ b/server/core/middlewares/validators/videos/video-studio.ts @@ -12,7 +12,7 @@ import { CONFIG } from '@server/initializers/config.js' import { approximateIntroOutroAdditionalSize, getTaskFileFromReq } from '@server/lib/video-studio.js' import { isAudioFile } from '@peertube/peertube-ffmpeg' import { HttpStatusCode, UserRight, VideoStudioCreateEdition, VideoStudioTask } from '@peertube/peertube-models' -import { areValidationErrors, checkUserCanManageVideo, checkUserQuota, doesVideoExist } from '../shared/index.js' +import { areValidationErrors, checkCanManageVideo, checkUserQuota, doesVideoExist } from '../shared/index.js' import { checkVideoFileCanBeEdited } from './shared/index.js' const videoStudioAddEditionValidator = [ @@ -82,7 +82,9 @@ const videoStudioAddEditionValidator = [ if (!checkVideoFileCanBeEdited(video, res)) return cleanUpReqFiles(req) const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res })) return cleanUpReqFiles(req) + if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) { + return cleanUpReqFiles(req) + } // Try to make an approximation of bytes added by the intro/outro const additionalBytes = await approximateIntroOutroAdditionalSize(video, body.tasks, i => getTaskFileFromReq(files, i).path) diff --git a/server/core/middlewares/validators/videos/video-transcoding.ts b/server/core/middlewares/validators/videos/video-transcoding.ts index 8bc10d059..32799bcd8 100644 --- a/server/core/middlewares/validators/videos/video-transcoding.ts +++ b/server/core/middlewares/validators/videos/video-transcoding.ts @@ -6,7 +6,7 @@ import { VideoJobInfoModel } from '@server/models/video/video-job-info.js' import express from 'express' import { body } from 'express-validator' import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js' -import { checkVideoCanBeTranscribedOrTranscripted } from './shared/video-validators.js' +import { checkVideoCanBeTranscribed } from './shared/video-validators.js' const createTranscodingValidator = [ isValidVideoIdParam('videoId'), @@ -25,7 +25,7 @@ const createTranscodingValidator = [ const video = res.locals.videoAll - if (!checkVideoCanBeTranscribedOrTranscripted(video, res)) return + if (!checkVideoCanBeTranscribed(video, res)) return if (CONFIG.TRANSCODING.ENABLED !== true) { return res.fail({ diff --git a/server/core/middlewares/validators/videos/videos.ts b/server/core/middlewares/validators/videos/videos.ts index be0add2a0..b5a9ceeac 100644 --- a/server/core/middlewares/validators/videos/videos.ts +++ b/server/core/middlewares/validators/videos/videos.ts @@ -59,8 +59,8 @@ import { areValidationErrors, checkCanAccessVideoStaticFiles, checkCanSeeVideo, - checkUserCanManageVideo, - doesVideoChannelOfAccountExist, + checkCanManageVideo, + doesChannelIdExist, doesVideoExist, doesVideoFileOfVideoExist, isValidVideoIdParam, @@ -122,7 +122,6 @@ export const videosAddLegacyValidator = [ */ export const videosAddResumableValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { - const user = res.locals.oauth.token.User const file = buildUploadXFile(req.body as express.CustomUploadXFile) const cleanup = () => { safeUploadXCleanup(file) @@ -145,7 +144,12 @@ export const videosAddResumableValidator = [ await Redis.Instance.setUploadSession(uploadId) - if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup() + if ( + !await doesChannelIdExist({ id: file.metadata.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false }) + ) { + return cleanup() + } + if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'videosAddResumableValidator' })) return cleanup() if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.upload.accept.result' })) return cleanup() @@ -231,12 +235,26 @@ export const videosUpdateValidator = getCommonVideoEditAttributes().concat([ // Check if the user who did the request is able to update the video const user = res.locals.oauth.token.User - if (!checkUserCanManageVideo({ user, video: res.locals.videoAll, right: UserRight.UPDATE_ANY_VIDEO, req, res })) { + if ( + !await checkCanManageVideo({ + user, + video: res.locals.videoAll, + right: UserRight.UPDATE_ANY_VIDEO, + req, + res, + checkIsLocal: true, + checkIsOwner: false + }) + ) { return cleanUpReqFiles(req) } - if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) - + if ( + req.body.channelId && + !await doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false }) + ) { + return cleanUpReqFiles(req) + } return next() } ]) @@ -245,7 +263,7 @@ export async function checkVideoFollowConstraints (req: express.Request, res: ex const video = getVideoWithAttributes(res) // Anybody can watch local videos - if (video.isOwned() === true) return next() + if (video.isLocal() === true) return next() // Logged user if (res.locals.oauth) { @@ -346,12 +364,14 @@ export const videosRemoveValidator = [ // Check if the user who did the request is able to delete the video if ( - !checkUserCanManageVideo({ + !await checkCanManageVideo({ user: res.locals.oauth.token.User, video: res.locals.videoAll, right: UserRight.REMOVE_ANY_VIDEO, req, - res + res, + checkIsLocal: true, + checkIsOwner: false }) ) return @@ -625,12 +645,14 @@ async function commonVideoChecksPass (options: { videoFileSize: number files: express.UploadFilesForCheck }): Promise { - const { req, res, user } = options + const { req, res } = options if (areErrorsInScheduleUpdate(req, res)) return false if (areErrorsInNSFW(req, res)) return false - if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false + if (!await doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })) { + return false + } if (!await commonVideoFileChecks(options)) return false diff --git a/server/core/middlewares/validators/watched-words.ts b/server/core/middlewares/validators/watched-words.ts index 774d30c62..51e75a9a8 100644 --- a/server/core/middlewares/validators/watched-words.ts +++ b/server/core/middlewares/validators/watched-words.ts @@ -16,7 +16,7 @@ export const manageAccountWatchedWordsListValidator = [ async (req: express.Request, res: express.Response, next: express.NextFunction) => { if (areValidationErrors(req, res)) return - if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkManage: true })) return + if (!await doesAccountHandleExist({ handle: req.params.accountName, req, res, checkIsLocal: true, checkCanManage: true })) return return next() } diff --git a/server/core/models/account/account.ts b/server/core/models/account/account.ts index c43f0aa91..b7464a5a6 100644 --- a/server/core/models/account/account.ts +++ b/server/core/models/account/account.ts @@ -1,4 +1,5 @@ import { Account, AccountSummary, ActivityPubActor, VideoPrivacy } from '@peertube/peertube-models' +import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { ModelCache } from '@server/models/shared/model-cache.js' import { FindOptions, IncludeOptions, Includeable, Op, Transaction, WhereOptions, literal } from 'sequelize' import { @@ -33,15 +34,16 @@ import { } from '../../types/models/index.js' import { ActorFollowModel } from '../actor/actor-follow.js' import { ActorImageModel } from '../actor/actor-image.js' -import { ActorModel } from '../actor/actor.js' +import { ActorModel, actorSummaryAttributes } from '../actor/actor.js' import { ApplicationModel } from '../application/application.js' import { AccountAutomaticTagPolicyModel } from '../automatic-tag/account-automatic-tag-policy.js' import { CommentAutomaticTagModel } from '../automatic-tag/comment-automatic-tag.js' import { VideoAutomaticTagModel } from '../automatic-tag/video-automatic-tag.js' import { ServerBlocklistModel } from '../server/server-blocklist.js' -import { ServerModel } from '../server/server.js' +import { ServerModel, serverSummaryAttributes } from '../server/server.js' import { SequelizeModel, buildSQLAttributes, getSort, throwIfNotValid } from '../shared/index.js' import { UserModel } from '../user/user.js' +import { VideoChannelCollaboratorModel } from '../video/video-channel-collaborator.js' import { VideoChannelModel } from '../video/video-channel.js' import { VideoCommentModel } from '../video/video-comment.js' import { VideoPlaylistModel } from '../video/video-playlist.js' @@ -52,6 +54,8 @@ export enum ScopeNames { SUMMARY = 'SUMMARY' } +const accountSummaryAttributes = [ 'id', 'name', 'actorId' ] as const satisfies (keyof AttributesOnly)[] + export type SummaryOptions = { actorRequired?: boolean // Default: true whereActor?: WhereOptions @@ -71,14 +75,14 @@ export type SummaryOptions = { @Scopes(() => ({ [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { const serverInclude: IncludeOptions = { - attributes: [ 'host' ], + attributes: serverSummaryAttributes, model: ServerModel.unscoped(), required: !!options.whereServer, where: options.whereServer } const actorInclude: Includeable = { - attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ], + attributes: actorSummaryAttributes, model: ActorModel.unscoped(), required: options.actorRequired ?? true, where: options.whereActor, @@ -98,7 +102,7 @@ export type SummaryOptions = { ] const query: FindOptions = { - attributes: [ 'id', 'name', 'actorId' ] + attributes: accountSummaryAttributes } if (options.withAccountBlockerIds) { @@ -259,6 +263,12 @@ export class AccountModel extends SequelizeModel { }) declare VideoAutomaticTags: Awaited[] + @HasMany(() => VideoChannelCollaboratorModel, { + foreignKey: 'accountId', + onDelete: 'CASCADE' + }) + declare VideoChannelCollaborators: Awaited[] + @BeforeDestroy static async sendDeleteIfOwned (instance: AccountModel, options) { if (!instance.Actor) { @@ -267,7 +277,7 @@ export class AccountModel extends SequelizeModel { await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction) - if (instance.isOwned()) { + if (instance.isLocal()) { return sendDeleteActor(instance.Actor, options.transaction) } @@ -296,6 +306,15 @@ export class AccountModel extends SequelizeModel { }) } + static getSQLSummaryAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: this, + tableName, + aliasPrefix, + includeAttributes: accountSummaryAttributes + }) + } + // --------------------------------------------------------------------------- static load (id: number, transaction?: Transaction): Promise { @@ -505,8 +524,8 @@ export class AccountModel extends SequelizeModel { }) } - isOwned () { - return this.Actor.isOwned() + isLocal () { + return this.Actor.isLocal() } isOutdated () { diff --git a/server/core/models/actor/actor-image.ts b/server/core/models/actor/actor-image.ts index b54b1ae67..49d87070a 100644 --- a/server/core/models/actor/actor-image.ts +++ b/server/core/models/actor/actor-image.ts @@ -190,7 +190,7 @@ export class ActorImageModel extends SequelizeModel { return remove(imagePath) } - isOwned () { + isLocal () { return !this.fileUrl } diff --git a/server/core/models/actor/actor.ts b/server/core/models/actor/actor.ts index 371a2c57d..243bebc1e 100644 --- a/server/core/models/actor/actor.ts +++ b/server/core/models/actor/actor.ts @@ -55,6 +55,13 @@ import { VideoModel } from '../video/video.js' import { ActorFollowModel } from './actor-follow.js' import { ActorImageModel } from './actor-image.js' +export const actorSummaryAttributes = [ + 'id', + 'preferredUsername', + 'url', + 'serverId' +] as const satisfies (keyof AttributesOnly)[] + enum ScopeNames { FULL = 'FULL' } @@ -330,6 +337,15 @@ export class ActorModel extends SequelizeModel { }) } + static getSQLActorSummaryAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: ActorModel, + tableName, + aliasPrefix, + includeAttributes: actorSummaryAttributes + }) + } + // --------------------------------------------------------------------------- // FIXME: have to specify the result type to not break peertube typings generation @@ -678,7 +694,7 @@ export class ActorModel extends SequelizeModel { return this.url + '#main-key' } - isOwned () { + isLocal () { return this.serverId === null } @@ -733,7 +749,7 @@ export class ActorModel extends SequelizeModel { } isOutdated () { - if (this.isOwned()) return false + if (this.isLocal()) return false return isOutdated(this, ACTIVITY_PUB.ACTOR_REFRESH_INTERVAL) } diff --git a/server/core/models/actor/sql/shared/instance-list-follows-query-builder.ts b/server/core/models/actor/sql/shared/instance-list-follows-query-builder.ts index e0569e2f8..028823cea 100644 --- a/server/core/models/actor/sql/shared/instance-list-follows-query-builder.ts +++ b/server/core/models/actor/sql/shared/instance-list-follows-query-builder.ts @@ -10,7 +10,7 @@ type BaseOptions = { start: number } -export abstract class InstanceListFollowsQueryBuilder extends AbstractRunQuery { +export abstract class InstanceListFollowsQueryBuilder extends AbstractRunQuery { protected readonly tableAttributes = new ActorFollowTableAttributes() protected innerQuery: string @@ -45,11 +45,8 @@ export abstract class InstanceListFollowsQueryBuilder ex `${this.getServerJoin('ActorFollowing')} ` + `${this.getServerJoin('ActorFollower')} ` + `${this.getWhere()} ` + - `${this.getOrder()} ` + - `LIMIT :limit OFFSET :offset ` - - this.replacements.limit = this.options.count - this.replacements.offset = this.options.start + `${this.getOrder(this.options.sort)} ` + + `${this.getLimit(this.options.start, this.options.count)} ` } protected buildListQuery () { @@ -59,7 +56,7 @@ export abstract class InstanceListFollowsQueryBuilder ex `FROM (${this.innerQuery}) AS "ActorFollowModel" ` + `${this.getAvatarsJoin('ActorFollower')} ` + `${this.getAvatarsJoin('ActorFollowing')} ` + - `${this.getOrder()}` + `${this.getOrder(this.options.sort)}` } protected buildCountQuery () { @@ -89,9 +86,7 @@ export abstract class InstanceListFollowsQueryBuilder ex ]) } - private getOrder () { - const orders = getInstanceFollowsSort(this.options.sort) - - return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ') + protected getSort (sort: string) { + return getInstanceFollowsSort(sort) } } diff --git a/server/core/models/application/upload-image.ts b/server/core/models/application/upload-image.ts index 9db64fd23..241eeb8a9 100644 --- a/server/core/models/application/upload-image.ts +++ b/server/core/models/application/upload-image.ts @@ -121,7 +121,7 @@ export class UploadImageModel extends SequelizeModel { return remove(this.getPath()) } - isOwned () { + isLocal () { return !this.fileUrl } } diff --git a/server/core/models/redundancy/video-redundancy.ts b/server/core/models/redundancy/video-redundancy.ts index 6c8b0787d..c62072c7c 100644 --- a/server/core/models/redundancy/video-redundancy.ts +++ b/server/core/models/redundancy/video-redundancy.ts @@ -126,7 +126,7 @@ export class VideoRedundancyModel extends SequelizeModel { @BeforeDestroy static async removeFile (instance: VideoRedundancyModel) { - if (!instance.isOwned()) return + if (!instance.isLocal()) return const videoStreamingPlaylist = await VideoStreamingPlaylistModel.loadWithVideo(instance.videoStreamingPlaylistId) @@ -578,7 +578,7 @@ export class VideoRedundancyModel extends SequelizeModel { return this.getVideo()?.uuid } - isOwned () { + isLocal () { return !!this.strategy } diff --git a/server/core/models/server/server.ts b/server/core/models/server/server.ts index 4538f1705..fc2acf7e0 100644 --- a/server/core/models/server/server.ts +++ b/server/core/models/server/server.ts @@ -5,6 +5,9 @@ import { isHostValid } from '../../helpers/custom-validators/servers.js' import { ActorModel } from '../actor/actor.js' import { SequelizeModel, buildSQLAttributes, throwIfNotValid } from '../shared/index.js' import { ServerBlocklistModel } from './server-blocklist.js' +import { AttributesOnly } from '@peertube/peertube-typescript-utils' + +export const serverSummaryAttributes = [ 'id', 'host' ] as const satisfies (keyof AttributesOnly)[] @Table({ tableName: 'server', @@ -60,6 +63,15 @@ export class ServerModel extends SequelizeModel { }) } + static getSQLSummaryAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: this, + tableName, + aliasPrefix, + includeAttributes: serverSummaryAttributes + }) + } + // --------------------------------------------------------------------------- static load (id: number, transaction?: Transaction): Promise { diff --git a/server/core/models/shared/abstract-run-query.ts b/server/core/models/shared/abstract-run-query.ts index e856cc523..703aef5ac 100644 --- a/server/core/models/shared/abstract-run-query.ts +++ b/server/core/models/shared/abstract-run-query.ts @@ -1,9 +1,8 @@ import { QueryTypes, Sequelize, Transaction } from 'sequelize' +import { getSort } from './sort.js' /** - * * Abstract builder to run video SQL queries - * */ export class AbstractRunQuery { @@ -13,7 +12,6 @@ export class AbstractRunQuery { protected queryConfig = '' constructor (protected readonly sequelize: Sequelize) { - } protected async runQuery (options: { nest?: boolean, transaction?: Transaction, logging?: boolean } = {}) { @@ -32,7 +30,32 @@ export class AbstractRunQuery { return this.sequelize.query(this.query, queryOptions) } - protected buildSelect (entities: string[]) { - return `SELECT ${entities.join(', ')} ` + protected buildSelect (attributes: string[]) { + return `SELECT ${attributes.join(', ')} ` + } + + // --------------------------------------------------------------------------- + + protected getOrder (sort: string) { + if (!sort) return '' + + const orders = this.getSort(sort) + + return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ') + } + + protected getSort (sort: string) { + return getSort(sort) + } + + // --------------------------------------------------------------------------- + + protected getLimit (start: number, count: number) { + if (!count) return '' + + this.replacements.limit = count + this.replacements.offset = start || 0 + + return `LIMIT :limit OFFSET :offset ` } } diff --git a/server/core/models/shared/model-builder.ts b/server/core/models/shared/model-builder.ts index 26ccfa90f..eaf30b683 100644 --- a/server/core/models/shared/model-builder.ts +++ b/server/core/models/shared/model-builder.ts @@ -3,7 +3,6 @@ import isPlainObject from 'lodash-es/isPlainObject.js' import { ModelStatic, Sequelize, Model as SequelizeModel } from 'sequelize' /** - * * Build Sequelize models from sequelize raw query (that must use { nest: true } options) * * In order to sequelize to correctly build the JSON this class will ingest, @@ -19,11 +18,10 @@ import { ModelStatic, Sequelize, Model as SequelizeModel } from 'sequelize' * * All tables must contain the row id */ -export class ModelBuilder { +export class ModelBuilder { private readonly modelRegistry = new Map() constructor (private readonly sequelize: Sequelize) { - } createModels (jsonArray: any[], baseModelName: string): T[] { @@ -83,10 +81,14 @@ export class ModelBuilder { } const Model = this.findModelBuilder(modelName) + if (!Model) { + throw new Error(`Cannot find model builder for model ${modelName}. You may have to add an alias in ModelBuilder class`) + } if (!Model) { logger.error( - 'Cannot build model %s that does not exist', this.buildSequelizeModelName(modelName), + 'Cannot build model %s that does not exist', + this.buildSequelizeModelName(modelName), { existing: this.sequelize.modelManager.all.map(m => m.name) } ) return { created: false, model: null } @@ -109,6 +111,9 @@ export class ModelBuilder { if (modelName === 'ActorFollower') return 'ActorModel' if (modelName === 'FlaggedAccount') return 'AccountModel' if (modelName === 'CommentAutomaticTags') return 'CommentAutomaticTagModel' + if (modelName === 'OwnerAccount') return 'AccountModel' + if (modelName === 'Thumbnails') return 'ThumbnailModel' + if (modelName === 'Channel') return 'VideoChannelModel' return modelName + 'Model' } diff --git a/server/core/models/shared/query.ts b/server/core/models/shared/query.ts index 60dd92b27..48890a6c1 100644 --- a/server/core/models/shared/query.ts +++ b/server/core/models/shared/query.ts @@ -27,9 +27,7 @@ async function doesExist (options: { function createSimilarityAttribute (col: string, value: string): Fn { return Sequelize.fn( 'similarity', - searchTrigramNormalizeCol(col), - searchTrigramNormalizeValue(value) ) } @@ -47,7 +45,7 @@ function parseAggregateResult (result: any) { return total } -function parseRowCountResult (result: any) { +function parseRowCountResult (result: any): number { if (result.length !== 0) return result[0].total return 0 @@ -73,8 +71,13 @@ function searchAttribute (sourceField?: string, targetField?: string) { } export { - buildWhereIdOrUUID, createSafeIn, createSimilarityAttribute, doesExist, parseAggregateResult, - parseRowCountResult, searchAttribute + buildWhereIdOrUUID, + createSafeIn, + createSimilarityAttribute, + doesExist, + parseAggregateResult, + parseRowCountResult, + searchAttribute } // --------------------------------------------------------------------------- diff --git a/server/core/models/shared/sql.ts b/server/core/models/shared/sql.ts index 7520f5dce..086e3182a 100644 --- a/server/core/models/shared/sql.ts +++ b/server/core/models/shared/sql.ts @@ -43,12 +43,14 @@ export function buildSQLAttributes (options: { model: ModelStatic tableName: string - excludeAttributes?: Exclude, symbol>[] + excludeAttributes?: readonly Exclude, symbol>[] + includeAttributes?: readonly Exclude, symbol>[] + aliasPrefix?: string idBuilder?: string[] }) { - const { model, tableName, aliasPrefix = '', excludeAttributes, idBuilder } = options + const { model, tableName, aliasPrefix = '', excludeAttributes, includeAttributes, idBuilder } = options const attributes = Object.keys(model.getAttributes()) as Exclude, symbol>[] @@ -59,6 +61,12 @@ export function buildSQLAttributes (options: { return true }) + .filter(a => { + if (!includeAttributes) return true + if (includeAttributes.includes(a)) return true + + return false + }) .map(a => { return `"${tableName}"."${a}" AS "${aliasPrefix}${a}"` }) diff --git a/server/core/models/user/sql/user-notification-list-query-builder.ts b/server/core/models/user/sql/user-notification-list-query-builder.ts index 5f67b1854..78428d090 100644 --- a/server/core/models/user/sql/user-notification-list-query-builder.ts +++ b/server/core/models/user/sql/user-notification-list-query-builder.ts @@ -2,7 +2,6 @@ import { ActorImageType, UserNotificationType_Type } from '@peertube/peertube-mo import { AbstractRunQuery, ModelBuilder } from '@server/models/shared/index.js' import { UserNotificationModelForApi } from '@server/types/models/index.js' import { Sequelize } from 'sequelize' -import { getSort } from '../../shared/index.js' export interface ListNotificationsOptions { userId: number @@ -36,11 +35,8 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { private buildInnerQuery () { this.innerQuery = `SELECT * FROM "userNotification" AS "UserNotificationModel" ` + `${this.getWhere()} ` + - `${this.getOrder()} ` + - `LIMIT :limit OFFSET :offset ` - - this.replacements.limit = this.options.limit - this.replacements.offset = this.options.offset + `${this.getOrder(this.options.sort)} ` + + `${this.getLimit(this.options.offset, this.options.limit)} ` } private buildQuery () { @@ -50,7 +46,7 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { ${this.getSelect()} FROM (${this.innerQuery}) "UserNotificationModel" ${this.getJoins()} - ${this.getOrder()}` + ${this.getOrder(this.options.sort)}` } private getWhere () { @@ -71,50 +67,30 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { return `WHERE ${base}` } - private getOrder () { - const orders = getSort(this.options.sort) - - return 'ORDER BY ' + orders.map(o => `"UserNotificationModel"."${o[0]}" ${o[1]}`).join(', ') - } - private getSelect () { return `SELECT "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", - "Video->VideoChannel"."id" AS "Video.VideoChannel.id", - "Video->VideoChannel"."name" AS "Video.VideoChannel.name", - "Video->VideoChannel->Actor"."id" AS "Video.VideoChannel.Actor.id", - "Video->VideoChannel->Actor"."preferredUsername" AS "Video.VideoChannel.Actor.preferredUsername", - "Video->VideoChannel->Actor->Avatars"."id" AS "Video.VideoChannel.Actor.Avatars.id", - "Video->VideoChannel->Actor->Avatars"."width" AS "Video.VideoChannel.Actor.Avatars.width", - "Video->VideoChannel->Actor->Avatars"."type" AS "Video.VideoChannel.Actor.Avatars.type", - "Video->VideoChannel->Actor->Avatars"."filename" AS "Video.VideoChannel.Actor.Avatars.filename", - "Video->VideoChannel->Actor->Server"."id" AS "Video.VideoChannel.Actor.Server.id", - "Video->VideoChannel->Actor->Server"."host" AS "Video.VideoChannel.Actor.Server.host", + ${this.buildAccountOrChannelSelect('Video->VideoChannel', 'Video.VideoChannel')}, + "VideoComment"."id" AS "VideoComment.id", "VideoComment"."originCommentId" AS "VideoComment.originCommentId", "VideoComment"."heldForReview" AS "VideoComment.heldForReview", - "VideoComment->Account"."id" AS "VideoComment.Account.id", - "VideoComment->Account"."name" AS "VideoComment.Account.name", - "VideoComment->Account->Actor"."id" AS "VideoComment.Account.Actor.id", - "VideoComment->Account->Actor"."preferredUsername" AS "VideoComment.Account.Actor.preferredUsername", - "VideoComment->Account->Actor->Avatars"."id" AS "VideoComment.Account.Actor.Avatars.id", - "VideoComment->Account->Actor->Avatars"."width" AS "VideoComment.Account.Actor.Avatars.width", - "VideoComment->Account->Actor->Avatars"."type" AS "VideoComment.Account.Actor.Avatars.type", - "VideoComment->Account->Actor->Avatars"."filename" AS "VideoComment.Account.Actor.Avatars.filename", - "VideoComment->Account->Actor->Server"."id" AS "VideoComment.Account.Actor.Server.id", - "VideoComment->Account->Actor->Server"."host" AS "VideoComment.Account.Actor.Server.host", "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.buildAccountOrChannelSelect('VideoComment->Account', 'VideoComment.Account')}, + "Abuse"."id" AS "Abuse.id", "Abuse"."state" AS "Abuse.state", "Abuse->VideoAbuse"."id" AS "Abuse.VideoAbuse.id", @@ -129,27 +105,14 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { "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", - "Abuse->FlaggedAccount"."id" AS "Abuse.FlaggedAccount.id", - "Abuse->FlaggedAccount"."name" AS "Abuse.FlaggedAccount.name", - "Abuse->FlaggedAccount"."description" AS "Abuse.FlaggedAccount.description", - "Abuse->FlaggedAccount"."actorId" AS "Abuse.FlaggedAccount.actorId", - "Abuse->FlaggedAccount"."userId" AS "Abuse.FlaggedAccount.userId", - "Abuse->FlaggedAccount"."applicationId" AS "Abuse.FlaggedAccount.applicationId", - "Abuse->FlaggedAccount"."createdAt" AS "Abuse.FlaggedAccount.createdAt", - "Abuse->FlaggedAccount"."updatedAt" AS "Abuse.FlaggedAccount.updatedAt", - "Abuse->FlaggedAccount->Actor"."id" AS "Abuse.FlaggedAccount.Actor.id", - "Abuse->FlaggedAccount->Actor"."preferredUsername" AS "Abuse.FlaggedAccount.Actor.preferredUsername", - "Abuse->FlaggedAccount->Actor->Avatars"."id" AS "Abuse.FlaggedAccount.Actor.Avatars.id", - "Abuse->FlaggedAccount->Actor->Avatars"."width" AS "Abuse.FlaggedAccount.Actor.Avatars.width", - "Abuse->FlaggedAccount->Actor->Avatars"."type" AS "Abuse.FlaggedAccount.Actor.Avatars.type", - "Abuse->FlaggedAccount->Actor->Avatars"."filename" AS "Abuse.FlaggedAccount.Actor.Avatars.filename", - "Abuse->FlaggedAccount->Actor->Server"."id" AS "Abuse.FlaggedAccount.Actor.Server.id", - "Abuse->FlaggedAccount->Actor->Server"."host" AS "Abuse.FlaggedAccount.Actor.Server.host", + ${this.buildAccountOrChannelSelect('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", @@ -158,12 +121,15 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { "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", @@ -185,48 +151,35 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { "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", - "Account"."id" AS "Account.id", - "Account"."name" AS "Account.name", - "Account->Actor"."id" AS "Account.Actor.id", - "Account->Actor"."preferredUsername" AS "Account.Actor.preferredUsername", - "Account->Actor->Avatars"."id" AS "Account.Actor.Avatars.id", - "Account->Actor->Avatars"."width" AS "Account.Actor.Avatars.width", - "Account->Actor->Avatars"."type" AS "Account.Actor.Avatars.type", - "Account->Actor->Avatars"."filename" AS "Account.Actor.Avatars.filename", - "Account->Actor->Server"."id" AS "Account.Actor.Server.id", - "Account->Actor->Server"."host" AS "Account.Actor.Server.host", + + ${this.buildAccountOrChannelSelect('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"` + "VideoCaption->Video"."state" AS "VideoCaption.Video.state", + + "VideoChannelCollaborator"."id" AS "VideoChannelCollaborator.id", + "VideoChannelCollaborator"."state" AS "VideoChannelCollaborator.state", + ${this.buildAccountOrChannelSelect('VideoChannelCollaborator->Account', 'VideoChannelCollaborator.Account')}, + ${this.buildAccountOrChannelSelect('VideoChannelCollaborator->Channel', 'VideoChannelCollaborator.Channel')}` } private getJoins () { return ` LEFT JOIN ( "video" AS "Video" - INNER JOIN "videoChannel" AS "Video->VideoChannel" ON "Video"."channelId" = "Video->VideoChannel"."id" - INNER JOIN "actor" AS "Video->VideoChannel->Actor" ON "Video->VideoChannel"."actorId" = "Video->VideoChannel->Actor"."id" - LEFT JOIN "actorImage" AS "Video->VideoChannel->Actor->Avatars" - ON "Video->VideoChannel->Actor"."id" = "Video->VideoChannel->Actor->Avatars"."actorId" - AND "Video->VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "Video->VideoChannel->Actor->Server" - ON "Video->VideoChannel->Actor"."serverId" = "Video->VideoChannel->Actor->Server"."id" + ${this.buildChannelJoin('Video', 'channelId')} ) ON "UserNotificationModel"."videoId" = "Video"."id" LEFT JOIN ( "videoComment" AS "VideoComment" - INNER JOIN "account" AS "VideoComment->Account" ON "VideoComment"."accountId" = "VideoComment->Account"."id" - INNER JOIN "actor" AS "VideoComment->Account->Actor" ON "VideoComment->Account"."actorId" = "VideoComment->Account->Actor"."id" - LEFT JOIN "actorImage" AS "VideoComment->Account->Actor->Avatars" - ON "VideoComment->Account->Actor"."id" = "VideoComment->Account->Actor->Avatars"."actorId" - AND "VideoComment->Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "VideoComment->Account->Actor->Server" - ON "VideoComment->Account->Actor"."serverId" = "VideoComment->Account->Actor->Server"."id" + ${this.buildAccountJoin('VideoComment', 'accountId')} INNER JOIN "video" AS "VideoComment->Video" ON "VideoComment"."videoId" = "VideoComment->Video"."id" ) ON "UserNotificationModel"."commentId" = "VideoComment"."id" @@ -240,12 +193,7 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { ON "Abuse->VideoCommentAbuse->VideoComment"."videoId" = "Abuse->VideoCommentAbuse->VideoComment->Video"."id" LEFT JOIN ( "account" AS "Abuse->FlaggedAccount" - INNER JOIN "actor" AS "Abuse->FlaggedAccount->Actor" ON "Abuse->FlaggedAccount"."actorId" = "Abuse->FlaggedAccount->Actor"."id" - LEFT JOIN "actorImage" AS "Abuse->FlaggedAccount->Actor->Avatars" - ON "Abuse->FlaggedAccount->Actor"."id" = "Abuse->FlaggedAccount->Actor->Avatars"."actorId" - AND "Abuse->FlaggedAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "Abuse->FlaggedAccount->Actor->Server" - ON "Abuse->FlaggedAccount->Actor"."serverId" = "Abuse->FlaggedAccount->Actor->Server"."id" + ${this.buildActorJoin('Abuse->FlaggedAccount')} ) ON "Abuse"."flaggedAccountId" = "Abuse->FlaggedAccount"."id" LEFT JOIN ( @@ -265,27 +213,20 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { INNER JOIN "actor" AS "ActorFollow->ActorFollower" ON "ActorFollow"."actorId" = "ActorFollow->ActorFollower"."id" INNER JOIN "account" AS "ActorFollow->ActorFollower->Account" ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Account"."actorId" - LEFT JOIN "actorImage" AS "ActorFollow->ActorFollower->Avatars" - ON "ActorFollow->ActorFollower"."id" = "ActorFollow->ActorFollower->Avatars"."actorId" - AND "ActorFollow->ActorFollower->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "ActorFollow->ActorFollower->Server" - ON "ActorFollow->ActorFollower"."serverId" = "ActorFollow->ActorFollower->Server"."id" + ${this.buildActorImageJoin('ActorFollow->ActorFollower')} + ${this.buildActorServerJoin('ActorFollow->ActorFollower')} + INNER JOIN "actor" AS "ActorFollow->ActorFollowing" ON "ActorFollow"."targetActorId" = "ActorFollow->ActorFollowing"."id" LEFT JOIN "videoChannel" AS "ActorFollow->ActorFollowing->VideoChannel" ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->VideoChannel"."actorId" LEFT JOIN "account" AS "ActorFollow->ActorFollowing->Account" ON "ActorFollow->ActorFollowing"."id" = "ActorFollow->ActorFollowing->Account"."actorId" - LEFT JOIN "server" AS "ActorFollow->ActorFollowing->Server" - ON "ActorFollow->ActorFollowing"."serverId" = "ActorFollow->ActorFollowing->Server"."id" + ${this.buildActorServerJoin('ActorFollow->ActorFollowing')} ) ON "UserNotificationModel"."actorFollowId" = "ActorFollow"."id" LEFT JOIN ( "account" AS "Account" - INNER JOIN "actor" AS "Account->Actor" ON "Account"."actorId" = "Account->Actor"."id" - LEFT JOIN "actorImage" AS "Account->Actor->Avatars" - ON "Account->Actor"."id" = "Account->Actor->Avatars"."actorId" - AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} - LEFT JOIN "server" AS "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" + ${this.buildActorJoin('Account')} ) ON "UserNotificationModel"."accountId" = "Account"."id" LEFT JOIN "userRegistration" as "UserRegistration" ON "UserNotificationModel"."userRegistrationId" = "UserRegistration"."id" @@ -293,6 +234,54 @@ export class UserNotificationListQueryBuilder extends AbstractRunQuery { LEFT JOIN ( "videoCaption" AS "VideoCaption" INNER JOIN "video" AS "VideoCaption->Video" ON "VideoCaption"."videoId" = "VideoCaption->Video"."id" - ) ON "UserNotificationModel"."videoCaptionId" = "VideoCaption"."id"` + ) ON "UserNotificationModel"."videoCaptionId" = "VideoCaption"."id" + + LEFT JOIN ( + "videoChannelCollaborator" AS "VideoChannelCollaborator" + ${this.buildAccountJoin('VideoChannelCollaborator', 'accountId')} + ${this.buildChannelJoin('VideoChannelCollaborator', 'channelId', 'Channel')} + ) ON "UserNotificationModel"."channelCollaboratorId" = "VideoChannelCollaborator"."id"` + } + + private buildAccountOrChannelSelect (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 buildAccountJoin (tableName: string, columnJoin: string) { + return `INNER JOIN "account" AS "${tableName}->Account" ON "${tableName}"."${columnJoin}" = "${tableName}->Account"."id" ` + + this.buildActorJoin(`${tableName}->Account`) + } + + private buildChannelJoin (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.buildActorJoin(`${tableName}->${aliasTableName}`) + } + + private buildActorJoin (tableName: string) { + return `INNER JOIN "actor" AS "${tableName}->Actor" ON "${tableName}"."actorId" = "${tableName}->Actor"."id" ` + + this.buildActorImageJoin(`${tableName}->Actor`) + + this.buildActorServerJoin(`${tableName}->Actor`) + } + + private buildActorImageJoin (tableName: string) { + return `LEFT JOIN "actorImage" AS "${tableName}->Avatars" + ON "${tableName}"."id" = "${tableName}->Avatars"."actorId" + AND "${tableName}->Avatars"."type" = ${ActorImageType.AVATAR} ` + } + + private buildActorServerJoin (tableName: string) { + return `LEFT JOIN "server" AS "${tableName}->Server" + ON "${tableName}"."serverId" = "${tableName}->Server"."id" ` } } diff --git a/server/core/models/user/user-notification.ts b/server/core/models/user/user-notification.ts index b7fcad0f9..84ba123a3 100644 --- a/server/core/models/user/user-notification.ts +++ b/server/core/models/user/user-notification.ts @@ -1,27 +1,28 @@ import { forceNumber, maxBy } from '@peertube/peertube-core-utils' -import { UserNotification, type UserNotificationType_Type } from '@peertube/peertube-models' +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, Default, ForeignKey, Is, Table, UpdatedAt } from 'sequelize-typescript' +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-list-query-builder.js' import { UserRegistrationModel } from './user-registration.js' import { UserModel } from './user.js' -import { ActorImageModel } from '../actor/actor-image.js' @Table({ tableName: 'userNotification', @@ -108,6 +109,14 @@ import { ActorImageModel } from '../actor/actor-image.js' [Op.ne]: null } } + }, + { + fields: [ 'channelCollaboratorId' ], + where: { + channelCollaboratorId: { + [Op.ne]: null + } + } } ] as (ModelIndexesOptions & { where?: WhereOptions })[] }) @@ -124,6 +133,10 @@ export class UserNotificationModel extends SequelizeModel @Column declare read: boolean + @AllowNull(true) + @Column(DataType.JSONB) + declare data: UserNotificationData + @CreatedAt declare createdAt: Date @@ -274,6 +287,18 @@ export class UserNotificationModel extends SequelizeModel }) declare VideoCaption: Awaited + @ForeignKey(() => VideoChannelCollaboratorModel) + @Column + declare channelCollaboratorId: number + + @BelongsTo(() => VideoChannelCollaboratorModel, { + foreignKey: { + allowNull: true + }, + onDelete: 'cascade' + }) + declare VideoChannelCollaborator: Awaited + static listForApi (options: { userId: number start: number @@ -347,6 +372,7 @@ export class UserNotificationModel extends SequelizeModel } const queries = [ + // Remove notifications from muted accounts buildAccountWhereQuery( `SELECT "userNotification"."id" FROM "userNotification" ` + `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` + @@ -369,6 +395,7 @@ export class UserNotificationModel extends SequelizeModel `INNER JOIN account ON account."actorId" = actor.id ` ), + // Remove notifications of comments from muted accounts buildAccountWhereQuery( `SELECT "userNotification"."id" FROM "userNotification" ` + `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` + @@ -477,10 +504,23 @@ export class UserNotificationModel extends SequelizeModel } : undefined + const videoChannelCollaborator = this.VideoChannelCollaborator + ? { + id: this.VideoChannelCollaborator.id, + 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, @@ -492,6 +532,7 @@ export class UserNotificationModel extends SequelizeModel peertube, registration, videoCaption, + videoChannelCollaborator, createdAt: this.createdAt.toISOString(), updatedAt: this.updatedAt.toISOString() } diff --git a/server/core/models/user/user.ts b/server/core/models/user/user.ts index 105f2cfe6..c19207cb1 100644 --- a/server/core/models/user/user.ts +++ b/server/core/models/user/user.ts @@ -730,6 +730,8 @@ export class UserModel extends SequelizeModel { return UserModel.findAll(query) } + // --------------------------------------------------------------------------- + static loadByVideoId (videoId: number): Promise { const query = { include: [ @@ -807,8 +809,7 @@ export class UserModel extends SequelizeModel { include: [ { required: true, - attributes: [ 'id' ], - model: AccountModel.unscoped(), + model: AccountModel, where: { id: accountId } diff --git a/server/core/models/video/formatter/video-api-format.ts b/server/core/models/video/formatter/video-api-format.ts index d5d9dd3cb..40e28221a 100644 --- a/server/core/models/video/formatter/video-api-format.ts +++ b/server/core/models/video/formatter/video-api-format.ts @@ -105,7 +105,7 @@ export function videoModelToFormattedJSON (video: MVideoFormattable, options: Vi ? video.description : video.getTruncatedDescription(), - isLocal: video.isOwned(), + isLocal: video.isLocal(), duration: video.duration, aspectRatio: video.aspectRatio, diff --git a/server/core/models/video/sql/channel/video-channel-list-query-builder.ts b/server/core/models/video/sql/channel/video-channel-list-query-builder.ts new file mode 100644 index 000000000..c22f7a7e3 --- /dev/null +++ b/server/core/models/video/sql/channel/video-channel-list-query-builder.ts @@ -0,0 +1,335 @@ +import { ActorImageType, VideoChannelCollaboratorState } from '@peertube/peertube-models' +import { WEBSERVER } from '@server/initializers/constants.js' +import { AbstractRunQuery, buildServerIdsFollowedBy, getPlaylistSort, ModelBuilder } from '@server/models/shared/index.js' +import { Model, Sequelize, Transaction } from 'sequelize' +import { parseRowCountResult } from '../../../shared/index.js' +import { VideoChannelTableAttributes } from './video-channel-table-attributes.js' + +export interface ListVideoChannelsOptions { + start?: number + count?: number + sort?: string + + actorId?: number + search?: string + host?: string + handles?: string[] + forCount?: boolean + + accountId?: number + + // If accountId is provided, include channels where the account is a collaborator + // default: false + includeCollaborations?: boolean + + statsDaysPrior?: number + + transaction?: Transaction +} + +export class VideoChannelListQueryBuilder extends AbstractRunQuery { + private readonly tableAttributes = new VideoChannelTableAttributes() + + private innerQuery: string + + private attributes: string[] = [] + private innerAttributes: string[] = [] + + private join = '' + + private innerJoin = '' + private innerWhere = '' + + private readonly built = { + actorJoin: false, + accountJoin: false, + channelCollaboratorsJoin: false, + accountAvatarJoin: false, + channelAvatarJoin: false, + channelBannerJoin: false + } + + constructor ( + protected readonly sequelize: Sequelize, + private readonly options: ListVideoChannelsOptions + ) { + super(sequelize) + } + + async list () { + this.buildListQuery() + + const results = await this.runQuery({ nest: true, transaction: this.options.transaction }) + const modelBuilder = new ModelBuilder(this.sequelize) + + return modelBuilder.createModels(results, 'VideoChannel') + } + + async count () { + this.buildCountQuery() + + const result = await this.runQuery({ transaction: this.options.transaction }) + + return parseRowCountResult(result) + } + + // --------------------------------------------------------------------------- + + private buildListQuery () { + this.buildInnerListQuery() + this.buildListSelect() + + this.query = `${this.buildSelect(this.attributes)} ` + + `FROM (${this.innerQuery}) AS "VideoChannelModel" ` + + `${this.join} ` + + `${this.getOrder(this.options.sort)}` + } + + private buildInnerListQuery () { + this.buildWhere() + this.buildInnerListSelect() + + this.innerQuery = `${this.buildSelect(this.innerAttributes)} ` + + `FROM "videoChannel" AS "VideoChannelModel" ` + + `${this.innerJoin} ` + + `${this.innerWhere} ` + + `${this.getOrder(this.options.sort)} ` + + `${this.getLimit(this.options.start, this.options.count)}` + } + + // --------------------------------------------------------------------------- + + private buildCountQuery () { + this.buildWhere() + + this.query = `SELECT COUNT(*) AS "total" ` + + `FROM "videoChannel" AS "VideoChannelModel" ` + + `${this.innerJoin} ` + + `${this.innerWhere}` + } + + // --------------------------------------------------------------------------- + + private buildWhere () { + const where: string[] = [] + + if (this.options.host) { + this.buildActorJoin() + + if (this.options.host === WEBSERVER.HOST) { + where.push('"Actor"."serverId" IS NULL') + } else { + where.push('"Actor->Server"."host" = :host') + + this.replacements.host = this.options.host + } + } + + // Only list local channels OR channels that are on an instance followed by actorId + if (this.options.actorId) { + this.buildActorJoin() + + where.push( + `(` + + `"Actor"."serverId" IS NULL OR ` + + `"Actor"."serverId" IN ${buildServerIdsFollowedBy(this.options.actorId)}` + + `)` + ) + } + + if (this.options.accountId) { + this.buildAccountJoin() + + if (this.options.includeCollaborations !== true) { + where.push('"VideoChannelModel"."accountId" = :accountId') + + this.replacements.accountId = this.options.accountId + } else { + this.buildChannelCollaboratorsJoin() + + where.push( + `("VideoChannelModel"."accountId" = :accountId OR "VideoChannelCollaborators"."accountId" = :accountId)` + ) + + this.replacements.accountId = this.options.accountId + } + } + + if (Array.isArray(this.options.handles) && this.options.handles.length !== 0) { + this.buildActorJoin() + + const or: string[] = [] + + for (const handle of this.options.handles || []) { + const [ preferredUsername, host ] = handle.split('@') + + const sanitizedPreferredUsername = this.sequelize.escape(preferredUsername.toLowerCase()) + const sanitizedHost = this.sequelize.escape(host) + + if (!host || host === WEBSERVER.HOST) { + or.push(`(LOWER("Actor"."preferredUsername") = ${sanitizedPreferredUsername} AND "serverId" IS NULL)`) + } else { + or.push(`(LOWER("Actor"."preferredUsername") = ${sanitizedPreferredUsername} AND "host" = ${sanitizedHost})`) + } + + where.push(`(${or.join(' OR ')})`) + } + } + + if (this.options.search) { + const escapedSearch = this.sequelize.escape(this.options.search) + const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%') + + this.attributes.push( + `word_similarity(lower(immutable_unaccent(${escapedSearch})), lower(immutable_unaccent("VideoChannelModel"."name"))) as similarity` + ) + + where.push( + `(` + + `lower(immutable_unaccent(${escapedSearch})) <% lower(immutable_unaccent("VideoChannelModel"."name")) OR ` + + `lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(${escapedLikeSearch}))` + + `)` + ) + } else { + this.attributes.push('0 as similarity') + } + + if (where.length !== 0) { + this.innerWhere = `WHERE ${where.join(' AND ')}` + } + } + + private buildActorJoin () { + if (this.built.actorJoin) return + + this.innerJoin += ' INNER JOIN "actor" "Actor" ON "Actor"."id" = "VideoChannelModel"."actorId" ' + + 'LEFT JOIN "server" "Actor->Server" ON "Actor"."serverId" = "Actor->Server"."id" ' + + this.built.actorJoin = true + } + + private buildAccountJoin () { + if (this.built.accountJoin) return + + this.innerJoin += ' INNER JOIN "account" "Account" ON "Account"."id" = "VideoChannelModel"."accountId" ' + + 'INNER JOIN "actor" "Account->Actor" ON "Account->Actor"."id" = "Account"."actorId" ' + + 'LEFT JOIN "server" "Account->Actor->Server" ON "Account->Actor"."serverId" = "Account->Actor->Server"."id" ' + + this.built.accountJoin = true + } + + private buildChannelCollaboratorsJoin () { + if (this.built.channelCollaboratorsJoin) return + + this.innerJoin += ' LEFT JOIN "videoChannelCollaborator" "VideoChannelCollaborators" ' + + 'ON "VideoChannelCollaborators"."channelId" = "VideoChannelModel"."id" ' + + 'AND "VideoChannelCollaborators"."state" = :channelCollaboratorState ' + + // Ensure we join with max 1 collaborator to not duplicate rows + 'AND "VideoChannelCollaborators"."accountId" = :accountId ' + + this.replacements.channelCollaboratorState = VideoChannelCollaboratorState.ACCEPTED + this.replacements.accountId = this.options.accountId + + this.built.channelCollaboratorsJoin = true + } + + private buildAccountAvatarsJoin () { + if (this.built.accountAvatarJoin) return + + this.join += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` + + `ON "Account->Actor->Avatars"."actorId" = "VideoChannelModel"."Account.Actor.id" ` + + `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR} ` + + this.built.accountAvatarJoin = true + } + + private buildChannelAvatarsJoin () { + if (this.built.channelAvatarJoin) return + + this.join += `LEFT JOIN "actorImage" "Actor->Avatars" ` + + `ON "Actor->Avatars"."actorId" = "VideoChannelModel"."actorId" ` + + `AND "Actor->Avatars"."type" = ${ActorImageType.AVATAR} ` + + this.built.channelAvatarJoin = true + } + + private buildChannelBannersJoin () { + if (this.built.channelBannerJoin) return + + this.join += `LEFT JOIN "actorImage" "Actor->Banners" ` + + `ON "Actor->Banners"."actorId" = "VideoChannelModel"."actorId" ` + + `AND "Actor->Banners"."type" = ${ActorImageType.BANNER} ` + + this.built.channelBannerJoin = true + } + + // --------------------------------------------------------------------------- + + private buildListSelect () { + this.buildChannelAvatarsJoin() + this.buildAccountAvatarsJoin() + this.buildChannelBannersJoin() + + this.attributes.push('"VideoChannelModel".*') + + this.attributes = this.attributes.concat([ + this.tableAttributes.getAccountAvatarAttributes(), + this.tableAttributes.getChannelAvatarAttributes(), + this.tableAttributes.getChannelBannerAttributes() + ]) + + if (this.options.statsDaysPrior) { + this.attributes.push( + `(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id") AS "videosCount"` + ) + + this.attributes.push( + // dprint-ignore + '(' + + `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` + + 'FROM ( ' + + 'WITH days AS ( ' + + `SELECT generate_series(date_trunc('day', now()) - '${this.options.statsDaysPrior} day'::interval, ` + + `date_trunc('day', now()), '1 day'::interval) AS day ` + + ') ' + + 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' + + 'FROM days ' + + 'LEFT JOIN (' + + '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' + + 'AND "video"."channelId" = "VideoChannelModel"."id"' + + `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` + + 'GROUP BY day ORDER BY day ' + + ') t' + + ') AS "viewsPerDay"' + ) + + this.attributes.push( + '(' + + 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' + + 'FROM "video" ' + + 'WHERE "video"."channelId" = "VideoChannelModel"."id"' + + ') AS "totalViews"' + ) + } + } + + private buildInnerListSelect () { + this.buildActorJoin() + this.buildAccountJoin() + + this.innerAttributes = [ + this.tableAttributes.getVideoChannelAttributes(), + + this.tableAttributes.getChannelActorAttributes(), + this.tableAttributes.getChannelServerAttributes(), + + this.tableAttributes.getAccountAttributes(), + this.tableAttributes.getAccountActorAttributes(), + this.tableAttributes.getAccountServerAttributes() + ] + } + + protected getSort (sort: string) { + return getPlaylistSort(sort) + } +} diff --git a/server/core/models/video/sql/channel/video-channel-table-attributes.ts b/server/core/models/video/sql/channel/video-channel-table-attributes.ts new file mode 100644 index 000000000..1dfe0e2df --- /dev/null +++ b/server/core/models/video/sql/channel/video-channel-table-attributes.ts @@ -0,0 +1,57 @@ +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 { VideoChannelModel } from '../../video-channel.js' + +export class VideoChannelTableAttributes { + @Memoize() + getVideoChannelAttributes () { + return VideoChannelModel.getSQLAttributes('VideoChannelModel').join(', ') + } + + // --------------------------------------------------------------------------- + + @Memoize() + getChannelActorAttributes () { + return ActorModel.getSQLActorSummaryAttributes('Actor', `Actor.`).join(', ') + } + + @Memoize() + getChannelServerAttributes () { + return ServerModel.getSQLSummaryAttributes('Actor->Server', `Actor.Server.`).join(', ') + } + + @Memoize() + getChannelAvatarAttributes () { + return ActorImageModel.getSQLAttributes('Actor->Avatars', 'Actor.Avatars.').join(', ') + } + + @Memoize() + getChannelBannerAttributes () { + return ActorImageModel.getSQLAttributes('Actor->Banners', 'Actor.Banners.').join(', ') + } + + // --------------------------------------------------------------------------- + + @Memoize() + getAccountAttributes () { + return AccountModel.getSQLSummaryAttributes('Account', 'Account.').join(', ') + } + + @Memoize() + getAccountActorAttributes () { + return ActorModel.getSQLActorSummaryAttributes('Account->Actor', `Account.Actor.`).join(', ') + } + + @Memoize() + getAccountServerAttributes () { + return ServerModel.getSQLSummaryAttributes('Account->Actor->Server', `Account.Actor.Server.`).join(', ') + } + + @Memoize() + getAccountAvatarAttributes () { + return ActorImageModel.getSQLAttributes('Account->Actor->Avatars', 'Account.Actor.Avatars.').join(', ') + } +} diff --git a/server/core/models/video/sql/comment/video-comment-list-query-builder.ts b/server/core/models/video/sql/comment/video-comment-list-query-builder.ts index 906a8889f..0ed52c01e 100644 --- a/server/core/models/video/sql/comment/video-comment-list-query-builder.ts +++ b/server/core/models/video/sql/comment/video-comment-list-query-builder.ts @@ -1,7 +1,7 @@ -import { ActorImageType, VideoPrivacy } from '@peertube/peertube-models' +import { ActorImageType, VideoChannelCollaboratorState, VideoPrivacy } from '@peertube/peertube-models' import { AbstractRunQuery, ModelBuilder } from '@server/models/shared/index.js' import { Model, Sequelize, Transaction } from 'sequelize' -import { createSafeIn, getSort, parseRowCountResult } from '../../../shared/index.js' +import { createSafeIn, parseRowCountResult } from '../../../shared/index.js' import { VideoCommentTableAttributes } from './video-comment-table-attributes.js' export interface ListVideoCommentsOptions { @@ -28,6 +28,7 @@ export interface ListVideoCommentsOptions { onPublicVideo?: boolean videoChannelOwnerId?: number videoAccountOwnerId?: number + videoAccountOwnerIncludeCollaborations?: boolean heldForReview: boolean heldForReviewAccountIdException?: number @@ -61,6 +62,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { accountJoin: false, videoJoin: false, videoChannelJoin: false, + videoChannelCollaboratorsJoin: false, avatarJoin: false, automaticTagsJoin: false } @@ -76,7 +78,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { } } - async listComments () { + async listComments () { this.buildListQuery() const results = await this.runQuery({ nest: true, transaction: this.options.transaction }) @@ -102,7 +104,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { this.query = `${this.select} ` + `FROM (${this.innerQuery}) AS "VideoCommentModel" ` + `${this.joins} ` + - `${this.getOrder()}` + `${this.getOrder(this.options.sort)}` } private buildInnerListQuery () { @@ -114,8 +116,8 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `${this.innerJoins} ` + `${this.innerLateralJoins} ` + `${this.innerWhere} ` + - `${this.getOrder()} ` + - `${this.getInnerLimit()}` + `${this.getOrder(this.options.sort)} ` + + `${this.getLimit(this.options.start, this.options.count)}` } // --------------------------------------------------------------------------- @@ -218,7 +220,18 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId - where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`) + if (this.options.videoAccountOwnerIncludeCollaborations !== true) { + where.push(`"Video->VideoChannel"."accountId" = :videoAccountOwnerId`) + } else { + this.buildVideoChannelCollaboratorsJoin() + + where.push( + `(` + + `"Video->VideoChannel"."accountId" = :videoAccountOwnerId OR ` + + `"Video->VideoChannel->VideoChannelCollaborators"."accountId" = :videoAccountOwnerId` + + `)` + ) + } } if (this.options.videoChannelOwnerId) { @@ -241,7 +254,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` + `"Account"."name" ILIKE ${escapedLikeSearch} OR ` + `"Video"."name" ILIKE ${escapedLikeSearch} ` + - `)` + `)` ) } @@ -254,7 +267,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `(` + `"Account->Actor"."preferredUsername" ILIKE ${escapedLikeSearch} OR ` + `"Account"."name" ILIKE ${escapedLikeSearch} ` + - `)` + `)` ) } @@ -298,13 +311,29 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { this.built.videoChannelJoin = true } + private buildVideoChannelCollaboratorsJoin () { + if (this.built.videoChannelCollaboratorsJoin) return + + this.buildVideoChannelJoin() + + this.innerJoins += ' LEFT JOIN "videoChannelCollaborator" "Video->VideoChannel->VideoChannelCollaborators" ' + + 'ON "Video->VideoChannel->VideoChannelCollaborators"."channelId" = "Video->VideoChannel"."id" ' + + 'AND "Video->VideoChannel->VideoChannelCollaborators"."state" = :channelCollaboratorState ' + + // Ensure we join with max 1 collaborator to not duplicate rows + 'AND "Video->VideoChannel->VideoChannelCollaborators"."accountId" = :videoAccountOwnerId ' + + this.replacements.videoAccountOwnerId = this.options.videoAccountOwnerId + this.replacements.channelCollaboratorState = VideoChannelCollaboratorState.ACCEPTED + + this.built.videoChannelCollaboratorsJoin = true + } private buildAvatarsJoin () { if (this.built.avatarJoin) return this.joins += `LEFT JOIN "actorImage" "Account->Actor->Avatars" ` + `ON "VideoCommentModel"."Account.Actor.id" = "Account->Actor->Avatars"."actorId" ` + - `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}` + `AND "Account->Actor->Avatars"."type" = ${ActorImageType.AVATAR}` this.built.avatarJoin = true } @@ -314,8 +343,8 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { this.innerJoins += 'LEFT JOIN (' + '"commentAutomaticTag" AS "CommentAutomaticTags" INNER JOIN "automaticTag" AS "CommentAutomaticTags->AutomaticTag" ' + - 'ON "CommentAutomaticTags->AutomaticTag"."id" = "CommentAutomaticTags"."automaticTagId" ' + - ') ON "VideoCommentModel"."id" = "CommentAutomaticTags"."commentId" AND "CommentAutomaticTags"."accountId" = :autoTagOfAccountId' + 'ON "CommentAutomaticTags->AutomaticTag"."id" = "CommentAutomaticTags"."automaticTagId" ' + + ') ON "VideoCommentModel"."id" = "CommentAutomaticTags"."commentId" AND "CommentAutomaticTags"."accountId" = :autoTagOfAccountId' this.replacements.autoTagOfAccountId = this.options.autoTagOfAccountId this.built.automaticTagsJoin = true @@ -386,7 +415,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `SELECT 1 FROM "accountBlocklist" ` + `WHERE "targetAccountId" = "${commentTableName}"."accountId" ` + `AND "accountId" IN (${blockerIdsString})` + - `)` + `)` ) where.push( @@ -396,7 +425,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `INNER JOIN "serverBlocklist" ON "actor"."serverId" = "serverBlocklist"."targetServerId" ` + `WHERE "account"."id" = "${commentTableName}"."accountId" ` + `AND "serverBlocklist"."accountId" IN (${blockerIdsString})` + - `)` + `)` ) return where @@ -415,9 +444,9 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` + `LEFT JOIN "videoChannel" ON "video"."channelId" = "videoChannel"."id" ` + `WHERE ("replies"."inReplyToCommentId" = "VideoCommentModel"."id" OR "replies"."originCommentId" = "VideoCommentModel"."id") ` + - `AND "deletedAt" IS NULL ` + - `AND ${blockWhereString} ` + - `) "totalReplies" ON TRUE ` + `AND "deletedAt" IS NULL ` + + `AND ${blockWhereString} ` + + `) "totalReplies" ON TRUE ` } private buildAuthorTotalRepliesSelect () { @@ -429,24 +458,7 @@ export class VideoCommentListQueryBuilder extends AbstractRunQuery { `INNER JOIN "video" ON "video"."id" = "replies"."videoId" AND "replies"."videoId" = :videoId ` + `INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId" ` + `WHERE ("replies"."inReplyToCommentId" = "VideoCommentModel"."id" OR "replies"."originCommentId" = "VideoCommentModel"."id") ` + - `AND "replies"."accountId" = "videoChannel"."accountId"` + - `) "totalRepliesFromVideoAuthor" ON TRUE ` - } - - private getOrder () { - if (!this.options.sort) return '' - - const orders = getSort(this.options.sort) - - return 'ORDER BY ' + orders.map(o => `"${o[0]}" ${o[1]}`).join(', ') - } - - private getInnerLimit () { - if (!this.options.count) return '' - - this.replacements.limit = this.options.count - this.replacements.offset = this.options.start || 0 - - return `LIMIT :limit OFFSET :offset ` + `AND "replies"."accountId" = "videoChannel"."accountId"` + + `) "totalRepliesFromVideoAuthor" ON TRUE ` } } diff --git a/server/core/models/video/sql/playlist/video-playlist-list-query-builder.ts b/server/core/models/video/sql/playlist/video-playlist-list-query-builder.ts new file mode 100644 index 000000000..bd1198578 --- /dev/null +++ b/server/core/models/video/sql/playlist/video-playlist-list-query-builder.ts @@ -0,0 +1,313 @@ +import { ActorImageType, VideoChannelCollaboratorState, VideoPlaylistPrivacy, VideoPlaylistType_Type } from '@peertube/peertube-models' +import { WEBSERVER } from '@server/initializers/constants.js' +import { AbstractRunQuery, buildServerIdsFollowedBy, getPlaylistSort, ModelBuilder } from '@server/models/shared/index.js' +import { Model, Sequelize, Transaction } from 'sequelize' +import { parseRowCountResult } from '../../../shared/index.js' +import { VideoPlaylistTableAttributes } from './video-playlist-table-attributes.js' + +export interface ListVideoPlaylistsOptions { + start?: number + count?: number + sort?: string + + followerActorId?: number + type?: VideoPlaylistType_Type + + accountId?: number + includeCollaborations?: boolean + + videoChannelId?: number + listMyPlaylists?: boolean + search?: string + host?: string + uuids?: string[] + channelNameOneOf?: string[] + withVideos?: boolean + + transaction?: Transaction +} + +export class VideoPlaylistListQueryBuilder extends AbstractRunQuery { + private readonly tableAttributes = new VideoPlaylistTableAttributes() + + private innerQuery: string + + private attributes: string[] = [] + private innerAttributes: string[] = [] + + private join = '' + + private innerJoin = '' + private innerWhere = '' + + private readonly built = { + accountJoin: false, + videoChannelJoin: false, + channelCollaboratorsJoin: false, + accountAvatarJoin: false, + channelAvatarJoin: false, + thumbnailJoin: false + } + + constructor ( + protected readonly sequelize: Sequelize, + private readonly options: ListVideoPlaylistsOptions + ) { + super(sequelize) + } + + async list () { + this.buildListQuery() + + const results = await this.runQuery({ nest: true, transaction: this.options.transaction }) + const modelBuilder = new ModelBuilder(this.sequelize) + + return modelBuilder.createModels(results, 'VideoPlaylist') + } + + async count () { + this.buildCountQuery() + + const result = await this.runQuery({ transaction: this.options.transaction }) + + return parseRowCountResult(result) + } + + // --------------------------------------------------------------------------- + + private buildListQuery () { + this.buildInnerListQuery() + this.buildListSelect() + + this.query = `${this.buildSelect(this.attributes)} ` + + `FROM (${this.innerQuery}) AS "VideoPlaylistModel" ` + + `${this.join} ` + + `${this.getOrder(this.options.sort)}` + } + + private buildInnerListQuery () { + this.buildWhere() + this.buildInnerListSelect() + + this.innerQuery = `${this.buildSelect(this.innerAttributes)} ` + + `FROM "videoPlaylist" AS "VideoPlaylistModel" ` + + `${this.innerJoin} ` + + `${this.innerWhere} ` + + `${this.getOrder(this.options.sort)} ` + + `${this.getLimit(this.options.start, this.options.count)}` + } + + // --------------------------------------------------------------------------- + + private buildCountQuery () { + this.buildWhere() + + this.query = `SELECT COUNT(*) AS "total" ` + + `FROM "videoPlaylist" AS "VideoPlaylistModel" ` + + `${this.innerJoin} ` + + `${this.innerWhere}` + } + + // --------------------------------------------------------------------------- + + private buildWhere () { + const where: string[] = [] + + if (this.options.host) { + this.buildAccountJoin() + + if (this.options.host === WEBSERVER.HOST) { + where.push('"OwnerAccount->Actor"."serverId" IS NULL') + } else { + where.push('"OwnerAccount->Actor->Server"."host" = :host') + + this.replacements.host = this.options.host + } + } + + if (this.options.listMyPlaylists !== true) { + where.push('"VideoPlaylistModel"."privacy" = :privacy') + + this.replacements.privacy = VideoPlaylistPrivacy.PUBLIC + } + + if (this.options.followerActorId) { + this.buildAccountJoin() + + where.push( + `(` + + `"OwnerAccount->Actor"."serverId" IS NULL OR ` + + `"OwnerAccount->Actor"."serverId" IN ${buildServerIdsFollowedBy(this.options.followerActorId)}` + + `)` + ) + } + + if (this.options.accountId) { + if (this.options.includeCollaborations !== true) { + where.push('"VideoPlaylistModel"."ownerAccountId" = :accountId') + + this.replacements.accountId = this.options.accountId + } else { + this.buildChannelCollaboratorsJoin() + + where.push( + `("VideoPlaylistModel"."ownerAccountId" = :accountId OR "VideoChannel->VideoChannelCollaborators"."accountId" = :accountId)` + ) + + this.replacements.accountId = this.options.accountId + } + } + + if (this.options.videoChannelId) { + where.push('"VideoPlaylistModel"."videoChannelId" = :videoChannelId') + + this.replacements.videoChannelId = this.options.videoChannelId + } + + if (this.options.type) { + where.push('"VideoPlaylistModel"."type" = :type') + + this.replacements.type = this.options.type + } + + if (this.options.uuids) { + where.push('"VideoPlaylistModel"."uuid" IN (:uuids)') + + this.replacements.uuids = this.options.uuids + } + + if (this.options.withVideos === true) { + where.push(`(${this.getTotalVideosQuery()}) !== 0`) + } + + if (this.options.search) { + const escapedSearch = this.sequelize.escape(this.options.search) + const escapedLikeSearch = this.sequelize.escape('%' + this.options.search + '%') + + this.attributes.push( + `word_similarity(lower(immutable_unaccent(${escapedSearch})), lower(immutable_unaccent("VideoPlaylistModel"."name"))) as similarity` + ) + + where.push( + `(` + + `lower(immutable_unaccent(${escapedSearch})) <% lower(immutable_unaccent("VideoPlaylistModel"."name")) OR ` + + `lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(${escapedLikeSearch}))` + + `)` + ) + } else { + this.attributes.push('0 as similarity') + } + + if (where.length !== 0) { + this.innerWhere = `WHERE ${where.join(' AND ')}` + } + } + + private buildAccountJoin () { + if (this.built.accountJoin) return + + this.innerJoin += ' INNER JOIN "account" "OwnerAccount" ON "OwnerAccount"."id" = "VideoPlaylistModel"."ownerAccountId" ' + + 'INNER JOIN "actor" "OwnerAccount->Actor" ON "OwnerAccount->Actor"."id" = "OwnerAccount"."actorId" ' + + 'LEFT JOIN "server" "OwnerAccount->Actor->Server" ON "OwnerAccount->Actor"."serverId" = "OwnerAccount->Actor->Server"."id" ' + + this.built.accountJoin = true + } + + private buildChannelJoin () { + if (this.built.videoChannelJoin) return + + this.innerJoin += ' LEFT JOIN "videoChannel" "VideoChannel" ON "VideoPlaylistModel"."videoChannelId" = "VideoChannel"."id" ' + + 'LEFT JOIN "actor" "VideoChannel->Actor" ON "VideoChannel->Actor"."id" = "VideoChannel"."actorId" ' + + 'LEFT JOIN "server" "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id" ' + + this.built.videoChannelJoin = true + } + + private buildChannelCollaboratorsJoin () { + if (this.built.channelCollaboratorsJoin) return + + this.buildChannelJoin() + + this.innerJoin += ' LEFT JOIN "videoChannelCollaborator" "VideoChannel->VideoChannelCollaborators" ' + + 'ON "VideoChannel->VideoChannelCollaborators"."channelId" = "VideoChannel"."id" ' + + 'AND "VideoChannel->VideoChannelCollaborators"."state" = :channelCollaboratorState ' + + // Ensure we join with max 1 collaborator to not duplicate rows + 'AND "VideoChannel->VideoChannelCollaborators"."accountId" = :accountId ' + + this.replacements.channelCollaboratorState = VideoChannelCollaboratorState.ACCEPTED + this.replacements.accountId = this.options.accountId + + this.built.channelCollaboratorsJoin = true + } + + private buildAccountAvatarsJoin () { + if (this.built.accountAvatarJoin) return + + this.join += `LEFT JOIN "actorImage" "OwnerAccount->Actor->Avatars" ` + + `ON "OwnerAccount->Actor->Avatars"."actorId" = "VideoPlaylistModel"."OwnerAccount.Actor.id" ` + + `AND "OwnerAccount->Actor->Avatars"."type" = ${ActorImageType.AVATAR} ` + + this.built.accountAvatarJoin = true + } + + private buildChannelAvatarsJoin () { + if (this.built.channelAvatarJoin) return + + this.join += `LEFT JOIN "actorImage" "VideoChannel->Actor->Avatars" ` + + `ON "VideoChannel->Actor->Avatars"."actorId" = "VideoPlaylistModel"."VideoChannel.actorId" ` + + `AND "VideoChannel->Actor->Avatars"."type" = ${ActorImageType.AVATAR} ` + + this.built.channelAvatarJoin = true + } + + private buildThumbnailJoin () { + if (this.built.thumbnailJoin) return + + this.join += ' LEFT JOIN "thumbnail" "Thumbnail" ON "Thumbnail"."videoPlaylistId" = "VideoPlaylistModel"."id" ' + + this.built.thumbnailJoin = true + } + + // --------------------------------------------------------------------------- + + private buildListSelect () { + this.buildChannelAvatarsJoin() + this.buildAccountAvatarsJoin() + this.buildThumbnailJoin() + + this.attributes.push('"VideoPlaylistModel".*') + + this.attributes = this.attributes.concat([ + this.tableAttributes.getAccountAvatarAttributes(), + this.tableAttributes.getChannelAvatarAttributes(), + this.tableAttributes.getThumbnailAttributes() + ]) + } + + private buildInnerListSelect () { + this.buildAccountJoin() + this.buildChannelJoin() + + this.innerAttributes = [ + this.tableAttributes.getVideoPlaylistAttributes(), + + this.tableAttributes.getChannelAttributes(), + this.tableAttributes.getChannelActorAttributes(), + this.tableAttributes.getChannelServerAttributes(), + + this.tableAttributes.getAccountAttributes(), + this.tableAttributes.getAccountActorAttributes(), + this.tableAttributes.getAccountServerAttributes(), + + `(${this.getTotalVideosQuery()}) AS "videosLength"` + ] + } + + private getTotalVideosQuery () { + return `SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"` + } + + protected getSort (sort: string) { + return getPlaylistSort(sort) + } +} diff --git a/server/core/models/video/sql/playlist/video-playlist-table-attributes.ts b/server/core/models/video/sql/playlist/video-playlist-table-attributes.ts new file mode 100644 index 000000000..d1e40de80 --- /dev/null +++ b/server/core/models/video/sql/playlist/video-playlist-table-attributes.ts @@ -0,0 +1,66 @@ +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 { VideoChannelModel } from '../../video-channel.js' +import { VideoPlaylistModel } from '../../video-playlist.js' +import { ThumbnailModel } from '../../thumbnail.js' + +export class VideoPlaylistTableAttributes { + @Memoize() + getVideoPlaylistAttributes () { + return VideoPlaylistModel.getSQLAttributes('VideoPlaylistModel').join(', ') + } + + // --------------------------------------------------------------------------- + + @Memoize() + getChannelAttributes () { + return VideoChannelModel.getSQLSummaryAttributes('VideoChannel', 'VideoChannel.').join(', ') + } + + @Memoize() + getChannelActorAttributes () { + return ActorModel.getSQLActorSummaryAttributes('VideoChannel->Actor', `VideoChannel.Actor.`).join(', ') + } + + @Memoize() + getChannelServerAttributes () { + return ServerModel.getSQLSummaryAttributes('VideoChannel->Actor->Server', `VideoChannel.Actor.Server.`).join(', ') + } + + @Memoize() + getChannelAvatarAttributes () { + return ActorImageModel.getSQLAttributes('VideoChannel->Actor->Avatars', 'VideoChannel.Actor.Avatars.').join(', ') + } + + // --------------------------------------------------------------------------- + + @Memoize() + getAccountAttributes () { + return AccountModel.getSQLSummaryAttributes('OwnerAccount', 'OwnerAccount.').join(', ') + } + + @Memoize() + getAccountActorAttributes () { + return ActorModel.getSQLActorSummaryAttributes('OwnerAccount->Actor', `OwnerAccount.Actor.`).join(', ') + } + + @Memoize() + getAccountServerAttributes () { + return ServerModel.getSQLSummaryAttributes('OwnerAccount->Actor->Server', `OwnerAccount.Actor.Server.`).join(', ') + } + + @Memoize() + getAccountAvatarAttributes () { + return ActorImageModel.getSQLAttributes('OwnerAccount->Actor->Avatars', 'OwnerAccount.Actor.Avatars.').join(', ') + } + + // --------------------------------------------------------------------------- + + @Memoize() + getThumbnailAttributes () { + return ThumbnailModel.getSQLAttributes('Thumbnail', 'Thumbnail.').join(', ') + } +} diff --git a/server/core/models/video/sql/video/videos-id-list-query-builder.ts b/server/core/models/video/sql/video/videos-id-list-query-builder.ts index 622b4ea13..837b4b9db 100644 --- a/server/core/models/video/sql/video/videos-id-list-query-builder.ts +++ b/server/core/models/video/sql/video/videos-id-list-query-builder.ts @@ -1,5 +1,12 @@ import { forceNumber } from '@peertube/peertube-core-utils' -import { VideoInclude, VideoIncludeType, VideoPrivacy, VideoPrivacyType, VideoState } from '@peertube/peertube-models' +import { + VideoChannelCollaboratorState, + VideoInclude, + VideoIncludeType, + VideoPrivacy, + VideoPrivacyType, + VideoState +} from '@peertube/peertube-models' import { exists } from '@server/helpers/custom-validators/misc.js' import { WEBSERVER } from '@server/initializers/constants.js' import { buildSortDirectionAndField } from '@server/models/shared/index.js' @@ -58,6 +65,7 @@ export type BuildVideosListQueryOptions = { hasWebVideoFiles?: boolean accountId?: number + includeCollaborations?: boolean videoChannelId?: number channelNameOneOf?: string[] @@ -177,7 +185,7 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { } if (options.accountId) { - this.whereAccountId(options.accountId) + this.whereAccountId({ accountId: options.accountId, includeCollaborations: options.includeCollaborations }) } if (options.videoChannelId) { @@ -405,9 +413,27 @@ export class VideosIdListQueryBuilder extends AbstractRunQuery { this.replacements.host = host } - private whereAccountId (accountId: number) { - this.and.push('"account"."id" = :accountId') - this.replacements.accountId = accountId + private whereAccountId (options: { + accountId: number + includeCollaborations: boolean + }) { + if (options.includeCollaborations !== true) { + this.and.push('"account"."id" = :accountId') + this.replacements.accountId = options.accountId + return + } + + this.joins.push( + 'LEFT JOIN "videoChannelCollaborator" ON "videoChannelCollaborator"."channelId" = "videoChannel".id ' + + 'AND "videoChannelCollaborator"."state" = :channelCollaboratorState ' + + // Ensure we join with max 1 collaborator to not duplicate rows + 'AND "videoChannelCollaborator"."accountId" = :accountId' + ) + + this.and.push('("account"."id" = :accountId OR "videoChannelCollaborator"."accountId" = :accountId)') + + this.replacements.accountId = options.accountId + this.replacements.channelCollaboratorState = VideoChannelCollaboratorState.ACCEPTED } private whereChannelId (channelId: number) { diff --git a/server/core/models/video/storyboard.ts b/server/core/models/video/storyboard.ts index 00ce7b1d6..c2685dde5 100644 --- a/server/core/models/video/storyboard.ts +++ b/server/core/models/video/storyboard.ts @@ -133,7 +133,7 @@ export class StoryboardModel extends SequelizeModel { // --------------------------------------------------------------------------- getOriginFileUrl (video: MVideo) { - if (video.isOwned()) { + if (video.isLocal()) { return WEBSERVER.URL + this.getLocalStaticPath() } diff --git a/server/core/models/video/thumbnail.ts b/server/core/models/video/thumbnail.ts index 27503ec08..e3be1cd46 100644 --- a/server/core/models/video/thumbnail.ts +++ b/server/core/models/video/thumbnail.ts @@ -20,9 +20,10 @@ import { import { logger } from '../../helpers/logger.js' import { CONFIG } from '../../initializers/config.js' import { CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, WEBSERVER } from '../../initializers/constants.js' +import { SequelizeModel } from '../shared/sequelize-type.js' +import { buildSQLAttributes } from '../shared/sql.js' import { VideoPlaylistModel } from './video-playlist.js' import { VideoModel } from './video.js' -import { SequelizeModel } from '../shared/sequelize-type.js' @Table({ tableName: 'thumbnail', @@ -132,6 +133,18 @@ export class ThumbnailModel extends SequelizeModel { .catch(err => logger.error('Cannot remove thumbnail file %s.', instance.filename, { err })) } + // --------------------------------------------------------------------------- + + static getSQLAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: this, + tableName, + aliasPrefix + }) + } + + // --------------------------------------------------------------------------- + static loadByFilename (filename: string, thumbnailType: ThumbnailType_Type): Promise { const query = { where: { @@ -192,7 +205,7 @@ export class ThumbnailModel extends SequelizeModel { const staticPath = ThumbnailModel.types[this.type].staticPath + this.filename // FIXME: typings - if ((videoOrPlaylist as MVideo).isOwned()) return WEBSERVER.URL + staticPath + if ((videoOrPlaylist as MVideo).isLocal()) return WEBSERVER.URL + staticPath return this.fileUrl } @@ -223,7 +236,7 @@ export class ThumbnailModel extends SequelizeModel { this.previousThumbnailFilename = undefined } - isOwned () { + isLocal () { return !this.fileUrl } diff --git a/server/core/models/video/video-caption.ts b/server/core/models/video/video-caption.ts index 73edcd12d..06e1ef926 100644 --- a/server/core/models/video/video-caption.ts +++ b/server/core/models/video/video-caption.ts @@ -128,7 +128,7 @@ export class VideoCaptionModel extends SequelizeModel { instance.Video = await instance.$get('Video', { transaction: options.transaction }) } - if (instance.isOwned()) { + if (instance.isLocal()) { logger.info('Removing caption %s.', instance.filename) instance.removeAllCaptionFiles() @@ -278,7 +278,7 @@ export class VideoCaptionModel extends SequelizeModel { }, automaticallyGenerated: this.automaticallyGenerated, - captionPath: this.Video.isOwned() && this.fileUrl + captionPath: this.Video.isLocal() && this.fileUrl ? null // On object storage : this.getFileStaticPath(), @@ -315,7 +315,7 @@ export class VideoCaptionModel extends SequelizeModel { // --------------------------------------------------------------------------- - isOwned () { + isLocal () { return this.Video.remote === false } @@ -382,7 +382,7 @@ export class VideoCaptionModel extends SequelizeModel { // --------------------------------------------------------------------------- getFileUrl (this: MVideoCaptionUrl, video: MVideoOwned) { - if (video.isOwned() && this.storage === FileStorage.OBJECT_STORAGE) { + if (video.isLocal() && this.storage === FileStorage.OBJECT_STORAGE) { return getObjectStoragePublicFileUrl(this.fileUrl, CONFIG.OBJECT_STORAGE.CAPTIONS) } @@ -390,7 +390,7 @@ export class VideoCaptionModel extends SequelizeModel { } getOriginFileUrl (this: MVideoCaptionUrl, video: MVideoOwned) { - if (video.isOwned()) return this.getFileUrl(video) + if (video.isLocal()) return this.getFileUrl(video) return this.fileUrl } @@ -400,7 +400,7 @@ export class VideoCaptionModel extends SequelizeModel { getM3U8Url (this: MVideoCaptionUrl, video: MVideoOwned & MVideoPrivacy) { if (!this.m3u8Filename) return null - if (video.isOwned()) { + if (video.isLocal()) { if (this.storage === FileStorage.OBJECT_STORAGE) { return getObjectStoragePublicFileUrl(this.m3u8Url, CONFIG.OBJECT_STORAGE.STREAMING_PLAYLISTS) } diff --git a/server/core/models/video/video-channel-collaborator.ts b/server/core/models/video/video-channel-collaborator.ts new file mode 100644 index 000000000..33ae83a70 --- /dev/null +++ b/server/core/models/video/video-channel-collaborator.ts @@ -0,0 +1,187 @@ +import { + VideoChannelCollaboratorState, + type VideoChannelCollaborator, + type VideoChannelCollaboratorStateType +} from '@peertube/peertube-models' +import { CHANNEL_COLLABORATOR_STATE } from '@server/initializers/constants.js' +import { MChannelCollaboratorAccount, MChannelId, MUserId } from '@server/types/models/index.js' +import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Scopes, Table, UpdatedAt } from 'sequelize-typescript' +import { AccountModel } from '../account/account.js' +import { ActorModel } from '../actor/actor.js' +import { SequelizeModel, doesExist, getSort } from '../shared/index.js' +import { VideoChannelModel } from './video-channel.js' + +enum ScopeNames { + WITH_ACCOUNT = 'WITH_ACCOUNT' +} + +@Table({ + tableName: 'videoChannelCollaborator', + indexes: [ + { + fields: [ 'channelId', 'accountId' ], + unique: true + } + ] +}) +@Scopes(() => ({ + [ScopeNames.WITH_ACCOUNT]: { + include: [ + { + model: AccountModel, + required: true + } + ] + } +})) +export class VideoChannelCollaboratorModel extends SequelizeModel { + @CreatedAt + declare createdAt: Date + + @UpdatedAt + declare updatedAt: Date + + @AllowNull(false) + @Column + declare state: VideoChannelCollaboratorStateType + + @ForeignKey(() => AccountModel) + @Column + declare accountId: number + + @BelongsTo(() => AccountModel, { + foreignKey: { + name: 'accountId', + allowNull: false + }, + onDelete: 'cascade' + }) + declare Account: Awaited + + @ForeignKey(() => AccountModel) + @Column + declare channelId: number + + @BelongsTo(() => VideoChannelModel, { + foreignKey: { + name: 'channelId', + allowNull: false + }, + onDelete: 'cascade' + }) + declare Channel: Awaited + + static listForApi (options: { + channelId: number + start: number + count: number + sort: string + }) { + const { channelId, start, count, sort } = options + + const query = { + offset: start, + limit: count, + order: getSort(sort), + where: { + channelId + } + } + + return Promise.all([ + VideoChannelCollaboratorModel.count(query), + VideoChannelCollaboratorModel.scope([ ScopeNames.WITH_ACCOUNT ]).findAll(query) + ]).then(([ count, rows ]) => ({ total: count, data: rows })) + } + + static loadByChannelHandle (id: number, channelHandle: string): Promise { + return VideoChannelCollaboratorModel + .scope([ ScopeNames.WITH_ACCOUNT ]) + .findOne({ + where: { id }, + include: [ + { + model: VideoChannelModel.unscoped(), + attributes: [ 'id' ], + required: true, + include: [ + { + model: ActorModel.unscoped(), + required: true, + where: { + preferredUsername: channelHandle + } + } + ] + } + ] + }) + } + + static loadByCollaboratorAccountName (options: { + channelId: number + accountName: string + }): Promise { + const { channelId, accountName } = options + + return VideoChannelCollaboratorModel.findOne({ + where: { + channelId + }, + include: [ + { + model: AccountModel.unscoped(), + required: true, + include: [ + { + model: ActorModel.unscoped(), + required: true, + where: { + preferredUsername: accountName + } + } + ] + } + ] + }) + } + + static async isCollaborator (options: { + user: MUserId + channel: MChannelId + }): Promise { + const { user, channel } = options + + const query = `SELECT 1 FROM "videoChannelCollaborator" ` + + `INNER JOIN "account" ON "account"."id" = "videoChannelCollaborator"."accountId" AND account."userId" = $userId ` + + `WHERE "videoChannelCollaborator"."channelId" = $channelId AND "state" = $state ` + + `LIMIT 1` + + return doesExist({ + sequelize: this.sequelize, + query, + bind: { userId: user.id, channelId: channel.id, state: VideoChannelCollaboratorState.ACCEPTED } + }) + } + + // --------------------------------------------------------------------------- + + static getStateLabel (state: VideoChannelCollaboratorStateType) { + return CHANNEL_COLLABORATOR_STATE[state] + } + + toFormattedJSON (this: MChannelCollaboratorAccount): VideoChannelCollaborator { + return { + id: this.id, + + state: { + id: this.state, + label: VideoChannelCollaboratorModel.getStateLabel(this.state) + }, + + account: this.Account.toFormattedJSON(), + updatedAt: this.updatedAt.toISOString(), + createdAt: this.createdAt.toISOString() + } + } +} diff --git a/server/core/models/video/video-channel.ts b/server/core/models/video/video-channel.ts index e7d87e8ff..a75ff3c90 100644 --- a/server/core/models/video/video-channel.ts +++ b/server/core/models/video/video-channel.ts @@ -1,9 +1,9 @@ -import { forceNumber, pick } from '@peertube/peertube-core-utils' import { ActivityPubActor, VideoChannel, VideoChannelSummary, VideoPrivacy } from '@peertube/peertube-models' +import { AttributesOnly } from '@peertube/peertube-typescript-utils' import { CONFIG } from '@server/initializers/config.js' import { InternalEventEmitter } from '@server/lib/internal-event-emitter.js' import { MAccountIdHost } from '@server/types/models/index.js' -import { FindOptions, Includeable, literal, Op, QueryTypes, ScopeOptions, Transaction, WhereOptions } from 'sequelize' +import { FindOptions, Includeable, literal, Op, QueryTypes, Transaction } from 'sequelize' import { AfterCreate, AfterDestroy, @@ -20,7 +20,6 @@ import { HasMany, Is, Scopes, - Sequelize, Table, UpdatedAt } from 'sequelize-typescript' @@ -44,40 +43,22 @@ import { import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account.js' import { ActorFollowModel } from '../actor/actor-follow.js' import { ActorImageModel } from '../actor/actor-image.js' -import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor.js' -import { ServerModel } from '../server/server.js' -import { - buildServerIdsFollowedBy, - buildTrigramSearchIndex, - createSimilarityAttribute, - getSort, - SequelizeModel, - setAsUpdated, - throwIfNotValid -} from '../shared/index.js' +import { ActorModel, actorSummaryAttributes } from '../actor/actor.js' +import { ServerModel, serverSummaryAttributes } from '../server/server.js' +import { buildSQLAttributes, buildTrigramSearchIndex, getSort, SequelizeModel, setAsUpdated, throwIfNotValid } from '../shared/index.js' +import { ListVideoChannelsOptions, VideoChannelListQueryBuilder } from './sql/channel/video-channel-list-query-builder.js' +import { VideoChannelCollaboratorModel } from './video-channel-collaborator.js' import { VideoPlaylistModel } from './video-playlist.js' import { VideoModel } from './video.js' +const channelSummaryAttributes = [ 'id', 'name', 'description', 'actorId' ] as const satisfies (keyof AttributesOnly)[] + export enum ScopeNames { - FOR_API = 'FOR_API', SUMMARY = 'SUMMARY', WITH_ACCOUNT = 'WITH_ACCOUNT', WITH_ACTOR = 'WITH_ACTOR', WITH_ACTOR_BANNER = 'WITH_ACTOR_BANNER', - WITH_VIDEOS = 'WITH_VIDEOS', - WITH_STATS = 'WITH_STATS' -} - -type AvailableForListOptions = { - actorId: number - search?: string - host?: string - handles?: string[] - forCount?: boolean -} - -type AvailableWithStatsOptions = { - daysPrior: number + WITH_VIDEOS = 'WITH_VIDEOS' } export type SummaryOptions = { @@ -95,142 +76,15 @@ export type SummaryOptions = { ] })) @Scopes(() => ({ - [ScopeNames.FOR_API]: (options: AvailableForListOptions) => { - // Only list local channels OR channels that are on an instance followed by actorId - const inQueryInstanceFollow = buildServerIdsFollowedBy(options.actorId) - - const whereActorAnd: WhereOptions[] = [ - { - [Op.or]: [ - { - serverId: null - }, - { - serverId: { - [Op.in]: Sequelize.literal(inQueryInstanceFollow) - } - } - ] - } - ] - - let serverRequired = false - let whereServer: WhereOptions - - if (options.host && options.host !== WEBSERVER.HOST) { - serverRequired = true - whereServer = { host: options.host } - } - - if (options.host === WEBSERVER.HOST) { - whereActorAnd.push({ - serverId: null - }) - } - - if (Array.isArray(options.handles) && options.handles.length !== 0) { - const or: string[] = [] - - for (const handle of options.handles || []) { - const [ preferredUsername, host ] = handle.split('@') - - const sanitizedPreferredUsername = VideoChannelModel.sequelize.escape(preferredUsername.toLowerCase()) - const sanitizedHost = VideoChannelModel.sequelize.escape(host) - - if (!host || host === WEBSERVER.HOST) { - or.push(`(LOWER("preferredUsername") = ${sanitizedPreferredUsername} AND "serverId" IS NULL)`) - } else { - or.push( - `(` + - `LOWER("preferredUsername") = ${sanitizedPreferredUsername} ` + - `AND "host" = ${sanitizedHost}` + - `)` - ) - } - } - - whereActorAnd.push({ - id: { - [Op.in]: literal(`(SELECT "actor".id FROM actor LEFT JOIN server on server.id = actor."serverId" WHERE ${or.join(' OR ')})`) - } - }) - } - - const channelActorInclude: Includeable[] = [] - const accountActorInclude: Includeable[] = [] - - if (options.forCount !== true) { - accountActorInclude.push({ - model: ServerModel, - required: false - }) - - accountActorInclude.push({ - model: ActorImageModel, - as: 'Avatars', - required: false - }) - - channelActorInclude.push({ - model: ActorImageModel, - as: 'Avatars', - required: false - }) - - channelActorInclude.push({ - model: ActorImageModel, - as: 'Banners', - required: false - }) - } - - if (options.forCount !== true || serverRequired) { - channelActorInclude.push({ - model: ServerModel, - duplicating: false, - required: serverRequired, - where: whereServer - }) - } - - return { - include: [ - { - attributes: { - exclude: unusedActorAttributesForAPI - }, - model: ActorModel.unscoped(), - where: { - [Op.and]: whereActorAnd - }, - include: channelActorInclude - }, - { - model: AccountModel.unscoped(), - required: true, - include: [ - { - attributes: { - exclude: unusedActorAttributesForAPI - }, - model: ActorModel.unscoped(), - required: true, - include: accountActorInclude - } - ] - } - ] - } - }, [ScopeNames.SUMMARY]: (options: SummaryOptions = {}) => { const include: Includeable[] = [ { - attributes: [ 'id', 'preferredUsername', 'url', 'serverId' ], + attributes: actorSummaryAttributes, model: ActorModel.unscoped(), required: options.actorRequired ?? true, include: [ { - attributes: [ 'host' ], + attributes: serverSummaryAttributes, model: ServerModel.unscoped(), required: false }, @@ -244,7 +98,7 @@ export type SummaryOptions = { ] const base: FindOptions = { - attributes: [ 'id', 'name', 'description', 'actorId' ] + attributes: channelSummaryAttributes } if (options.withAccount === true) { @@ -291,53 +145,6 @@ export type SummaryOptions = { include: [ VideoModel ] - }, - [ScopeNames.WITH_STATS]: (options: AvailableWithStatsOptions = { daysPrior: 30 }) => { - const daysPrior = forceNumber(options.daysPrior) - - return { - attributes: { - include: [ - [ - literal('(SELECT COUNT(*) FROM "video" WHERE "channelId" = "VideoChannelModel"."id")'), - 'videosCount' - ], - [ - literal( - '(' + - `SELECT string_agg(concat_ws('|', t.day, t.views), ',') ` + - 'FROM ( ' + - 'WITH ' + - 'days AS ( ' + - `SELECT generate_series(date_trunc('day', now()) - '${daysPrior} day'::interval, ` + - `date_trunc('day', now()), '1 day'::interval) AS day ` + - ') ' + - 'SELECT days.day AS day, COALESCE(SUM("videoView".views), 0) AS views ' + - 'FROM days ' + - 'LEFT JOIN (' + - '"videoView" INNER JOIN "video" ON "videoView"."videoId" = "video"."id" ' + - 'AND "video"."channelId" = "VideoChannelModel"."id"' + - `) ON date_trunc('day', "videoView"."startDate") = date_trunc('day', days.day) ` + - 'GROUP BY day ' + - 'ORDER BY day ' + - ') t' + - ')' - ), - 'viewsPerDay' - ], - [ - literal( - '(' + - 'SELECT COALESCE(SUM("video".views), 0) AS totalViews ' + - 'FROM "video" ' + - 'WHERE "video"."channelId" = "VideoChannelModel"."id"' + - ')' - ), - 'totalViews' - ] - ] - } - } } })) @Table({ @@ -419,6 +226,12 @@ export class VideoChannelModel extends SequelizeModel { }) declare VideoPlaylists: Awaited[] + @HasMany(() => VideoChannelCollaboratorModel, { + foreignKey: 'accountId', + onDelete: 'CASCADE' + }) + declare VideoChannelCollaborators: Awaited[] + @AfterCreate static notifyCreate (channel: MChannel) { InternalEventEmitter.Instance.emit('channel-created', { channel }) @@ -442,7 +255,7 @@ export class VideoChannelModel extends SequelizeModel { await ActorFollowModel.removeFollowsOf(instance.Actor.id, options.transaction) - if (instance.Actor.isOwned()) { + if (instance.Actor.isLocal()) { return sendDeleteActor(instance.Actor, options.transaction) } @@ -461,6 +274,27 @@ export class VideoChannelModel extends SequelizeModel { } } + // --------------------------------------------------------------------------- + + static getSQLAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: this, + tableName, + aliasPrefix + }) + } + + static getSQLSummaryAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: this, + tableName, + aliasPrefix, + includeAttributes: channelSummaryAttributes + }) + } + + // --------------------------------------------------------------------------- + static countByAccount (accountId: number) { const query = { where: { @@ -536,147 +370,32 @@ export class VideoChannelModel extends SequelizeModel { .findAll(query) } - static listForApi ( - parameters: Pick & { - start: number - count: number - sort: string + // --------------------------------------------------------------------------- + + static listForApi (options: ListVideoChannelsOptions) { + return Promise.all([ + new VideoChannelListQueryBuilder(VideoChannelModel.sequelize, options).list() as Promise, + new VideoChannelListQueryBuilder(VideoChannelModel.sequelize, options).count() + ]).then(([ rows, count ]) => { + return { total: count, data: rows } + }) + } + + static listByAccountForAPI ( + options: Pick & { + withStats?: boolean } ) { - const { actorId } = parameters + const listOptions = options.withStats + ? { ...options, statsDaysPrior: 30 } + : options - const query = { - offset: parameters.start, - limit: parameters.count, - order: getSort(parameters.sort) - } - - const getScope = (forCount: boolean) => { - return { method: [ ScopeNames.FOR_API, { actorId, forCount } as AvailableForListOptions ] } - } - - return Promise.all([ - VideoChannelModel.scope(getScope(true)).count(), - VideoChannelModel.scope(getScope(false)).findAll(query) - ]).then(([ total, data ]) => ({ total, data })) + return this.listForApi(listOptions) } - static searchForApi ( - options: Pick & { - start: number - count: number - sort: string - } - ) { - let attributesInclude: any[] = [ literal('0 as similarity') ] - let where: WhereOptions + // --------------------------------------------------------------------------- - if (options.search) { - const escapedSearch = VideoChannelModel.sequelize.escape(options.search) - const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%') - attributesInclude = [ createSimilarityAttribute('VideoChannelModel.name', options.search) ] - - where = { - [Op.or]: [ - Sequelize.literal( - 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' - ), - Sequelize.literal( - 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' - ) - ] - } - } - - const query = { - attributes: { - include: attributesInclude - }, - offset: options.start, - limit: options.count, - order: getSort(options.sort), - where - } - - const getScope = (forCount: boolean) => { - return { - method: [ - ScopeNames.FOR_API, - { - ...pick(options, [ 'actorId', 'host', 'handles' ]), - - forCount - } as AvailableForListOptions - ] - } - } - - return Promise.all([ - VideoChannelModel.scope(getScope(true)).count(query), - VideoChannelModel.scope(getScope(false)).findAll(query) - ]).then(([ total, data ]) => ({ total, data })) - } - - static listByAccountForAPI (options: { - accountId: number - start: number - count: number - sort: string - withStats?: boolean - search?: string - }) { - const escapedSearch = VideoModel.sequelize.escape(options.search) - const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') - const where = options.search - ? { - [Op.or]: [ - Sequelize.literal( - 'lower(immutable_unaccent("VideoChannelModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' - ), - Sequelize.literal( - 'lower(immutable_unaccent("VideoChannelModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' - ) - ] - } - : null - - const getQuery = (forCount: boolean) => { - const accountModel = forCount - ? AccountModel.unscoped() - : AccountModel - - return { - offset: options.start, - limit: options.count, - order: getSort(options.sort), - include: [ - { - model: accountModel, - where: { - id: options.accountId - }, - required: true - } - ], - where - } - } - - const findScopes: string | ScopeOptions | (string | ScopeOptions)[] = [ ScopeNames.WITH_ACTOR_BANNER ] - - if (options.withStats === true) { - findScopes.push({ - method: [ ScopeNames.WITH_STATS, { daysPrior: 30 } as AvailableWithStatsOptions ] - }) - } - - return Promise.all([ - VideoChannelModel.unscoped().count(getQuery(true)), - VideoChannelModel.scope(findScopes).findAll(getQuery(false)) - ]).then(([ total, data ]) => ({ total, data })) - } - - static listAllByAccount (accountId: number): Promise { + static listAllOwnedByAccount (accountId: number): Promise { const query = { limit: CONFIG.VIDEO_CHANNELS.MAX_PER_USER, include: [ @@ -828,7 +547,7 @@ export class VideoChannelModel extends SequelizeModel { displayName: this.getDisplayName(), description: this.description, support: this.support, - isLocal: this.Actor.isOwned(), + isLocal: this.Actor.isLocal(), updatedAt: this.updatedAt, ownerAccount: undefined, @@ -890,6 +609,10 @@ export class VideoChannelModel extends SequelizeModel { return WEBSERVER.URL + '/c/' + this.Actor.getIdentifier() + suffix } + getClientManageUrl (this: MAccountIdHost | MChannelIdHost) { + return WEBSERVER.URL + '/my-library/video-channels/update/' + this.Actor.getIdentifier() + } + getDisplayName () { return this.name } diff --git a/server/core/models/video/video-comment.ts b/server/core/models/video/video-comment.ts index 862e98110..af01939fe 100644 --- a/server/core/models/video/video-comment.ts +++ b/server/core/models/video/video-comment.ts @@ -348,6 +348,8 @@ export class VideoCommentModel extends SequelizeModel { autoTagOfAccountId: number videoAccountOwnerId?: number + videoAccountOwnerIncludeCollaborations?: boolean + videoChannelOwnerId?: number onLocalVideo?: boolean @@ -378,6 +380,7 @@ export class VideoCommentModel extends SequelizeModel { 'autoTagOneOf', 'autoTagOfAccountId', 'videoAccountOwnerId', + 'videoAccountOwnerIncludeCollaborations', 'videoChannelOwnerId', 'heldForReview' ]), @@ -686,10 +689,10 @@ export class VideoCommentModel extends SequelizeModel { return this.originCommentId || this.id } - isOwned () { + isLocal () { if (!this.Account) return false - return this.Account.isOwned() + return this.Account.isLocal() } markAsDeleted () { @@ -703,7 +706,7 @@ export class VideoCommentModel extends SequelizeModel { } extractMentions () { - return extractMentions(this.text, this.isOwned()) + return extractMentions(this.text, this.isLocal()) } toFormattedJSON (this: MCommentFormattable) { @@ -792,7 +795,7 @@ export class VideoCommentModel extends SequelizeModel { } let replyApproval = this.replyApproval - if (this.Video.isOwned() && !this.heldForReview) { + if (this.Video.isLocal() && !this.heldForReview) { replyApproval = getLocalApproveReplyActivityPubUrl(this.Video, this) } diff --git a/server/core/models/video/video-file.ts b/server/core/models/video/video-file.ts index 3da015762..d6650155b 100644 --- a/server/core/models/video/video-file.ts +++ b/server/core/models/video/video-file.ts @@ -527,7 +527,7 @@ export class VideoFileModel extends SequelizeModel { // --------------------------------------------------------------------------- getFileUrl (video: MVideo) { - if (video.isOwned()) { + if (video.isLocal()) { if (this.storage === FileStorage.OBJECT_STORAGE) { return this.getObjectStorageUrl(video) } @@ -569,14 +569,14 @@ export class VideoFileModel extends SequelizeModel { ? join(DOWNLOAD_PATHS.HLS_VIDEOS, `${video.uuid}-${this.resolution}-fragmented${this.extname}`) : join(DOWNLOAD_PATHS.WEB_VIDEOS, `${video.uuid}-${this.resolution}${this.extname}`) - if (video.isOwned()) return WEBSERVER.URL + path + if (video.isLocal()) return WEBSERVER.URL + path // FIXME: don't guess remote URL return buildRemoteUrl(video, path) } getRemoteTorrentUrl (video: MVideo) { - if (video.isOwned()) throw new Error(`Video ${video.url} is not a remote video`) + if (video.isLocal()) throw new Error(`Video ${video.url} is not a remote video`) return this.torrentUrl } diff --git a/server/core/models/video/video-playlist.ts b/server/core/models/video/video-playlist.ts index 6afdaded2..1a9782994 100644 --- a/server/core/models/video/video-playlist.ts +++ b/server/core/models/video/video-playlist.ts @@ -1,4 +1,4 @@ -import { buildPlaylistEmbedPath, buildPlaylistWatchPath, pick } from '@peertube/peertube-core-utils' +import { buildPlaylistEmbedPath, buildPlaylistWatchPath } from '@peertube/peertube-core-utils' import { ActivityIconObject, PlaylistObject, @@ -12,7 +12,7 @@ import { buildUUID, uuidToShort } from '@peertube/peertube-node-utils' import { activityPubCollectionPagination } from '@server/lib/activitypub/collection.js' import { MAccountId, MChannelId, MVideoPlaylistElement } from '@server/types/models/index.js' import { join } from 'path' -import { FindOptions, Includeable, Op, ScopeOptions, Sequelize, Transaction, WhereOptions, literal } from 'sequelize' +import { FindOptions, Op, Transaction, literal } from 'sequelize' import { AllowNull, BelongsTo, @@ -55,26 +55,25 @@ import { MVideoPlaylistFullSummary, MVideoPlaylistSummaryWithElements } from '../../types/models/video/video-playlist.js' -import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account.js' +import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account.js' import { ActorModel } from '../actor/actor.js' import { SequelizeModel, - buildServerIdsFollowedBy, + buildSQLAttributes, buildTrigramSearchIndex, buildWhereIdOrUUID, - createSimilarityAttribute, - getPlaylistSort, isOutdated, setAsUpdated, throwIfNotValid } from '../shared/index.js' import { getNextPositionOf, increasePositionOf, reassignPositionOf } from '../shared/position.js' +import { ListVideoPlaylistsOptions, VideoPlaylistListQueryBuilder } from './sql/playlist/video-playlist-list-query-builder.js' import { ThumbnailModel } from './thumbnail.js' import { VideoChannelModel, ScopeNames as VideoChannelScopeNames } from './video-channel.js' +import { VideoCommentModel } from './video-comment.js' import { VideoPlaylistElementModel } from './video-playlist-element.js' enum ScopeNames { - AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', WITH_VIDEOS_LENGTH = 'WITH_VIDEOS_LENGTH', WITH_ACCOUNT_AND_CHANNEL_SUMMARY = 'WITH_ACCOUNT_AND_CHANNEL_SUMMARY', WITH_ACCOUNT = 'WITH_ACCOUNT', @@ -82,20 +81,6 @@ enum ScopeNames { WITH_ACCOUNT_AND_CHANNEL = 'WITH_ACCOUNT_AND_CHANNEL' } -type AvailableForListOptions = { - followerActorId?: number - type?: VideoPlaylistType_Type - accountId?: number - videoChannelId?: number - listMyPlaylists?: boolean - search?: string - host?: string - uuids?: string[] - channelNameOneOf?: string[] - withVideos?: boolean - forCount?: boolean -} - function getVideoLengthSelect () { return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"' } @@ -150,126 +135,6 @@ function getVideoLengthSelect () { required: false } ] - }, - [ScopeNames.AVAILABLE_FOR_LIST]: (options: AvailableForListOptions) => { - const whereAnd: WhereOptions[] = [] - - const whereServer = options.host && options.host !== WEBSERVER.HOST - ? { host: options.host } - : undefined - - let whereActor: WhereOptions = {} - - if (options.host === WEBSERVER.HOST) { - whereActor = { - [Op.and]: [ { serverId: null } ] - } - } - - if (options.listMyPlaylists !== true) { - whereAnd.push({ - privacy: VideoPlaylistPrivacy.PUBLIC - }) - - // … OR playlists that are on an instance followed by actorId - if (options.followerActorId) { - // Only list local playlists - const whereActorOr: WhereOptions[] = [ - { - serverId: null - } - ] - - const inQueryInstanceFollow = buildServerIdsFollowedBy(options.followerActorId) - - whereActorOr.push({ - serverId: { - [Op.in]: literal(inQueryInstanceFollow) - } - }) - - Object.assign(whereActor, { [Op.or]: whereActorOr }) - } - } - - if (options.accountId) { - whereAnd.push({ - ownerAccountId: options.accountId - }) - } - - if (options.videoChannelId) { - whereAnd.push({ - videoChannelId: options.videoChannelId - }) - } - - if (options.type) { - whereAnd.push({ - type: options.type - }) - } - - if (options.uuids) { - whereAnd.push({ - uuid: { - [Op.in]: options.uuids - } - }) - } - - if (options.withVideos === true) { - whereAnd.push( - literal(`(${getVideoLengthSelect()}) != 0`) - ) - } - - let attributesInclude: any[] = [ literal('0 as similarity') ] - - if (options.search) { - const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search) - const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%') - attributesInclude = [ createSimilarityAttribute('VideoPlaylistModel.name', options.search) ] - - whereAnd.push({ - [Op.or]: [ - Sequelize.literal( - 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))' - ), - Sequelize.literal( - 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' - ) - ] - }) - } - - const where = { - [Op.and]: whereAnd - } - - const include: Includeable[] = [ - { - model: AccountModel.scope({ - method: [ AccountScopeNames.SUMMARY, { whereActor, whereServer, forCount: options.forCount } as SummaryOptions ] - }), - required: true - } - ] - - if (options.forCount !== true) { - include.push({ - model: VideoChannelModel.scope(VideoChannelScopeNames.SUMMARY), - required: false - }) - } - - return { - attributes: { - include: attributesInclude - }, - where, - include - } as FindOptions } })) @Table({ @@ -374,74 +239,29 @@ export class VideoPlaylistModel extends SequelizeModel { }) declare Thumbnail: Awaited - static listForApi ( - options: AvailableForListOptions & { - start: number - count: number - sort: string - } - ) { - const query = { - offset: options.start, - limit: options.count, - order: getPlaylistSort(options.sort) - } + // --------------------------------------------------------------------------- - const commonAvailableForListOptions = pick(options, [ - 'type', - 'followerActorId', - 'accountId', - 'videoChannelId', - 'listMyPlaylists', - 'search', - 'host', - 'channelNameOneOf', - 'uuids' - ]) + static getSQLAttributes (tableName: string, aliasPrefix = '') { + return buildSQLAttributes({ + model: this, + tableName, + aliasPrefix + }) + } - const scopesFind: (string | ScopeOptions)[] = [ - { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, - { - ...commonAvailableForListOptions, - - withVideos: options.withVideos || false - } as AvailableForListOptions - ] - }, - ScopeNames.WITH_VIDEOS_LENGTH, - ScopeNames.WITH_THUMBNAIL - ] - - const scopesCount: (string | ScopeOptions)[] = [ - { - method: [ - ScopeNames.AVAILABLE_FOR_LIST, - - { - ...commonAvailableForListOptions, - - withVideos: options.withVideos || false, - forCount: true - } as AvailableForListOptions - ] - }, - ScopeNames.WITH_VIDEOS_LENGTH - ] + // --------------------------------------------------------------------------- + static listForApi (options: ListVideoPlaylistsOptions) { return Promise.all([ - VideoPlaylistModel.scope(scopesCount).count(), - VideoPlaylistModel.scope(scopesFind).findAll(query) - ]).then(([ count, rows ]) => ({ total: count, data: rows })) + new VideoPlaylistListQueryBuilder(VideoCommentModel.sequelize, options).list(), + new VideoPlaylistListQueryBuilder(VideoCommentModel.sequelize, options).count() + ]).then(([ rows, count ]) => { + return { total: count, data: rows } + }) } static searchForApi ( - options: Pick & { - start: number - count: number - sort: string - } + options: Pick ) { return VideoPlaylistModel.listForApi({ ...options, @@ -783,12 +603,12 @@ export class VideoPlaylistModel extends SequelizeModel { this.set('videosLength' as any, videosLength, { raw: true }) } - isOwned () { - return this.OwnerAccount.isOwned() + isLocal () { + return this.OwnerAccount.isLocal() } isOutdated () { - if (this.isOwned()) return false + if (this.isLocal()) return false return isOutdated(this, ACTIVITY_PUB.VIDEO_PLAYLIST_REFRESH_INTERVAL) } @@ -799,7 +619,7 @@ export class VideoPlaylistModel extends SequelizeModel { uuid: this.uuid, shortUUID: uuidToShort(this.uuid), - isLocal: this.isOwned(), + isLocal: this.isLocal(), url: this.url, diff --git a/server/core/models/video/video-streaming-playlist.ts b/server/core/models/video/video-streaming-playlist.ts index bd7856697..29dec28b6 100644 --- a/server/core/models/video/video-streaming-playlist.ts +++ b/server/core/models/video/video-streaming-playlist.ts @@ -272,7 +272,7 @@ export class VideoStreamingPlaylistModel extends SequelizeModel { @BeforeDestroy static async sendDelete (instance: MVideoAccountLight, options: { transaction: Transaction }) { - if (!instance.isOwned()) return undefined + if (!instance.isLocal()) return undefined // Lazy load channels if (!instance.VideoChannel) { @@ -848,7 +848,7 @@ export class VideoModel extends SequelizeModel { logger.info('Removing files of video ' + instance.url) - if (instance.isOwned()) { + if (instance.isLocal()) { if (!Array.isArray(instance.VideoFiles)) { instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction }) } @@ -1102,6 +1102,8 @@ export class VideoModel extends SequelizeModel { excludeAlreadyWatched?: boolean // default false autoTagOneOf?: string[] + + includeCollaborations?: boolean // default false }) { VideoModel.throwIfPrivateIncludeWithoutUser(options) VideoModel.throwIfPrivacyOneOfWithoutUser(options) @@ -1131,6 +1133,7 @@ export class VideoModel extends SequelizeModel { 'displayOnlyForFollower', 'hasFiles', 'accountId', + 'includeCollaborations', 'videoChannelId', 'channelNameOneOf', 'videoPlaylistId', @@ -1848,7 +1851,7 @@ export class VideoModel extends SequelizeModel { // --------------------------------------------------------------------------- - isOwned (this: MVideoOwned) { + isLocal (this: MVideoOwned) { return this.remote === false } @@ -2133,7 +2136,7 @@ export class VideoModel extends SequelizeModel { // --------------------------------------------------------------------------- isOutdated () { - if (this.isOwned()) return false + if (this.isLocal()) return false return isOutdated(this, ACTIVITY_PUB.VIDEO_REFRESH_INTERVAL) } @@ -2194,7 +2197,7 @@ export class VideoModel extends SequelizeModel { } getTrackerUrls () { - if (this.isOwned()) { + if (this.isLocal()) { return [ WEBSERVER.URL + '/tracker/announce', WEBSERVER.WS + '://' + WEBSERVER.HOSTNAME + ':' + WEBSERVER.PORT + '/tracker/socket' diff --git a/server/core/types/express.d.ts b/server/core/types/express.d.ts index 563ef3828..8f880cf88 100644 --- a/server/core/types/express.d.ts +++ b/server/core/types/express.d.ts @@ -14,7 +14,9 @@ import { MActorFollowActorsDefault, MActorUrl, MChannelBannerAccountDefault, + MChannelCollaboratorAccount, MChannelSyncChannel, + MLocalVideoViewerWithWatchSections, MRegistration, MStreamingPlaylist, MUserAccountUrl, @@ -25,6 +27,7 @@ import { MVideoId, MVideoImmutable, MVideoLiveSessionReplay, + MVideoLiveWithSettingSchedules, MVideoPassword, MVideoPlaylistFull, MVideoPlaylistFullSummary, @@ -248,6 +251,8 @@ declare module 'express' { watchedWordsList?: MWatchedWordsList tokenSession?: MOAuthToken + + channelCollaborator?: MChannelCollaboratorAccount } } } diff --git a/server/core/types/models/user/user-notification.ts b/server/core/types/models/user/user-notification.ts index 9c09877ce..513d6140a 100644 --- a/server/core/types/models/user/user-notification.ts +++ b/server/core/types/models/user/user-notification.ts @@ -1,30 +1,31 @@ /* eslint-disable @typescript-eslint/prefer-namespace-keyword */ +import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils' import { VideoAbuseModel } from '@server/models/abuse/video-abuse.js' import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse.js' import { ApplicationModel } from '@server/models/application/application.js' import { PluginModel } from '@server/models/server/plugin.js' import { UserNotificationModel } from '@server/models/user/user-notification.js' import { UserRegistrationModel } from '@server/models/user/user-registration.js' -import { PickWith, PickWithOpt } from '@peertube/peertube-typescript-utils' +import { VideoCaptionModel } from '@server/models/video/video-caption.js' +import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js' import { AbuseModel } from '../../../models/abuse/abuse.js' import { AccountModel } from '../../../models/account/account.js' -import { ActorModel } from '../../../models/actor/actor.js' import { ActorFollowModel } from '../../../models/actor/actor-follow.js' import { ActorImageModel } from '../../../models/actor/actor-image.js' +import { ActorModel } from '../../../models/actor/actor.js' import { ServerModel } from '../../../models/server/server.js' -import { VideoModel } from '../../../models/video/video.js' import { VideoBlacklistModel } from '../../../models/video/video-blacklist.js' import { VideoChannelModel } from '../../../models/video/video-channel.js' import { VideoCommentModel } from '../../../models/video/video-comment.js' import { VideoImportModel } from '../../../models/video/video-import.js' -import { VideoCaptionModel } from '@server/models/video/video-caption.js' +import { VideoModel } from '../../../models/video/video.js' type Use = PickWith // ############################################################################ -export module UserNotificationIncludes { +export namespace UserNotificationIncludes { export type ActorImageInclude = Pick export type VideoInclude = Pick @@ -105,6 +106,11 @@ export module UserNotificationIncludes { export type VideoCaptionInclude = & Pick & PickWith + + export type VideoChannelCollaboratorInclude = + & Pick + & PickWith + & PickWith } // ############################################################################ @@ -123,6 +129,7 @@ export type MUserNotification = Omit< | 'Application' | 'UserRegistration' | 'VideoCaption' + | 'VideoChannelCollaborator' > // ############################################################################ @@ -140,3 +147,4 @@ export type UserNotificationModelForApi = & Use<'Account', UserNotificationIncludes.AccountIncludeActor> & Use<'UserRegistration', UserNotificationIncludes.UserRegistrationInclude> & Use<'VideoCaption', UserNotificationIncludes.VideoCaptionInclude> + & Use<'VideoChannelCollaborator', UserNotificationIncludes.VideoChannelCollaboratorInclude> diff --git a/server/core/types/models/video/index.ts b/server/core/types/models/video/index.ts index 0eeb7aad2..bd44af21b 100644 --- a/server/core/types/models/video/index.ts +++ b/server/core/types/models/video/index.ts @@ -11,6 +11,7 @@ export * from './video-caption.js' export * from './video-change-ownership.js' export * from './video-channel-sync.js' export * from './video-channel.js' +export * from './video-channel-collaborator.js' export * from './video-chapter.js' export * from './video-comment.js' export * from './video-file.js' diff --git a/server/core/types/models/video/video-caption.ts b/server/core/types/models/video/video-caption.ts index 278ca15aa..a47f47481 100644 --- a/server/core/types/models/video/video-caption.ts +++ b/server/core/types/models/video/video-caption.ts @@ -37,7 +37,7 @@ export type MVideoCaptionLanguageUrl = Pick< export type MVideoCaptionVideo = & MVideoCaption - & Use<'Video', Pick> + & Use<'Video', Pick> // ############################################################################ diff --git a/server/core/types/models/video/video-channel-collaborator.ts b/server/core/types/models/video/video-channel-collaborator.ts new file mode 100644 index 000000000..34689f3fc --- /dev/null +++ b/server/core/types/models/video/video-channel-collaborator.ts @@ -0,0 +1,13 @@ +import { PickWith } from '@peertube/peertube-typescript-utils' +import { VideoChannelCollaboratorModel } from '@server/models/video/video-channel-collaborator.js' +import { MAccountDefault } from '../account/account.js' + +type Use = PickWith + +// ############################################################################ + +export type MChannelCollaborator = Omit + +export type MChannelCollaboratorAccount = + & MChannelCollaborator + & Use<'Account', MAccountDefault> diff --git a/server/core/types/models/video/video-channel.ts b/server/core/types/models/video/video-channel.ts index 4a92e9b38..b1541d047 100644 --- a/server/core/types/models/video/video-channel.ts +++ b/server/core/types/models/video/video-channel.ts @@ -5,6 +5,7 @@ import { MAccountActor, MAccountDefault, MAccountFormattable, + MAccountId, MAccountIdActorId, MAccountLight, MAccountSummaryBlocks, @@ -48,8 +49,13 @@ export type MChannelIdActor = & Use<'Actor', MActorAccountChannelId> export type MChannelUserId = - & Pick - & Use<'Account', MAccountUserId> + & Pick + & Use<'Account', MAccountId & MAccountUserId> + +export type MChannelAccountId = + & Pick + & Use<'Actor', MActorId> + & Use<'Account', MAccountIdActorId> export type MChannelAccountIdUrl = & Pick diff --git a/server/core/types/models/video/video.ts b/server/core/types/models/video/video.ts index bf31ba4c1..a7bed4af9 100644 --- a/server/core/types/models/video/video.ts +++ b/server/core/types/models/video/video.ts @@ -63,8 +63,8 @@ export type MVideoUrl = Pick export type MVideoUUID = Pick export type MVideoPrivacy = Pick -export type MVideoImmutable = Pick -export type MVideoOwned = Pick +export type MVideoImmutable = Pick +export type MVideoOwned = Pick export type MVideoIdUrl = MVideoId & MVideoUrl export type MVideoFeed = Pick diff --git a/server/scripts/create-import-video-file-job.ts b/server/scripts/create-import-video-file-job.ts index fb3263a60..004c3f2e9 100644 --- a/server/scripts/create-import-video-file-job.ts +++ b/server/scripts/create-import-video-file-job.ts @@ -37,7 +37,7 @@ async function run () { const video = await VideoModel.load(uuid) if (!video) throw new Error('Video not found.') - if (video.isOwned() === false) throw new Error('Cannot import files of a non owned video.') + if (video.isLocal() === false) throw new Error('Cannot import files of a non owned video.') const dataInput = { videoUUID: video.uuid, diff --git a/server/scripts/prune-storage.ts b/server/scripts/prune-storage.ts index ca0e6495b..256e9def4 100755 --- a/server/scripts/prune-storage.ts +++ b/server/scripts/prune-storage.ts @@ -271,7 +271,7 @@ class FSPruner { if (keepOnlyOwned) { const video = await VideoModel.load(thumbnail.videoId) - if (video.isOwned() === false) return false + if (video.isLocal() === false) return false } return true @@ -343,6 +343,6 @@ async function askPruneConfirmation (yes?: boolean) { return askConfirmation( 'These unknown files can be deleted, but please check your backups first (bugs happen). ' + - 'Can we delete these files?' + 'Can we delete these files?' ) }