Init commit

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

View File

@@ -0,0 +1,20 @@
export * from './video-blacklist.js'
export * from './video-captions.js'
export * from './video-channel-sync.js'
export * from './video-channels.js'
export * from './video-chapters.js'
export * from './video-comments.js'
export * from './video-files.js'
export * from './video-imports.js'
export * from './video-live.js'
export * from './video-ownership-changes.js'
export * from './video-passwords.js'
export * from './video-rates.js'
export * from './video-shares.js'
export * from './video-source.js'
export * from './video-stats.js'
export * from './video-studio.js'
export * from './video-token.js'
export * from './video-transcoding.js'
export * from './video-view.js'
export * from './videos.js'

View File

@@ -0,0 +1,2 @@
export * from './upload.js'
export * from './video-validators.js'

View File

@@ -0,0 +1,42 @@
import express from 'express'
import { logger } from '@server/helpers/logger.js'
import { ffprobePromise, getVideoStreamDuration } from '@peertube/peertube-ffmpeg'
import { HttpStatusCode } from '@peertube/peertube-models'
export async function addDurationToVideoFileIfNeeded (options: {
res: express.Response
videoFile: { path: string, duration?: number }
middlewareName: string
}) {
const { res, middlewareName, videoFile } = options
try {
if (!videoFile.duration) await addDurationToVideo(res, videoFile)
} catch (err) {
logger.error('Invalid input file in ' + middlewareName, { err })
res.fail({
status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
message: 'Video file unreadable.'
})
return false
}
return true
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function addDurationToVideo (res: express.Response, videoFile: { path: string, duration?: number }) {
const probe = await ffprobePromise(videoFile.path)
res.locals.ffprobe = probe
const duration = await getVideoStreamDuration(videoFile.path, probe)
// FFmpeg may not be able to guess video duration
// For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
if (isNaN(duration)) videoFile.duration = 0
else videoFile.duration = duration
}

View File

@@ -0,0 +1,138 @@
import { canVideoFileBeEdited } from '@peertube/peertube-core-utils'
import { HttpStatusCode, ServerErrorCode, ServerFilterHookName, VideoState, VideoStateType } from '@peertube/peertube-models'
import { isVideoFileMimeTypeValid, isVideoFileSizeValid } from '@server/helpers/custom-validators/videos.js'
import { logger } from '@server/helpers/logger.js'
import { CONSTRAINTS_FIELDS, VIDEO_STATES } from '@server/initializers/constants.js'
import { isLocalVideoFileAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { MUserAccountId, MVideo } from '@server/types/models/index.js'
import express from 'express'
import { checkUserQuota } from '../../shared/index.js'
export async function commonVideoFileChecks (options: {
req: express.Request
res: express.Response
user: MUserAccountId
videoFileSize: number
files: express.UploadFilesForCheck
}): Promise<boolean> {
const { req, res, user, videoFileSize, files } = options
if (!isVideoFileMimeTypeValid(files)) {
res.fail({
status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
message: req.t(
'This file is not supported. Please, make sure it is of the following type: {types}',
{ types: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') }
)
})
return false
}
if (!isVideoFileSizeValid(videoFileSize.toString())) {
res.fail({
status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
message: req.t('This file is too large. It exceeds the maximum file size authorized'),
type: ServerErrorCode.MAX_FILE_SIZE_REACHED
})
return false
}
if (await checkUserQuota({ user, videoFileSize, req, res }) === false) return false
return true
}
export async function isVideoFileAccepted (options: {
req: express.Request
res: express.Response
videoFile: express.VideoLegacyUploadFile
hook: Extract<ServerFilterHookName, 'filter:api.video.upload.accept.result' | 'filter:api.video.update-file.accept.result'>
}) {
const { req, res, videoFile, hook } = options
// Check we accept this video
const acceptParameters = {
videoBody: req.body,
videoFile,
user: res.locals.oauth.token.User
}
const acceptedResult = await Hooks.wrapFun(isLocalVideoFileAccepted, acceptParameters, hook)
if (acceptedResult?.accepted !== true) {
logger.info('Refused local video file.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult.errorMessage || req.t('Refused local video file')
})
return false
}
return true
}
export function checkVideoFileCanBeEdited (video: MVideo, req: express.Request, res: express.Response) {
if (video.isLive) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot edit a live video')
})
return false
}
if (video.state === VideoState.TO_TRANSCODE || video.state === VideoState.TO_EDIT) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot edit video that is already waiting for transcoding/edition')
})
return false
}
if (!canVideoFileBeEdited(video.state)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Video state is not compatible with edition')
})
return false
}
return true
}
export function checkVideoCanBeTranscribed (video: MVideo, req: express.Request, res: express.Response) {
if (video.remote) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot run this task on a remote video')
})
return false
}
if (video.isLive) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot run this task on a live video')
})
return false
}
const incompatibleStates = new Set<VideoStateType>([
VideoState.TO_IMPORT,
VideoState.TO_EDIT,
VideoState.TO_MOVE_TO_EXTERNAL_STORAGE,
VideoState.TO_MOVE_TO_FILE_SYSTEM,
VideoState.TO_IMPORT_FAILED
])
if (incompatibleStates.has(video.state)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot run this task on a video with "{state}" state', { state: VIDEO_STATES[video.state] })
})
return false
}
return true
}

View File

@@ -0,0 +1,87 @@
import express from 'express'
import { body, query } from 'express-validator'
import { HttpStatusCode } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../../helpers/custom-validators/video-blacklist.js'
import { areValidationErrors, doesVideoBlacklistExist, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
const videosBlacklistRemoveValidator = [
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 (!await doesVideoBlacklistExist(res.locals.videoAll.id, res)) return
return next()
}
]
const videosBlacklistAddValidator = [
isValidVideoIdParam('videoId'),
body('unfederate')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid unfederate boolean'),
body('reason')
.optional()
.custom(isVideoBlacklistReasonValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
const video = res.locals.videoAll
if (req.body.unfederate === true && video.remote === true) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'You cannot unfederate a remote video.'
})
}
return next()
}
]
const videosBlacklistUpdateValidator = [
isValidVideoIdParam('videoId'),
body('reason')
.optional()
.custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoBlacklistExist(res.locals.videoAll.id, res)) return
return next()
}
]
const videosBlacklistFiltersValidator = [
query('type')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoBlacklistTypeValid).withMessage('Should have a valid video blacklist type attribute'),
query('search')
.optional()
.not()
.isEmpty().withMessage('Should have a valid search'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videosBlacklistAddValidator,
videosBlacklistRemoveValidator,
videosBlacklistUpdateValidator,
videosBlacklistFiltersValidator
}

View File

@@ -0,0 +1,170 @@
import { HttpStatusCode, ServerErrorCode, UserRight, VideoCaptionGenerate } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoCaptionModel } from '@server/models/video/video-caption.js'
import { VideoJobInfoModel } from '@server/models/video/video-job-info.js'
import express from 'express'
import { body, param } from 'express-validator'
import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants.js'
import {
areValidationErrors,
checkCanSeeVideo,
checkCanManageVideo,
doesVideoCaptionExist,
doesVideoExist,
isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared/index.js'
import { checkVideoCanBeTranscribed } from './shared/video-validators.js'
export const addVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
param('captionLanguage')
.custom(isVideoCaptionLanguageValid).not().isEmpty(),
body('captionfile')
.custom((_, { req }) => isVideoCaptionFile(req.files, 'captionfile'))
.withMessage(
'This caption file is not supported or too large. ' +
`Please, make sure it is under ${CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max} bytes ` +
'and one of the following mimetypes: ' +
Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT).map(key => `${key} (${MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT[key]})`).join(', ')
),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) {
return cleanUpReqFiles(req)
}
return next()
}
]
export const generateVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
body('forceTranscription')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (CONFIG.VIDEO_TRANSCRIPTION.ENABLED !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Video transcription is disabled on this instance'
})
}
if (!await doesVideoExist(req.params.videoId, res)) return
const video = res.locals.videoAll
if (!checkVideoCanBeTranscribed(video, req, res)) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
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)
if (captions.length !== 0) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
type: ServerErrorCode.VIDEO_ALREADY_HAS_CAPTIONS,
message: 'This video already has captions'
})
}
// Bypass "video is already transcribed" check
const body = req.body as VideoCaptionGenerate
if (body.forceTranscription === true) {
if (user.hasRight(UserRight.UPDATE_ANY_VIDEO) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only admins can force transcription'
})
}
return next()
}
const info = await VideoJobInfoModel.load(video.id)
if (info && info.pendingTranscription > 0) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
type: ServerErrorCode.VIDEO_ALREADY_BEING_TRANSCRIBED,
message: 'This video is already being transcribed'
})
}
return next()
}
]
export const deleteVideoCaptionValidator = [
isValidVideoIdParam('videoId'),
param('captionLanguage')
.custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoCaptionExist(res.locals.videoAll, req.params.captionLanguage, res)) return
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const listVideoCaptionsValidator = [
isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
const video = res.locals.onlyVideo
if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.videoId })) return
return next()
}
]

View File

@@ -0,0 +1,171 @@
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'
import { CONFIG } from '@server/initializers/config.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
})
res.locals.channelCollaborator = collaborator
if (collaborator && collaborator.state !== VideoChannelCollaboratorState.REJECTED) {
res.fail({
message: req.t('This account is already a collaborator or has a pending invitation for this channel'),
status: HttpStatusCode.CONFLICT_409
})
return
}
const count = await VideoChannelCollaboratorModel.countByChannel(res.locals.videoChannel.id)
if (count >= CONFIG.VIDEO_CHANNELS.MAX_COLLABORATORS_PER_CHANNEL) {
res.fail({
message: req.t(
'The maximum number of collaborators for this channel ({limit}) has been reached',
{ limit: CONFIG.VIDEO_CHANNELS.MAX_COLLABORATORS_PER_CHANNEL }
),
status: HttpStatusCode.BAD_REQUEST_400
})
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
}

View File

@@ -0,0 +1,82 @@
import { HttpStatusCode, VideoChannelSyncCreate } from '@peertube/peertube-models'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { VideoChannelSyncModel } from '@server/models/video/video-channel-sync.js'
import * as express from 'express'
import { body, param, query } from 'express-validator'
import { areValidationErrors, doesChannelIdExist } from '../shared/index.js'
import { doesVideoChannelSyncIdExist } from '../shared/video-channel-syncs.js'
export const ensureSyncIsEnabled = (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.ENABLED) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Synchronization is impossible as video channel synchronization is not enabled on the server'
})
}
return next()
}
export const videoChannelSyncValidator = [
body('externalChannelUrl')
.custom(isUrlValid),
body('videoChannelId')
.isInt(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: VideoChannelSyncCreate = req.body
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) {
return res.fail({
message: `You cannot create more than ${CONFIG.IMPORT.VIDEO_CHANNEL_SYNCHRONIZATION.MAX_PER_USER} channel synchronizations`
})
}
return next()
}
]
export const ensureSyncExists = [
param('id').exists().isInt().withMessage('Should have an sync id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoChannelSyncIdExist(+req.params.id, res)) return
if (
!await doesChannelIdExist({
id: res.locals.videoChannelSync.videoChannelId,
checkCanManage: true,
checkIsOwner: false,
checkIsLocal: true,
req,
res
})
) {
return
}
return next()
}
]
export const listAccountChannelsSyncValidator = [
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]

View File

@@ -0,0 +1,184 @@
import { HttpStatusCode, VideosImportInChannelCreate } from '@peertube/peertube-models'
import { isUrlValid } from '@server/helpers/custom-validators/activitypub/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { loadReservedActorName } from '@server/lib/local-actor.js'
import { MChannelAccountDefault } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { isBooleanValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc.js'
import {
isVideoChannelDescriptionValid,
isVideoChannelDisplayNameValid,
isVideoChannelSupportValid,
isVideoChannelUsernameValid
} from '../../../helpers/custom-validators/video-channels.js'
import { VideoChannelModel } from '../../../models/video/video-channel.js'
import { areValidationErrors, checkUserQuota, doesChannelHandleExist } from '../shared/index.js'
import { doesVideoChannelSyncIdExist } from '../shared/video-channel-syncs.js'
export const videoChannelsAddValidator = [
body('name')
.custom(isVideoChannelUsernameValid),
body('displayName')
.custom(isVideoChannelDisplayNameValid),
body('description')
.optional()
.custom(isVideoChannelDescriptionValid),
body('support')
.optional()
.custom(isVideoChannelSupportValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const actor = await loadReservedActorName(req.body.name)
if (actor) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t(
'Another actor (account/channel) with name {name} on this instance already exists or has already existed.',
{ name: req.body.name }
)
})
return false
}
const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id)
if (count >= CONFIG.VIDEO_CHANNELS.MAX_PER_USER) {
res.fail({ message: req.t('You cannot create more than {count} channels', { count: CONFIG.VIDEO_CHANNELS.MAX_PER_USER }) })
return false
}
return next()
}
]
export const videoChannelsUpdateValidator = [
body('displayName')
.optional()
.custom(isVideoChannelDisplayNameValid),
body('description')
.optional()
.custom(isVideoChannelDescriptionValid),
body('support')
.optional()
.custom(isVideoChannelSupportValid),
body('bulkVideosSupportUpdate')
.optional()
.custom(isBooleanValid).withMessage('Should have a valid bulkVideosSupportUpdate boolean field'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoChannelsRemoveValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!await checkVideoChannelIsNotTheLastOne(res.locals.videoChannel, req, res)) return
return next()
}
]
export const videoChannelsHandleValidatorFactory = (options: {
checkIsLocal: boolean
checkCanManage: boolean
checkIsOwner: boolean
}) => {
const { checkIsLocal, checkCanManage, checkIsOwner } = options
return [
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, checkIsLocal, checkIsOwner, req, res })) return
return next()
}
]
}
export const listAccountChannelsValidator = [
query('withStats')
.optional()
.customSanitizer(toBooleanOrNull),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoChannelsListValidator = [
query('search')
.optional()
.not().isEmpty(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoChannelImportVideosValidator = [
body('externalChannelUrl')
.custom(isUrlValid),
body('videoChannelSyncId')
.optional()
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: VideosImportInChannelCreate = req.body
if (!CONFIG.IMPORT.VIDEOS.HTTP.ENABLED) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Channel import is impossible as video upload via HTTP is not enabled on the server')
})
}
if (body.videoChannelSyncId && !await doesVideoChannelSyncIdExist(body.videoChannelSyncId, res)) return
if (res.locals.videoChannelSync && res.locals.videoChannelSync.videoChannelId !== res.locals.videoChannel.id) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('This channel sync is not owned by this channel')
})
}
const user = { id: res.locals.videoChannel.Account.userId }
if (!await checkUserQuota({ user, videoFileSize: 1, req, res })) return
return next()
}
]
// ---------------------------------------------------------------------------
async function checkVideoChannelIsNotTheLastOne (videoChannel: MChannelAccountDefault, req: express.Request, res: express.Response) {
const count = await VideoChannelModel.countByAccount(videoChannel.Account.id)
if (count <= 1) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot remove the last channel of this user')
})
return false
}
return true
}

View File

@@ -0,0 +1,41 @@
import express from 'express'
import { body } from 'express-validator'
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { areValidationErrors, checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
import { areVideoChaptersValid } from '@server/helpers/custom-validators/video-chapters.js'
export const updateVideoChaptersValidator = [
isValidVideoIdParam('videoId'),
body('chapters')
.custom(areVideoChaptersValid)
.withMessage('Chapters must have a valid title and timecode, and each timecode must be unique'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (res.locals.videoAll.isLive) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'You cannot add chapters to a live video'
})
}
// 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 (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]

View File

@@ -0,0 +1,393 @@
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'
import {
exists,
isBooleanValid,
isIdOrUUIDValid,
isIdValid,
toBooleanOrNull,
toCompleteUUID,
toIntOrNull
} from '../../../helpers/custom-validators/misc.js'
import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments.js'
import { logger } from '../../../helpers/logger.js'
import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation.js'
import { Hooks } from '../../../lib/plugins/hooks.js'
import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video/index.js'
import {
areValidationErrors,
checkCanManageChannel,
checkCanManageVideo,
checkCanSeeVideo,
doesChannelIdExist,
doesVideoCommentExist,
doesVideoCommentThreadExist,
doesVideoExist,
isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared/index.js'
export const listAllVideoCommentsForAdminValidator = [
...getCommonVideoCommentsValidators(),
query('isLocal')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid)
.withMessage('Should have a valid isLocal boolean'),
query('onLocalVideo')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid)
.withMessage('Should have a valid onLocalVideo boolean'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
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, checkCanManage: true, checkIsOwner: false, checkIsLocal: true, req, res })
) return
return next()
}
]
export const listCommentsOnUserVideosValidator = [
...getCommonVideoCommentsValidators(),
query('isHeldForReview')
.optional()
.customSanitizer(toBooleanOrNull),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (req.query.videoId && !await doesVideoExist(req.query.videoId, res, 'all')) return
if (
req.query.videoChannelId &&
!await doesChannelIdExist({
id: req.query.videoChannelId,
checkCanManage: true,
checkIsLocal: true,
checkIsOwner: false,
req,
res,
specialRight: UserRight.SEE_ALL_COMMENTS
})
) return
const user = res.locals.oauth.token.User
const video = res.locals.videoAll
if (
video &&
!await checkCanManageVideo({ user, video, right: UserRight.SEE_ALL_COMMENTS, req, res, checkIsLocal: true, checkIsOwner: false })
) return
return next()
}
]
// ---------------------------------------------------------------------------
export const listVideoCommentThreadsValidator = [
isValidVideoIdParam('videoId'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return
return next()
}
]
export const listVideoThreadCommentsValidator = [
isValidVideoIdParam('videoId'),
param('threadId')
.custom(isIdValid),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
if (!await doesVideoCommentThreadExist(req.params.threadId, res.locals.onlyVideo, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.onlyVideo })) return
return next()
}
]
export const addVideoCommentThreadValidator = [
isValidVideoIdParam('videoId'),
body('text')
.custom(isValidVideoCommentText),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.videoAll })) return
if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return
if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, false)) return
return next()
}
]
export const addVideoCommentReplyValidator = [
isValidVideoIdParam('videoId'),
param('commentId').custom(isIdValid),
isValidVideoPasswordHeader(),
body('text').custom(isValidVideoCommentText),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.videoId, video: res.locals.videoAll })) return
if (!isVideoCommentsEnabled(res.locals.videoAll, res)) return
if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
if (!await isVideoCommentAccepted(req, res, res.locals.videoAll, true)) return
return next()
}
]
export const videoCommentGetValidator = [
isValidVideoIdParam('videoId'),
param('commentId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'only-video-and-blacklist')) return
if (!canVideoBeFederated(res.locals.onlyVideo)) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
if (!await doesVideoCommentExist(req.params.commentId, res.locals.onlyVideo, res)) return
return next()
}
]
export const removeVideoCommentValidator = [
isValidVideoIdParam('videoId'),
param('commentId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
if (!await checkCanDeleteVideoComment({ user: res.locals.oauth.token.User, videoComment: res.locals.videoCommentFull, req, res })) {
return
}
return next()
}
]
export const approveVideoCommentValidator = [
isValidVideoIdParam('videoId'),
param('commentId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res)) return
if (!await doesVideoCommentExist(req.params.commentId, res.locals.videoAll, res)) return
if (!await checkCanApproveVideoComment({ user: res.locals.oauth.token.User, videoComment: res.locals.videoCommentFull, req, res })) {
return
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
if (video.commentsPolicy === VideoCommentPolicy.DISABLED) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: 'Video comments are disabled for this video.'
})
return false
}
return true
}
function checkCanDeleteVideoComment (options: {
user: MUserAccountUrl
videoComment: MCommentOwnerVideoReply
req: express.Request
res: express.Response
}): Promise<boolean> {
const { user, videoComment, req, res } = options
if (videoComment.isDeleted()) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('This comment is already deleted')
})
return Promise.resolve(false)
}
// Owner of the comment
if (videoComment.accountId === user.Account.id) {
return Promise.resolve(true)
}
return checkCanManageCommentsOfVideo(options)
}
function checkCanApproveVideoComment (options: {
user: MUserAccountUrl
videoComment: MCommentOwnerVideoReply
req: express.Request
res: express.Response
}): Promise<boolean> {
const { user, videoComment, req, res } = options
if (videoComment.isDeleted()) {
res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('This comment is deleted')
})
return Promise.resolve(false)
}
if (videoComment.heldForReview !== true) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('This comment is not held for review')
})
return Promise.resolve(false)
}
return checkCanManageCommentsOfVideo({ user, videoComment, req, res })
}
async function checkCanManageCommentsOfVideo (options: {
user: MUserAccountUrl
videoComment: MCommentOwnerVideoReply
req: express.Request
res: express.Response
}) {
const { user, videoComment, req, res } = options
if (user.hasRight(UserRight.MANAGE_ANY_VIDEO_COMMENT)) return true
const channel = await VideoChannelModel.loadAndPopulateAccount(videoComment.Video.VideoChannel.id)
if (await checkCanManageChannel({ 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) {
const acceptParameters = {
video,
commentBody: req.body,
user: res.locals.oauth.token.User,
req
}
let acceptedResult: AcceptResult
if (isReply) {
const acceptReplyParameters = Object.assign(acceptParameters, { parentComment: res.locals.videoCommentFull })
acceptedResult = await Hooks.wrapFun(
isLocalVideoCommentReplyAccepted,
acceptReplyParameters,
'filter:api.video-comment-reply.create.accept.result'
)
} else {
acceptedResult = await Hooks.wrapFun(
isLocalVideoThreadAccepted,
acceptParameters,
'filter:api.video-thread.create.accept.result'
)
}
if (acceptedResult?.accepted !== true) {
logger.info('Refused local comment.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult?.errorMessage || 'Comment has been rejected.'
})
return false
}
return true
}
function getCommonVideoCommentsValidators () {
return [
query('search')
.optional()
.custom(exists),
query('searchAccount')
.optional()
.custom(exists),
query('searchVideo')
.optional()
.custom(exists),
query('videoId')
.optional()
.custom(toCompleteUUID)
.custom(isIdOrUUIDValid),
query('videoChannelId')
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid),
query('autoTagOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid autoTagOneOf array')
]
}

View File

@@ -0,0 +1,168 @@
import { HttpStatusCode, VideoResolution } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { MVideo } from '@server/types/models/index.js'
import express from 'express'
import { param } from 'express-validator'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
export const videoFilesDeleteWebVideoValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
if (!video.hasWebVideoFiles()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video does not have Web Video files'
})
}
if (!video.getHLSPlaylist()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete Web Video files since this video does not have HLS playlist'
})
}
return next()
}
]
export const videoFilesDeleteWebVideoFileValidator = [
isValidVideoIdParam('id'),
param('videoFileId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
const files = video.VideoFiles
if (!files.find(f => f.id === +req.params.videoFileId)) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'This video does not have this Web Video file id'
})
}
if (files.length === 1 && !video.getHLSPlaylist()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete Web Video files since this video does not have HLS playlist'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export const videoFilesDeleteHLSValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
if (!video.getHLSPlaylist()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video does not have HLS files'
})
}
if (!video.hasWebVideoFiles()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete HLS playlist since this video does not have Web Video files'
})
}
return next()
}
]
export const videoFilesDeleteHLSFileValidator = [
isValidVideoIdParam('id'),
param('videoFileId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!checkLocalVideo(video, res)) return
const hls = video.getHLSPlaylist()
if (!hls) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'This video does not have HLS files'
})
}
const hlsFiles = hls.VideoFiles
const file = hlsFiles.find(f => f.id === +req.params.videoFileId)
if (!file) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'This HLS playlist does not have this file id'
})
}
// Last file to delete
if (hlsFiles.length === 1 && !video.hasWebVideoFiles()) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete last HLS playlist file since this video does not have Web Video files'
})
}
if (hls.hasAudioAndVideoSplitted() && file.resolution === VideoResolution.H_NOVIDEO) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete audio file of HLS playlist with splitted audio/video. Delete all the videos first'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function checkLocalVideo (video: MVideo, res: express.Response) {
if (video.remote) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot delete files of remote video'
})
return false
}
return true
}

View File

@@ -0,0 +1,266 @@
import { forceNumber } from '@peertube/peertube-core-utils'
import { HttpStatusCode, UserRight, VideoImportCreate, VideoImportState } from '@peertube/peertube-models'
import { isResolvingToUnicastOnly } from '@server/helpers/dns.js'
import { isPreImportVideoAccepted } from '@server/lib/moderation.js'
import { Hooks } from '@server/lib/plugins/hooks.js'
import { MUserAccountId, MVideoImportDefault } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query } from 'express-validator'
import { isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports.js'
import { isValidPasswordProtectedPrivacy, isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos.js'
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, checkCanManageVideo, doesChannelIdExist, doesVideoImportExist } from '../shared/index.js'
import { areErrorsInNSFW, getCommonVideoEditAttributes } from './videos.js'
export const videoImportAddValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('targetUrl')
.optional()
.custom(isVideoImportTargetUrlValid),
body('magnetUri')
.optional()
.custom(isVideoMagnetUriValid),
body('torrentfile')
.custom((value, { req }) => isVideoImportTorrentFile(req.files))
.withMessage(
'This torrent file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.EXTNAME.join(', ')
),
body('name')
.optional()
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const torrentFile = req.files?.['torrentfile'] ? req.files['torrentfile'][0] : undefined
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('HTTP import is not enabled on this instance')
})
}
if (CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED !== true && (req.body.magnetUri || torrentFile)) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Torrent/magnet URI import is not enabled on this instance')
})
}
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) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Should have a magnetUri or a targetUrl or a torrent file') })
}
if (req.body.targetUrl) {
const hostname = new URL(req.body.targetUrl).hostname
if (await isResolvingToUnicastOnly(hostname) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot use non unicast IP as targetUrl')
})
}
}
if (!await isImportAccepted(req, res)) return cleanUpReqFiles(req)
return next()
}
])
export const listMyVideoImportsValidator = [
query('id')
.optional()
.custom(isIdValid),
query('videoId')
.optional()
.custom(isIdValid),
query('videoChannelSyncId')
.optional()
.custom(isIdValid),
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoImportDeleteValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoImportExist(parseInt(req.params.id), 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({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot delete a pending video import. Cancel it or wait for the end of the import first')
})
}
return next()
}
]
export const videoImportCancelValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoImportExist(forceNumber(req.params.id), 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({
status: HttpStatusCode.CONFLICT_409,
message: req.t('Cannot cancel a non pending video import')
})
}
return next()
}
]
export const videoImportRetryValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoImportExist(forceNumber(req.params.id), res)) return
if (!await checkCanManageImport({ user: res.locals.oauth.token.User, videoImport: res.locals.videoImport, req, res })) return
if (res.locals.videoImport.state !== VideoImportState.FAILED) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot retry a non failed video import')
})
}
if (!res.locals.videoImport.Video) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot retry video import because the associated video metadata has been deleted')
})
}
if (res.locals.videoImport.attempts >= CONFIG.IMPORT.VIDEOS.MAX_ATTEMPTS) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot retry video import since it has reached the maximum number of attempts')
})
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function isImportAccepted (req: express.Request, res: express.Response) {
const body: VideoImportCreate = req.body
const hookName = body.targetUrl
? 'filter:api.video.pre-import-url.accept.result'
: 'filter:api.video.pre-import-torrent.accept.result'
// Check we accept this video
const acceptParameters = {
videoImportBody: body,
user: res.locals.oauth.token.User
}
const acceptedResult = await Hooks.wrapFun(
isPreImportVideoAccepted,
acceptParameters,
hookName
)
if (acceptedResult?.accepted !== true) {
logger.info('Refused to import video.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult.errorMessage || req.t('Refused to import video')
})
return false
}
return true
}
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) === true) 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 true
}
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage video import of another user')
})
return false
}

View File

@@ -0,0 +1,350 @@
import {
HttpStatusCode,
LiveVideoCreate,
LiveVideoLatencyMode,
LiveVideoUpdate,
ServerErrorCode,
UserRight,
VideoState
} from '@peertube/peertube-models'
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'
import { VideoLiveSessionModel } from '@server/models/video/video-live-session.js'
import { VideoLiveModel } from '@server/models/video/video-live.js'
import { VideoModel } from '@server/models/video/video.js'
import express from 'express'
import { body } from 'express-validator'
import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { isValidPasswordProtectedPrivacy, isVideoNameValid, isVideoReplayPrivacyValid } from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { CONFIG } from '../../../initializers/config.js'
import { areValidationErrors, checkCanManageVideo, doesChannelIdExist, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
import { areErrorsInNSFW, getCommonVideoEditAttributes } from './videos.js'
export const videoLiveGetValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
const videoLive = await VideoLiveModel.loadByVideoIdFull(res.locals.videoAll.id)
if (!videoLive) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Live video not found'
})
}
res.locals.videoLive = videoLive
return next()
}
]
export const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('name')
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('saveReplay')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid saveReplay boolean'),
body('replaySettings.privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoReplayPrivacyValid),
body('permanentLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid permanentLive boolean'),
body('latencyMode')
.optional()
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
body('schedules')
.optional()
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
if (CONFIG.LIVE.ENABLED !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Live is not enabled on this instance',
type: ServerErrorCode.LIVE_NOT_ENABLED
})
}
const body: LiveVideoCreate = req.body
if (hasValidSaveReplay(body) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Saving live replay is not enabled on this instance',
type: ServerErrorCode.LIVE_NOT_ALLOWING_REPLAY
})
}
if (hasValidLatencyMode(body) !== true) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Custom latency mode is not allowed by this instance'
})
}
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' })
if (totalInstanceLives >= CONFIG.LIVE.MAX_INSTANCE_LIVES) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot create this live because the max instance lives limit is reached.',
type: ServerErrorCode.MAX_INSTANCE_LIVES_LIMIT_REACHED
})
}
}
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) {
cleanUpReqFiles(req)
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot create this live because the max user lives limit is reached.',
type: ServerErrorCode.MAX_USER_LIVES_LIMIT_REACHED
})
}
}
if (!await isLiveVideoAccepted(req, res)) return cleanUpReqFiles(req)
return next()
}
])
export const videoLiveUpdateValidator = [
body('saveReplay')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid saveReplay boolean'),
body('replaySettings.privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoReplayPrivacyValid),
body('latencyMode')
.optional()
.customSanitizer(toIntOrNull)
.custom(isLiveLatencyModeValid),
body('schedules')
.optional()
.custom(areLiveSchedulesValid).withMessage('Should have a valid schedules array'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const body: LiveVideoUpdate = req.body
if (hasValidSaveReplay(body) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Saving live replay is not allowed by this instance'
})
}
if (hasValidLatencyMode(body) !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Custom latency mode is not allowed by this instance'
})
}
if (!checkLiveSettingsReplayConsistency({ res, body })) return
if (res.locals.videoAll.state !== VideoState.WAITING_FOR_LIVE) {
return res.fail({ message: 'Cannot update a live that has already started' })
}
// Check the user can manage the live
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.GET_ANY_LIVE,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
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 (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.GET_ANY_LIVE,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const videoLiveFindReplaySessionValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'id')) return
const session = await VideoLiveSessionModel.findSessionOfReplay(res.locals.videoId.id)
if (!session) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'No live replay found'
})
}
res.locals.videoLiveSession = session
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function isLiveVideoAccepted (req: express.Request, res: express.Response) {
// Check we accept this video
const acceptParameters = {
liveVideoBody: req.body,
user: res.locals.oauth.token.User
}
const acceptedResult = await Hooks.wrapFun(
isLocalLiveVideoAccepted,
acceptParameters,
'filter:api.live-video.create.accept.result'
)
if (acceptedResult?.accepted !== true) {
logger.info('Refused local live video.', { acceptedResult, acceptParameters })
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: acceptedResult.errorMessage || 'Refused local live video'
})
return false
}
return true
}
function hasValidSaveReplay (body: LiveVideoUpdate | LiveVideoCreate) {
if (CONFIG.LIVE.ALLOW_REPLAY !== true && body.saveReplay === true) return false
return true
}
function hasValidLatencyMode (body: LiveVideoUpdate | LiveVideoCreate) {
if (
CONFIG.LIVE.LATENCY_SETTING.ENABLED !== true &&
exists(body.latencyMode) &&
body.latencyMode !== LiveVideoLatencyMode.DEFAULT
) return false
return true
}
function checkLiveSettingsReplayConsistency (options: {
res: express.Response
body: LiveVideoUpdate
}) {
const { res, body } = options
// We now save replays of this live, so replay settings are mandatory
if (res.locals.videoLive.saveReplay !== true && body.saveReplay === true) {
if (!exists(body.replaySettings)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Replay settings are missing now the live replay is saved'
})
return false
}
if (!exists(body.replaySettings.privacy)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Privacy replay setting is missing now the live replay is saved'
})
return false
}
}
// Save replay was and is not enabled, so send an error the user if it specified replay settings
if ((!exists(body.saveReplay) && res.locals.videoLive.saveReplay === false) || body.saveReplay === false) {
if (exists(body.replaySettings)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot save replay settings since live replay is not enabled'
})
return false
}
}
return true
}

View File

@@ -0,0 +1,142 @@
import { HttpStatusCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@peertube/peertube-models'
import { isIdValid } from '@server/helpers/custom-validators/misc.js'
import { CONFIG } from '@server/initializers/config.js'
import { AccountModel } from '@server/models/account/account.js'
import { MUserAccountId, MVideoChangeOwnershipFull, MVideoWithAllFiles } from '@server/types/models/index.js'
import express from 'express'
import { param } from 'express-validator'
import {
areValidationErrors,
checkCanManageAccount,
checkCanManageVideo,
checkUserQuota,
doesChangeVideoOwnershipExist,
doesChannelIdExist,
doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
export const videosChangeOwnershipValidator = [
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
// Check if the user who did the request is able to change the ownership of the video
if (
!await checkCanManageVideo({
user: res.locals.oauth.token.User,
video: res.locals.videoAll,
right: UserRight.CHANGE_VIDEO_OWNERSHIP,
checkIsOwner: true,
checkIsLocal: true,
req,
res
})
) return
const nextOwner = await AccountModel.loadLocalByName(req.body.username)
if (!nextOwner) {
res.fail({
message: req.t('{username} does not exist on {instanceName}', { username: req.body.username, instanceName: CONFIG.INSTANCE.NAME })
})
return
}
res.locals.nextOwner = nextOwner
return next()
}
]
export const videosTerminateChangeOwnershipValidator = [
param('id')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesChangeVideoOwnershipExist(req.params.id, req, res)) return
// Check if the user who did the request is able to change the ownership of the video
if (
!checkCanTerminateOwnershipChange({
user: res.locals.oauth.token.User,
videoChangeOwnership: res.locals.videoChangeOwnership,
req,
res
})
) return
const videoChangeOwnership = res.locals.videoChangeOwnership
if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Ownership already accepted or refused')
})
return
}
return next()
}
]
export const videosAcceptChangeOwnershipValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const body = req.body as VideoChangeOwnershipAccept
if (!await doesChannelIdExist({ id: body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: true })) return
const videoChangeOwnership = res.locals.videoChangeOwnership
const video = videoChangeOwnership.Video
if (!await checkCanAccept(video, req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkCanAccept (video: MVideoWithAllFiles, req: express.Request, res: express.Response): Promise<boolean> {
if (video.isLive) {
if (video.state !== VideoState.WAITING_FOR_LIVE) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('You can accept an ownership change of a published live.')
})
return false
}
return true
}
const user = res.locals.oauth.token.User
if (!await checkUserQuota({ user, videoFileSize: video.getMaxQualityBytes(), req, res })) return false
return true
}
function checkCanTerminateOwnershipChange (options: {
user: MUserAccountId
videoChangeOwnership: MVideoChangeOwnershipFull
req: express.Request
res: express.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
}
return true
}

View File

@@ -0,0 +1,119 @@
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 express from 'express'
import { body, param } from 'express-validator'
import {
areValidationErrors,
checkCanDeleteVideoPassword,
checkCanManageVideo,
doesVideoExist,
doesVideoPasswordExist,
isValidVideoIdParam,
checkVideoIsPasswordProtected
} from '../shared/index.js'
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 (!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 (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.SEE_ALL_VIDEOS,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const updateVideoPasswordListValidator = [
isValidVideoIdParam('videoId'),
body('passwords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkAddOrUpdatePasswords(req, res)) return
return next()
}
]
export const addVideoPasswordValidator = [
isValidVideoIdParam('videoId'),
body('password')
.isString()
.withMessage('Video password should be a string')
.notEmpty()
.withMessage('Password string should not be empty'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await checkAddOrUpdatePasswords(req, res)) return
return next()
}
]
export const removeVideoPasswordValidator = [
isValidVideoIdParam('videoId'),
param('passwordId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, 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()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkAddOrUpdatePasswords (req: express.Request, res: express.Response) {
if (!await doesVideoExist(req.params.videoId, res)) return false
if (!isValidPasswordProtectedPrivacy(req, res)) return false
// Check if the user who did the request is able to update video passwords
const user = res.locals.oauth.token.User
if (
!await checkCanManageVideo({
user,
video: res.locals.videoAll,
right: UserRight.UPDATE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return false
return true
}

View File

@@ -0,0 +1,551 @@
import { arrayify, forceNumber } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
UserRight,
UserRightType,
VideoPlaylistCreate,
VideoPlaylistPrivacy,
VideoPlaylistType,
VideoPlaylistUpdate
} from '@peertube/peertube-models'
import { isStringArray } from '@server/helpers/custom-validators/search.js'
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'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import {
isArrayOf,
isIdOrUUIDValid,
isIdValid,
isUUIDValid,
toBooleanOrNull,
toCompleteUUID,
toIntArray,
toIntOrNull,
toValueOrNull
} from '../../../helpers/custom-validators/misc.js'
import {
isVideoPlaylistDescriptionValid,
isVideoPlaylistNameValid,
isVideoPlaylistPrivacyValid,
isVideoPlaylistTimestampValid,
isVideoPlaylistTypeValid
} from '../../../helpers/custom-validators/video-playlists.js'
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 { MVideoPlaylistFullSummary } from '../../../types/models/video/video-playlist.js'
import { authenticateOrFail } from '../../auth.js'
import {
areValidationErrors,
checkCanManageAccount,
checkCanManageChannel,
doesChannelIdExist,
doesVideoExist,
doesVideoPlaylistExist,
isValidPlaylistIdParam,
VideoPlaylistFetchType
} from '../shared/index.js'
export const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
body('displayName')
.custom(isVideoPlaylistNameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
const body: VideoPlaylistCreate = req.body
if (
body.videoChannelId &&
!await doesChannelIdExist({ id: body.videoChannelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanUpReqFiles(req)
}
if (
!body.videoChannelId &&
(body.privacy === VideoPlaylistPrivacy.PUBLIC || body.privacy === VideoPlaylistPrivacy.UNLISTED)
) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Cannot set "public" or "unlisted" a playlist that is not assigned to a channel.') })
}
return next()
}
])
export const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
isValidPlaylistIdParam('playlistId'),
body('displayName')
.optional()
.custom(isVideoPlaylistNameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return cleanUpReqFiles(req)
const videoPlaylist = getPlaylist(res)
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return cleanUpReqFiles(req)
}
const body: VideoPlaylistUpdate = req.body
const newPrivacy = body.privacy || videoPlaylist.privacy
if (
newPrivacy === VideoPlaylistPrivacy.PUBLIC &&
(
(!videoPlaylist.videoChannelId && !body.videoChannelId) ||
body.videoChannelId === null
)
) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Cannot set "public" a playlist that is not assigned to a channel.') })
}
if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) {
cleanUpReqFiles(req)
return res.fail({ message: req.t('Cannot update a watch later playlist.') })
}
if (
body.videoChannelId &&
!await doesChannelIdExist({ id: body.videoChannelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanUpReqFiles(req)
}
if (res.locals.videoChannel && res.locals.videoChannel.accountId !== videoPlaylist.ownerAccountId) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('The channel must be owned by the same account as the playlist')
})
return cleanUpReqFiles(req)
}
return next()
}
])
export const videoPlaylistsDeleteValidator = [
isValidPlaylistIdParam('playlistId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res })) return
const videoPlaylist = getPlaylist(res)
if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) {
return res.fail({ message: req.t('Cannot delete a watch later playlist.') })
}
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.REMOVE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return
}
return next()
}
]
export const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
return [
isValidPlaylistIdParam('playlistId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType })) return
const videoPlaylist = res.locals.videoPlaylistFull || res.locals.videoPlaylistSummary
// Playlist is unlisted, check we used the uuid to fetch it
if (videoPlaylist.privacy === VideoPlaylistPrivacy.UNLISTED) {
if (isUUIDValid(req.params.playlistId)) return next()
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Playlist not found')
})
}
if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
if (!await authenticateOrFail({ req, res })) return
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return
}
}
return next()
}
]
}
export const videoPlaylistsSearchValidator = [
query('search')
.optional()
.not().isEmpty(),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoPlaylistsAccountValidator = [
query('includeCollaborations')
.optional()
.customSanitizer(toBooleanOrNull),
query('channelNameOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid channelNameOneOf array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videoPlaylistsAddVideoValidator = [
isValidPlaylistIdParam('playlistId'),
body('videoId')
.customSanitizer(toCompleteUUID)
.custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid/short uuid'),
body('startTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
body('stopTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return
if (!await doesVideoExist(req.body.videoId, res, 'only-video-and-blacklist')) return
const videoPlaylist = getPlaylist(res)
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) {
return
}
return next()
}
]
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),
body('stopTimestamp')
.optional()
.custom(isVideoPlaylistTimestampValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return
const videoPlaylist = getPlaylist(res)
const videoPlaylistElement = await VideoPlaylistElementModel.loadById(req.params.playlistElementId)
if (!videoPlaylistElement) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video playlist element not found')
})
return
}
res.locals.videoPlaylistElement = videoPlaylistElement
if (
!await checkCanManagePlaylist({
user: res.locals.oauth.token.User,
videoPlaylist,
right: UserRight.UPDATE_ANY_VIDEO_PLAYLIST,
req,
res
})
) return
return next()
}
]
export const videoPlaylistElementAPGetValidator = [
isValidPlaylistIdParam('playlistId'),
param('playlistElementId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const playlistElementId = forceNumber(req.params.playlistElementId)
const playlistId = req.params.playlistId
const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndElementIdForAP(playlistId, playlistElementId)
if (!videoPlaylistElement) {
res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video playlist element not found')
})
return
}
if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot get this private video playlist.')
})
}
res.locals.videoPlaylistElementAP = videoPlaylistElement
return next()
}
]
export const videoPlaylistsReorderInChannelValidator = [
body('startPosition')
.isInt({ min: 1 }),
body('insertAfterPosition')
.isInt({ min: 0 }),
body('reorderLength')
.optional()
.isInt({ min: 1 }),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const nextPosition = await VideoPlaylistModel.getNextPositionOf({ videoChannelId: res.locals.videoChannel.id })
const startPosition: number = req.body.startPosition
const insertAfterPosition: number = req.body.insertAfterPosition
const reorderLength: number = req.body.reorderLength
if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) {
res.fail({
message: req.t('Start position or insert after position exceed the channel limits (max: {max})', { max: nextPosition - 1 })
})
return
}
if (reorderLength && reorderLength + startPosition > nextPosition) {
res.fail({
message: req.t(
'Reorder length with this start position exceeds the channel limits (max: {max})',
{ max: nextPosition - startPosition }
)
})
return
}
return next()
}
]
export const videoPlaylistsReorderVideosValidator = [
isValidPlaylistIdParam('playlistId'),
body('startPosition')
.isInt({ min: 1 }),
body('insertAfterPosition')
.isInt({ min: 0 }),
body('reorderLength')
.optional()
.isInt({ min: 1 }),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoPlaylistExist({ id: req.params.playlistId, req, res, fetchType: 'all' })) return
const videoPlaylist = getPlaylist(res)
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
const insertAfterPosition: number = req.body.insertAfterPosition
const reorderLength: number = req.body.reorderLength
if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) {
res.fail({
message: req.t('Start position or insert after position exceed the playlist limits (max: {max})', { max: nextPosition - 1 })
})
return
}
if (reorderLength && reorderLength + startPosition > nextPosition) {
res.fail({
message: req.t('Reorder length with this start position exceeds the playlist limits (max: {max})', {
max: nextPosition - startPosition
})
})
return
}
return next()
}
]
export const commonVideoPlaylistFiltersValidator = [
query('playlistType')
.optional()
.custom(isVideoPlaylistTypeValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const doVideosInPlaylistExistValidator = [
query('videoIds')
.customSanitizer(toIntArray)
.custom(v => isArrayOf(v, isIdValid)).withMessage('Should have a valid video ids array'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function getCommonPlaylistEditAttributes () {
return [
body('thumbnailfile')
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile'))
.withMessage(
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEO_PLAYLISTS.IMAGE.EXTNAME.join(', ')
),
body('description')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoPlaylistDescriptionValid),
body('privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoPlaylistPrivacyValid),
body('videoChannelId')
.optional()
.customSanitizer(toIntOrNull)
] as (ValidationChain | ExpressPromiseHandler)[]
}
async function checkCanManagePlaylist (options: {
user: MUserAccountId
videoPlaylist: MVideoPlaylistFullSummary
right: UserRightType
req: express.Request
res: express.Response
}) {
const { user, videoPlaylist, right, res, req } = options
if (videoPlaylist.isLocal() === false) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot manage video playlist of another server.')
})
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 checkCanManageChannel({
channel,
user,
req,
res: null,
checkCanManage: true,
checkIsOwner: false,
specialRight: right
})
) {
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) {
return res.locals.videoPlaylistFull || res.locals.videoPlaylistSummary
}

View File

@@ -0,0 +1,72 @@
import express from 'express'
import { body, param, query } from 'express-validator'
import { HttpStatusCode, VideoRateType } from '@peertube/peertube-models'
import { isAccountNameValid } from '../../../helpers/custom-validators/accounts.js'
import { isIdValid } from '../../../helpers/custom-validators/misc.js'
import { isRatingValid } from '../../../helpers/custom-validators/video-rates.js'
import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos.js'
import { AccountVideoRateModel } from '../../../models/account/account-video-rate.js'
import { areValidationErrors, checkCanSeeVideo, doesVideoExist, isValidVideoIdParam, isValidVideoPasswordHeader } from '../shared/index.js'
const videoUpdateRateValidator = [
isValidVideoIdParam('id'),
body('rating')
.custom(isVideoRatingTypeValid),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
if (!await checkCanSeeVideo({ req, res, paramId: req.params.id, video: res.locals.videoAll })) return
return next()
}
]
const getAccountVideoRateValidatorFactory = function (rateType: VideoRateType) {
return [
param('accountName')
.custom(isAccountNameValid),
param('videoId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.accountName, +req.params.videoId)
if (!rate) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Video rate not found'
})
}
res.locals.accountVideoRate = rate
return next()
}
]
}
const videoRatingValidator = [
query('rating')
.optional()
.custom(isRatingValid).withMessage('Value must be one of "like" or "dislike"'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoUpdateRateValidator,
getAccountVideoRateValidatorFactory,
videoRatingValidator
}

View File

@@ -0,0 +1,28 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import { canVideoBeFederated } from '@server/lib/activitypub/videos/federate.js'
import express from 'express'
import { param } from 'express-validator'
import { isIdValid } from '../../../helpers/custom-validators/misc.js'
import { VideoShareModel } from '../../../models/video/video-share.js'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
export const videosShareValidator = [
isValidVideoIdParam('id'),
param('actorId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
const video = res.locals.videoAll
if (!canVideoBeFederated(video)) res.sendStatus(HttpStatusCode.NOT_FOUND_404)
const share = await VideoShareModel.load(req.params.actorId, video.id)
if (!share) return res.sendStatus(HttpStatusCode.NOT_FOUND_404)
res.locals.videoShare = share
return next()
}
]

View File

@@ -0,0 +1,135 @@
import { HttpStatusCode, UserRight } from '@peertube/peertube-models'
import { getVideoWithAttributes } from '@server/helpers/video.js'
import { CONFIG } from '@server/initializers/config.js'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { VideoSourceModel } from '@server/models/video/video-source.js'
import { MVideoFullLight } from '@server/types/models/index.js'
import { Metadata as UploadXMetadata } from '@uploadx/core'
import express from 'express'
import { param } from 'express-validator'
import {
areValidationErrors,
checkCanAccessVideoSourceFile,
checkCanManageVideo,
doesVideoExist,
isValidVideoIdParam
} from '../shared/index.js'
import { addDurationToVideoFileIfNeeded, checkVideoFileCanBeEdited, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js'
export const videoSourceGetLatestValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, 'all')) return
const video = getVideoWithAttributes(res) as MVideoFullLight
const user = res.locals.oauth.token.User
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)
if (!res.locals.videoSource) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Video source not found')
})
}
return next()
}
]
export const replaceVideoSourceResumableValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const file = buildUploadXFile(req.body as express.CustomUploadXFile<UploadXMetadata>)
const cleanup = () => safeUploadXCleanup(file)
if (!await checkCanUpdateVideoFile({ req, res })) {
return cleanup()
}
if (!await addDurationToVideoFileIfNeeded({ videoFile: file, res, middlewareName: 'updateVideoFileResumableValidator' })) {
return cleanup()
}
if (!await isVideoFileAccepted({ req, res, videoFile: file, hook: 'filter:api.video.update-file.accept.result' })) {
return cleanup()
}
res.locals.updateVideoFileResumable = { ...file, originalname: file.filename }
return next()
}
]
export const replaceVideoSourceResumableInitValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
if (!await checkCanUpdateVideoFile({ req, res })) return
const fileMetadata = res.locals.uploadVideoFileResumableMetadata
const files = { videofile: [ fileMetadata ] }
if (await commonVideoFileChecks({ req, res, user, videoFileSize: fileMetadata.size, files }) === false) return
return next()
}
]
export const originalVideoFileDownloadValidator = [
param('filename').exists(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const videoSource = await VideoSourceModel.loadByKeptOriginalFilename(req.params.filename)
if (!videoSource) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: req.t('Original video file not found')
})
}
if (!await checkCanAccessVideoSourceFile({ req, res, videoId: videoSource.videoId })) return
res.locals.videoSource = videoSource
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function checkCanUpdateVideoFile (options: {
req: express.Request
res: express.Response
}) {
const { req, res } = options
if (!CONFIG.VIDEO_FILE.UPDATE.ENABLED) {
res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Updating the file of an existing video is not allowed on this instance')
})
return false
}
if (!await doesVideoExist(req.params.id, res)) return false
const user = res.locals.oauth.token.User
const video = res.locals.videoAll
if (!await checkCanManageVideo({ user, video, right: UserRight.UPDATE_ANY_VIDEO, req, res, checkIsLocal: true, checkIsOwner: false })) {
return false
}
if (!checkVideoFileCanBeEdited(video, req, res)) return false
return true
}

View File

@@ -0,0 +1,115 @@
import express from 'express'
import { param, query } from 'express-validator'
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, checkCanManageVideo, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
export const videoOverallOrUserAgentStatsValidator = [
isValidVideoIdParam('videoId'),
query('startDate')
.optional()
.custom(isDateValid),
query('endDate')
.optional()
.custom(isDateValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
return next()
}
]
export const videoRetentionStatsValidator = [
isValidVideoIdParam('videoId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
if (res.locals.videoAll.isLive) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot get retention stats of live video'
})
}
return next()
}
]
export const videoTimeseriesStatsValidator = [
isValidVideoIdParam('videoId'),
param('metric')
.custom(isValidStatTimeserieMetric),
query('startDate')
.optional()
.custom(isDateValid),
query('endDate')
.optional()
.custom(isDateValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await commonStatsCheck(req, res)) return
const query: VideoStatsTimeserieQuery = req.query
if (
(query.startDate && !query.endDate) ||
(!query.startDate && query.endDate)
) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Both start date and end date should be defined if one of them is specified'
})
}
if (query.startDate && getIntervalByDays(query.startDate, query.endDate) > STATS_TIMESERIE.MAX_DAYS) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Start date and end date interval is too big'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
async function commonStatsCheck (req: express.Request, res: express.Response) {
if (!await doesVideoExist(req.params.videoId, res, 'all')) return false
if (
!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
}
return true
}
function getIntervalByDays (startDateString: string, endDateString: string) {
const startDate = new Date(startDateString)
const endDate = new Date(endDateString)
return (endDate.getTime() - startDate.getTime()) / 1000 / 86400
}

View File

@@ -0,0 +1,119 @@
import express from 'express'
import { body, param } from 'express-validator'
import { isIdOrUUIDValid } from '@server/helpers/custom-validators/misc.js'
import {
isStudioCutTaskValid,
isStudioTaskAddIntroOutroValid,
isStudioTaskAddWatermarkValid,
isValidStudioTasksArray
} from '@server/helpers/custom-validators/video-studio.js'
import { cleanUpReqFiles } from '@server/helpers/express-utils.js'
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, checkCanManageVideo, checkUserQuota, doesVideoExist } from '../shared/index.js'
import { checkVideoFileCanBeEdited } from './shared/index.js'
const videoStudioAddEditionValidator = [
param('videoId')
.custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid/short uuid'),
body('tasks')
.custom(isValidStudioTasksArray).withMessage('Should have a valid array of tasks'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (CONFIG.VIDEO_STUDIO.ENABLED !== true) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Video studio is disabled on this instance'
})
return cleanUpReqFiles(req)
}
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.videoId, res)) return cleanUpReqFiles(req)
const body: VideoStudioCreateEdition = req.body
const files = req.files as Express.Multer.File[]
const video = res.locals.videoAll
const videoIsAudio = video.hasAudio() && !video.hasVideo()
for (let i = 0; i < body.tasks.length; i++) {
const task = body.tasks[i]
if (!checkTask(req, task, i)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid`
})
return cleanUpReqFiles(req)
}
if (videoIsAudio) {
if (task.name === 'add-intro' || task.name === 'add-outro' || task.name === 'add-watermark') {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid: video does not contain a video stream`
})
return cleanUpReqFiles(req)
}
}
if (task.name === 'add-intro' || task.name === 'add-outro') {
const filePath = getTaskFileFromReq(files, i).path
// Our concat filter needs a video stream
if (await isAudioFile(filePath)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Task ${task.name} is invalid: input file does not contain a video stream`
})
return cleanUpReqFiles(req)
}
}
}
if (!checkVideoFileCanBeEdited(video, req, res)) return cleanUpReqFiles(req)
const user = res.locals.oauth.token.User
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)
if (await checkUserQuota({ user, videoFileSize: additionalBytes, req, res }) === false) return cleanUpReqFiles(req)
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoStudioAddEditionValidator
}
// ---------------------------------------------------------------------------
const taskCheckers: {
[id in VideoStudioTask['name']]: (task: VideoStudioTask, indice?: number, files?: Express.Multer.File[]) => boolean
} = {
'cut': isStudioCutTaskValid,
'add-intro': isStudioTaskAddIntroOutroValid,
'add-outro': isStudioTaskAddIntroOutroValid,
'add-watermark': isStudioTaskAddWatermarkValid
}
function checkTask (req: express.Request, task: VideoStudioTask, indice?: number) {
const checker = taskCheckers[task.name]
if (!checker) return false
return checker(task, indice, req.files as Express.Multer.File[])
}

View File

@@ -0,0 +1,23 @@
import express from 'express'
import { HttpStatusCode, VideoPrivacy } from '@peertube/peertube-models'
import { exists } from '@server/helpers/custom-validators/misc.js'
const videoFileTokenValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const video = res.locals.onlyVideo
if (video.privacy !== VideoPrivacy.PASSWORD_PROTECTED && !exists(res.locals.oauth.token.User)) {
return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: 'Not authenticated'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
videoFileTokenValidator
}

View File

@@ -0,0 +1,57 @@
import { HttpStatusCode, ServerErrorCode, VideoTranscodingCreate } from '@peertube/peertube-models'
import { isBooleanValid, toBooleanOrNull } from '@server/helpers/custom-validators/misc.js'
import { isValidCreateTranscodingType } from '@server/helpers/custom-validators/video-transcoding.js'
import { CONFIG } from '@server/initializers/config.js'
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 { checkVideoCanBeTranscribed } from './shared/video-validators.js'
const createTranscodingValidator = [
isValidVideoIdParam('videoId'),
body('transcodingType')
.custom(isValidCreateTranscodingType),
body('forceTranscoding')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.videoId, res, 'all')) return
const video = res.locals.videoAll
if (!checkVideoCanBeTranscribed(video, req, res)) return
if (CONFIG.TRANSCODING.ENABLED !== true) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: 'Cannot run transcoding job because transcoding is disabled on this instance'
})
}
const body = req.body as VideoTranscodingCreate
if (body.forceTranscoding === true) return next()
const info = await VideoJobInfoModel.load(video.id)
if (info && info.pendingTranscode > 0) {
return res.fail({
status: HttpStatusCode.CONFLICT_409,
type: ServerErrorCode.VIDEO_ALREADY_BEING_TRANSCODED,
message: 'This video is already being transcoded'
})
}
return next()
}
]
// ---------------------------------------------------------------------------
export {
createTranscodingValidator
}

View File

@@ -0,0 +1,83 @@
import { HttpStatusCode } from '@peertube/peertube-models'
import {
isVideoTimeValid,
isVideoViewEvent,
isVideoViewUAInfo,
toVideoViewUADeviceOrNull
} from '@server/helpers/custom-validators/video-view.js'
import { getCachedVideoDuration } from '@server/lib/video.js'
import { LocalVideoViewerModel } from '@server/models/view/local-video-viewer.js'
import express from 'express'
import { body, param } from 'express-validator'
import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc.js'
import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared/index.js'
const tags = [ 'views' ]
export const getVideoLocalViewerValidator = [
param('localViewerId')
.custom(isIdValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
const localViewer = await LocalVideoViewerModel.loadFullById(+req.params.localViewerId)
if (!localViewer) {
return res.fail({
status: HttpStatusCode.NOT_FOUND_404,
message: 'Local viewer not found',
tags
})
}
res.locals.localViewerFull = localViewer
return next()
}
]
export const videoViewValidator = [
isValidVideoIdParam('videoId'),
body('currentTime')
.customSanitizer(toIntOrNull)
.isInt(),
body('sessionId')
.optional()
.isAlphanumeric(undefined, { ignore: '-' }),
body('viewEvent')
.optional()
.custom(isVideoViewEvent),
body('client')
.optional()
.custom(isVideoViewUAInfo),
body('device')
.optional()
.customSanitizer(toVideoViewUADeviceOrNull),
body('operatingSystem')
.optional()
.custom(isVideoViewUAInfo),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res, { tags })) return
if (!await doesVideoExist(req.params.videoId, res, 'unsafe-only-immutable-attributes')) return
const video = res.locals.onlyImmutableVideo
const { duration } = await getCachedVideoDuration(video.id)
const currentTime = req.body.currentTime
if (!isVideoTimeValid(currentTime, duration)) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: `Current time ${currentTime} is invalid (video ${video.uuid} duration: ${duration})`,
logLevel: 'warn',
tags
})
}
return next()
}
]

View File

@@ -0,0 +1,667 @@
import { arrayify } from '@peertube/peertube-core-utils'
import {
HttpStatusCode,
ServerErrorCode,
UserRight,
VideoCreateUpdateCommon,
VideosCommonQuery,
VideoState
} from '@peertube/peertube-models'
import { isHostValid } from '@server/helpers/custom-validators/servers.js'
import { VideoLoadType } from '@server/lib/model-loaders/video.js'
import { Redis } from '@server/lib/redis.js'
import { buildUploadXFile, safeUploadXCleanup } from '@server/lib/uploadx.js'
import { ExpressPromiseHandler } from '@server/types/express-handler.js'
import { MUserAccountId, MVideoFullLight } from '@server/types/models/index.js'
import express from 'express'
import { body, param, query, ValidationChain } from 'express-validator'
import {
exists,
hasArrayLength,
isBooleanValid,
isDateValid,
isFileValid,
isIdValid,
isNotEmptyIntArray,
toBooleanOrNull,
toIntArray,
toIntOrNull,
toValueOrNull
} from '../../../helpers/custom-validators/misc.js'
import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search.js'
import {
areVideoTagsValid,
isNSFWFlagsValid,
isNSFWSummaryValid,
isScheduleVideoUpdatePrivacyValid,
isValidPasswordProtectedPrivacy,
isVideoCategoryValid,
isVideoCommentsPolicyValid,
isVideoDescriptionValid,
isVideoImageValid,
isVideoIncludeValid,
isVideoLanguageValid,
isVideoLicenceValid,
isVideoNameValid,
isVideoOriginallyPublishedAtValid,
isVideoPrivacyValid,
isVideoSourceFilenameValid,
isVideoSupportValid
} from '../../../helpers/custom-validators/videos.js'
import { cleanUpReqFiles } from '../../../helpers/express-utils.js'
import { logger } from '../../../helpers/logger.js'
import { getVideoWithAttributes } from '../../../helpers/video.js'
import { CONFIG } from '../../../initializers/config.js'
import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants.js'
import { VideoModel } from '../../../models/video/video.js'
import {
areValidationErrors,
checkCanAccessVideoStaticFiles,
checkCanManageVideo,
checkCanSeeVideo,
doesChannelIdExist,
doesVideoExist,
doesVideoFileOfVideoExist,
isValidVideoIdParam,
isValidVideoPasswordHeader
} from '../shared/index.js'
import { addDurationToVideoFileIfNeeded, commonVideoFileChecks, isVideoFileAccepted } from './shared/index.js'
const getVideoUploadCommonValidator = () => [
body('name')
.trim()
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('channelId')
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
body('generateTranscription')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid generateTranscription boolean')
]
export const videosAddLegacyValidator = [
...getCommonVideoEditAttributes(),
...getVideoUploadCommonValidator(),
body('videofile')
.custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
.withMessage('Should have a file'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
const videoFile: express.VideoLegacyUploadFile = req.files['videofile'][0]
const user = res.locals.oauth.token.User
if (
!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files }) ||
!isValidPasswordProtectedPrivacy(req, res) ||
!await addDurationToVideoFileIfNeeded({ videoFile, res, middlewareName: 'videosAddLegacyValidator' }) ||
!await isVideoFileAccepted({ req, res, videoFile, hook: 'filter:api.video.upload.accept.result' })
) {
return cleanUpReqFiles(req)
}
return next()
}
]
/**
* Gets called after the last PUT request
*/
export const videosAddResumableValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const file = buildUploadXFile(req.body as express.CustomUploadXFile<express.UploadNewVideoXFileMetadata>)
const cleanup = () => {
safeUploadXCleanup(file)
Redis.Instance.deleteUploadSession(req.query.upload_id)
.catch(err => logger.error('Cannot delete upload session', { err }))
}
const uploadId = req.query.upload_id
const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
if (sessionExists) {
res.setHeader('Retry-After', 300) // ask to retry after 5 min, knowing the upload_id is kept for up to 15 min after completion
return res.fail({
status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
message: req.t('The upload is already being processed')
})
}
await Redis.Instance.setUploadSession(uploadId)
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()
res.locals.uploadVideoFileResumable = { ...file, originalname: file.filename }
return next()
}
]
/**
* File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
* see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
*
* Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
* see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
*/
export const videosAddResumableInitValidator = [
...getCommonVideoEditAttributes(),
...getVideoUploadCommonValidator(),
body('filename')
.custom(isVideoSourceFilenameValid),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
const cleanup = () => cleanUpReqFiles(req)
logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
parameters: req.body,
headers: req.headers,
files: req.files
})
if (areValidationErrors(req, res, { omitLog: true })) return cleanup()
const fileMetadata = res.locals.uploadVideoFileResumableMetadata
const files = { videofile: [ fileMetadata ] }
if (!await commonVideoChecksPass({ req, res, user, videoFileSize: fileMetadata.size, files })) return cleanup()
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanup()
// Multer required unsetting the Content-Type, now we can set it for node-uploadx
req.headers['content-type'] = 'application/json; charset=utf-8'
// Place thumbnail/previewfile in metadata so that uploadx saves it in .META
if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
if (req.files?.['thumbnailfile']) req.body.thumbnailfile = req.files['thumbnailfile']
return next()
}
]
export const videosUpdateValidator = getCommonVideoEditAttributes().concat([
isValidVideoIdParam('id'),
body('name')
.optional()
.trim()
.custom(isVideoNameValid).withMessage(
`Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
),
body('channelId')
.optional()
.customSanitizer(toIntOrNull)
.custom(isIdValid),
body('videoPasswords')
.optional()
.isArray()
.withMessage('Video passwords should be an array.'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
if (areErrorsInNSFW(req, res)) return cleanUpReqFiles(req)
if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
if (!isValidPasswordProtectedPrivacy(req, res)) return cleanUpReqFiles(req)
const video = res.locals.videoAll
if (exists(req.body.privacy) && video.isLive && video.privacy !== req.body.privacy && video.state !== VideoState.WAITING_FOR_LIVE) {
return res.fail({ message: req.t('Cannot update privacy of a live that has already started') })
}
// Check if the user who did the request is able to update the video
const user = res.locals.oauth.token.User
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 doesChannelIdExist({ id: req.body.channelId, req, res, checkCanManage: true, checkIsLocal: true, checkIsOwner: false })
) {
return cleanUpReqFiles(req)
}
if (res.locals.videoChannel && res.locals.videoChannel.accountId !== video.VideoChannel.accountId) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('The channel must belong to the same account as the original channel')
})
return cleanUpReqFiles(req)
}
return next()
}
])
export async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
const video = getVideoWithAttributes(res)
// Anybody can watch local videos
if (video.isLocal() === true) return next()
// Logged user
if (res.locals.oauth) {
// Users can search or watch remote videos
if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
}
// Anybody can search or watch remote videos
if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
// Check our instance follows an actor that shared this video
if (await VideoModel.checkVideoHasInstanceFollow(video.id) === true) return next()
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: req.t('Cannot get this video regarding follow constraints'),
type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
data: {
originUrl: video.url
}
})
}
type FetchType = Extract<VideoLoadType, 'for-api' | 'all' | 'only-video-and-blacklist' | 'unsafe-only-immutable-attributes'>
export const videosCustomGetValidator = (fetchType: FetchType) => {
return [
isValidVideoIdParam('id'),
isValidVideoPasswordHeader(),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, fetchType)) return
// Controllers does not need to check video rights
if (fetchType === 'unsafe-only-immutable-attributes') return next()
const video = getVideoWithAttributes(res) as MVideoFullLight
if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id })) return
return next()
}
]
}
export const videosGetValidator = videosCustomGetValidator('all')
export const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
isValidVideoIdParam('id'),
param('videoFileId')
.custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
return next()
}
])
export const videosDownloadValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res, 'all')) return
const video = getVideoWithAttributes(res)
if (!await checkCanAccessVideoStaticFiles({ req, res, video, paramId: req.params.id })) return
return next()
}
]
export const videosGenerateDownloadValidator = [
query('videoFileIds')
.customSanitizer(toIntArray)
.custom(isNotEmptyIntArray)
.custom(v => hasArrayLength(v, { max: 2 })),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export const videosRemoveValidator = [
isValidVideoIdParam('id'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
if (!await doesVideoExist(req.params.id, res)) return
// Check if the user who did the request is able to delete the video
if (
!await checkCanManageVideo({
user: res.locals.oauth.token.User,
video: res.locals.videoAll,
right: UserRight.REMOVE_ANY_VIDEO,
req,
res,
checkIsLocal: true,
checkIsOwner: false
})
) return
return next()
}
]
export const videosOverviewValidator = [
query('page')
.optional()
.isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
return next()
}
]
export function getCommonVideoEditAttributes () {
return [
body('thumbnailfile')
.custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('previewfile')
.custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
),
body('category')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoCategoryValid),
body('licence')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoLicenceValid),
body('language')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoLanguageValid),
body('nsfw')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
body('nsfwFlags')
.optional()
.customSanitizer(toIntOrNull)
.custom(isNSFWFlagsValid),
body('nsfwSummary')
.optional()
.customSanitizer(toValueOrNull)
.custom(isNSFWSummaryValid),
body('waitTranscoding')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
body('privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isVideoPrivacyValid),
body('description')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoDescriptionValid),
body('support')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoSupportValid),
body('tags')
.optional()
.customSanitizer(toValueOrNull)
.custom(areVideoTagsValid)
.withMessage(
`Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
`${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
),
body('commentsPolicy')
.optional()
.custom(isVideoCommentsPolicyValid),
body('downloadEnabled')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
body('originallyPublishedAt')
.optional()
.customSanitizer(toValueOrNull)
.custom(isVideoOriginallyPublishedAtValid),
body('scheduleUpdate')
.optional()
.customSanitizer(toValueOrNull),
body('scheduleUpdate.updateAt')
.optional()
.custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
body('scheduleUpdate.privacy')
.optional()
.customSanitizer(toIntOrNull)
.custom(isScheduleVideoUpdatePrivacyValid)
] as (ValidationChain | ExpressPromiseHandler)[]
}
export const commonVideosFiltersValidatorFactory = (options: {
allowPrivacyFilterForAllUsers?: boolean
} = {}) => {
return [
query('categoryOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
query('licenceOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
query('languageOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
query('privacyOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
query('tagsOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
query('tagsAllOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
query('nsfw')
.optional()
.custom(isBooleanBothQueryValid),
query('nsfwFlagsIncluded')
.optional()
.customSanitizer(toIntOrNull)
.custom(isNSFWFlagsValid),
query('nsfwFlagsExcluded')
.optional()
.customSanitizer(toIntOrNull)
.custom(isNSFWFlagsValid),
query('isLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
query('includeScheduledLive')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid includeScheduledLive boolean'),
query('include')
.optional()
.custom(isVideoIncludeValid),
query('isLocal')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
query('hasHLSFiles')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
query('hasWebVideoFiles')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid hasWebVideoFiles boolean'),
query('skipCount')
.optional()
.customSanitizer(toBooleanOrNull)
.custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
query('search')
.optional()
.custom(exists),
query('excludeAlreadyWatched')
.optional()
.customSanitizer(toBooleanOrNull)
.isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
query('autoTagOneOf')
.optional()
.customSanitizer(arrayify)
.custom(isStringArray).withMessage('Should have a valid autoTagOneOf array'),
query('host')
.optional()
.custom(isHostValid),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
if (areValidationErrors(req, res)) return
const query = req.query as VideosCommonQuery
if (((query.nsfwFlagsExcluded || 0) & (query.nsfwFlagsIncluded || 0)) !== 0) {
return res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot use same flags in nsfwFlagsIncluded and nsfwFlagsExcluded at the same time')
})
}
const user = res.locals.oauth?.token.User
if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
if (query.include || (options.allowPrivacyFilterForAllUsers !== true && query.privacyOneOf) || query.autoTagOneOf) {
return res.fail({
status: HttpStatusCode.UNAUTHORIZED_401,
message: req.t('You are not allowed to see all videos, specify a custom include or auto tags filter')
})
}
}
if (!user && exists(query.excludeAlreadyWatched)) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('Cannot use excludeAlreadyWatched parameter when auth token is not provided')
})
return false
}
if (req.query.filter) {
res.fail({
status: HttpStatusCode.BAD_REQUEST_400,
message: req.t('"filter" query parameter is not supported anymore by PeerTube. Please use "isLocal" and "include" instead')
})
return false
}
return next()
}
]
}
export function areErrorsInNSFW (req: express.Request, res: express.Response) {
const body = req.body as VideoCreateUpdateCommon
if (!body.nsfw) {
if (body.nsfwFlags) {
res.fail({ message: req.t('Cannot set nsfwFlags if the video is not NSFW') })
return true
}
if (body.nsfwSummary) {
res.fail({ message: req.t('Cannot set nsfwSummary if the video is not NSFW') })
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// Private
// ---------------------------------------------------------------------------
function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
if (req.body.scheduleUpdate) {
if (!req.body.scheduleUpdate.updateAt) {
logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
res.fail({ message: req.t('"scheduleUpdate.updateAt" parameter is mandatory') })
return true
}
}
return false
}
async function commonVideoChecksPass (options: {
req: express.Request
res: express.Response
user: MUserAccountId
videoFileSize: number
files: express.UploadFilesForCheck
}): Promise<boolean> {
const { req, res } = options
if (areErrorsInScheduleUpdate(req, res)) return false
if (areErrorsInNSFW(req, 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
return true
}