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 })
91 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
92 .json({ error: 'Video file unreadable.' })
94 return cleanUpReqFiles(req)
97 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
104 * Gets called after the last PUT request
106 const videosAddResumableValidator = [
107 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
108 const user = res.locals.oauth.token.User
110 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
111 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
113 const cleanup = () => deleteFileAndCatch(file.path)
115 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
118 if (!file.duration) await addDurationToVideo(file)
120 logger.error('Invalid input file in videosAddResumableValidator.', { err })
121 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
122 .json({ error: 'Video file unreadable.' })
127 if (!await isVideoAccepted(req, res, file)) return cleanup()
129 res.locals.videoFileResumable = file
136 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
137 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
139 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
140 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
143 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
147 .withMessage('Should have a valid filename'),
150 .custom(isVideoNameValid).withMessage(
151 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
154 .customSanitizer(toIntOrNull)
155 .custom(isIdValid).withMessage('Should have correct video channel id'),
157 header('x-upload-content-length')
160 .withMessage('Should specify the file length'),
161 header('x-upload-content-type')
164 .withMessage('Should specify the file mimetype'),
166 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
167 const videoFileMetadata = {
168 mimetype: req.headers['x-upload-content-type'] as string,
169 size: +req.headers['x-upload-content-length'],
170 originalname: req.body.name
173 const user = res.locals.oauth.token.User
174 const cleanup = () => cleanUpReqFiles(req)
176 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
177 parameters: req.body,
178 headers: req.headers,
182 if (areValidationErrors(req, res)) return cleanup()
184 const files = { videofile: [ videoFileMetadata ] }
185 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
187 // multer required unsetting the Content-Type, now we can set it for node-uploadx
188 req.headers['content-type'] = 'application/json; charset=utf-8'
189 // place previewfile in metadata so that uploadx saves it in .META
190 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
196 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
197 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
201 .custom(isVideoNameValid).withMessage(
202 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
206 .customSanitizer(toIntOrNull)
207 .custom(isIdValid).withMessage('Should have correct video channel id'),
209 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
210 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
212 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
213 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
214 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
216 // Check if the user who did the request is able to update the video
217 const user = res.locals.oauth.token.User
218 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
220 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
226 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
227 const video = getVideoWithAttributes(res)
229 // Anybody can watch local videos
230 if (video.isOwned() === true) return next()
233 if (res.locals.oauth) {
234 // Users can search or watch remote videos
235 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
238 // Anybody can search or watch remote videos
239 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
241 // Check our instance follows an actor that shared this video
242 const serverActor = await getServerActor()
243 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
245 return res.status(HttpStatusCode.FORBIDDEN_403)
247 errorCode: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
248 error: 'Cannot get this video regarding follow constraints.',
253 const videosCustomGetValidator = (
254 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
255 authenticateInQuery = false
258 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
260 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
261 logger.debug('Checking videosGet parameters', { parameters: req.params })
263 if (areValidationErrors(req, res)) return
264 if (!await doesVideoExist(req.params.id, res, fetchType)) return
266 // Controllers does not need to check video rights
267 if (fetchType === 'only-immutable-attributes') return next()
269 const video = getVideoWithAttributes(res) as MVideoWithRights
271 // Video private or blacklisted
272 if (video.requiresAuth()) {
273 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
275 const user = res.locals.oauth ? res.locals.oauth.token.User : null
277 // Only the owner or a user that have blacklist rights can see the video
278 if (!user || !user.canGetVideo(video)) {
279 return res.status(HttpStatusCode.FORBIDDEN_403)
280 .json({ error: 'Cannot get this private/internal or blacklisted video.' })
286 // Video is public, anyone can access it
287 if (video.privacy === VideoPrivacy.PUBLIC) return next()
289 // Video is unlisted, check we used the uuid to fetch it
290 if (video.privacy === VideoPrivacy.UNLISTED) {
291 if (isUUIDValid(req.params.id)) return next()
293 // Don't leak this unlisted video
294 return res.status(HttpStatusCode.NOT_FOUND_404).end()
300 const videosGetValidator = videosCustomGetValidator('all')
301 const videosDownloadValidator = videosCustomGetValidator('all', true)
303 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
304 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
305 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
307 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
308 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
310 if (areValidationErrors(req, res)) return
311 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
317 const videosRemoveValidator = [
318 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
320 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
321 logger.debug('Checking videosRemove parameters', { parameters: req.params })
323 if (areValidationErrors(req, res)) return
324 if (!await doesVideoExist(req.params.id, res)) return
326 // Check if the user who did the request is able to delete the video
327 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
333 const videosChangeOwnershipValidator = [
334 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
336 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
337 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
339 if (areValidationErrors(req, res)) return
340 if (!await doesVideoExist(req.params.videoId, res)) return
342 // Check if the user who did the request is able to change the ownership of the video
343 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
345 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
347 res.status(HttpStatusCode.BAD_REQUEST_400)
348 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
352 res.locals.nextOwner = nextOwner
358 const videosTerminateChangeOwnershipValidator = [
359 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
361 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
362 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
364 if (areValidationErrors(req, res)) return
365 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
367 // Check if the user who did the request is able to change the ownership of the video
368 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
370 const videoChangeOwnership = res.locals.videoChangeOwnership
372 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
373 res.status(HttpStatusCode.FORBIDDEN_403)
374 .json({ error: 'Ownership already accepted or refused' })
382 const videosAcceptChangeOwnershipValidator = [
383 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
384 const body = req.body as VideoChangeOwnershipAccept
385 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
387 const user = res.locals.oauth.token.User
388 const videoChangeOwnership = res.locals.videoChangeOwnership
389 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
390 if (isAble === false) {
391 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
392 .json({ error: 'The user video quota is exceeded with this video.' })
401 const videosOverviewValidator = [
404 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
405 .withMessage('Should have a valid pagination'),
407 (req: express.Request, res: express.Response, next: express.NextFunction) => {
408 if (areValidationErrors(req, res)) return
414 function getCommonVideoEditAttributes () {
416 body('thumbnailfile')
417 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
418 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
419 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
422 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
423 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
424 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
429 .customSanitizer(toIntOrNull)
430 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
433 .customSanitizer(toIntOrNull)
434 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
437 .customSanitizer(toValueOrNull)
438 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
441 .customSanitizer(toBooleanOrNull)
442 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
443 body('waitTranscoding')
445 .customSanitizer(toBooleanOrNull)
446 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
449 .customSanitizer(toValueOrNull)
450 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
453 .customSanitizer(toValueOrNull)
454 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
457 .customSanitizer(toValueOrNull)
458 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
461 .customSanitizer(toValueOrNull)
462 .custom(isVideoTagsValid)
464 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
465 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
467 body('commentsEnabled')
469 .customSanitizer(toBooleanOrNull)
470 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
471 body('downloadEnabled')
473 .customSanitizer(toBooleanOrNull)
474 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
475 body('originallyPublishedAt')
477 .customSanitizer(toValueOrNull)
478 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
479 body('scheduleUpdate')
481 .customSanitizer(toValueOrNull),
482 body('scheduleUpdate.updateAt')
484 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
485 body('scheduleUpdate.privacy')
487 .customSanitizer(toIntOrNull)
488 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
489 ] as (ValidationChain | ExpressPromiseHandler)[]
492 const commonVideosFiltersValidator = [
493 query('categoryOneOf')
495 .customSanitizer(toArray)
496 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
497 query('licenceOneOf')
499 .customSanitizer(toArray)
500 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
501 query('languageOneOf')
503 .customSanitizer(toArray)
504 .custom(isStringArray).withMessage('Should have a valid one of language array'),
507 .customSanitizer(toArray)
508 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
511 .customSanitizer(toArray)
512 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
515 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
518 .customSanitizer(toBooleanOrNull)
519 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
522 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
525 .customSanitizer(toBooleanOrNull)
526 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
529 .custom(exists).withMessage('Should have a valid search'),
531 (req: express.Request, res: express.Response, next: express.NextFunction) => {
532 logger.debug('Checking commons video filters query', { parameters: req.query })
534 if (areValidationErrors(req, res)) return
536 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
538 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
539 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
541 res.status(HttpStatusCode.UNAUTHORIZED_401)
542 .json({ error: 'You are not allowed to see all local videos.' })
551 // ---------------------------------------------------------------------------
554 videosAddLegacyValidator,
555 videosAddResumableValidator,
556 videosAddResumableInitValidator,
558 videosUpdateValidator,
560 videoFileMetadataGetValidator,
561 videosDownloadValidator,
562 checkVideoFollowConstraints,
563 videosCustomGetValidator,
564 videosRemoveValidator,
566 videosChangeOwnershipValidator,
567 videosTerminateChangeOwnershipValidator,
568 videosAcceptChangeOwnershipValidator,
570 getCommonVideoEditAttributes,
572 commonVideosFiltersValidator,
574 videosOverviewValidator
577 // ---------------------------------------------------------------------------
579 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
580 if (req.body.scheduleUpdate) {
581 if (!req.body.scheduleUpdate.updateAt) {
582 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
584 res.status(HttpStatusCode.BAD_REQUEST_400)
585 .json({ error: 'Schedule update at is mandatory.' })
594 async function commonVideoChecksPass (parameters: {
596 res: express.Response
598 videoFileSize: number
599 files: express.UploadFilesForCheck
600 }): Promise<boolean> {
601 const { req, res, user, videoFileSize, files } = parameters
603 if (areErrorsInScheduleUpdate(req, res)) return false
605 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
607 if (!isVideoFileMimeTypeValid(files)) {
608 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415)
610 error: 'This file is not supported. Please, make sure it is of the following type: ' +
611 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
617 if (!isVideoFileSizeValid(videoFileSize.toString())) {
618 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
619 .json({ error: 'This file is too large. It exceeds the maximum file size authorized.' })
624 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
625 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
626 .json({ error: 'The user video quota is exceeded with this video.' })
634 export async function isVideoAccepted (
635 req: express.Request,
636 res: express.Response,
637 videoFile: express.VideoUploadFile
639 // Check we accept this video
640 const acceptParameters = {
643 user: res.locals.oauth.token.User
645 const acceptedResult = await Hooks.wrapFun(
646 isLocalVideoAccepted,
648 'filter:api.video.upload.accept.result'
651 if (!acceptedResult || acceptedResult.accepted !== true) {
652 logger.info('Refused local video.', { acceptedResult, acceptParameters })
653 res.status(HttpStatusCode.FORBIDDEN_403)
654 .json({ error: acceptedResult.errorMessage || 'Refused local video' })
662 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
663 const duration: number = await getDurationFromVideoFile(videoFile.path)
665 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
667 videoFile.duration = duration