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 } 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'
45 import { deleteFileAndCatch } from '../../../helpers/utils'
46 import { getVideoWithAttributes } from '../../../helpers/video'
47 import { CONFIG } from '../../../initializers/config'
48 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
49 import { isLocalVideoAccepted } from '../../../lib/moderation'
50 import { Hooks } from '../../../lib/plugins/hooks'
51 import { AccountModel } from '../../../models/account/account'
52 import { VideoModel } from '../../../models/video/video'
53 import { authenticatePromiseIfNeeded } from '../../auth'
56 checkUserCanManageVideo,
57 doesChangeVideoOwnershipExist,
58 doesVideoChannelOfAccountExist,
60 doesVideoFileOfVideoExist
63 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
65 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
66 .withMessage('Should have a file'),
69 .custom(isVideoNameValid).withMessage(
70 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
73 .customSanitizer(toIntOrNull)
74 .custom(isIdValid).withMessage('Should have correct video channel id'),
76 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
77 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
79 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
81 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
82 const user = res.locals.oauth.token.User
84 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
85 return cleanUpReqFiles(req)
89 if (!videoFile.duration) await addDurationToVideo(videoFile)
91 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
94 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
95 message: 'Video file unreadable.'
97 return cleanUpReqFiles(req)
100 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
107 * Gets called after the last PUT request
109 const videosAddResumableValidator = [
110 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
111 const user = res.locals.oauth.token.User
113 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
114 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
116 const cleanup = () => deleteFileAndCatch(file.path)
118 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
121 if (!file.duration) await addDurationToVideo(file)
123 logger.error('Invalid input file in videosAddResumableValidator.', { err })
126 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
127 message: 'Video file unreadable.'
132 if (!await isVideoAccepted(req, res, file)) return cleanup()
134 res.locals.videoFileResumable = file
141 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
142 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
144 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
145 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
148 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
152 .withMessage('Should have a valid filename'),
155 .custom(isVideoNameValid).withMessage(
156 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
159 .customSanitizer(toIntOrNull)
160 .custom(isIdValid).withMessage('Should have correct video channel id'),
162 header('x-upload-content-length')
165 .withMessage('Should specify the file length'),
166 header('x-upload-content-type')
169 .withMessage('Should specify the file mimetype'),
171 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
172 const videoFileMetadata = {
173 mimetype: req.headers['x-upload-content-type'] as string,
174 size: +req.headers['x-upload-content-length'],
175 originalname: req.body.name
178 const user = res.locals.oauth.token.User
179 const cleanup = () => cleanUpReqFiles(req)
181 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
182 parameters: req.body,
183 headers: req.headers,
187 if (areValidationErrors(req, res)) return cleanup()
189 const files = { videofile: [ videoFileMetadata ] }
190 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
192 // multer required unsetting the Content-Type, now we can set it for node-uploadx
193 req.headers['content-type'] = 'application/json; charset=utf-8'
194 // place previewfile in metadata so that uploadx saves it in .META
195 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
201 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
202 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
206 .custom(isVideoNameValid).withMessage(
207 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
211 .customSanitizer(toIntOrNull)
212 .custom(isIdValid).withMessage('Should have correct video channel id'),
214 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
215 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
217 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
218 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
219 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
221 // Check if the user who did the request is able to update the video
222 const user = res.locals.oauth.token.User
223 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
225 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
231 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
232 const video = getVideoWithAttributes(res)
234 // Anybody can watch local videos
235 if (video.isOwned() === true) return next()
238 if (res.locals.oauth) {
239 // Users can search or watch remote videos
240 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
243 // Anybody can search or watch remote videos
244 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
246 // Check our instance follows an actor that shared this video
247 const serverActor = await getServerActor()
248 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
251 status: HttpStatusCode.FORBIDDEN_403,
252 message: 'Cannot get this video regarding follow constraints',
253 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
260 const videosCustomGetValidator = (
261 fetchType: 'for-api' | 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
262 authenticateInQuery = false
265 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
267 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
268 logger.debug('Checking videosGet parameters', { parameters: req.params })
270 if (areValidationErrors(req, res)) return
271 if (!await doesVideoExist(req.params.id, res, fetchType)) return
273 // Controllers does not need to check video rights
274 if (fetchType === 'only-immutable-attributes') return next()
276 const video = getVideoWithAttributes(res) as MVideoWithRights
278 // Video private or blacklisted
279 if (video.requiresAuth()) {
280 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
282 const user = res.locals.oauth ? res.locals.oauth.token.User : null
284 // Only the owner or a user that have blocklist rights can see the video
285 if (!user || !user.canGetVideo(video)) {
287 status: HttpStatusCode.FORBIDDEN_403,
288 message: 'Cannot get this private/internal or blocklisted video'
295 // Video is public, anyone can access it
296 if (video.privacy === VideoPrivacy.PUBLIC) return next()
298 // Video is unlisted, check we used the uuid to fetch it
299 if (video.privacy === VideoPrivacy.UNLISTED) {
300 if (isUUIDValid(req.params.id)) return next()
302 // Don't leak this unlisted video
304 status: HttpStatusCode.NOT_FOUND_404,
305 message: 'Video not found'
312 const videosGetValidator = videosCustomGetValidator('all')
313 const videosDownloadValidator = videosCustomGetValidator('all', true)
315 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
316 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
317 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
319 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
320 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
322 if (areValidationErrors(req, res)) return
323 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
329 const videosRemoveValidator = [
330 param('id').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 videosRemove parameters', { parameters: req.params })
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 videosChangeOwnershipValidator = [
346 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
348 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
349 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
351 if (areValidationErrors(req, res)) return
352 if (!await doesVideoExist(req.params.videoId, res)) return
354 // Check if the user who did the request is able to change the ownership of the video
355 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
357 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
359 res.fail({ message: 'Changing video ownership to a remote account is not supported yet' })
363 res.locals.nextOwner = nextOwner
368 const videosTerminateChangeOwnershipValidator = [
369 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
371 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
372 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
374 if (areValidationErrors(req, res)) return
375 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
377 // Check if the user who did the request is able to change the ownership of the video
378 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
380 const videoChangeOwnership = res.locals.videoChangeOwnership
382 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
384 status: HttpStatusCode.FORBIDDEN_403,
385 message: 'Ownership already accepted or refused'
394 const videosAcceptChangeOwnershipValidator = [
395 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
396 const body = req.body as VideoChangeOwnershipAccept
397 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
399 const user = res.locals.oauth.token.User
400 const videoChangeOwnership = res.locals.videoChangeOwnership
401 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
402 if (isAble === false) {
404 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
405 message: 'The user video quota is exceeded with this video.',
406 type: ServerErrorCode.QUOTA_REACHED
415 const videosOverviewValidator = [
418 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
419 .withMessage('Should have a valid pagination'),
421 (req: express.Request, res: express.Response, next: express.NextFunction) => {
422 if (areValidationErrors(req, res)) return
428 function getCommonVideoEditAttributes () {
430 body('thumbnailfile')
431 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
432 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
433 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
436 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
437 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
438 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
443 .customSanitizer(toIntOrNull)
444 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
447 .customSanitizer(toIntOrNull)
448 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
451 .customSanitizer(toValueOrNull)
452 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
455 .customSanitizer(toBooleanOrNull)
456 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
457 body('waitTranscoding')
459 .customSanitizer(toBooleanOrNull)
460 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
463 .customSanitizer(toValueOrNull)
464 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
467 .customSanitizer(toValueOrNull)
468 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
471 .customSanitizer(toValueOrNull)
472 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
475 .customSanitizer(toValueOrNull)
476 .custom(isVideoTagsValid)
478 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
479 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
481 body('commentsEnabled')
483 .customSanitizer(toBooleanOrNull)
484 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
485 body('downloadEnabled')
487 .customSanitizer(toBooleanOrNull)
488 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
489 body('originallyPublishedAt')
491 .customSanitizer(toValueOrNull)
492 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
493 body('scheduleUpdate')
495 .customSanitizer(toValueOrNull),
496 body('scheduleUpdate.updateAt')
498 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
499 body('scheduleUpdate.privacy')
501 .customSanitizer(toIntOrNull)
502 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
503 ] as (ValidationChain | ExpressPromiseHandler)[]
506 const commonVideosFiltersValidator = [
507 query('categoryOneOf')
509 .customSanitizer(toArray)
510 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
511 query('licenceOneOf')
513 .customSanitizer(toArray)
514 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
515 query('languageOneOf')
517 .customSanitizer(toArray)
518 .custom(isStringArray).withMessage('Should have a valid one of language array'),
521 .customSanitizer(toArray)
522 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
525 .customSanitizer(toArray)
526 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
529 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
532 .customSanitizer(toBooleanOrNull)
533 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
536 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
539 .customSanitizer(toBooleanOrNull)
540 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
543 .custom(exists).withMessage('Should have a valid search'),
545 (req: express.Request, res: express.Response, next: express.NextFunction) => {
546 logger.debug('Checking commons video filters query', { parameters: req.query })
548 if (areValidationErrors(req, res)) return
550 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
552 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
553 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
556 status: HttpStatusCode.UNAUTHORIZED_401,
557 message: 'You are not allowed to see all local videos.'
566 // ---------------------------------------------------------------------------
569 videosAddLegacyValidator,
570 videosAddResumableValidator,
571 videosAddResumableInitValidator,
573 videosUpdateValidator,
575 videoFileMetadataGetValidator,
576 videosDownloadValidator,
577 checkVideoFollowConstraints,
578 videosCustomGetValidator,
579 videosRemoveValidator,
581 videosChangeOwnershipValidator,
582 videosTerminateChangeOwnershipValidator,
583 videosAcceptChangeOwnershipValidator,
585 getCommonVideoEditAttributes,
587 commonVideosFiltersValidator,
589 videosOverviewValidator
592 // ---------------------------------------------------------------------------
594 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
595 if (req.body.scheduleUpdate) {
596 if (!req.body.scheduleUpdate.updateAt) {
597 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
599 res.fail({ message: 'Schedule update at is mandatory.' })
607 async function commonVideoChecksPass (parameters: {
609 res: express.Response
611 videoFileSize: number
612 files: express.UploadFilesForCheck
613 }): Promise<boolean> {
614 const { req, res, user, videoFileSize, files } = parameters
616 if (areErrorsInScheduleUpdate(req, res)) return false
618 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
620 if (!isVideoFileMimeTypeValid(files)) {
622 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
623 message: 'This file is not supported. Please, make sure it is of the following type: ' +
624 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
629 if (!isVideoFileSizeValid(videoFileSize.toString())) {
631 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
632 message: 'This file is too large. It exceeds the maximum file size authorized.',
633 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
638 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
640 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
641 message: 'The user video quota is exceeded with this video.',
642 type: ServerErrorCode.QUOTA_REACHED
650 export async function isVideoAccepted (
651 req: express.Request,
652 res: express.Response,
653 videoFile: express.VideoUploadFile
655 // Check we accept this video
656 const acceptParameters = {
659 user: res.locals.oauth.token.User
661 const acceptedResult = await Hooks.wrapFun(
662 isLocalVideoAccepted,
664 'filter:api.video.upload.accept.result'
667 if (!acceptedResult || acceptedResult.accepted !== true) {
668 logger.info('Refused local video.', { acceptedResult, acceptParameters })
670 status: HttpStatusCode.FORBIDDEN_403,
671 message: acceptedResult.errorMessage || 'Refused local video'
679 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
680 const duration: number = await getDurationFromVideoFile(videoFile.path)
682 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
684 videoFile.duration = duration