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 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
78 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
80 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
81 const user = res.locals.oauth.token.User
83 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
84 return cleanUpReqFiles(req)
88 if (!videoFile.duration) await addDurationToVideo(videoFile)
90 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
93 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
94 message: 'Video file unreadable.'
96 return cleanUpReqFiles(req)
99 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
106 * Gets called after the last PUT request
108 const videosAddResumableValidator = [
109 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
110 const user = res.locals.oauth.token.User
112 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
113 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
115 const cleanup = () => deleteFileAndCatch(file.path)
117 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
120 if (!file.duration) await addDurationToVideo(file)
122 logger.error('Invalid input file in videosAddResumableValidator.', { err })
125 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
126 message: 'Video file unreadable.'
131 if (!await isVideoAccepted(req, res, file)) return cleanup()
133 res.locals.videoFileResumable = file
140 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
141 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
143 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
144 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
147 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
151 .withMessage('Should have a valid filename'),
154 .custom(isVideoNameValid).withMessage(
155 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
158 .customSanitizer(toIntOrNull)
159 .custom(isIdValid).withMessage('Should have correct video channel id'),
161 header('x-upload-content-length')
164 .withMessage('Should specify the file length'),
165 header('x-upload-content-type')
168 .withMessage('Should specify the file mimetype'),
170 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
171 const videoFileMetadata = {
172 mimetype: req.headers['x-upload-content-type'] as string,
173 size: +req.headers['x-upload-content-length'],
174 originalname: req.body.name
177 const user = res.locals.oauth.token.User
178 const cleanup = () => cleanUpReqFiles(req)
180 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
181 parameters: req.body,
182 headers: req.headers,
186 if (areValidationErrors(req, res)) return cleanup()
188 const files = { videofile: [ videoFileMetadata ] }
189 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
191 // multer required unsetting the Content-Type, now we can set it for node-uploadx
192 req.headers['content-type'] = 'application/json; charset=utf-8'
193 // place previewfile in metadata so that uploadx saves it in .META
194 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
200 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
201 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
205 .custom(isVideoNameValid).withMessage(
206 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
210 .customSanitizer(toIntOrNull)
211 .custom(isIdValid).withMessage('Should have correct video channel id'),
213 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
214 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
216 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
217 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
218 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
220 // Check if the user who did the request is able to update the video
221 const user = res.locals.oauth.token.User
222 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
224 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
230 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
231 const video = getVideoWithAttributes(res)
233 // Anybody can watch local videos
234 if (video.isOwned() === true) return next()
237 if (res.locals.oauth) {
238 // Users can search or watch remote videos
239 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
242 // Anybody can search or watch remote videos
243 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
245 // Check our instance follows an actor that shared this video
246 const serverActor = await getServerActor()
247 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
250 status: HttpStatusCode.FORBIDDEN_403,
251 message: 'Cannot get this video regarding follow constraints',
252 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
259 const videosCustomGetValidator = (
260 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
261 authenticateInQuery = false
264 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
266 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
267 logger.debug('Checking videosGet parameters', { parameters: req.params })
269 if (areValidationErrors(req, res)) return
270 if (!await doesVideoExist(req.params.id, res, fetchType)) return
272 // Controllers does not need to check video rights
273 if (fetchType === 'only-immutable-attributes') return next()
275 const video = getVideoWithAttributes(res) as MVideoWithRights
277 // Video private or blacklisted
278 if (video.requiresAuth()) {
279 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
281 const user = res.locals.oauth ? res.locals.oauth.token.User : null
283 // Only the owner or a user that have blocklist rights can see the video
284 if (!user || !user.canGetVideo(video)) {
286 status: HttpStatusCode.FORBIDDEN_403,
287 message: 'Cannot get this private/internal or blocklisted video'
294 // Video is public, anyone can access it
295 if (video.privacy === VideoPrivacy.PUBLIC) return next()
297 // Video is unlisted, check we used the uuid to fetch it
298 if (video.privacy === VideoPrivacy.UNLISTED) {
299 if (isUUIDValid(req.params.id)) return next()
301 // Don't leak this unlisted video
303 status: HttpStatusCode.NOT_FOUND_404,
304 message: 'Video not found'
311 const videosGetValidator = videosCustomGetValidator('all')
312 const videosDownloadValidator = videosCustomGetValidator('all', true)
314 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
315 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
316 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
318 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
319 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
321 if (areValidationErrors(req, res)) return
322 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
328 const videosRemoveValidator = [
329 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
331 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
332 logger.debug('Checking videosRemove parameters', { parameters: req.params })
334 if (areValidationErrors(req, res)) return
335 if (!await doesVideoExist(req.params.id, res)) return
337 // Check if the user who did the request is able to delete the video
338 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
344 const videosChangeOwnershipValidator = [
345 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
347 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
348 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
350 if (areValidationErrors(req, res)) return
351 if (!await doesVideoExist(req.params.videoId, res)) return
353 // Check if the user who did the request is able to change the ownership of the video
354 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
356 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
358 res.fail({ message: 'Changing video ownership to a remote account is not supported yet' })
362 res.locals.nextOwner = nextOwner
367 const videosTerminateChangeOwnershipValidator = [
368 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
370 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
371 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
373 if (areValidationErrors(req, res)) return
374 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
376 // Check if the user who did the request is able to change the ownership of the video
377 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
379 const videoChangeOwnership = res.locals.videoChangeOwnership
381 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
383 status: HttpStatusCode.FORBIDDEN_403,
384 message: 'Ownership already accepted or refused'
393 const videosAcceptChangeOwnershipValidator = [
394 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
395 const body = req.body as VideoChangeOwnershipAccept
396 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
398 const user = res.locals.oauth.token.User
399 const videoChangeOwnership = res.locals.videoChangeOwnership
400 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
401 if (isAble === false) {
403 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
404 message: 'The user video quota is exceeded with this video.'
413 const videosOverviewValidator = [
416 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
417 .withMessage('Should have a valid pagination'),
419 (req: express.Request, res: express.Response, next: express.NextFunction) => {
420 if (areValidationErrors(req, res)) return
426 function getCommonVideoEditAttributes () {
428 body('thumbnailfile')
429 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
430 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
431 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
434 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
435 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
436 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
441 .customSanitizer(toIntOrNull)
442 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
445 .customSanitizer(toIntOrNull)
446 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
449 .customSanitizer(toValueOrNull)
450 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
453 .customSanitizer(toBooleanOrNull)
454 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
455 body('waitTranscoding')
457 .customSanitizer(toBooleanOrNull)
458 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
461 .customSanitizer(toValueOrNull)
462 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
465 .customSanitizer(toValueOrNull)
466 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
469 .customSanitizer(toValueOrNull)
470 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
473 .customSanitizer(toValueOrNull)
474 .custom(isVideoTagsValid)
476 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
477 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
479 body('commentsEnabled')
481 .customSanitizer(toBooleanOrNull)
482 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
483 body('downloadEnabled')
485 .customSanitizer(toBooleanOrNull)
486 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
487 body('originallyPublishedAt')
489 .customSanitizer(toValueOrNull)
490 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
491 body('scheduleUpdate')
493 .customSanitizer(toValueOrNull),
494 body('scheduleUpdate.updateAt')
496 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
497 body('scheduleUpdate.privacy')
499 .customSanitizer(toIntOrNull)
500 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
501 ] as (ValidationChain | ExpressPromiseHandler)[]
504 const commonVideosFiltersValidator = [
505 query('categoryOneOf')
507 .customSanitizer(toArray)
508 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
509 query('licenceOneOf')
511 .customSanitizer(toArray)
512 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
513 query('languageOneOf')
515 .customSanitizer(toArray)
516 .custom(isStringArray).withMessage('Should have a valid one of language array'),
519 .customSanitizer(toArray)
520 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
523 .customSanitizer(toArray)
524 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
527 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
530 .customSanitizer(toBooleanOrNull)
531 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
534 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
537 .customSanitizer(toBooleanOrNull)
538 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
541 .custom(exists).withMessage('Should have a valid search'),
543 (req: express.Request, res: express.Response, next: express.NextFunction) => {
544 logger.debug('Checking commons video filters query', { parameters: req.query })
546 if (areValidationErrors(req, res)) return
548 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
550 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
551 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
554 status: HttpStatusCode.UNAUTHORIZED_401,
555 message: 'You are not allowed to see all local videos.'
564 // ---------------------------------------------------------------------------
567 videosAddLegacyValidator,
568 videosAddResumableValidator,
569 videosAddResumableInitValidator,
571 videosUpdateValidator,
573 videoFileMetadataGetValidator,
574 videosDownloadValidator,
575 checkVideoFollowConstraints,
576 videosCustomGetValidator,
577 videosRemoveValidator,
579 videosChangeOwnershipValidator,
580 videosTerminateChangeOwnershipValidator,
581 videosAcceptChangeOwnershipValidator,
583 getCommonVideoEditAttributes,
585 commonVideosFiltersValidator,
587 videosOverviewValidator
590 // ---------------------------------------------------------------------------
592 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
593 if (req.body.scheduleUpdate) {
594 if (!req.body.scheduleUpdate.updateAt) {
595 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
597 res.fail({ message: 'Schedule update at is mandatory.' })
605 async function commonVideoChecksPass (parameters: {
607 res: express.Response
609 videoFileSize: number
610 files: express.UploadFilesForCheck
611 }): Promise<boolean> {
612 const { req, res, user, videoFileSize, files } = parameters
614 if (areErrorsInScheduleUpdate(req, res)) return false
616 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
618 if (!isVideoFileMimeTypeValid(files)) {
620 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
621 message: 'This file is not supported. Please, make sure it is of the following type: ' +
622 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
627 if (!isVideoFileSizeValid(videoFileSize.toString())) {
629 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
630 message: 'This file is too large. It exceeds the maximum file size authorized.'
635 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
637 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
638 message: 'The user video quota is exceeded with this video.'
646 export async function isVideoAccepted (
647 req: express.Request,
648 res: express.Response,
649 videoFile: express.VideoUploadFile
651 // Check we accept this video
652 const acceptParameters = {
655 user: res.locals.oauth.token.User
657 const acceptedResult = await Hooks.wrapFun(
658 isLocalVideoAccepted,
660 'filter:api.video.upload.accept.result'
663 if (!acceptedResult || acceptedResult.accepted !== true) {
664 logger.info('Refused local video.', { acceptedResult, acceptParameters })
666 status: HttpStatusCode.FORBIDDEN_403,
667 message: acceptedResult.errorMessage || 'Refused local video'
675 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
676 const duration: number = await getDurationFromVideoFile(videoFile.path)
678 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
680 videoFile.duration = duration