1 import * as express from 'express'
2 import { body, header, param, query, ValidationChain } from 'express-validator'
3 import { getResumableUploadPath } from '@server/helpers/upload'
4 import { isAbleToUploadVideo } from '@server/lib/user'
5 import { getServerActor } from '@server/models/application/application'
6 import { ExpressPromiseHandler } from '@server/types/express'
7 import { MUserAccountId, MVideoWithRights } from '@server/types/models'
8 import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
9 import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
10 import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/change-ownership/video-change-ownership-accept.model'
23 } from '../../../helpers/custom-validators/misc'
24 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
25 import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
27 isScheduleVideoUpdatePrivacyValid,
29 isVideoDescriptionValid,
30 isVideoFileMimeTypeValid,
37 isVideoOriginallyPublishedAtValid,
41 } from '../../../helpers/custom-validators/videos'
42 import { cleanUpReqFiles } from '../../../helpers/express-utils'
43 import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
44 import { logger } from '../../../helpers/logger'
46 checkUserCanManageVideo,
47 doesVideoChannelOfAccountExist,
49 doesVideoFileOfVideoExist
50 } from '../../../helpers/middlewares'
51 import { deleteFileAndCatch } from '../../../helpers/utils'
52 import { getVideoWithAttributes } from '../../../helpers/video'
53 import { CONFIG } from '../../../initializers/config'
54 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
55 import { isLocalVideoAccepted } from '../../../lib/moderation'
56 import { Hooks } from '../../../lib/plugins/hooks'
57 import { AccountModel } from '../../../models/account/account'
58 import { VideoModel } from '../../../models/video/video'
59 import { authenticatePromiseIfNeeded } from '../../auth'
60 import { areValidationErrors } from '../utils'
62 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
64 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
65 .withMessage('Should have a file'),
68 .custom(isVideoNameValid).withMessage(
69 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
72 .customSanitizer(toIntOrNull)
73 .custom(isIdValid).withMessage('Should have correct video channel id'),
75 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
76 res.docs = "https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadLegacy"
77 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
79 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
81 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
82 const user = res.locals.oauth.token.User
84 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
85 return cleanUpReqFiles(req)
89 if (!videoFile.duration) await addDurationToVideo(videoFile)
91 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
94 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
95 message: 'Video file unreadable.'
97 return cleanUpReqFiles(req)
100 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
107 * Gets called after the last PUT request
109 const videosAddResumableValidator = [
110 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
111 res.docs = "https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadResumable"
112 const user = res.locals.oauth.token.User
114 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
115 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
117 const cleanup = () => deleteFileAndCatch(file.path)
119 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
122 if (!file.duration) await addDurationToVideo(file)
124 logger.error('Invalid input file in videosAddResumableValidator.', { err })
127 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
128 message: 'Video file unreadable.'
133 if (!await isVideoAccepted(req, res, file)) return cleanup()
135 res.locals.videoFileResumable = file
142 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
143 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
145 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
146 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
149 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
153 .withMessage('Should have a valid filename'),
156 .custom(isVideoNameValid).withMessage(
157 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
160 .customSanitizer(toIntOrNull)
161 .custom(isIdValid).withMessage('Should have correct video channel id'),
163 header('x-upload-content-length')
166 .withMessage('Should specify the file length'),
167 header('x-upload-content-type')
170 .withMessage('Should specify the file mimetype'),
172 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
173 res.docs = "https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadResumableInit"
174 const videoFileMetadata = {
175 mimetype: req.headers['x-upload-content-type'] as string,
176 size: +req.headers['x-upload-content-length'],
177 originalname: req.body.name
180 const user = res.locals.oauth.token.User
181 const cleanup = () => cleanUpReqFiles(req)
183 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
184 parameters: req.body,
185 headers: req.headers,
189 if (areValidationErrors(req, res)) return cleanup()
191 const files = { videofile: [ videoFileMetadata ] }
192 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
194 // multer required unsetting the Content-Type, now we can set it for node-uploadx
195 req.headers['content-type'] = 'application/json; charset=utf-8'
196 // place previewfile in metadata so that uploadx saves it in .META
197 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
203 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
204 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
208 .custom(isVideoNameValid).withMessage(
209 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
213 .customSanitizer(toIntOrNull)
214 .custom(isIdValid).withMessage('Should have correct video channel id'),
216 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
217 res.docs = 'https://docs.joinpeertube.org/api-rest-reference.html#operation/putVideo'
218 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
220 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
221 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
222 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
224 // Check if the user who did the request is able to update the video
225 const user = res.locals.oauth.token.User
226 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
228 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
234 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
235 const video = getVideoWithAttributes(res)
237 // Anybody can watch local videos
238 if (video.isOwned() === true) return next()
241 if (res.locals.oauth) {
242 // Users can search or watch remote videos
243 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
246 // Anybody can search or watch remote videos
247 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
249 // Check our instance follows an actor that shared this video
250 const serverActor = await getServerActor()
251 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
254 status: HttpStatusCode.FORBIDDEN_403,
255 message: 'Cannot get this video regarding follow constraints',
256 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
263 const videosCustomGetValidator = (
264 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
265 authenticateInQuery = false
268 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
270 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
271 res.docs = 'https://docs.joinpeertube.org/api-rest-reference.html#operation/getVideo'
272 logger.debug('Checking videosGet parameters', { parameters: req.params })
274 if (areValidationErrors(req, res)) return
275 if (!await doesVideoExist(req.params.id, res, fetchType)) return
277 // Controllers does not need to check video rights
278 if (fetchType === 'only-immutable-attributes') return next()
280 const video = getVideoWithAttributes(res) as MVideoWithRights
282 // Video private or blacklisted
283 if (video.requiresAuth()) {
284 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
286 const user = res.locals.oauth ? res.locals.oauth.token.User : null
288 // Only the owner or a user that have blocklist rights can see the video
289 if (!user || !user.canGetVideo(video)) {
291 status: HttpStatusCode.FORBIDDEN_403,
292 message: 'Cannot get this private/internal or blocklisted video'
299 // Video is public, anyone can access it
300 if (video.privacy === VideoPrivacy.PUBLIC) return next()
302 // Video is unlisted, check we used the uuid to fetch it
303 if (video.privacy === VideoPrivacy.UNLISTED) {
304 if (isUUIDValid(req.params.id)) return next()
306 // Don't leak this unlisted video
308 status: HttpStatusCode.NOT_FOUND_404,
309 message: 'Video not found'
316 const videosGetValidator = videosCustomGetValidator('all')
317 const videosDownloadValidator = videosCustomGetValidator('all', true)
319 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
320 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
321 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
323 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
324 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
326 if (areValidationErrors(req, res)) return
327 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
333 const videosRemoveValidator = [
334 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
336 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
337 res.docs = "https://docs.joinpeertube.org/api-rest-reference.html#operation/delVideo"
338 logger.debug('Checking videosRemove parameters', { parameters: req.params })
340 if (areValidationErrors(req, res)) return
341 if (!await doesVideoExist(req.params.id, res)) return
343 // Check if the user who did the request is able to delete the video
344 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
350 const videosChangeOwnershipValidator = [
351 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
353 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
354 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
356 if (areValidationErrors(req, res)) return
357 if (!await doesVideoExist(req.params.videoId, res)) return
359 // Check if the user who did the request is able to change the ownership of the video
360 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
362 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
364 res.fail({ message: 'Changing video ownership to a remote account is not supported yet' })
368 res.locals.nextOwner = nextOwner
373 const videosTerminateChangeOwnershipValidator = [
374 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
376 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
377 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
379 if (areValidationErrors(req, res)) return
380 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
382 // Check if the user who did the request is able to change the ownership of the video
383 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
385 const videoChangeOwnership = res.locals.videoChangeOwnership
387 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
389 status: HttpStatusCode.FORBIDDEN_403,
390 message: 'Ownership already accepted or refused'
399 const videosAcceptChangeOwnershipValidator = [
400 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
401 const body = req.body as VideoChangeOwnershipAccept
402 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
404 const user = res.locals.oauth.token.User
405 const videoChangeOwnership = res.locals.videoChangeOwnership
406 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
407 if (isAble === false) {
409 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
410 message: 'The user video quota is exceeded with this video.'
419 const videosOverviewValidator = [
422 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
423 .withMessage('Should have a valid pagination'),
425 (req: express.Request, res: express.Response, next: express.NextFunction) => {
426 if (areValidationErrors(req, res)) return
432 function getCommonVideoEditAttributes () {
434 body('thumbnailfile')
435 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
436 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
437 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
440 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
441 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
442 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
447 .customSanitizer(toIntOrNull)
448 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
451 .customSanitizer(toIntOrNull)
452 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
455 .customSanitizer(toValueOrNull)
456 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
459 .customSanitizer(toBooleanOrNull)
460 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
461 body('waitTranscoding')
463 .customSanitizer(toBooleanOrNull)
464 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
467 .customSanitizer(toValueOrNull)
468 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
471 .customSanitizer(toValueOrNull)
472 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
475 .customSanitizer(toValueOrNull)
476 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
479 .customSanitizer(toValueOrNull)
480 .custom(isVideoTagsValid)
482 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
483 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
485 body('commentsEnabled')
487 .customSanitizer(toBooleanOrNull)
488 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
489 body('downloadEnabled')
491 .customSanitizer(toBooleanOrNull)
492 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
493 body('originallyPublishedAt')
495 .customSanitizer(toValueOrNull)
496 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
497 body('scheduleUpdate')
499 .customSanitizer(toValueOrNull),
500 body('scheduleUpdate.updateAt')
502 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
503 body('scheduleUpdate.privacy')
505 .customSanitizer(toIntOrNull)
506 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
507 ] as (ValidationChain | ExpressPromiseHandler)[]
510 const commonVideosFiltersValidator = [
511 query('categoryOneOf')
513 .customSanitizer(toArray)
514 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
515 query('licenceOneOf')
517 .customSanitizer(toArray)
518 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
519 query('languageOneOf')
521 .customSanitizer(toArray)
522 .custom(isStringArray).withMessage('Should have a valid one of language array'),
525 .customSanitizer(toArray)
526 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
529 .customSanitizer(toArray)
530 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
533 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
536 .customSanitizer(toBooleanOrNull)
537 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
540 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
543 .customSanitizer(toBooleanOrNull)
544 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
547 .custom(exists).withMessage('Should have a valid search'),
549 (req: express.Request, res: express.Response, next: express.NextFunction) => {
550 logger.debug('Checking commons video filters query', { parameters: req.query })
552 if (areValidationErrors(req, res)) return
554 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
556 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
557 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
560 status: HttpStatusCode.UNAUTHORIZED_401,
561 message: 'You are not allowed to see all local videos.'
570 // ---------------------------------------------------------------------------
573 videosAddLegacyValidator,
574 videosAddResumableValidator,
575 videosAddResumableInitValidator,
577 videosUpdateValidator,
579 videoFileMetadataGetValidator,
580 videosDownloadValidator,
581 checkVideoFollowConstraints,
582 videosCustomGetValidator,
583 videosRemoveValidator,
585 videosChangeOwnershipValidator,
586 videosTerminateChangeOwnershipValidator,
587 videosAcceptChangeOwnershipValidator,
589 getCommonVideoEditAttributes,
591 commonVideosFiltersValidator,
593 videosOverviewValidator
596 // ---------------------------------------------------------------------------
598 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
599 if (req.body.scheduleUpdate) {
600 if (!req.body.scheduleUpdate.updateAt) {
601 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
603 res.fail({ message: 'Schedule update at is mandatory.' })
611 async function commonVideoChecksPass (parameters: {
613 res: express.Response
615 videoFileSize: number
616 files: express.UploadFilesForCheck
617 }): Promise<boolean> {
618 const { req, res, user, videoFileSize, files } = parameters
620 if (areErrorsInScheduleUpdate(req, res)) return false
622 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
624 if (!isVideoFileMimeTypeValid(files)) {
626 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
627 message: 'This file is not supported. Please, make sure it is of the following type: ' +
628 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
633 if (!isVideoFileSizeValid(videoFileSize.toString())) {
635 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
636 message: 'This file is too large. It exceeds the maximum file size authorized.'
641 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
643 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
644 message: 'The user video quota is exceeded with this video.'
652 export async function isVideoAccepted (
653 req: express.Request,
654 res: express.Response,
655 videoFile: express.VideoUploadFile
657 // Check we accept this video
658 const acceptParameters = {
661 user: res.locals.oauth.token.User
663 const acceptedResult = await Hooks.wrapFun(
664 isLocalVideoAccepted,
666 'filter:api.video.upload.accept.result'
669 if (!acceptedResult || acceptedResult.accepted !== true) {
670 logger.info('Refused local video.', { acceptedResult, acceptParameters })
672 status: HttpStatusCode.FORBIDDEN_403,
673 message: acceptedResult.errorMessage || 'Refused local video'
681 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
682 const duration: number = await getDurationFromVideoFile(videoFile.path)
684 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
686 videoFile.duration = duration