]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
prevent multiple post-process triggering of upload-resumable (#4175)
[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 { isAbleToUploadVideo } from '@server/lib/user'
7 import { getServerActor } from '@server/models/application/application'
8 import { ExpressPromiseHandler } from '@server/types/express'
9 import { MUserAccountId, MVideoFullLight } from '@server/types/models'
10 import { ServerErrorCode, UserRight, VideoPrivacy } from '../../../../shared'
11 import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
12 import {
13 exists,
14 isBooleanValid,
15 isDateValid,
16 isFileFieldValid,
17 isIdValid,
18 isUUIDValid,
19 toArray,
20 toBooleanOrNull,
21 toIntOrNull,
22 toValueOrNull
23 } from '../../../helpers/custom-validators/misc'
24 import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
25 import {
26 isScheduleVideoUpdatePrivacyValid,
27 isVideoCategoryValid,
28 isVideoDescriptionValid,
29 isVideoFileMimeTypeValid,
30 isVideoFileSizeValid,
31 isVideoFilterValid,
32 isVideoImage,
33 isVideoLanguageValid,
34 isVideoLicenceValid,
35 isVideoNameValid,
36 isVideoOriginallyPublishedAtValid,
37 isVideoPrivacyValid,
38 isVideoSupportValid,
39 isVideoTagsValid
40 } from '../../../helpers/custom-validators/videos'
41 import { cleanUpReqFiles } from '../../../helpers/express-utils'
42 import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
43 import { logger } from '../../../helpers/logger'
44 import { deleteFileAndCatch } from '../../../helpers/utils'
45 import { getVideoWithAttributes } from '../../../helpers/video'
46 import { CONFIG } from '../../../initializers/config'
47 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
48 import { isLocalVideoAccepted } from '../../../lib/moderation'
49 import { Hooks } from '../../../lib/plugins/hooks'
50 import { VideoModel } from '../../../models/video/video'
51 import { authenticatePromiseIfNeeded } from '../../auth'
52 import {
53 areValidationErrors,
54 checkUserCanManageVideo,
55 doesVideoChannelOfAccountExist,
56 doesVideoExist,
57 doesVideoFileOfVideoExist,
58 isValidVideoIdParam
59 } from '../shared'
60
61 const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
62 body('videofile')
63 .custom((value, { req }) => isFileFieldValid(req.files, 'videofile'))
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).withMessage('Should have correct video channel id'),
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.id), 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
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 .isString()
171 .exists()
172 .withMessage('Should have a valid filename'),
173 body('name')
174 .trim()
175 .custom(isVideoNameValid).withMessage(
176 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
177 ),
178 body('channelId')
179 .customSanitizer(toIntOrNull)
180 .custom(isIdValid).withMessage('Should have correct video channel id'),
181
182 header('x-upload-content-length')
183 .isNumeric()
184 .exists()
185 .withMessage('Should specify the file length'),
186 header('x-upload-content-type')
187 .isString()
188 .exists()
189 .withMessage('Should specify the file mimetype'),
190
191 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
192 const videoFileMetadata = {
193 mimetype: req.headers['x-upload-content-type'] as string,
194 size: +req.headers['x-upload-content-length'],
195 originalname: req.body.name
196 }
197
198 const user = res.locals.oauth.token.User
199 const cleanup = () => cleanUpReqFiles(req)
200
201 logger.debug('Checking videosAddResumableInitValidator parameters and headers', {
202 parameters: req.body,
203 headers: req.headers,
204 files: req.files
205 })
206
207 if (areValidationErrors(req, res)) return cleanup()
208
209 const files = { videofile: [ videoFileMetadata ] }
210 if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup()
211
212 // multer required unsetting the Content-Type, now we can set it for node-uploadx
213 req.headers['content-type'] = 'application/json; charset=utf-8'
214 // place previewfile in metadata so that uploadx saves it in .META
215 if (req.files?.['previewfile']) req.body.previewfile = req.files['previewfile']
216
217 return next()
218 }
219 ])
220
221 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
222 isValidVideoIdParam('id'),
223
224 body('name')
225 .optional()
226 .trim()
227 .custom(isVideoNameValid).withMessage(
228 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
229 ),
230 body('channelId')
231 .optional()
232 .customSanitizer(toIntOrNull)
233 .custom(isIdValid).withMessage('Should have correct video channel id'),
234
235 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
236 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
237
238 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
239 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
240 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
241
242 // Check if the user who did the request is able to update the video
243 const user = res.locals.oauth.token.User
244 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
245
246 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
247
248 return next()
249 }
250 ])
251
252 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
253 const video = getVideoWithAttributes(res)
254
255 // Anybody can watch local videos
256 if (video.isOwned() === true) return next()
257
258 // Logged user
259 if (res.locals.oauth) {
260 // Users can search or watch remote videos
261 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
262 }
263
264 // Anybody can search or watch remote videos
265 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
266
267 // Check our instance follows an actor that shared this video
268 const serverActor = await getServerActor()
269 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
270
271 return res.fail({
272 status: HttpStatusCode.FORBIDDEN_403,
273 message: 'Cannot get this video regarding follow constraints',
274 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
275 data: {
276 originUrl: video.url
277 }
278 })
279 }
280
281 const videosCustomGetValidator = (
282 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
283 authenticateInQuery = false
284 ) => {
285 return [
286 isValidVideoIdParam('id'),
287
288 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
289 logger.debug('Checking videosGet parameters', { parameters: req.params })
290
291 if (areValidationErrors(req, res)) return
292 if (!await doesVideoExist(req.params.id, res, fetchType)) return
293
294 // Controllers does not need to check video rights
295 if (fetchType === 'only-immutable-attributes') return next()
296
297 const video = getVideoWithAttributes(res) as MVideoFullLight
298
299 // Video private or blacklisted
300 if (video.requiresAuth()) {
301 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
302
303 const user = res.locals.oauth ? res.locals.oauth.token.User : null
304
305 // Only the owner or a user that have blocklist rights can see the video
306 if (!user || !user.canGetVideo(video)) {
307 return res.fail({
308 status: HttpStatusCode.FORBIDDEN_403,
309 message: 'Cannot get this private/internal or blocklisted video'
310 })
311 }
312
313 return next()
314 }
315
316 // Video is public, anyone can access it
317 if (video.privacy === VideoPrivacy.PUBLIC) return next()
318
319 // Video is unlisted, check we used the uuid to fetch it
320 if (video.privacy === VideoPrivacy.UNLISTED) {
321 if (isUUIDValid(req.params.id)) return next()
322
323 // Don't leak this unlisted video
324 return res.fail({
325 status: HttpStatusCode.NOT_FOUND_404,
326 message: 'Video not found'
327 })
328 }
329 }
330 ]
331 }
332
333 const videosGetValidator = videosCustomGetValidator('all')
334 const videosDownloadValidator = videosCustomGetValidator('all', true)
335
336 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
337 isValidVideoIdParam('id'),
338
339 param('videoFileId')
340 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
341
342 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
343 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
344
345 if (areValidationErrors(req, res)) return
346 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
347
348 return next()
349 }
350 ])
351
352 const videosRemoveValidator = [
353 isValidVideoIdParam('id'),
354
355 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
356 logger.debug('Checking videosRemove parameters', { parameters: req.params })
357
358 if (areValidationErrors(req, res)) return
359 if (!await doesVideoExist(req.params.id, res)) return
360
361 // Check if the user who did the request is able to delete the video
362 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
363
364 return next()
365 }
366 ]
367
368 const videosOverviewValidator = [
369 query('page')
370 .optional()
371 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
372 .withMessage('Should have a valid pagination'),
373
374 (req: express.Request, res: express.Response, next: express.NextFunction) => {
375 if (areValidationErrors(req, res)) return
376
377 return next()
378 }
379 ]
380
381 function getCommonVideoEditAttributes () {
382 return [
383 body('thumbnailfile')
384 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
385 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
386 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
387 ),
388 body('previewfile')
389 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
390 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
391 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
392 ),
393
394 body('category')
395 .optional()
396 .customSanitizer(toIntOrNull)
397 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
398 body('licence')
399 .optional()
400 .customSanitizer(toIntOrNull)
401 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
402 body('language')
403 .optional()
404 .customSanitizer(toValueOrNull)
405 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
406 body('nsfw')
407 .optional()
408 .customSanitizer(toBooleanOrNull)
409 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
410 body('waitTranscoding')
411 .optional()
412 .customSanitizer(toBooleanOrNull)
413 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
414 body('privacy')
415 .optional()
416 .customSanitizer(toValueOrNull)
417 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
418 body('description')
419 .optional()
420 .customSanitizer(toValueOrNull)
421 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
422 body('support')
423 .optional()
424 .customSanitizer(toValueOrNull)
425 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
426 body('tags')
427 .optional()
428 .customSanitizer(toValueOrNull)
429 .custom(isVideoTagsValid)
430 .withMessage(
431 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
432 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
433 ),
434 body('commentsEnabled')
435 .optional()
436 .customSanitizer(toBooleanOrNull)
437 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
438 body('downloadEnabled')
439 .optional()
440 .customSanitizer(toBooleanOrNull)
441 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
442 body('originallyPublishedAt')
443 .optional()
444 .customSanitizer(toValueOrNull)
445 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
446 body('scheduleUpdate')
447 .optional()
448 .customSanitizer(toValueOrNull),
449 body('scheduleUpdate.updateAt')
450 .optional()
451 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
452 body('scheduleUpdate.privacy')
453 .optional()
454 .customSanitizer(toIntOrNull)
455 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
456 ] as (ValidationChain | ExpressPromiseHandler)[]
457 }
458
459 const commonVideosFiltersValidator = [
460 query('categoryOneOf')
461 .optional()
462 .customSanitizer(toArray)
463 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
464 query('licenceOneOf')
465 .optional()
466 .customSanitizer(toArray)
467 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
468 query('languageOneOf')
469 .optional()
470 .customSanitizer(toArray)
471 .custom(isStringArray).withMessage('Should have a valid one of language array'),
472 query('tagsOneOf')
473 .optional()
474 .customSanitizer(toArray)
475 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
476 query('tagsAllOf')
477 .optional()
478 .customSanitizer(toArray)
479 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
480 query('nsfw')
481 .optional()
482 .custom(isBooleanBothQueryValid).withMessage('Should have a valid NSFW attribute'),
483 query('isLive')
484 .optional()
485 .customSanitizer(toBooleanOrNull)
486 .custom(isBooleanValid).withMessage('Should have a valid live boolean'),
487 query('filter')
488 .optional()
489 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
490 query('skipCount')
491 .optional()
492 .customSanitizer(toBooleanOrNull)
493 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
494 query('search')
495 .optional()
496 .custom(exists).withMessage('Should have a valid search'),
497
498 (req: express.Request, res: express.Response, next: express.NextFunction) => {
499 logger.debug('Checking commons video filters query', { parameters: req.query })
500
501 if (areValidationErrors(req, res)) return
502
503 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
504 if (
505 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
506 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
507 ) {
508 res.fail({
509 status: HttpStatusCode.UNAUTHORIZED_401,
510 message: 'You are not allowed to see all local videos.'
511 })
512 return
513 }
514
515 return next()
516 }
517 ]
518
519 // ---------------------------------------------------------------------------
520
521 export {
522 videosAddLegacyValidator,
523 videosAddResumableValidator,
524 videosAddResumableInitValidator,
525
526 videosUpdateValidator,
527 videosGetValidator,
528 videoFileMetadataGetValidator,
529 videosDownloadValidator,
530 checkVideoFollowConstraints,
531 videosCustomGetValidator,
532 videosRemoveValidator,
533
534 getCommonVideoEditAttributes,
535
536 commonVideosFiltersValidator,
537
538 videosOverviewValidator
539 }
540
541 // ---------------------------------------------------------------------------
542
543 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
544 if (req.body.scheduleUpdate) {
545 if (!req.body.scheduleUpdate.updateAt) {
546 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
547
548 res.fail({ message: 'Schedule update at is mandatory.' })
549 return true
550 }
551 }
552
553 return false
554 }
555
556 async function commonVideoChecksPass (parameters: {
557 req: express.Request
558 res: express.Response
559 user: MUserAccountId
560 videoFileSize: number
561 files: express.UploadFilesForCheck
562 }): Promise<boolean> {
563 const { req, res, user, videoFileSize, files } = parameters
564
565 if (areErrorsInScheduleUpdate(req, res)) return false
566
567 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
568
569 if (!isVideoFileMimeTypeValid(files)) {
570 res.fail({
571 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
572 message: 'This file is not supported. Please, make sure it is of the following type: ' +
573 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
574 })
575 return false
576 }
577
578 if (!isVideoFileSizeValid(videoFileSize.toString())) {
579 res.fail({
580 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
581 message: 'This file is too large. It exceeds the maximum file size authorized.',
582 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
583 })
584 return false
585 }
586
587 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
588 res.fail({
589 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
590 message: 'The user video quota is exceeded with this video.',
591 type: ServerErrorCode.QUOTA_REACHED
592 })
593 return false
594 }
595
596 return true
597 }
598
599 export async function isVideoAccepted (
600 req: express.Request,
601 res: express.Response,
602 videoFile: express.VideoUploadFile
603 ) {
604 // Check we accept this video
605 const acceptParameters = {
606 videoBody: req.body,
607 videoFile,
608 user: res.locals.oauth.token.User
609 }
610 const acceptedResult = await Hooks.wrapFun(
611 isLocalVideoAccepted,
612 acceptParameters,
613 'filter:api.video.upload.accept.result'
614 )
615
616 if (!acceptedResult || acceptedResult.accepted !== true) {
617 logger.info('Refused local video.', { acceptedResult, acceptParameters })
618 res.fail({
619 status: HttpStatusCode.FORBIDDEN_403,
620 message: acceptedResult.errorMessage || 'Refused local video'
621 })
622 return false
623 }
624
625 return true
626 }
627
628 async function addDurationToVideo (videoFile: { path: string, duration?: number }) {
629 const duration: number = await getDurationFromVideoFile(videoFile.path)
630
631 if (isNaN(duration)) throw new Error(`Couldn't get video duration`)
632
633 videoFile.duration = duration
634 }