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)
69 .withMessage('Should have a valid name'),
71 .customSanitizer(toIntOrNull)
72 .custom(isIdValid).withMessage('Should have correct video channel id'),
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 })
90 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
91 .json({ error: '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
109 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
110 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
112 const cleanup = () => deleteFileAndCatch(file.path)
114 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
117 if (!file.duration) await addDurationToVideo(file)
119 logger.error('Invalid input file in videosAddResumableValidator.', { err })
120 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
121 .json({ error: 'Video file unreadable.' })
126 if (!await isVideoAccepted(req, res, file)) return cleanup()
128 res.locals.videoFileResumable = file
135 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
136 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
138 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
139 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
142 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
146 .withMessage('Should have a valid filename'),
149 .custom(isVideoNameValid)
150 .withMessage('Should have a valid name'),
152 .customSanitizer(toIntOrNull)
153 .custom(isIdValid).withMessage('Should have correct video channel id'),
155 header('x-upload-content-length')
158 .withMessage('Should specify the file length'),
159 header('x-upload-content-type')
162 .withMessage('Should specify the file mimetype'),
164 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
165 const videoFileMetadata = {
166 mimetype: req.headers['x-upload-content-type'] as string,
167 size: +req.headers['x-upload-content-length'],
168 originalname: req.body.name
171 const user = res.locals.oauth.token.User
172 const cleanup = () => cleanUpReqFiles(req)
174 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
175 parameters: req.body,
176 headers: req.headers,
180 if (areValidationErrors(req, res)) return cleanup()
182 const files = { videofile: [ videoFileMetadata ] }
183 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
185 // multer required unsetting the Content-Type, now we can set it for node-uploadx
186 req.headers['content-type'] = 'application/json; charset=utf-8'
187 // place previewfile in metadata so that uploadx saves it in .META
188 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
194 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
195 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
199 .custom(isVideoNameValid).withMessage('Should have a valid name'),
202 .customSanitizer(toIntOrNull)
203 .custom(isIdValid).withMessage('Should have correct video channel id'),
205 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
206 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
208 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
209 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
210 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
212 // Check if the user who did the request is able to update the video
213 const user = res.locals.oauth.token.User
214 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
216 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
222 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
223 const video = getVideoWithAttributes(res)
225 // Anybody can watch local videos
226 if (video.isOwned() === true) return next()
229 if (res.locals.oauth) {
230 // Users can search or watch remote videos
231 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
234 // Anybody can search or watch remote videos
235 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
237 // Check our instance follows an actor that shared this video
238 const serverActor = await getServerActor()
239 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
241 return res.status(HttpStatusCode.FORBIDDEN_403)
243 errorCode: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
244 error: 'Cannot get this video regarding follow constraints.',
249 const videosCustomGetValidator = (
250 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
251 authenticateInQuery = false
254 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
256 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
257 logger.debug('Checking videosGet parameters', { parameters: req.params })
259 if (areValidationErrors(req, res)) return
260 if (!await doesVideoExist(req.params.id, res, fetchType)) return
262 // Controllers does not need to check video rights
263 if (fetchType === 'only-immutable-attributes') return next()
265 const video = getVideoWithAttributes(res) as MVideoWithRights
267 // Video private or blacklisted
268 if (video.requiresAuth()) {
269 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
271 const user = res.locals.oauth ? res.locals.oauth.token.User : null
273 // Only the owner or a user that have blacklist rights can see the video
274 if (!user || !user.canGetVideo(video)) {
275 return res.status(HttpStatusCode.FORBIDDEN_403)
276 .json({ error: 'Cannot get this private/internal or blacklisted video.' })
282 // Video is public, anyone can access it
283 if (video.privacy === VideoPrivacy.PUBLIC) return next()
285 // Video is unlisted, check we used the uuid to fetch it
286 if (video.privacy === VideoPrivacy.UNLISTED) {
287 if (isUUIDValid(req.params.id)) return next()
289 // Don't leak this unlisted video
290 return res.status(HttpStatusCode.NOT_FOUND_404).end()
296 const videosGetValidator = videosCustomGetValidator('all')
297 const videosDownloadValidator = videosCustomGetValidator('all', true)
299 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
300 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
301 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
303 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
304 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
306 if (areValidationErrors(req, res)) return
307 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
313 const videosRemoveValidator = [
314 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
316 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
317 logger.debug('Checking videosRemove parameters', { parameters: req.params })
319 if (areValidationErrors(req, res)) return
320 if (!await doesVideoExist(req.params.id, res)) return
322 // Check if the user who did the request is able to delete the video
323 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
329 const videosChangeOwnershipValidator = [
330 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
332 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
333 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
335 if (areValidationErrors(req, res)) return
336 if (!await doesVideoExist(req.params.videoId, res)) return
338 // Check if the user who did the request is able to change the ownership of the video
339 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
341 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
343 res.status(HttpStatusCode.BAD_REQUEST_400)
344 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
348 res.locals.nextOwner = nextOwner
354 const videosTerminateChangeOwnershipValidator = [
355 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
357 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
358 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
360 if (areValidationErrors(req, res)) return
361 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
363 // Check if the user who did the request is able to change the ownership of the video
364 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
366 const videoChangeOwnership = res.locals.videoChangeOwnership
368 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
369 res.status(HttpStatusCode.FORBIDDEN_403)
370 .json({ error: 'Ownership already accepted or refused' })
378 const videosAcceptChangeOwnershipValidator = [
379 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
380 const body = req.body as VideoChangeOwnershipAccept
381 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
383 const user = res.locals.oauth.token.User
384 const videoChangeOwnership = res.locals.videoChangeOwnership
385 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
386 if (isAble === false) {
387 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
388 .json({ error: 'The user video quota is exceeded with this video.' })
397 const videosOverviewValidator = [
400 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
401 .withMessage('Should have a valid pagination'),
403 (req: express.Request, res: express.Response, next: express.NextFunction) => {
404 if (areValidationErrors(req, res)) return
410 function getCommonVideoEditAttributes () {
412 body('thumbnailfile')
413 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
414 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
415 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
418 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
419 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
420 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
425 .customSanitizer(toIntOrNull)
426 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
429 .customSanitizer(toIntOrNull)
430 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
433 .customSanitizer(toValueOrNull)
434 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
437 .customSanitizer(toBooleanOrNull)
438 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
439 body('waitTranscoding')
441 .customSanitizer(toBooleanOrNull)
442 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
445 .customSanitizer(toValueOrNull)
446 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
449 .customSanitizer(toValueOrNull)
450 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
453 .customSanitizer(toValueOrNull)
454 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
457 .customSanitizer(toValueOrNull)
458 .custom(isVideoTagsValid).withMessage('Should have correct tags'),
459 body('commentsEnabled')
461 .customSanitizer(toBooleanOrNull)
462 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
463 body('downloadEnabled')
465 .customSanitizer(toBooleanOrNull)
466 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
467 body('originallyPublishedAt')
469 .customSanitizer(toValueOrNull)
470 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
471 body('scheduleUpdate')
473 .customSanitizer(toValueOrNull),
474 body('scheduleUpdate.updateAt')
476 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
477 body('scheduleUpdate.privacy')
479 .customSanitizer(toIntOrNull)
480 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
481 ] as (ValidationChain | ExpressPromiseHandler)[]
484 const commonVideosFiltersValidator = [
485 query('categoryOneOf')
487 .customSanitizer(toArray)
488 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
489 query('licenceOneOf')
491 .customSanitizer(toArray)
492 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
493 query('languageOneOf')
495 .customSanitizer(toArray)
496 .custom(isStringArray).withMessage('Should have a valid one of language array'),
499 .customSanitizer(toArray)
500 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
503 .customSanitizer(toArray)
504 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
507 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
510 .customSanitizer(toBooleanOrNull)
511 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
514 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
517 .customSanitizer(toBooleanOrNull)
518 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
521 .custom(exists).withMessage('Should have a valid search'),
523 (req: express.Request, res: express.Response, next: express.NextFunction) => {
524 logger.debug('Checking commons video filters query', { parameters: req.query })
526 if (areValidationErrors(req, res)) return
528 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
530 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
531 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
533 res.status(HttpStatusCode.UNAUTHORIZED_401)
534 .json({ error: 'You are not allowed to see all local videos.' })
543 // ---------------------------------------------------------------------------
546 videosAddLegacyValidator,
547 videosAddResumableValidator,
548 videosAddResumableInitValidator,
550 videosUpdateValidator,
552 videoFileMetadataGetValidator,
553 videosDownloadValidator,
554 checkVideoFollowConstraints,
555 videosCustomGetValidator,
556 videosRemoveValidator,
558 videosChangeOwnershipValidator,
559 videosTerminateChangeOwnershipValidator,
560 videosAcceptChangeOwnershipValidator,
562 getCommonVideoEditAttributes,
564 commonVideosFiltersValidator,
566 videosOverviewValidator
569 // ---------------------------------------------------------------------------
571 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
572 if (req.body.scheduleUpdate) {
573 if (!req.body.scheduleUpdate.updateAt) {
574 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
576 res.status(HttpStatusCode.BAD_REQUEST_400)
577 .json({ error: 'Schedule update at is mandatory.' })
586 async function commonVideoChecksPass (parameters: {
588 res: express.Response
590 videoFileSize: number
591 files: express.UploadFilesForCheck
592 }): Promise<boolean> {
593 const { req, res, user, videoFileSize, files } = parameters
595 if (areErrorsInScheduleUpdate(req, res)) return false
597 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
599 if (!isVideoFileMimeTypeValid(files)) {
600 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415)
602 error: 'This file is not supported. Please, make sure it is of the following type: ' +
603 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
609 if (!isVideoFileSizeValid(videoFileSize.toString())) {
610 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
611 .json({ error: 'This file is too large. It exceeds the maximum file size authorized.' })
616 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
617 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
618 .json({ error: 'The user video quota is exceeded with this video.' })
626 export async function isVideoAccepted (
627 req: express.Request,
628 res: express.Response,
629 videoFile: express.VideoUploadFile
631 // Check we accept this video
632 const acceptParameters = {
635 user: res.locals.oauth.token.User
637 const acceptedResult = await Hooks.wrapFun(
638 isLocalVideoAccepted,
640 'filter:api.video.upload.accept.result'
643 if (!acceptedResult || acceptedResult.accepted !== true) {
644 logger.info('Refused local video.', { acceptedResult, acceptParameters })
645 res.status(HttpStatusCode.FORBIDDEN_403)
646 .json({ error: acceptedResult.errorMessage || 'Refused local video' })
654 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
655 const duration: number = await getDurationFromVideoFile(videoFile.path)
657 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
659 videoFile.duration = duration