1 import express from 'express'
2 import { body, header, param, query, ValidationChain } from 'express-validator'
3 import { isTestInstance } from '@server/helpers/core-utils'
4 import { getResumableUploadPath } from '@server/helpers/upload'
5 import { Redis } from '@server/lib/redis'
6 import { getServerActor } from '@server/models/application/application'
7 import { ExpressPromiseHandler } from '@server/types/express-handler'
8 import { MUserAccountId, MVideoFullLight } from '@server/types/models'
9 import { getAllPrivacies } from '@shared/core-utils'
10 import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude } from '@shared/models'
21 } from '../../../helpers/custom-validators/misc'
22 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
25 isScheduleVideoUpdatePrivacyValid,
27 isVideoDescriptionValid,
28 isVideoFileMimeTypeValid,
36 isVideoOriginallyPublishedAtValid,
39 } from '../../../helpers/custom-validators/videos'
40 import { cleanUpReqFiles } from '../../../helpers/express-utils'
41 import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
42 import { logger } from '../../../helpers/logger'
43 import { deleteFileAndCatch } from '../../../helpers/utils'
44 import { getVideoWithAttributes } from '../../../helpers/video'
45 import { CONFIG } from '../../../initializers/config'
46 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
47 import { isLocalVideoAccepted } from '../../../lib/moderation'
48 import { Hooks } from '../../../lib/plugins/hooks'
49 import { VideoModel } from '../../../models/video/video'
53 checkUserCanManageVideo,
55 doesVideoChannelOfAccountExist,
57 doesVideoFileOfVideoExist,
61 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
63 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
64 .withMessage('Should have a file'),
67 .custom(isVideoNameValid).withMessage(
68 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
71 .customSanitizer(toIntOrNull)
74 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
75 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
77 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
79 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
80 const user = res.locals.oauth.token.User
82 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
83 return cleanUpReqFiles(req)
87 if (!videoFile.duration) await addDurationToVideo(videoFile)
89 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
92 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
93 message: 'Video file unreadable.'
95 return cleanUpReqFiles(req)
98 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
105 * Gets called after the last PUT request
107 const videosAddResumableValidator = [
108 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
109 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.name), filename: body.metadata.filename }
112 const cleanup = () => deleteFileAndCatch(file.path)
114 const uploadId = req.query.upload_id
115 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
118 const sessionResponse = await Redis.Instance.getUploadSession(uploadId)
120 if (!sessionResponse) {
121 res.setHeader('Retry-After', 300) // ask to retry after 5 min, knowing the upload_id is kept for up to 15 min after completion
124 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
125 message: 'The upload is already being processed'
129 if (isTestInstance()) {
130 res.setHeader('x-resumable-upload-cached', 'true')
133 return res.json(sessionResponse)
136 await Redis.Instance.setUploadSession(uploadId)
138 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
141 if (!file.duration) await addDurationToVideo(file)
143 logger.error('Invalid input file in videosAddResumableValidator.', { err })
146 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
147 message: 'Video file unreadable.'
152 if (!await isVideoAccepted(req, res, file)) return cleanup()
154 res.locals.videoFileResumable = { ...file, originalname: file.filename }
161 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
162 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
164 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
165 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
168 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
173 .custom(isVideoNameValid).withMessage(
174 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
177 .customSanitizer(toIntOrNull)
180 header('x-upload-content-length')
183 .withMessage('Should specify the file length'),
184 header('x-upload-content-type')
187 .withMessage('Should specify the file mimetype'),
189 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
190 const videoFileMetadata = {
191 mimetype: req.headers['x-upload-content-type'] as string,
192 size: +req.headers['x-upload-content-length'],
193 originalname: req.body.filename
196 const user = res.locals.oauth.token.User
197 const cleanup = () => cleanUpReqFiles(req)
199 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
200 parameters: req.body,
201 headers: req.headers,
205 if (areValidationErrors(req, res)) return cleanup()
207 const files = { videofile: [ videoFileMetadata ] }
208 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
210 // multer required unsetting the Content-Type, now we can set it for node-uploadx
211 req.headers['content-type'] = 'application/json; charset=utf-8'
212 // place previewfile in metadata so that uploadx saves it in .META
213 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
219 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
220 isValidVideoIdParam('id'),
225 .custom(isVideoNameValid).withMessage(
226 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
230 .customSanitizer(toIntOrNull)
233 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
234 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
236 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
237 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
238 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
240 // Check if the user who did the request is able to update the video
241 const user = res.locals.oauth.token.User
242 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
244 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
250 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
251 const video = getVideoWithAttributes(res)
253 // Anybody can watch local videos
254 if (video.isOwned() === true) return next()
257 if (res.locals.oauth) {
258 // Users can search or watch remote videos
259 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
262 // Anybody can search or watch remote videos
263 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
265 // Check our instance follows an actor that shared this video
266 const serverActor = await getServerActor()
267 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
270 status: HttpStatusCode.FORBIDDEN_403,
271 message: 'Cannot get this video regarding follow constraints',
272 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
279 const videosCustomGetValidator = (
280 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
281 authenticateInQuery = false
284 isValidVideoIdParam('id'),
286 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
287 logger.debug('Checking videosGet parameters', { parameters: req.params })
289 if (areValidationErrors(req, res)) return
290 if (!await doesVideoExist(req.params.id, res, fetchType)) return
292 // Controllers does not need to check video rights
293 if (fetchType === 'only-immutable-attributes') return next()
295 const video = getVideoWithAttributes(res) as MVideoFullLight
297 if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return
304 const videosGetValidator = videosCustomGetValidator('all')
305 const videosDownloadValidator = videosCustomGetValidator('all', true)
307 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
308 isValidVideoIdParam('id'),
311 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
313 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
314 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
316 if (areValidationErrors(req, res)) return
317 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
323 const videosRemoveValidator = [
324 isValidVideoIdParam('id'),
326 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
327 logger.debug('Checking videosRemove parameters', { parameters: req.params })
329 if (areValidationErrors(req, res)) return
330 if (!await doesVideoExist(req.params.id, res)) return
332 // Check if the user who did the request is able to delete the video
333 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
339 const videosOverviewValidator = [
342 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
344 (req: express.Request, res: express.Response, next: express.NextFunction) => {
345 if (areValidationErrors(req, res)) return
351 function getCommonVideoEditAttributes () {
353 body('thumbnailfile')
354 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
355 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
356 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
359 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
360 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
361 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
366 .customSanitizer(toIntOrNull)
367 .custom(isVideoCategoryValid),
370 .customSanitizer(toIntOrNull)
371 .custom(isVideoLicenceValid),
374 .customSanitizer(toValueOrNull)
375 .custom(isVideoLanguageValid),
378 .customSanitizer(toBooleanOrNull)
379 .custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
380 body('waitTranscoding')
382 .customSanitizer(toBooleanOrNull)
383 .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
386 .customSanitizer(toValueOrNull)
387 .custom(isVideoPrivacyValid),
390 .customSanitizer(toValueOrNull)
391 .custom(isVideoDescriptionValid),
394 .customSanitizer(toValueOrNull)
395 .custom(isVideoSupportValid),
398 .customSanitizer(toValueOrNull)
399 .custom(areVideoTagsValid)
401 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
402 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
404 body('commentsEnabled')
406 .customSanitizer(toBooleanOrNull)
407 .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'),
408 body('downloadEnabled')
410 .customSanitizer(toBooleanOrNull)
411 .custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
412 body('originallyPublishedAt')
414 .customSanitizer(toValueOrNull)
415 .custom(isVideoOriginallyPublishedAtValid),
416 body('scheduleUpdate')
418 .customSanitizer(toValueOrNull),
419 body('scheduleUpdate.updateAt')
421 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
422 body('scheduleUpdate.privacy')
424 .customSanitizer(toIntOrNull)
425 .custom(isScheduleVideoUpdatePrivacyValid)
426 ] as (ValidationChain | ExpressPromiseHandler)[]
429 const commonVideosFiltersValidator = [
430 query('categoryOneOf')
432 .customSanitizer(toArray)
433 .custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
434 query('licenceOneOf')
436 .customSanitizer(toArray)
437 .custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
438 query('languageOneOf')
440 .customSanitizer(toArray)
441 .custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
442 query('privacyOneOf')
444 .customSanitizer(toArray)
445 .custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
448 .customSanitizer(toArray)
449 .custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
452 .customSanitizer(toArray)
453 .custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
456 .custom(isBooleanBothQueryValid),
459 .customSanitizer(toBooleanOrNull)
460 .custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
463 .custom(isVideoFilterValid),
466 .custom(isVideoIncludeValid),
469 .customSanitizer(toBooleanOrNull)
470 .custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
473 .customSanitizer(toBooleanOrNull)
474 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
475 query('hasWebtorrentFiles')
477 .customSanitizer(toBooleanOrNull)
478 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'),
481 .customSanitizer(toBooleanOrNull)
482 .custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
487 (req: express.Request, res: express.Response, next: express.NextFunction) => {
488 logger.debug('Checking commons video filters query', { parameters: req.query })
490 if (areValidationErrors(req, res)) return
492 // FIXME: deprecated in 4.0, to remove
494 if (req.query.filter === 'all-local') {
495 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
496 req.query.isLocal = true
497 req.query.privacyOneOf = getAllPrivacies()
498 } else if (req.query.filter === 'all') {
499 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
500 req.query.privacyOneOf = getAllPrivacies()
501 } else if (req.query.filter === 'local') {
502 req.query.isLocal = true
505 req.query.filter = undefined
508 const user = res.locals.oauth?.token.User
510 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
511 if (req.query.include || req.query.privacyOneOf) {
513 status: HttpStatusCode.UNAUTHORIZED_401,
514 message: 'You are not allowed to see all videos.'
523 // ---------------------------------------------------------------------------
526 videosAddLegacyValidator,
527 videosAddResumableValidator,
528 videosAddResumableInitValidator,
530 videosUpdateValidator,
532 videoFileMetadataGetValidator,
533 videosDownloadValidator,
534 checkVideoFollowConstraints,
535 videosCustomGetValidator,
536 videosRemoveValidator,
538 getCommonVideoEditAttributes,
540 commonVideosFiltersValidator,
542 videosOverviewValidator
545 // ---------------------------------------------------------------------------
547 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
548 if (req.body.scheduleUpdate) {
549 if (!req.body.scheduleUpdate.updateAt) {
550 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
552 res.fail({ message: 'Schedule update at is mandatory.' })
560 async function commonVideoChecksPass (parameters: {
562 res: express.Response
564 videoFileSize: number
565 files: express.UploadFilesForCheck
566 }): Promise<boolean> {
567 const { req, res, user, videoFileSize, files } = parameters
569 if (areErrorsInScheduleUpdate(req, res)) return false
571 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
573 if (!isVideoFileMimeTypeValid(files)) {
575 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
576 message: 'This file is not supported. Please, make sure it is of the following type: ' +
577 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
582 if (!isVideoFileSizeValid(videoFileSize.toString())) {
584 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
585 message: 'This file is too large. It exceeds the maximum file size authorized.',
586 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
591 if (await checkUserQuota(user, videoFileSize, res) === false) return false
596 export async function isVideoAccepted (
597 req: express.Request,
598 res: express.Response,
599 videoFile: express.VideoUploadFile
601 // Check we accept this video
602 const acceptParameters = {
605 user: res.locals.oauth.token.User
607 const acceptedResult = await Hooks.wrapFun(
608 isLocalVideoAccepted,
610 'filter:api.video.upload.accept.result'
613 if (!acceptedResult || acceptedResult.accepted !== true) {
614 logger.info('Refused local video.', { acceptedResult, acceptParameters })
616 status: HttpStatusCode.FORBIDDEN_403,
617 message: acceptedResult.errorMessage || 'Refused local video'
625 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
626 const duration = await getVideoStreamDuration(videoFile.path)
628 // FFmpeg may not be able to guess video duration
629 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
630 if (isNaN(duration)) videoFile.duration = 0
631 else videoFile.duration = duration