]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
5e8e25a9c71695ca4c3f29691fc3de90470e26e5
[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 { 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 toArray,
18 toBooleanOrNull,
19 toIntOrNull,
20 toValueOrNull
21 } from '../../../helpers/custom-validators/misc'
22 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
23 import {
24 areVideoTagsValid,
25 isScheduleVideoUpdatePrivacyValid,
26 isVideoCategoryValid,
27 isVideoDescriptionValid,
28 isVideoFileMimeTypeValid,
29 isVideoFileSizeValid,
30 isVideoFilterValid,
31 isVideoImageValid,
32 isVideoIncludeValid,
33 isVideoLanguageValid,
34 isVideoLicenceValid,
35 isVideoNameValid,
36 isVideoOriginallyPublishedAtValid,
37 isVideoPrivacyValid,
38 isVideoSupportValid
39 } from '../../../helpers/custom-validators/videos'
40 import { cleanUpReqFiles } from '../../../helpers/express-utils'
41 import { getVideoStreamDuration } from '../../../helpers/ffmpeg'
42 import { logger } from '../../../helpers/logger'
43 import { deleteFileAndCatch } from '../../../helpers/utils'
44 import { getVideoWithAttributes } from '../../../helpers/video'
45 import { CONFIG } from '../../../initializers/config'
46 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
47 import { isLocalVideoAccepted } from '../../../lib/moderation'
48 import { Hooks } from '../../../lib/plugins/hooks'
49 import { VideoModel } from '../../../models/video/video'
50 import {
51 areValidationErrors,
52 checkCanSeeVideo,
53 checkUserCanManageVideo,
54 checkUserQuota,
55 doesVideoChannelOfAccountExist,
56 doesVideoExist,
57 doesVideoFileOfVideoExist,
58 isValidVideoIdParam
59 } from '../shared'
60
61 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
62 body('videofile')
63 .custom((_, { req }) => isFileValid({ files: req.files, field: 'videofile', mimeTypeRegex: null, maxSize: null }))
64 .withMessage('Should have a file'),
65 body('name')
66 .trim()
67 .custom(isVideoNameValid).withMessage(
68 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
69 ),
70 body('channelId')
71 .customSanitizer(toIntOrNull)
72 .custom(isIdValid),
73
74 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
75 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
76
77 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
78
79 const videoFile: express.VideoUploadFile = req.files['videofile'][0]
80 const user = res.locals.oauth.token.User
81
82 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) {
83 return cleanUpReqFiles(req)
84 }
85
86 try {
87 if (!videoFile.duration) await addDurationToVideo(videoFile)
88 } catch (err) {
89 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
90
91 res.fail({
92 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
93 message: 'Video file unreadable.'
94 })
95 return cleanUpReqFiles(req)
96 }
97
98 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
99
100 return next()
101 }
102 ])
103
104 /**
105 * Gets called after the last PUT request
106 */
107 const videosAddResumableValidator = [
108 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
109 const user = res.locals.oauth.token.User
110 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
111 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
112 const cleanup = () => deleteFileAndCatch(file.path)
113
114 const uploadId = req.query.upload_id
115 const sessionExists = await Redis.Instance.doesUploadSessionExist(uploadId)
116
117 if (sessionExists) {
118 const sessionResponse = await Redis.Instance.getUploadSession(uploadId)
119
120 if (!sessionResponse) {
121 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
123 return res.fail({
124 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
125 message: 'The upload is already being processed'
126 })
127 }
128
129 if (isTestInstance()) {
130 res.setHeader('x-resumable-upload-cached', 'true')
131 }
132
133 return res.json(sessionResponse)
134 }
135
136 await Redis.Instance.setUploadSession(uploadId)
137
138 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
139
140 try {
141 if (!file.duration) await addDurationToVideo(file)
142 } catch (err) {
143 logger.error('Invalid input file in videosAddResumableValidator.', { err })
144
145 res.fail({
146 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
147 message: 'Video file unreadable.'
148 })
149 return cleanup()
150 }
151
152 if (!await isVideoAccepted(req, res, file)) return cleanup()
153
154 res.locals.videoFileResumable = { ...file, originalname: file.filename }
155
156 return next()
157 }
158 ]
159
160 /**
161 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
162 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
163 *
164 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
165 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
166 *
167 */
168 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
169 body('filename')
170 .exists(),
171 body('name')
172 .trim()
173 .custom(isVideoNameValid).withMessage(
174 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
175 ),
176 body('channelId')
177 .customSanitizer(toIntOrNull)
178 .custom(isIdValid),
179
180 header('x-upload-content-length')
181 .isNumeric()
182 .exists()
183 .withMessage('Should specify the file length'),
184 header('x-upload-content-type')
185 .isString()
186 .exists()
187 .withMessage('Should specify the file mimetype'),
188
189 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
190 const videoFileMetadata = {
191 mimetype: req.headers['x-upload-content-type'] as string,
192 size: +req.headers['x-upload-content-length'],
193 originalname: req.body.filename
194 }
195
196 const user = res.locals.oauth.token.User
197 const cleanup = () => cleanUpReqFiles(req)
198
199 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
200 parameters: req.body,
201 headers: req.headers,
202 files: req.files
203 })
204
205 if (areValidationErrors(req, res)) return cleanup()
206
207 const files = { videofile: [ videoFileMetadata ] }
208 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
209
210 // multer required unsetting the Content-Type, now we can set it for node-uploadx
211 req.headers['content-type'] = 'application/json; charset=utf-8'
212 // place previewfile in metadata so that uploadx saves it in .META
213 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
214
215 return next()
216 }
217 ])
218
219 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
220 isValidVideoIdParam('id'),
221
222 body('name')
223 .optional()
224 .trim()
225 .custom(isVideoNameValid).withMessage(
226 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
227 ),
228 body('channelId')
229 .optional()
230 .customSanitizer(toIntOrNull)
231 .custom(isIdValid),
232
233 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
234 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
235
236 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
237 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
238 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
239
240 // Check if the user who did the request is able to update the video
241 const user = res.locals.oauth.token.User
242 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
243
244 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
245
246 return next()
247 }
248 ])
249
250 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
251 const video = getVideoWithAttributes(res)
252
253 // Anybody can watch local videos
254 if (video.isOwned() === true) return next()
255
256 // Logged user
257 if (res.locals.oauth) {
258 // Users can search or watch remote videos
259 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
260 }
261
262 // Anybody can search or watch remote videos
263 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
264
265 // Check our instance follows an actor that shared this video
266 const serverActor = await getServerActor()
267 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
268
269 return res.fail({
270 status: HttpStatusCode.FORBIDDEN_403,
271 message: 'Cannot get this video regarding follow constraints',
272 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
273 data: {
274 originUrl: video.url
275 }
276 })
277 }
278
279 const videosCustomGetValidator = (
280 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
281 authenticateInQuery = false
282 ) => {
283 return [
284 isValidVideoIdParam('id'),
285
286 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
287 logger.debug('Checking videosGet parameters', { parameters: req.params })
288
289 if (areValidationErrors(req, res)) return
290 if (!await doesVideoExist(req.params.id, res, fetchType)) return
291
292 // Controllers does not need to check video rights
293 if (fetchType === 'only-immutable-attributes') return next()
294
295 const video = getVideoWithAttributes(res) as MVideoFullLight
296
297 if (!await checkCanSeeVideo({ req, res, video, paramId: req.params.id, authenticateInQuery })) return
298
299 return next()
300 }
301 ]
302 }
303
304 const videosGetValidator = videosCustomGetValidator('all')
305 const videosDownloadValidator = videosCustomGetValidator('all', true)
306
307 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
308 isValidVideoIdParam('id'),
309
310 param('videoFileId')
311 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
312
313 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
314 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
315
316 if (areValidationErrors(req, res)) return
317 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
318
319 return next()
320 }
321 ])
322
323 const videosRemoveValidator = [
324 isValidVideoIdParam('id'),
325
326 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
327 logger.debug('Checking videosRemove parameters', { parameters: req.params })
328
329 if (areValidationErrors(req, res)) return
330 if (!await doesVideoExist(req.params.id, res)) return
331
332 // Check if the user who did the request is able to delete the video
333 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
334
335 return next()
336 }
337 ]
338
339 const videosOverviewValidator = [
340 query('page')
341 .optional()
342 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT }),
343
344 (req: express.Request, res: express.Response, next: express.NextFunction) => {
345 if (areValidationErrors(req, res)) return
346
347 return next()
348 }
349 ]
350
351 function getCommonVideoEditAttributes () {
352 return [
353 body('thumbnailfile')
354 .custom((value, { req }) => isVideoImageValid(req.files, 'thumbnailfile')).withMessage(
355 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
356 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
357 ),
358 body('previewfile')
359 .custom((value, { req }) => isVideoImageValid(req.files, 'previewfile')).withMessage(
360 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
361 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
362 ),
363
364 body('category')
365 .optional()
366 .customSanitizer(toIntOrNull)
367 .custom(isVideoCategoryValid),
368 body('licence')
369 .optional()
370 .customSanitizer(toIntOrNull)
371 .custom(isVideoLicenceValid),
372 body('language')
373 .optional()
374 .customSanitizer(toValueOrNull)
375 .custom(isVideoLanguageValid),
376 body('nsfw')
377 .optional()
378 .customSanitizer(toBooleanOrNull)
379 .custom(isBooleanValid).withMessage('Should have a valid nsfw boolean'),
380 body('waitTranscoding')
381 .optional()
382 .customSanitizer(toBooleanOrNull)
383 .custom(isBooleanValid).withMessage('Should have a valid waitTranscoding boolean'),
384 body('privacy')
385 .optional()
386 .customSanitizer(toValueOrNull)
387 .custom(isVideoPrivacyValid),
388 body('description')
389 .optional()
390 .customSanitizer(toValueOrNull)
391 .custom(isVideoDescriptionValid),
392 body('support')
393 .optional()
394 .customSanitizer(toValueOrNull)
395 .custom(isVideoSupportValid),
396 body('tags')
397 .optional()
398 .customSanitizer(toValueOrNull)
399 .custom(areVideoTagsValid)
400 .withMessage(
401 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
402 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
403 ),
404 body('commentsEnabled')
405 .optional()
406 .customSanitizer(toBooleanOrNull)
407 .custom(isBooleanValid).withMessage('Should have commentsEnabled boolean'),
408 body('downloadEnabled')
409 .optional()
410 .customSanitizer(toBooleanOrNull)
411 .custom(isBooleanValid).withMessage('Should have downloadEnabled boolean'),
412 body('originallyPublishedAt')
413 .optional()
414 .customSanitizer(toValueOrNull)
415 .custom(isVideoOriginallyPublishedAtValid),
416 body('scheduleUpdate')
417 .optional()
418 .customSanitizer(toValueOrNull),
419 body('scheduleUpdate.updateAt')
420 .optional()
421 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
422 body('scheduleUpdate.privacy')
423 .optional()
424 .customSanitizer(toIntOrNull)
425 .custom(isScheduleVideoUpdatePrivacyValid)
426 ] as (ValidationChain | ExpressPromiseHandler)[]
427 }
428
429 const commonVideosFiltersValidator = [
430 query('categoryOneOf')
431 .optional()
432 .customSanitizer(toArray)
433 .custom(isNumberArray).withMessage('Should have a valid categoryOneOf array'),
434 query('licenceOneOf')
435 .optional()
436 .customSanitizer(toArray)
437 .custom(isNumberArray).withMessage('Should have a valid licenceOneOf array'),
438 query('languageOneOf')
439 .optional()
440 .customSanitizer(toArray)
441 .custom(isStringArray).withMessage('Should have a valid languageOneOf array'),
442 query('privacyOneOf')
443 .optional()
444 .customSanitizer(toArray)
445 .custom(isNumberArray).withMessage('Should have a valid privacyOneOf array'),
446 query('tagsOneOf')
447 .optional()
448 .customSanitizer(toArray)
449 .custom(isStringArray).withMessage('Should have a valid tagsOneOf array'),
450 query('tagsAllOf')
451 .optional()
452 .customSanitizer(toArray)
453 .custom(isStringArray).withMessage('Should have a valid tagsAllOf array'),
454 query('nsfw')
455 .optional()
456 .custom(isBooleanBothQueryValid),
457 query('isLive')
458 .optional()
459 .customSanitizer(toBooleanOrNull)
460 .custom(isBooleanValid).withMessage('Should have a valid isLive boolean'),
461 query('filter')
462 .optional()
463 .custom(isVideoFilterValid),
464 query('include')
465 .optional()
466 .custom(isVideoIncludeValid),
467 query('isLocal')
468 .optional()
469 .customSanitizer(toBooleanOrNull)
470 .custom(isBooleanValid).withMessage('Should have a valid isLocal boolean'),
471 query('hasHLSFiles')
472 .optional()
473 .customSanitizer(toBooleanOrNull)
474 .custom(isBooleanValid).withMessage('Should have a valid hasHLSFiles boolean'),
475 query('hasWebtorrentFiles')
476 .optional()
477 .customSanitizer(toBooleanOrNull)
478 .custom(isBooleanValid).withMessage('Should have a valid hasWebtorrentFiles boolean'),
479 query('skipCount')
480 .optional()
481 .customSanitizer(toBooleanOrNull)
482 .custom(isBooleanValid).withMessage('Should have a valid skipCount boolean'),
483 query('search')
484 .optional()
485 .custom(exists),
486
487 (req: express.Request, res: express.Response, next: express.NextFunction) => {
488 logger.debug('Checking commons video filters query', { parameters: req.query })
489
490 if (areValidationErrors(req, res)) return
491
492 // FIXME: deprecated in 4.0, to remove
493 {
494 if (req.query.filter === 'all-local') {
495 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
496 req.query.isLocal = true
497 req.query.privacyOneOf = getAllPrivacies()
498 } else if (req.query.filter === 'all') {
499 req.query.include = VideoInclude.NOT_PUBLISHED_STATE
500 req.query.privacyOneOf = getAllPrivacies()
501 } else if (req.query.filter === 'local') {
502 req.query.isLocal = true
503 }
504
505 req.query.filter = undefined
506 }
507
508 const user = res.locals.oauth?.token.User
509
510 if ((!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) !== true)) {
511 if (req.query.include || req.query.privacyOneOf) {
512 return res.fail({
513 status: HttpStatusCode.UNAUTHORIZED_401,
514 message: 'You are not allowed to see all videos.'
515 })
516 }
517 }
518
519 return next()
520 }
521 ]
522
523 // ---------------------------------------------------------------------------
524
525 export {
526 videosAddLegacyValidator,
527 videosAddResumableValidator,
528 videosAddResumableInitValidator,
529
530 videosUpdateValidator,
531 videosGetValidator,
532 videoFileMetadataGetValidator,
533 videosDownloadValidator,
534 checkVideoFollowConstraints,
535 videosCustomGetValidator,
536 videosRemoveValidator,
537
538 getCommonVideoEditAttributes,
539
540 commonVideosFiltersValidator,
541
542 videosOverviewValidator
543 }
544
545 // ---------------------------------------------------------------------------
546
547 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
548 if (req.body.scheduleUpdate) {
549 if (!req.body.scheduleUpdate.updateAt) {
550 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
551
552 res.fail({ message: 'Schedule update at is mandatory.' })
553 return true
554 }
555 }
556
557 return false
558 }
559
560 async function commonVideoChecksPass (parameters: {
561 req: express.Request
562 res: express.Response
563 user: MUserAccountId
564 videoFileSize: number
565 files: express.UploadFilesForCheck
566 }): Promise<boolean> {
567 const { req, res, user, videoFileSize, files } = parameters
568
569 if (areErrorsInScheduleUpdate(req, res)) return false
570
571 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
572
573 if (!isVideoFileMimeTypeValid(files)) {
574 res.fail({
575 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
576 message: 'This file is not supported. Please, make sure it is of the following type: ' +
577 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
578 })
579 return false
580 }
581
582 if (!isVideoFileSizeValid(videoFileSize.toString())) {
583 res.fail({
584 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
585 message: 'This file is too large. It exceeds the maximum file size authorized.',
586 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
587 })
588 return false
589 }
590
591 if (await checkUserQuota(user, videoFileSize, res) === false) return false
592
593 return true
594 }
595
596 export async function isVideoAccepted (
597 req: express.Request,
598 res: express.Response,
599 videoFile: express.VideoUploadFile
600 ) {
601 // Check we accept this video
602 const acceptParameters = {
603 videoBody: req.body,
604 videoFile,
605 user: res.locals.oauth.token.User
606 }
607 const acceptedResult = await Hooks.wrapFun(
608 isLocalVideoAccepted,
609 acceptParameters,
610 'filter:api.video.upload.accept.result'
611 )
612
613 if (!acceptedResult || acceptedResult.accepted !== true) {
614 logger.info('Refused local video.', { acceptedResult, acceptParameters })
615 res.fail({
616 status: HttpStatusCode.FORBIDDEN_403,
617 message: acceptedResult.errorMessage || 'Refused local video'
618 })
619 return false
620 }
621
622 return true
623 }
624
625 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
626 const duration = await getVideoStreamDuration(videoFile.path)
627
628 // FFmpeg may not be able to guess video duration
629 // For example with m2v files: https://trac.ffmpeg.org/ticket/9726#comment:2
630 if (isNaN(duration)) videoFile.duration = 0
631 else videoFile.duration = duration
632 }