]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
Add test for RTMP stream without audio
[github/Chocobozzz/PeerTube.git] / server / middlewares / validators / videos / videos.ts
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'
11 import {
12 exists,
13 isBooleanValid,
14 isDateValid,
15 isFileValid,
16 isIdValid,
17 toBooleanOrNull,
18 toIntOrNull,
19 toValueOrNull
20 } from '../../../helpers/custom-validators/misc'
21 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
22 import {
23 areVideoTagsValid,
24 isScheduleVideoUpdatePrivacyValid,
25 isVideoCategoryValid,
26 isVideoDescriptionValid,
27 isVideoFileMimeTypeValid,
28 isVideoFileSizeValid,
29 isVideoFilterValid,
30 isVideoImageValid,
31 isVideoIncludeValid,
32 isVideoLanguageValid,
33 isVideoLicenceValid,
34 isVideoNameValid,
35 isVideoOriginallyPublishedAtValid,
36 isVideoPrivacyValid,
37 isVideoSupportValid
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'
49 import {
50 areValidationErrors,
51 checkCanSeeVideo,
52 checkUserCanManageVideo,
53 checkUserQuota,
54 doesVideoChannelOfAccountExist,
55 doesVideoExist,
56 doesVideoFileOfVideoExist,
57 isValidVideoIdParam
58 } from '../shared'
59
60 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
61 body('videofile')
62 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
63 .withMessage('Should have a file'),
64 body('name')
65 .trim()
66 .custom(isVideoNameValid).withMessage(
67 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
68 ),
69 body('channelId')
70 .customSanitizer(toIntOrNull)
71 .custom(isIdValid),
72
73 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
74 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
75
76 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
77 const user = res.locals.oauth.token.User
78
79 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
80 return cleanUpReqFiles(req)
81 }
82
83 try {
84 if (!videoFile.duration) await addDurationToVideo(videoFile)
85 } catch (err) {
86 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
87
88 res.fail({
89 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
90 message: 'Video file unreadable.'
91 })
92 return cleanUpReqFiles(req)
93 }
94
95 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
96
97 return next()
98 }
99 ])
100
101 /**
102 * Gets called after the last PUT request
103 */
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)
110
111 const uploadId = req.query.upload_id
112 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
113
114 if (sessionExists) {
115 const sessionResponse = await Redis.Instance.getUploadSession(uploadId)
116
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
119
120 return res.fail({
121 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
122 message: 'The upload is already being processed'
123 })
124 }
125
126 if (isTestInstance()) {
127 res.setHeader('x-resumable-upload-cached', 'true')
128 }
129
130 return res.json(sessionResponse)
131 }
132
133 await Redis.Instance.setUploadSession(uploadId)
134
135 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
136
137 try {
138 if (!file.duration) await addDurationToVideo(file)
139 } catch (err) {
140 logger.error('Invalid input file in videosAddResumableValidator.', { err })
141
142 res.fail({
143 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
144 message: 'Video file unreadable.'
145 })
146 return cleanup()
147 }
148
149 if (!await isVideoAccepted(req, res, file)) return cleanup()
150
151 res.locals.videoFileResumable = { ...file, originalname: file.filename }
152
153 return next()
154 }
155 ]
156
157 /**
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
160 *
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
163 *
164 */
165 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
166 body('filename')
167 .exists(),
168 body('name')
169 .trim()
170 .custom(isVideoNameValid).withMessage(
171 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
172 ),
173 body('channelId')
174 .customSanitizer(toIntOrNull)
175 .custom(isIdValid),
176
177 header('x-upload-content-length')
178 .isNumeric()
179 .exists()
180 .withMessage('Should specify the file length'),
181 header('x-upload-content-type')
182 .isString()
183 .exists()
184 .withMessage('Should specify the file mimetype'),
185
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
191 }
192
193 const user = res.locals.oauth.token.User
194 const cleanup = () => cleanUpReqFiles(req)
195
196 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
197 parameters: req.body,
198 headers: req.headers,
199 files: req.files
200 })
201
202 if (areValidationErrors(req, res, { omitLog: true })) return cleanup()
203
204 const files = { videofile: [ videoFileMetadata ] }
205 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
206
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']
211
212 return next()
213 }
214 ])
215
216 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
217 isValidVideoIdParam('id'),
218
219 body('name')
220 .optional()
221 .trim()
222 .custom(isVideoNameValid).withMessage(
223 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
224 ),
225 body('channelId')
226 .optional()
227 .customSanitizer(toIntOrNull)
228 .custom(isIdValid),
229
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)
234
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)
238
239 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
240
241 return next()
242 }
243 ])
244
245 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
246 const video = getVideoWithAttributes(res)
247
248 // Anybody can watch local videos
249 if (video.isOwned() === true) return next()
250
251 // Logged user
252 if (res.locals.oauth) {
253 // Users can search or watch remote videos
254 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
255 }
256
257 // Anybody can search or watch remote videos
258 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
259
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()
263
264 return res.fail({
265 status: HttpStatusCode.FORBIDDEN_403,
266 message: 'Cannot get this video regarding follow constraints',
267 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
268 data: {
269 originUrl: video.url
270 }
271 })
272 }
273
274 const videosCustomGetValidator = (
275 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
276 authenticateInQuery = false
277 ) => {
278 return [
279 isValidVideoIdParam('id'),
280
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
284
285 // Controllers does not need to check video rights
286 if (fetchType === 'only-immutable-attributes') return next()
287
288 const video = getVideoWithAttributes(res) as MVideoFullLight
289
290 if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return
291
292 return next()
293 }
294 ]
295 }
296
297 const videosGetValidator = videosCustomGetValidator('all')
298 const videosDownloadValidator = videosCustomGetValidator('all', true)
299
300 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
301 isValidVideoIdParam('id'),
302
303 param('videoFileId')
304 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
305
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
309
310 return next()
311 }
312 ])
313
314 const videosRemoveValidator = [
315 isValidVideoIdParam('id'),
316
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
320
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
323
324 return next()
325 }
326 ]
327
328 const videosOverviewValidator = [
329 query('page')
330 .optional()
331 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
332
333 (req: express.Request, res: express.Response, next: express.NextFunction) => {
334 if (areValidationErrors(req, res)) return
335
336 return next()
337 }
338 ]
339
340 function getCommonVideoEditAttributes () {
341 return [
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(', ')
346 ),
347 body('previewfile')
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(', ')
351 ),
352
353 body('category')
354 .optional()
355 .customSanitizer(toIntOrNull)
356 .custom(isVideoCategoryValid),
357 body('licence')
358 .optional()
359 .customSanitizer(toIntOrNull)
360 .custom(isVideoLicenceValid),
361 body('language')
362 .optional()
363 .customSanitizer(toValueOrNull)
364 .custom(isVideoLanguageValid),
365 body('nsfw')
366 .optional()
367 .customSanitizer(toBooleanOrNull)
368 .custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
369 body('waitTranscoding')
370 .optional()
371 .customSanitizer(toBooleanOrNull)
372 .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
373 body('privacy')
374 .optional()
375 .customSanitizer(toValueOrNull)
376 .custom(isVideoPrivacyValid),
377 body('description')
378 .optional()
379 .customSanitizer(toValueOrNull)
380 .custom(isVideoDescriptionValid),
381 body('support')
382 .optional()
383 .customSanitizer(toValueOrNull)
384 .custom(isVideoSupportValid),
385 body('tags')
386 .optional()
387 .customSanitizer(toValueOrNull)
388 .custom(areVideoTagsValid)
389 .withMessage(
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`
392 ),
393 body('commentsEnabled')
394 .optional()
395 .customSanitizer(toBooleanOrNull)
396 .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'),
397 body('downloadEnabled')
398 .optional()
399 .customSanitizer(toBooleanOrNull)
400 .custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
401 body('originallyPublishedAt')
402 .optional()
403 .customSanitizer(toValueOrNull)
404 .custom(isVideoOriginallyPublishedAtValid),
405 body('scheduleUpdate')
406 .optional()
407 .customSanitizer(toValueOrNull),
408 body('scheduleUpdate.updateAt')
409 .optional()
410 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
411 body('scheduleUpdate.privacy')
412 .optional()
413 .customSanitizer(toIntOrNull)
414 .custom(isScheduleVideoUpdatePrivacyValid)
415 ] as (ValidationChain | ExpressPromiseHandler)[]
416 }
417
418 const commonVideosFiltersValidator = [
419 query('categoryOneOf')
420 .optional()
421 .customSanitizer(arrayify)
422 .custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
423 query('licenceOneOf')
424 .optional()
425 .customSanitizer(arrayify)
426 .custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
427 query('languageOneOf')
428 .optional()
429 .customSanitizer(arrayify)
430 .custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
431 query('privacyOneOf')
432 .optional()
433 .customSanitizer(arrayify)
434 .custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
435 query('tagsOneOf')
436 .optional()
437 .customSanitizer(arrayify)
438 .custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
439 query('tagsAllOf')
440 .optional()
441 .customSanitizer(arrayify)
442 .custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
443 query('nsfw')
444 .optional()
445 .custom(isBooleanBothQueryValid),
446 query('isLive')
447 .optional()
448 .customSanitizer(toBooleanOrNull)
449 .custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
450 query('filter')
451 .optional()
452 .custom(isVideoFilterValid),
453 query('include')
454 .optional()
455 .custom(isVideoIncludeValid),
456 query('isLocal')
457 .optional()
458 .customSanitizer(toBooleanOrNull)
459 .custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
460 query('hasHLSFiles')
461 .optional()
462 .customSanitizer(toBooleanOrNull)
463 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
464 query('hasWebtorrentFiles')
465 .optional()
466 .customSanitizer(toBooleanOrNull)
467 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'),
468 query('skipCount')
469 .optional()
470 .customSanitizer(toBooleanOrNull)
471 .custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
472 query('search')
473 .optional()
474 .custom(exists),
475
476 (req: express.Request, res: express.Response, next: express.NextFunction) => {
477 if (areValidationErrors(req, res)) return
478
479 // FIXME: deprecated in 4.0, to remove
480 {
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
490 }
491
492 req.query.filter = undefined
493 }
494
495 const user = res.locals.oauth?.token.User
496
497 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
498 if (req.query.include || req.query.privacyOneOf) {
499 return res.fail({
500 status: HttpStatusCode.UNAUTHORIZED_401,
501 message: 'You are not allowed to see all videos.'
502 })
503 }
504 }
505
506 return next()
507 }
508 ]
509
510 // ---------------------------------------------------------------------------
511
512 export {
513 videosAddLegacyValidator,
514 videosAddResumableValidator,
515 videosAddResumableInitValidator,
516
517 videosUpdateValidator,
518 videosGetValidator,
519 videoFileMetadataGetValidator,
520 videosDownloadValidator,
521 checkVideoFollowConstraints,
522 videosCustomGetValidator,
523 videosRemoveValidator,
524
525 getCommonVideoEditAttributes,
526
527 commonVideosFiltersValidator,
528
529 videosOverviewValidator
530 }
531
532 // ---------------------------------------------------------------------------
533
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.')
538
539 res.fail({ message: 'Schedule update at is mandatory.' })
540 return true
541 }
542 }
543
544 return false
545 }
546
547 async function commonVideoChecksPass (parameters: {
548 req: express.Request
549 res: express.Response
550 user: MUserAccountId
551 videoFileSize: number
552 files: express.UploadFilesForCheck
553 }): Promise<boolean> {
554 const { req, res, user, videoFileSize, files } = parameters
555
556 if (areErrorsInScheduleUpdate(req, res)) return false
557
558 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
559
560 if (!isVideoFileMimeTypeValid(files)) {
561 res.fail({
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(', ')
565 })
566 return false
567 }
568
569 if (!isVideoFileSizeValid(videoFileSize.toString())) {
570 res.fail({
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
574 })
575 return false
576 }
577
578 if (await checkUserQuota(user, videoFileSize, res) === false) return false
579
580 return true
581 }
582
583 export async function isVideoAccepted (
584 req: express.Request,
585 res: express.Response,
586 videoFile: express.VideoUploadFile
587 ) {
588 // Check we accept this video
589 const acceptParameters = {
590 videoBody: req.body,
591 videoFile,
592 user: res.locals.oauth.token.User
593 }
594 const acceptedResult = await Hooks.wrapFun(
595 isLocalVideoAccepted,
596 acceptParameters,
597 'filter:api.video.upload.accept.result'
598 )
599
600 if (!acceptedResult || acceptedResult.accepted !== true) {
601 logger.info('Refused local video.', { acceptedResult, acceptParameters })
602 res.fail({
603 status: HttpStatusCode.FORBIDDEN_403,
604 message: acceptedResult.errorMessage || 'Refused local video'
605 })
606 return false
607 }
608
609 return true
610 }
611
612 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
613 const duration = await getVideoStreamDuration(videoFile.path)
614
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
619 }