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