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