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 } 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'
52 checkUserCanManageVideo,
54 doesVideoChannelOfAccountExist,
56 doesVideoFileOfVideoExist,
60 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
62 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
63 .withMessage('Should have a file'),
66 .custom(isVideoNameValid).withMessage(
67 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
70 .customSanitizer(toIntOrNull)
73 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
74 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
76 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
77 const user = res.locals.oauth.token.User
79 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
80 return cleanUpReqFiles(req)
84 if (!videoFile.duration) await addDurationToVideo(videoFile)
86 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
89 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
90 message: 'Video file unreadable.'
92 return cleanUpReqFiles(req)
95 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
102 * Gets called after the last PUT request
104 const videosAddResumableValidator = [
105 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
106 const user = res.locals.oauth.token.User
107 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
108 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
109 const cleanup = () => deleteFileAndCatch(file.path)
111 const uploadId = req.query.upload_id
112 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
115 const sessionResponse = await Redis.Instance.getUploadSession(uploadId)
117 if (!sessionResponse) {
118 res.setHeader('Retry-After', 300) // ask to retry after 5 min, knowing the upload_id is kept for up to 15 min after completion
121 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
122 message: 'The upload is already being processed'
126 if (isTestInstance()) {
127 res.setHeader('x-resumable-upload-cached', 'true')
130 return res.json(sessionResponse)
133 await Redis.Instance.setUploadSession(uploadId)
135 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
138 if (!file.duration) await addDurationToVideo(file)
140 logger.error('Invalid input file in videosAddResumableValidator.', { err })
143 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
144 message: 'Video file unreadable.'
149 if (!await isVideoAccepted(req, res, file)) return cleanup()
151 res.locals.videoFileResumable = { ...file, originalname: file.filename }
158 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
159 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
161 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
162 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
165 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
170 .custom(isVideoNameValid).withMessage(
171 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
174 .customSanitizer(toIntOrNull)
177 header('x-upload-content-length')
180 .withMessage('Should specify the file length'),
181 header('x-upload-content-type')
184 .withMessage('Should specify the file mimetype'),
186 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
187 const videoFileMetadata = {
188 mimetype: req.headers['x-upload-content-type'] as string,
189 size: +req.headers['x-upload-content-length'],
190 originalname: req.body.filename
193 const user = res.locals.oauth.token.User
194 const cleanup = () => cleanUpReqFiles(req)
196 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
197 parameters: req.body,
198 headers: req.headers,
202 if (areValidationErrors(req, res, { omitLog: true })) return cleanup()
204 const files = { videofile: [ videoFileMetadata ] }
205 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
207 // multer required unsetting the Content-Type, now we can set it for node-uploadx
208 req.headers['content-type'] = 'application/json; charset=utf-8'
209 // place previewfile in metadata so that uploadx saves it in .META
210 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
216 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
217 isValidVideoIdParam('id'),
222 .custom(isVideoNameValid).withMessage(
223 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
227 .customSanitizer(toIntOrNull)
230 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
231 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
232 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
233 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
235 // Check if the user who did the request is able to update the video
236 const user = res.locals.oauth.token.User
237 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
239 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
245 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
246 const video = getVideoWithAttributes(res)
248 // Anybody can watch local videos
249 if (video.isOwned() === true) return next()
252 if (res.locals.oauth) {
253 // Users can search or watch remote videos
254 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
257 // Anybody can search or watch remote videos
258 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
260 // Check our instance follows an actor that shared this video
261 const serverActor = await getServerActor()
262 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
265 status: HttpStatusCode.FORBIDDEN_403,
266 message: 'Cannot get this video regarding follow constraints',
267 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
274 const videosCustomGetValidator = (
275 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
276 authenticateInQuery = false
279 isValidVideoIdParam('id'),
281 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
282 if (areValidationErrors(req, res)) return
283 if (!await doesVideoExist(req.params.id, res, fetchType)) return
285 // Controllers does not need to check video rights
286 if (fetchType === 'only-immutable-attributes') return next()
288 const video = getVideoWithAttributes(res) as MVideoFullLight
290 if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return
297 const videosGetValidator = videosCustomGetValidator('all')
298 const videosDownloadValidator = videosCustomGetValidator('all', true)
300 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
301 isValidVideoIdParam('id'),
304 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
306 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
307 if (areValidationErrors(req, res)) return
308 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
314 const videosRemoveValidator = [
315 isValidVideoIdParam('id'),
317 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
318 if (areValidationErrors(req, res)) return
319 if (!await doesVideoExist(req.params.id, res)) return
321 // Check if the user who did the request is able to delete the video
322 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
328 const videosOverviewValidator = [
331 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
333 (req: express.Request, res: express.Response, next: express.NextFunction) => {
334 if (areValidationErrors(req, res)) return
340 function getCommonVideoEditAttributes () {
342 body('thumbnailfile')
343 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
344 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
345 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
348 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
349 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
350 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
355 .customSanitizer(toIntOrNull)
356 .custom(isVideoCategoryValid),
359 .customSanitizer(toIntOrNull)
360 .custom(isVideoLicenceValid),
363 .customSanitizer(toValueOrNull)
364 .custom(isVideoLanguageValid),
367 .customSanitizer(toBooleanOrNull)
368 .custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
369 body('waitTranscoding')
371 .customSanitizer(toBooleanOrNull)
372 .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
375 .customSanitizer(toValueOrNull)
376 .custom(isVideoPrivacyValid),
379 .customSanitizer(toValueOrNull)
380 .custom(isVideoDescriptionValid),
383 .customSanitizer(toValueOrNull)
384 .custom(isVideoSupportValid),
387 .customSanitizer(toValueOrNull)
388 .custom(areVideoTagsValid)
390 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
391 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
393 body('commentsEnabled')
395 .customSanitizer(toBooleanOrNull)
396 .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'),
397 body('downloadEnabled')
399 .customSanitizer(toBooleanOrNull)
400 .custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
401 body('originallyPublishedAt')
403 .customSanitizer(toValueOrNull)
404 .custom(isVideoOriginallyPublishedAtValid),
405 body('scheduleUpdate')
407 .customSanitizer(toValueOrNull),
408 body('scheduleUpdate.updateAt')
410 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
411 body('scheduleUpdate.privacy')
413 .customSanitizer(toIntOrNull)
414 .custom(isScheduleVideoUpdatePrivacyValid)
415 ] as (ValidationChain | ExpressPromiseHandler)[]
418 const commonVideosFiltersValidator = [
419 query('categoryOneOf')
421 .customSanitizer(arrayify)
422 .custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
423 query('licenceOneOf')
425 .customSanitizer(arrayify)
426 .custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
427 query('languageOneOf')
429 .customSanitizer(arrayify)
430 .custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
431 query('privacyOneOf')
433 .customSanitizer(arrayify)
434 .custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
437 .customSanitizer(arrayify)
438 .custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
441 .customSanitizer(arrayify)
442 .custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
445 .custom(isBooleanBothQueryValid),
448 .customSanitizer(toBooleanOrNull)
449 .custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
452 .custom(isVideoFilterValid),
455 .custom(isVideoIncludeValid),
458 .customSanitizer(toBooleanOrNull)
459 .custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
462 .customSanitizer(toBooleanOrNull)
463 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
464 query('hasWebtorrentFiles')
466 .customSanitizer(toBooleanOrNull)
467 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'),
470 .customSanitizer(toBooleanOrNull)
471 .custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
476 (req: express.Request, res: express.Response, next: express.NextFunction) => {
477 if (areValidationErrors(req, res)) return
479 // FIXME: deprecated in 4.0, to remove
481 if (req.query.filter === 'all-local') {
482 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
483 req.query.isLocal = true
484 req.query.privacyOneOf = getAllPrivacies()
485 } else if (req.query.filter === 'all') {
486 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
487 req.query.privacyOneOf = getAllPrivacies()
488 } else if (req.query.filter === 'local') {
489 req.query.isLocal = true
492 req.query.filter = undefined
495 const user = res.locals.oauth?.token.User
497 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
498 if (req.query.include || req.query.privacyOneOf) {
500 status: HttpStatusCode.UNAUTHORIZED_401,
501 message: 'You are not allowed to see all videos.'
510 // ---------------------------------------------------------------------------
513 videosAddLegacyValidator,
514 videosAddResumableValidator,
515 videosAddResumableInitValidator,
517 videosUpdateValidator,
519 videoFileMetadataGetValidator,
520 videosDownloadValidator,
521 checkVideoFollowConstraints,
522 videosCustomGetValidator,
523 videosRemoveValidator,
525 getCommonVideoEditAttributes,
527 commonVideosFiltersValidator,
529 videosOverviewValidator
532 // ---------------------------------------------------------------------------
534 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
535 if (req.body.scheduleUpdate) {
536 if (!req.body.scheduleUpdate.updateAt) {
537 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
539 res.fail({ message: 'Schedule update at is mandatory.' })
547 async function commonVideoChecksPass (parameters: {
549 res: express.Response
551 videoFileSize: number
552 files: express.UploadFilesForCheck
553 }): Promise<boolean> {
554 const { req, res, user, videoFileSize, files } = parameters
556 if (areErrorsInScheduleUpdate(req, res)) return false
558 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
560 if (!isVideoFileMimeTypeValid(files)) {
562 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
563 message: 'This file is not supported. Please, make sure it is of the following type: ' +
564 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
569 if (!isVideoFileSizeValid(videoFileSize.toString())) {
571 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
572 message: 'This file is too large. It exceeds the maximum file size authorized.',
573 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
578 if (await checkUserQuota(user, videoFileSize, res) === false) return false
583 export async function isVideoAccepted (
584 req: express.Request,
585 res: express.Response,
586 videoFile: express.VideoUploadFile
588 // Check we accept this video
589 const acceptParameters = {
592 user: res.locals.oauth.token.User
594 const acceptedResult = await Hooks.wrapFun(
595 isLocalVideoAccepted,
597 'filter:api.video.upload.accept.result'
600 if (!acceptedResult || acceptedResult.accepted !== true) {
601 logger.info('Refused local video.', { acceptedResult, acceptParameters })
603 status: HttpStatusCode.FORBIDDEN_403,
604 message: acceptedResult.errorMessage || 'Refused local video'
612 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
613 const duration = await getVideoStreamDuration(videoFile.path)
615 // FFmpeg may not be able to guess video duration
616 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
617 if (isNaN(duration)) videoFile.duration = 0
618 else videoFile.duration = duration