]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
374a59c50d850c13a83147b1d5b4f81f5045760a
[github/Chocobozzz/PeerTube.git] / server / middlewares / validators / videos / videos.ts
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, MVideoFullLight } from '@server/types/models'
8 import { ServerErrorCode, UserRight, VideoPrivacy } from '../../../../shared'
9 import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
10 import {
11 exists,
12 isBooleanValid,
13 isDateValid,
14 isFileFieldValid,
15 isIdValid,
16 isUUIDValid,
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 isScheduleVideoUpdatePrivacyValid,
25 isVideoCategoryValid,
26 isVideoDescriptionValid,
27 isVideoFileMimeTypeValid,
28 isVideoFileSizeValid,
29 isVideoFilterValid,
30 isVideoImage,
31 isVideoLanguageValid,
32 isVideoLicenceValid,
33 isVideoNameValid,
34 isVideoOriginallyPublishedAtValid,
35 isVideoPrivacyValid,
36 isVideoSupportValid,
37 isVideoTagsValid
38 } from '../../../helpers/custom-validators/videos'
39 import { cleanUpReqFiles } from '../../../helpers/express-utils'
40 import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
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 { authenticatePromiseIfNeeded } from '../../auth'
50 import {
51 areValidationErrors,
52 checkUserCanManageVideo,
53 doesVideoChannelOfAccountExist,
54 doesVideoExist,
55 doesVideoFileOfVideoExist,
56 isValidVideoIdParam
57 } from '../shared'
58
59 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
60 body('videofile')
61 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
62 .withMessage('Should have a file'),
63 body('name')
64 .trim()
65 .custom(isVideoNameValid).withMessage(
66 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
67 ),
68 body('channelId')
69 .customSanitizer(toIntOrNull)
70 .custom(isIdValid).withMessage('Should have correct video channel id'),
71
72 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
73 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
74
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
109 const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
110 const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename }
111
112 const cleanup = () => deleteFileAndCatch(file.path)
113
114 if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup()
115
116 try {
117 if (!file.duration) await addDurationToVideo(file)
118 } catch (err) {
119 logger.error('Invalid input file in videosAddResumableValidator.', { err })
120
121 res.fail({
122 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
123 message: 'Video file unreadable.'
124 })
125 return cleanup()
126 }
127
128 if (!await isVideoAccepted(req, res, file)) return cleanup()
129
130 res.locals.videoFileResumable = file
131
132 return next()
133 }
134 ]
135
136 /**
137 * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use.
138 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts
139 *
140 * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx
141 * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts
142 *
143 */
144 const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
145 body('filename')
146 .isString()
147 .exists()
148 .withMessage('Should have a valid filename'),
149 body('name')
150 .trim()
151 .custom(isVideoNameValid).withMessage(
152 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
153 ),
154 body('channelId')
155 .customSanitizer(toIntOrNull)
156 .custom(isIdValid).withMessage('Should have correct video channel id'),
157
158 header('x-upload-content-length')
159 .isNumeric()
160 .exists()
161 .withMessage('Should specify the file length'),
162 header('x-upload-content-type')
163 .isString()
164 .exists()
165 .withMessage('Should specify the file mimetype'),
166
167 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
168 const videoFileMetadata = {
169 mimetype: req.headers['x-upload-content-type'] as string,
170 size: +req.headers['x-upload-content-length'],
171 originalname: req.body.name
172 }
173
174 const user = res.locals.oauth.token.User
175 const cleanup = () => cleanUpReqFiles(req)
176
177 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
178 parameters: req.body,
179 headers: req.headers,
180 files: req.files
181 })
182
183 if (areValidationErrors(req, res)) return cleanup()
184
185 const files = { videofile: [ videoFileMetadata ] }
186 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
187
188 // multer required unsetting the Content-Type, now we can set it for node-uploadx
189 req.headers['content-type'] = 'application/json; charset=utf-8'
190 // place previewfile in metadata so that uploadx saves it in .META
191 if (req.files['previewfile']) req.body.previewfile = req.files['previewfile']
192
193 return next()
194 }
195 ])
196
197 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
198 isValidVideoIdParam('id'),
199
200 body('name')
201 .optional()
202 .trim()
203 .custom(isVideoNameValid).withMessage(
204 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
205 ),
206 body('channelId')
207 .optional()
208 .customSanitizer(toIntOrNull)
209 .custom(isIdValid).withMessage('Should have correct video channel id'),
210
211 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
212 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
213
214 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
215 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
216 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
217
218 // Check if the user who did the request is able to update the video
219 const user = res.locals.oauth.token.User
220 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
221
222 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
223
224 return next()
225 }
226 ])
227
228 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
229 const video = getVideoWithAttributes(res)
230
231 // Anybody can watch local videos
232 if (video.isOwned() === true) return next()
233
234 // Logged user
235 if (res.locals.oauth) {
236 // Users can search or watch remote videos
237 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
238 }
239
240 // Anybody can search or watch remote videos
241 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
242
243 // Check our instance follows an actor that shared this video
244 const serverActor = await getServerActor()
245 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
246
247 return res.fail({
248 status: HttpStatusCode.FORBIDDEN_403,
249 message: 'Cannot get this video regarding follow constraints',
250 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
251 data: {
252 originUrl: video.url
253 }
254 })
255 }
256
257 const videosCustomGetValidator = (
258 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
259 authenticateInQuery = false
260 ) => {
261 return [
262 isValidVideoIdParam('id'),
263
264 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
265 logger.debug('Checking videosGet parameters', { parameters: req.params })
266
267 if (areValidationErrors(req, res)) return
268 if (!await doesVideoExist(req.params.id, res, fetchType)) return
269
270 // Controllers does not need to check video rights
271 if (fetchType === 'only-immutable-attributes') return next()
272
273 const video = getVideoWithAttributes(res) as MVideoFullLight
274
275 // Video private or blacklisted
276 if (video.requiresAuth()) {
277 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
278
279 const user = res.locals.oauth ? res.locals.oauth.token.User : null
280
281 // Only the owner or a user that have blocklist rights can see the video
282 if (!user || !user.canGetVideo(video)) {
283 return res.fail({
284 status: HttpStatusCode.FORBIDDEN_403,
285 message: 'Cannot get this private/internal or blocklisted video'
286 })
287 }
288
289 return next()
290 }
291
292 // Video is public, anyone can access it
293 if (video.privacy === VideoPrivacy.PUBLIC) return next()
294
295 // Video is unlisted, check we used the uuid to fetch it
296 if (video.privacy === VideoPrivacy.UNLISTED) {
297 if (isUUIDValid(req.params.id)) return next()
298
299 // Don't leak this unlisted video
300 return res.fail({
301 status: HttpStatusCode.NOT_FOUND_404,
302 message: 'Video not found'
303 })
304 }
305 }
306 ]
307 }
308
309 const videosGetValidator = videosCustomGetValidator('all')
310 const videosDownloadValidator = videosCustomGetValidator('all', true)
311
312 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
313 isValidVideoIdParam('id'),
314
315 param('videoFileId')
316 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
317
318 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
319 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
320
321 if (areValidationErrors(req, res)) return
322 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
323
324 return next()
325 }
326 ])
327
328 const videosRemoveValidator = [
329 isValidVideoIdParam('id'),
330
331 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
332 logger.debug('Checking videosRemove parameters', { parameters: req.params })
333
334 if (areValidationErrors(req, res)) return
335 if (!await doesVideoExist(req.params.id, res)) return
336
337 // Check if the user who did the request is able to delete the video
338 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
339
340 return next()
341 }
342 ]
343
344 const videosOverviewValidator = [
345 query('page')
346 .optional()
347 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
348 .withMessage('Should have a valid pagination'),
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 }) => isVideoImage(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 }) => isVideoImage(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).withMessage('Should have a valid category'),
374 body('licence')
375 .optional()
376 .customSanitizer(toIntOrNull)
377 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
378 body('language')
379 .optional()
380 .customSanitizer(toValueOrNull)
381 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
382 body('nsfw')
383 .optional()
384 .customSanitizer(toBooleanOrNull)
385 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
386 body('waitTranscoding')
387 .optional()
388 .customSanitizer(toBooleanOrNull)
389 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
390 body('privacy')
391 .optional()
392 .customSanitizer(toValueOrNull)
393 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
394 body('description')
395 .optional()
396 .customSanitizer(toValueOrNull)
397 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
398 body('support')
399 .optional()
400 .customSanitizer(toValueOrNull)
401 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
402 body('tags')
403 .optional()
404 .customSanitizer(toValueOrNull)
405 .custom(isVideoTagsValid)
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 comments enabled boolean'),
414 body('downloadEnabled')
415 .optional()
416 .customSanitizer(toBooleanOrNull)
417 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
418 body('originallyPublishedAt')
419 .optional()
420 .customSanitizer(toValueOrNull)
421 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
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).withMessage('Should have correct schedule update privacy')
432 ] as (ValidationChain | ExpressPromiseHandler)[]
433 }
434
435 const commonVideosFiltersValidator = [
436 query('categoryOneOf')
437 .optional()
438 .customSanitizer(toArray)
439 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
440 query('licenceOneOf')
441 .optional()
442 .customSanitizer(toArray)
443 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
444 query('languageOneOf')
445 .optional()
446 .customSanitizer(toArray)
447 .custom(isStringArray).withMessage('Should have a valid one of language array'),
448 query('tagsOneOf')
449 .optional()
450 .customSanitizer(toArray)
451 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
452 query('tagsAllOf')
453 .optional()
454 .customSanitizer(toArray)
455 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
456 query('nsfw')
457 .optional()
458 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
459 query('isLive')
460 .optional()
461 .customSanitizer(toBooleanOrNull)
462 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
463 query('filter')
464 .optional()
465 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
466 query('skipCount')
467 .optional()
468 .customSanitizer(toBooleanOrNull)
469 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
470 query('search')
471 .optional()
472 .custom(exists).withMessage('Should have a valid search'),
473
474 (req: express.Request, res: express.Response, next: express.NextFunction) => {
475 logger.debug('Checking commons video filters query', { parameters: req.query })
476
477 if (areValidationErrors(req, res)) return
478
479 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
480 if (
481 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
482 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
483 ) {
484 res.fail({
485 status: HttpStatusCode.UNAUTHORIZED_401,
486 message: 'You are not allowed to see all local videos.'
487 })
488 return
489 }
490
491 return next()
492 }
493 ]
494
495 // ---------------------------------------------------------------------------
496
497 export {
498 videosAddLegacyValidator,
499 videosAddResumableValidator,
500 videosAddResumableInitValidator,
501
502 videosUpdateValidator,
503 videosGetValidator,
504 videoFileMetadataGetValidator,
505 videosDownloadValidator,
506 checkVideoFollowConstraints,
507 videosCustomGetValidator,
508 videosRemoveValidator,
509
510 getCommonVideoEditAttributes,
511
512 commonVideosFiltersValidator,
513
514 videosOverviewValidator
515 }
516
517 // ---------------------------------------------------------------------------
518
519 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
520 if (req.body.scheduleUpdate) {
521 if (!req.body.scheduleUpdate.updateAt) {
522 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
523
524 res.fail({ message: 'Schedule update at is mandatory.' })
525 return true
526 }
527 }
528
529 return false
530 }
531
532 async function commonVideoChecksPass (parameters: {
533 req: express.Request
534 res: express.Response
535 user: MUserAccountId
536 videoFileSize: number
537 files: express.UploadFilesForCheck
538 }): Promise<boolean> {
539 const { req, res, user, videoFileSize, files } = parameters
540
541 if (areErrorsInScheduleUpdate(req, res)) return false
542
543 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
544
545 if (!isVideoFileMimeTypeValid(files)) {
546 res.fail({
547 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
548 message: 'This file is not supported. Please, make sure it is of the following type: ' +
549 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
550 })
551 return false
552 }
553
554 if (!isVideoFileSizeValid(videoFileSize.toString())) {
555 res.fail({
556 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
557 message: 'This file is too large. It exceeds the maximum file size authorized.',
558 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
559 })
560 return false
561 }
562
563 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
564 res.fail({
565 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
566 message: 'The user video quota is exceeded with this video.',
567 type: ServerErrorCode.QUOTA_REACHED
568 })
569 return false
570 }
571
572 return true
573 }
574
575 export async function isVideoAccepted (
576 req: express.Request,
577 res: express.Response,
578 videoFile: express.VideoUploadFile
579 ) {
580 // Check we accept this video
581 const acceptParameters = {
582 videoBody: req.body,
583 videoFile,
584 user: res.locals.oauth.token.User
585 }
586 const acceptedResult = await Hooks.wrapFun(
587 isLocalVideoAccepted,
588 acceptParameters,
589 'filter:api.video.upload.accept.result'
590 )
591
592 if (!acceptedResult || acceptedResult.accepted !== true) {
593 logger.info('Refused local video.', { acceptedResult, acceptParameters })
594 res.fail({
595 status: HttpStatusCode.FORBIDDEN_403,
596 message: acceptedResult.errorMessage || 'Refused local video'
597 })
598 return false
599 }
600
601 return true
602 }
603
604 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
605 const duration: number = await getDurationFromVideoFile(videoFile.path)
606
607 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
608
609 videoFile.duration = duration
610 }