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 { arrayify, getAllPrivacies } from '@shared/core-utils'
10 import { HttpStatusCode, ServerErrorCode, UserRight, VideoInclude, VideoState } from '@shared/models'
20 } from '../../../helpers/custom-validators/misc'
21 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
24 isScheduleVideoUpdatePrivacyValid,
26 isVideoDescriptionValid,
27 isVideoFileMimeTypeValid,
35 isVideoOriginallyPublishedAtValid,
38 } from '../../../helpers/custom-validators/videos'
39 import { cleanUpReqFiles } from '../../../helpers/express-utils'
40 import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
41 import { logger } from '../../../helpers/logger'
42 import { deleteFileAndCatch } from '../../../helpers/utils'
43 import { getVideoWithAttributes } from '../../../helpers/video'
44 import { CONFIG } from '../../../initializers/config'
45 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
46 import { isLocalVideoAccepted } from '../../../lib/moderation'
47 import { Hooks } from '../../../lib/plugins/hooks'
48 import { VideoModel } from '../../../models/video/video'
51 checkCanAccessVideoStaticFiles,
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 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
77 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
78 const user = res.locals.oauth.token.User
80 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
81 return cleanUpReqFiles(req)
85 if (!videoFile.duration) await addDurationToVideo(videoFile)
87 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
90 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
91 message: 'Video file unreadable.'
93 return cleanUpReqFiles(req)
96 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
103 * Gets called after the last PUT request
105 const videosAddResumableValidator = [
106 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
107 const user = res.locals.oauth.token.User
108 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
109 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
110 const cleanup = () => deleteFileAndCatch(file.path)
112 const uploadId = req.query.upload_id
113 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
116 const sessionResponse = await Redis.Instance.getUploadSession(uploadId)
118 if (!sessionResponse) {
119 res.setHeader('Retry-After', 300) // ask to retry after 5 min, knowing the upload_id is kept for up to 15 min after completion
122 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
123 message: 'The upload is already being processed'
127 if (isTestInstance()) {
128 res.setHeader('x-resumable-upload-cached', 'true')
131 return res.json(sessionResponse)
134 await Redis.Instance.setUploadSession(uploadId)
136 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
139 if (!file.duration) await addDurationToVideo(file)
141 logger.error('Invalid input file in videosAddResumableValidator.', { err })
144 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
145 message: 'Video file unreadable.'
150 if (!await isVideoAccepted(req, res, file)) return cleanup()
152 res.locals.videoFileResumable = { ...file, originalname: file.filename }
159 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
160 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
162 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
163 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
166 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
171 .custom(isVideoNameValid).withMessage(
172 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
175 .customSanitizer(toIntOrNull)
178 header('x-upload-content-length')
181 .withMessage('Should specify the file length'),
182 header('x-upload-content-type')
185 .withMessage('Should specify the file mimetype'),
187 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
188 const videoFileMetadata = {
189 mimetype: req.headers['x-upload-content-type'] as string,
190 size: +req.headers['x-upload-content-length'],
191 originalname: req.body.filename
194 const user = res.locals.oauth.token.User
195 const cleanup = () => cleanUpReqFiles(req)
197 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
198 parameters: req.body,
199 headers: req.headers,
203 if (areValidationErrors(req, res, { omitLog: true })) return cleanup()
205 const files = { videofile: [ videoFileMetadata ] }
206 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
208 // multer required unsetting the Content-Type, now we can set it for node-uploadx
209 req.headers['content-type'] = 'application/json; charset=utf-8'
210 // place previewfile in metadata so that uploadx saves it in .META
211 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
217 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
218 isValidVideoIdParam('id'),
223 .custom(isVideoNameValid).withMessage(
224 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
228 .customSanitizer(toIntOrNull)
231 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
232 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
233 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
234 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
236 const video = getVideoWithAttributes(res)
237 if (req.body.privacy && video.isLive && video.state !== VideoState.WAITING_FOR_LIVE) {
238 return res.fail({ message: 'Cannot update privacy of a live that has already started' })
241 // Check if the user who did the request is able to update the video
242 const user = res.locals.oauth.token.User
243 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
245 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
251 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
252 const video = getVideoWithAttributes(res)
254 // Anybody can watch local videos
255 if (video.isOwned() === true) return next()
258 if (res.locals.oauth) {
259 // Users can search or watch remote videos
260 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
263 // Anybody can search or watch remote videos
264 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
266 // Check our instance follows an actor that shared this video
267 const serverActor = await getServerActor()
268 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
271 status: HttpStatusCode.FORBIDDEN_403,
272 message: 'Cannot get this video regarding follow constraints',
273 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
280 const videosCustomGetValidator = (fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes') => {
282 isValidVideoIdParam('id'),
284 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
285 if (areValidationErrors(req, res)) return
286 if (!await doesVideoExist(req.params.id, res, fetchType)) return
288 // Controllers does not need to check video rights
289 if (fetchType === 'only-immutable-attributes') return next()
291 const video = getVideoWithAttributes(res) as MVideoFullLight
293 if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id })) return
300 const videosGetValidator = videosCustomGetValidator('all')
302 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
303 isValidVideoIdParam('id'),
306 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
308 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
309 if (areValidationErrors(req, res)) return
310 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
316 const videosDownloadValidator = [
317 isValidVideoIdParam('id'),
319 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
320 if (areValidationErrors(req, res)) return
321 if (!await doesVideoExist(req.params.id, res, 'all')) return
323 const video = getVideoWithAttributes(res)
325 if (!await checkCanAccessVideoStaticFiles({ req, res, video, paramId: req.params.id })) return
331 const videosRemoveValidator = [
332 isValidVideoIdParam('id'),
334 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
335 if (areValidationErrors(req, res)) return
336 if (!await doesVideoExist(req.params.id, res)) return
338 // Check if the user who did the request is able to delete the video
339 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
345 const videosOverviewValidator = [
348 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
350 (req: express.Request, res: express.Response, next: express.NextFunction) => {
351 if (areValidationErrors(req, res)) return
357 function getCommonVideoEditAttributes () {
359 body('thumbnailfile')
360 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
361 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
362 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
365 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
366 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
367 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
372 .customSanitizer(toIntOrNull)
373 .custom(isVideoCategoryValid),
376 .customSanitizer(toIntOrNull)
377 .custom(isVideoLicenceValid),
380 .customSanitizer(toValueOrNull)
381 .custom(isVideoLanguageValid),
384 .customSanitizer(toBooleanOrNull)
385 .custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
386 body('waitTranscoding')
388 .customSanitizer(toBooleanOrNull)
389 .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
392 .customSanitizer(toIntOrNull)
393 .custom(isVideoPrivacyValid),
396 .customSanitizer(toValueOrNull)
397 .custom(isVideoDescriptionValid),
400 .customSanitizer(toValueOrNull)
401 .custom(isVideoSupportValid),
404 .customSanitizer(toValueOrNull)
405 .custom(areVideoTagsValid)
407 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
408 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
410 body('commentsEnabled')
412 .customSanitizer(toBooleanOrNull)
413 .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'),
414 body('downloadEnabled')
416 .customSanitizer(toBooleanOrNull)
417 .custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
418 body('originallyPublishedAt')
420 .customSanitizer(toValueOrNull)
421 .custom(isVideoOriginallyPublishedAtValid),
422 body('scheduleUpdate')
424 .customSanitizer(toValueOrNull),
425 body('scheduleUpdate.updateAt')
427 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
428 body('scheduleUpdate.privacy')
430 .customSanitizer(toIntOrNull)
431 .custom(isScheduleVideoUpdatePrivacyValid)
432 ] as (ValidationChain | ExpressPromiseHandler)[]
435 const commonVideosFiltersValidator = [
436 query('categoryOneOf')
438 .customSanitizer(arrayify)
439 .custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
440 query('licenceOneOf')
442 .customSanitizer(arrayify)
443 .custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
444 query('languageOneOf')
446 .customSanitizer(arrayify)
447 .custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
448 query('privacyOneOf')
450 .customSanitizer(arrayify)
451 .custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
454 .customSanitizer(arrayify)
455 .custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
458 .customSanitizer(arrayify)
459 .custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
462 .custom(isBooleanBothQueryValid),
465 .customSanitizer(toBooleanOrNull)
466 .custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
469 .custom(isVideoFilterValid),
472 .custom(isVideoIncludeValid),
475 .customSanitizer(toBooleanOrNull)
476 .custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
479 .customSanitizer(toBooleanOrNull)
480 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
481 query('hasWebtorrentFiles')
483 .customSanitizer(toBooleanOrNull)
484 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'),
487 .customSanitizer(toBooleanOrNull)
488 .custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
493 (req: express.Request, res: express.Response, next: express.NextFunction) => {
494 if (areValidationErrors(req, res)) return
496 // FIXME: deprecated in 4.0, to remove
498 if (req.query.filter === 'all-local') {
499 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
500 req.query.isLocal = true
501 req.query.privacyOneOf = getAllPrivacies()
502 } else if (req.query.filter === 'all') {
503 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
504 req.query.privacyOneOf = getAllPrivacies()
505 } else if (req.query.filter === 'local') {
506 req.query.isLocal = true
509 req.query.filter = undefined
512 const user = res.locals.oauth?.token.User
514 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
515 if (req.query.include || req.query.privacyOneOf) {
517 status: HttpStatusCode.UNAUTHORIZED_401,
518 message: 'You are not allowed to see all videos.'
527 // ---------------------------------------------------------------------------
530 videosAddLegacyValidator,
531 videosAddResumableValidator,
532 videosAddResumableInitValidator,
534 videosUpdateValidator,
536 videoFileMetadataGetValidator,
537 videosDownloadValidator,
538 checkVideoFollowConstraints,
539 videosCustomGetValidator,
540 videosRemoveValidator,
542 getCommonVideoEditAttributes,
544 commonVideosFiltersValidator,
546 videosOverviewValidator
549 // ---------------------------------------------------------------------------
551 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
552 if (req.body.scheduleUpdate) {
553 if (!req.body.scheduleUpdate.updateAt) {
554 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
556 res.fail({ message: 'Schedule update at is mandatory.' })
564 async function commonVideoChecksPass (parameters: {
566 res: express.Response
568 videoFileSize: number
569 files: express.UploadFilesForCheck
570 }): Promise<boolean> {
571 const { req, res, user, videoFileSize, files } = parameters
573 if (areErrorsInScheduleUpdate(req, res)) return false
575 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
577 if (!isVideoFileMimeTypeValid(files)) {
579 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
580 message: 'This file is not supported. Please, make sure it is of the following type: ' +
581 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
586 if (!isVideoFileSizeValid(videoFileSize.toString())) {
588 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
589 message: 'This file is too large. It exceeds the maximum file size authorized.',
590 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
595 if (await checkUserQuota(user, videoFileSize, res) === false) return false
600 export async function isVideoAccepted (
601 req: express.Request,
602 res: express.Response,
603 videoFile: express.VideoUploadFile
605 // Check we accept this video
606 const acceptParameters = {
609 user: res.locals.oauth.token.User
611 const acceptedResult = await Hooks.wrapFun(
612 isLocalVideoAccepted,
614 'filter:api.video.upload.accept.result'
617 if (!acceptedResult || acceptedResult.accepted !== true) {
618 logger.info('Refused local video.', { acceptedResult, acceptParameters })
620 status: HttpStatusCode.FORBIDDEN_403,
621 message: acceptedResult.errorMessage || 'Refused local video'
629 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
630 const duration = await getVideoStreamDuration(videoFile.path)
632 // FFmpeg may not be able to guess video duration
633 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
634 if (isNaN(duration)) videoFile.duration = 0
635 else videoFile.duration = duration