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 (video.isLive && video.privacy !== req.body.privacy && 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'),
492 query('excludeAlreadyWatched')
494 .customSanitizer(toBooleanOrNull)
495 .isBoolean().withMessage('Should be a valid excludeAlreadyWatched boolean'),
497 (req: express.Request, res: express.Response, next: express.NextFunction) => {
498 if (areValidationErrors(req, res)) return
500 // FIXME: deprecated in 4.0, to remove
502 if (req.query.filter === 'all-local') {
503 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
504 req.query.isLocal = true
505 req.query.privacyOneOf = getAllPrivacies()
506 } else if (req.query.filter === 'all') {
507 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
508 req.query.privacyOneOf = getAllPrivacies()
509 } else if (req.query.filter === 'local') {
510 req.query.isLocal = true
513 req.query.filter = undefined
516 const user = res.locals.oauth?.token.User
518 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
519 if (req.query.include || req.query.privacyOneOf) {
521 status: HttpStatusCode.UNAUTHORIZED_401,
522 message: 'You are not allowed to see all videos.'
527 if (!user && exists(req.query.excludeAlreadyWatched)) {
529 status: HttpStatusCode.BAD_REQUEST_400,
530 message: 'Cannot use excludeAlreadyWatched parameter when auth token is not provided'
538 // ---------------------------------------------------------------------------
541 videosAddLegacyValidator,
542 videosAddResumableValidator,
543 videosAddResumableInitValidator,
545 videosUpdateValidator,
547 videoFileMetadataGetValidator,
548 videosDownloadValidator,
549 checkVideoFollowConstraints,
550 videosCustomGetValidator,
551 videosRemoveValidator,
553 getCommonVideoEditAttributes,
555 commonVideosFiltersValidator,
557 videosOverviewValidator
560 // ---------------------------------------------------------------------------
562 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
563 if (req.body.scheduleUpdate) {
564 if (!req.body.scheduleUpdate.updateAt) {
565 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
567 res.fail({ message: 'Schedule update at is mandatory.' })
575 async function commonVideoChecksPass (parameters: {
577 res: express.Response
579 videoFileSize: number
580 files: express.UploadFilesForCheck
581 }): Promise<boolean> {
582 const { req, res, user, videoFileSize, files } = parameters
584 if (areErrorsInScheduleUpdate(req, res)) return false
586 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
588 if (!isVideoFileMimeTypeValid(files)) {
590 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
591 message: 'This file is not supported. Please, make sure it is of the following type: ' +
592 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
597 if (!isVideoFileSizeValid(videoFileSize.toString())) {
599 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
600 message: 'This file is too large. It exceeds the maximum file size authorized.',
601 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
606 if (await checkUserQuota(user, videoFileSize, res) === false) return false
611 export async function isVideoAccepted (
612 req: express.Request,
613 res: express.Response,
614 videoFile: express.VideoUploadFile
616 // Check we accept this video
617 const acceptParameters = {
620 user: res.locals.oauth.token.User
622 const acceptedResult = await Hooks.wrapFun(
623 isLocalVideoAccepted,
625 'filter:api.video.upload.accept.result'
628 if (!acceptedResult || acceptedResult.accepted !== true) {
629 logger.info('Refused local video.', { acceptedResult, acceptParameters })
631 status: HttpStatusCode.FORBIDDEN_403,
632 message: acceptedResult.errorMessage || 'Refused local video'
640 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
641 const duration = await getVideoStreamDuration(videoFile.path)
643 // FFmpeg may not be able to guess video duration
644 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
645 if (isNaN(duration)) videoFile.duration = 0
646 else videoFile.duration = duration