]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blame - server/middlewares/validators/videos/videos.ts
Split ffmpeg utils with ffprobe utils
[github/Chocobozzz/PeerTube.git] / server / middlewares / validators / videos / videos.ts
CommitLineData
69818c93 1import * as express from 'express'
c8861d5d 2import { body, param, query, ValidationChain } from 'express-validator'
fb719404 3import { isAbleToUploadVideo } from '@server/lib/user'
e6abf95e
C
4import { getServerActor } from '@server/models/application/application'
5import { MVideoFullLight } from '@server/types/models'
6import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
7import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
b60e5f38 8import {
2baea0c7
C
9 isBooleanValid,
10 isDateValid,
11 isIdOrUUIDValid,
12 isIdValid,
13 isUUIDValid,
1cd3facc 14 toArray,
c8861d5d 15 toBooleanOrNull,
2baea0c7
C
16 toIntOrNull,
17 toValueOrNull
6e46de09 18} from '../../../helpers/custom-validators/misc'
e6abf95e
C
19import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
20import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
2baea0c7
C
21import {
22 isScheduleVideoUpdatePrivacyValid,
ac81d1a0
C
23 isVideoCategoryValid,
24 isVideoDescriptionValid,
ac81d1a0 25 isVideoFile,
1cd3facc 26 isVideoFilterValid,
ac81d1a0
C
27 isVideoImage,
28 isVideoLanguageValid,
29 isVideoLicenceValid,
30 isVideoNameValid,
fd8710b8 31 isVideoOriginallyPublishedAtValid,
ac81d1a0 32 isVideoPrivacyValid,
360329cc 33 isVideoSupportValid,
4157cdb1 34 isVideoTagsValid
6e46de09 35} from '../../../helpers/custom-validators/videos'
e6abf95e 36import { cleanUpReqFiles } from '../../../helpers/express-utils'
daf6e480 37import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
6e46de09 38import { logger } from '../../../helpers/logger'
8319d6ae
RK
39import {
40 checkUserCanManageVideo,
41 doesVideoChannelOfAccountExist,
42 doesVideoExist,
43 doesVideoFileOfVideoExist
44} from '../../../helpers/middlewares'
0283eaac 45import { getVideoWithAttributes } from '../../../helpers/video'
e6abf95e
C
46import { CONFIG } from '../../../initializers/config'
47import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
48import { isLocalVideoAccepted } from '../../../lib/moderation'
49import { Hooks } from '../../../lib/plugins/hooks'
50import { AccountModel } from '../../../models/account/account'
51import { VideoModel } from '../../../models/video/video'
52import { authenticatePromiseIfNeeded } from '../../oauth'
53import { areValidationErrors } from '../utils'
34ca3b52 54
418d092a 55const videosAddValidator = getCommonVideoEditAttributes().concat([
0c237b19
C
56 body('videofile')
57 .custom((value, { req }) => isVideoFile(req.files)).withMessage(
a1587156
C
58 'This file is not supported or too large. Please, make sure it is of the following type: ' +
59 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
0c237b19 60 ),
b60e5f38 61 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
0f320037 62 body('channelId')
c8861d5d 63 .customSanitizer(toIntOrNull)
2baea0c7 64 .custom(isIdValid).withMessage('Should have correct video channel id'),
b60e5f38 65
a2431b7d 66 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
b60e5f38
C
67 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
68
cf7a61b5
C
69 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
70 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
a2431b7d 71
b4055e1c 72 const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0]
a2431b7d 73 const user = res.locals.oauth.token.User
b60e5f38 74
0f6acda1 75 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
a2431b7d 76
fb719404 77 if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
a2431b7d
C
78 res.status(403)
79 .json({ error: 'The user video quota is exceeded with this video.' })
a2431b7d 80
cf7a61b5 81 return cleanUpReqFiles(req)
a2431b7d
C
82 }
83
84 let duration: number
85
86 try {
87 duration = await getDurationFromVideoFile(videoFile.path)
88 } catch (err) {
d5b7d911 89 logger.error('Invalid input file in videosAddValidator.', { err })
215304ea 90 res.status(400)
a2431b7d 91 .json({ error: 'Invalid input file.' })
a2431b7d 92
cf7a61b5 93 return cleanUpReqFiles(req)
a2431b7d
C
94 }
95
b4055e1c
C
96 videoFile.duration = duration
97
98 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
a2431b7d
C
99
100 return next()
b60e5f38 101 }
a920fef1 102])
b60e5f38 103
418d092a 104const videosUpdateValidator = getCommonVideoEditAttributes().concat([
72c7248b 105 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
360329cc
C
106 body('name')
107 .optional()
108 .custom(isVideoNameValid).withMessage('Should have a valid name'),
0f320037
C
109 body('channelId')
110 .optional()
c8861d5d 111 .customSanitizer(toIntOrNull)
0f320037 112 .custom(isIdValid).withMessage('Should have correct video channel id'),
b60e5f38 113
a2431b7d 114 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
b60e5f38
C
115 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
116
cf7a61b5
C
117 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
118 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
0f6acda1 119 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
a2431b7d 120
6221f311 121 // Check if the user who did the request is able to update the video
0f320037 122 const user = res.locals.oauth.token.User
453e83ea 123 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
a2431b7d 124
0f6acda1 125 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
0f320037 126
a2431b7d 127 return next()
b60e5f38 128 }
a920fef1 129])
c173e565 130
8d427346 131async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
0283eaac 132 const video = getVideoWithAttributes(res)
8d427346
C
133
134 // Anybody can watch local videos
135 if (video.isOwned() === true) return next()
136
137 // Logged user
138 if (res.locals.oauth) {
139 // Users can search or watch remote videos
140 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
141 }
142
143 // Anybody can search or watch remote videos
144 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
145
146 // Check our instance follows an actor that shared this video
147 const serverActor = await getServerActor()
148 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
149
150 return res.status(403)
151 .json({
e6abf95e
C
152 errorCode: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
153 error: 'Cannot get this video regarding follow constraints.',
154 originUrl: video.url
8d427346
C
155 })
156}
157
7eba5e1f
C
158const videosCustomGetValidator = (
159 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
160 authenticateInQuery = false
161) => {
96f29c0f
C
162 return [
163 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
7b1f49de 164
96f29c0f
C
165 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
166 logger.debug('Checking videosGet parameters', { parameters: req.params })
11474c3c 167
96f29c0f 168 if (areValidationErrors(req, res)) return
0f6acda1 169 if (!await doesVideoExist(req.params.id, res, fetchType)) return
191764f3 170
943e5193
C
171 // Controllers does not need to check video rights
172 if (fetchType === 'only-immutable-attributes') return next()
173
0283eaac 174 const video = getVideoWithAttributes(res)
453e83ea 175 const videoAll = video as MVideoFullLight
191764f3 176
96f29c0f 177 // Video private or blacklisted
22a73cb8 178 if (videoAll.requiresAuth()) {
eccf70f0 179 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
8d427346 180
dae86118 181 const user = res.locals.oauth ? res.locals.oauth.token.User : null
191764f3 182
8d427346 183 // Only the owner or a user that have blacklist rights can see the video
22a73cb8 184 if (!user || !user.canGetVideo(videoAll)) {
8d427346 185 return res.status(403)
22a73cb8 186 .json({ error: 'Cannot get this private/internal or blacklisted video.' })
8d427346 187 }
191764f3 188
8d427346 189 return next()
96f29c0f 190 }
11474c3c 191
96f29c0f
C
192 // Video is public, anyone can access it
193 if (video.privacy === VideoPrivacy.PUBLIC) return next()
11474c3c 194
96f29c0f
C
195 // Video is unlisted, check we used the uuid to fetch it
196 if (video.privacy === VideoPrivacy.UNLISTED) {
197 if (isUUIDValid(req.params.id)) return next()
81ebea48 198
96f29c0f
C
199 // Don't leak this unlisted video
200 return res.status(404).end()
201 }
81ebea48 202 }
96f29c0f
C
203 ]
204}
205
206const videosGetValidator = videosCustomGetValidator('all')
eccf70f0 207const videosDownloadValidator = videosCustomGetValidator('all', true)
34ca3b52 208
8319d6ae
RK
209const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
210 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
211 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
212
213 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
214 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
215
216 if (areValidationErrors(req, res)) return
217 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
218
219 return next()
220 }
221])
222
b60e5f38 223const videosRemoveValidator = [
72c7248b 224 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
34ca3b52 225
a2431b7d 226 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
b60e5f38 227 logger.debug('Checking videosRemove parameters', { parameters: req.params })
34ca3b52 228
a2431b7d 229 if (areValidationErrors(req, res)) return
0f6acda1 230 if (!await doesVideoExist(req.params.id, res)) return
a2431b7d
C
231
232 // Check if the user who did the request is able to delete the video
453e83ea 233 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
a2431b7d
C
234
235 return next()
b60e5f38
C
236 }
237]
34ca3b52 238
74d63469
GR
239const videosChangeOwnershipValidator = [
240 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
241
242 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
243 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
244
245 if (areValidationErrors(req, res)) return
0f6acda1 246 if (!await doesVideoExist(req.params.videoId, res)) return
74d63469
GR
247
248 // Check if the user who did the request is able to change the ownership of the video
453e83ea 249 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
74d63469
GR
250
251 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
252 if (!nextOwner) {
253 res.status(400)
9ccff238
LD
254 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
255
74d63469
GR
256 return
257 }
258 res.locals.nextOwner = nextOwner
259
260 return next()
261 }
262]
263
264const videosTerminateChangeOwnershipValidator = [
265 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
266
267 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
268 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
269
270 if (areValidationErrors(req, res)) return
271 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
272
273 // Check if the user who did the request is able to change the ownership of the video
274 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
275
dae86118 276 const videoChangeOwnership = res.locals.videoChangeOwnership
74d63469 277
a1587156 278 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
74d63469 279 res.status(403)
a1587156 280 .json({ error: 'Ownership already accepted or refused' })
74d63469
GR
281 return
282 }
a1587156
C
283
284 return next()
74d63469
GR
285 }
286]
287
288const videosAcceptChangeOwnershipValidator = [
289 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
290 const body = req.body as VideoChangeOwnershipAccept
0f6acda1 291 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
74d63469
GR
292
293 const user = res.locals.oauth.token.User
dae86118 294 const videoChangeOwnership = res.locals.videoChangeOwnership
fb719404 295 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
74d63469
GR
296 if (isAble === false) {
297 res.status(403)
298 .json({ error: 'The user video quota is exceeded with this video.' })
9ccff238 299
74d63469
GR
300 return
301 }
302
303 return next()
304 }
305]
306
764a9657
C
307const videosOverviewValidator = [
308 query('page')
309 .optional()
310 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
311 .withMessage('Should have a valid pagination'),
312
313 (req: express.Request, res: express.Response, next: express.NextFunction) => {
314 if (areValidationErrors(req, res)) return
315
316 return next()
317 }
318]
319
418d092a 320function getCommonVideoEditAttributes () {
a920fef1
C
321 return [
322 body('thumbnailfile')
323 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
a1587156
C
324 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
325 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
326 ),
a920fef1
C
327 body('previewfile')
328 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
a1587156
C
329 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
330 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
331 ),
a920fef1
C
332
333 body('category')
334 .optional()
335 .customSanitizer(toIntOrNull)
336 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
337 body('licence')
338 .optional()
339 .customSanitizer(toIntOrNull)
340 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
341 body('language')
342 .optional()
343 .customSanitizer(toValueOrNull)
344 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
345 body('nsfw')
346 .optional()
c8861d5d 347 .customSanitizer(toBooleanOrNull)
a920fef1
C
348 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
349 body('waitTranscoding')
350 .optional()
c8861d5d 351 .customSanitizer(toBooleanOrNull)
a920fef1
C
352 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
353 body('privacy')
354 .optional()
c8861d5d 355 .customSanitizer(toValueOrNull)
a920fef1
C
356 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
357 body('description')
358 .optional()
359 .customSanitizer(toValueOrNull)
360 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
361 body('support')
362 .optional()
363 .customSanitizer(toValueOrNull)
364 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
365 body('tags')
366 .optional()
367 .customSanitizer(toValueOrNull)
368 .custom(isVideoTagsValid).withMessage('Should have correct tags'),
369 body('commentsEnabled')
370 .optional()
c8861d5d 371 .customSanitizer(toBooleanOrNull)
a920fef1 372 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
7f2cfe3a 373 body('downloadEnabled')
1e74f19a 374 .optional()
c8861d5d 375 .customSanitizer(toBooleanOrNull)
156c50af 376 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
b718fd22 377 body('originallyPublishedAt')
c8861d5d
C
378 .optional()
379 .customSanitizer(toValueOrNull)
380 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
a920fef1
C
381 body('scheduleUpdate')
382 .optional()
383 .customSanitizer(toValueOrNull),
384 body('scheduleUpdate.updateAt')
385 .optional()
386 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
387 body('scheduleUpdate.privacy')
388 .optional()
2b65c4e5 389 .customSanitizer(toIntOrNull)
a920fef1
C
390 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
391 ] as (ValidationChain | express.Handler)[]
392}
fbad87b0 393
1cd3facc
C
394const commonVideosFiltersValidator = [
395 query('categoryOneOf')
396 .optional()
397 .customSanitizer(toArray)
398 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
399 query('licenceOneOf')
400 .optional()
401 .customSanitizer(toArray)
402 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
403 query('languageOneOf')
404 .optional()
405 .customSanitizer(toArray)
406 .custom(isStringArray).withMessage('Should have a valid one of language array'),
407 query('tagsOneOf')
408 .optional()
409 .customSanitizer(toArray)
410 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
411 query('tagsAllOf')
412 .optional()
413 .customSanitizer(toArray)
414 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
415 query('nsfw')
416 .optional()
417 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
418 query('filter')
419 .optional()
420 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
fe987656
C
421 query('skipCount')
422 .optional()
423 .customSanitizer(toBooleanOrNull)
424 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
1cd3facc
C
425
426 (req: express.Request, res: express.Response, next: express.NextFunction) => {
427 logger.debug('Checking commons video filters query', { parameters: req.query })
428
429 if (areValidationErrors(req, res)) return
430
dae86118 431 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
0aa52e17
C
432 if (
433 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
434 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
435 ) {
1cd3facc
C
436 res.status(401)
437 .json({ error: 'You are not allowed to see all local videos.' })
438
439 return
440 }
441
442 return next()
443 }
444]
445
fbad87b0
C
446// ---------------------------------------------------------------------------
447
448export {
449 videosAddValidator,
450 videosUpdateValidator,
451 videosGetValidator,
8319d6ae 452 videoFileMetadataGetValidator,
eccf70f0 453 videosDownloadValidator,
8d427346 454 checkVideoFollowConstraints,
96f29c0f 455 videosCustomGetValidator,
fbad87b0 456 videosRemoveValidator,
fbad87b0 457
74d63469
GR
458 videosChangeOwnershipValidator,
459 videosTerminateChangeOwnershipValidator,
460 videosAcceptChangeOwnershipValidator,
461
418d092a 462 getCommonVideoEditAttributes,
1cd3facc 463
764a9657
C
464 commonVideosFiltersValidator,
465
466 videosOverviewValidator
fbad87b0
C
467}
468
469// ---------------------------------------------------------------------------
470
471function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
472 if (req.body.scheduleUpdate) {
473 if (!req.body.scheduleUpdate.updateAt) {
7373507f
C
474 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
475
fbad87b0
C
476 res.status(400)
477 .json({ error: 'Schedule update at is mandatory.' })
fbad87b0
C
478
479 return true
480 }
481 }
482
483 return false
484}
b4055e1c
C
485
486async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
487 // Check we accept this video
488 const acceptParameters = {
489 videoBody: req.body,
490 videoFile,
491 user: res.locals.oauth.token.User
492 }
89cd1275
C
493 const acceptedResult = await Hooks.wrapFun(
494 isLocalVideoAccepted,
495 acceptParameters,
b4055e1c
C
496 'filter:api.video.upload.accept.result'
497 )
498
499 if (!acceptedResult || acceptedResult.accepted !== true) {
500 logger.info('Refused local video.', { acceptedResult, acceptParameters })
501 res.status(403)
502 .json({ error: acceptedResult.errorMessage || 'Refused local video' })
503
504 return false
505 }
506
507 return true
508}