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