]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
af0072d73d4704525c00d555370651baa0d21e44
[github/Chocobozzz/PeerTube.git] / server / middlewares / validators / videos / videos.ts
1 import * as express from 'express'
2 import { body, param, query, ValidationChain } from 'express-validator'
3 import { isAbleToUploadVideo } from '@server/lib/user'
4 import { getServerActor } from '@server/models/application/application'
5 import { MVideoFullLight } from '@server/types/models'
6 import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
7 import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
8 import {
9 isBooleanValid,
10 isDateValid,
11 isIdOrUUIDValid,
12 isIdValid,
13 isUUIDValid,
14 toArray,
15 toBooleanOrNull,
16 toIntOrNull,
17 toValueOrNull
18 } from '../../../helpers/custom-validators/misc'
19 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
20 import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
21 import {
22 isScheduleVideoUpdatePrivacyValid,
23 isVideoCategoryValid,
24 isVideoDescriptionValid,
25 isVideoFile,
26 isVideoFilterValid,
27 isVideoImage,
28 isVideoLanguageValid,
29 isVideoLicenceValid,
30 isVideoNameValid,
31 isVideoOriginallyPublishedAtValid,
32 isVideoPrivacyValid,
33 isVideoSupportValid,
34 isVideoTagsValid
35 } from '../../../helpers/custom-validators/videos'
36 import { cleanUpReqFiles } from '../../../helpers/express-utils'
37 import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
38 import { logger } from '../../../helpers/logger'
39 import {
40 checkUserCanManageVideo,
41 doesVideoChannelOfAccountExist,
42 doesVideoExist,
43 doesVideoFileOfVideoExist
44 } from '../../../helpers/middlewares'
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 { AccountModel } from '../../../models/account/account'
51 import { VideoModel } from '../../../models/video/video'
52 import { authenticatePromiseIfNeeded } from '../../oauth'
53 import { areValidationErrors } from '../utils'
54
55 const videosAddValidator = getCommonVideoEditAttributes().concat([
56 body('videofile')
57 .custom((value, { req }) => isVideoFile(req.files)).withMessage(
58 'This file is not supported or too large. Please, make sure it is of the following type: ' +
59 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
60 ),
61 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
62 body('channelId')
63 .customSanitizer(toIntOrNull)
64 .custom(isIdValid).withMessage('Should have correct video channel id'),
65
66 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
67 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
68
69 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
70 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
71
72 const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0]
73 const user = res.locals.oauth.token.User
74
75 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
76
77 if (await isAbleToUploadVideo(user.id, videoFile.size) === false) {
78 res.status(403)
79 .json({ error: 'The user video quota is exceeded with this video.' })
80
81 return cleanUpReqFiles(req)
82 }
83
84 let duration: number
85
86 try {
87 duration = await getDurationFromVideoFile(videoFile.path)
88 } catch (err) {
89 logger.error('Invalid input file in videosAddValidator.', { err })
90 res.status(400)
91 .json({ error: 'Invalid input file.' })
92
93 return cleanUpReqFiles(req)
94 }
95
96 videoFile.duration = duration
97
98 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
99
100 return next()
101 }
102 ])
103
104 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
105 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
106 body('name')
107 .optional()
108 .custom(isVideoNameValid).withMessage('Should have a valid name'),
109 body('channelId')
110 .optional()
111 .customSanitizer(toIntOrNull)
112 .custom(isIdValid).withMessage('Should have correct video channel id'),
113
114 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
115 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
116
117 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
118 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
119 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
120
121 // Check if the user who did the request is able to update the video
122 const user = res.locals.oauth.token.User
123 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
124
125 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
126
127 return next()
128 }
129 ])
130
131 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
132 const video = getVideoWithAttributes(res)
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({
152 errorCode: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
153 error: 'Cannot get this video regarding follow constraints.',
154 originUrl: video.url
155 })
156 }
157
158 const videosCustomGetValidator = (
159 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
160 authenticateInQuery = false
161 ) => {
162 return [
163 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
164
165 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
166 logger.debug('Checking videosGet parameters', { parameters: req.params })
167
168 if (areValidationErrors(req, res)) return
169 if (!await doesVideoExist(req.params.id, res, fetchType)) return
170
171 // Controllers does not need to check video rights
172 if (fetchType === 'only-immutable-attributes') return next()
173
174 const video = getVideoWithAttributes(res)
175 const videoAll = video as MVideoFullLight
176
177 // Video private or blacklisted
178 if (videoAll.requiresAuth()) {
179 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
180
181 const user = res.locals.oauth ? res.locals.oauth.token.User : null
182
183 // Only the owner or a user that have blacklist rights can see the video
184 if (!user || !user.canGetVideo(videoAll)) {
185 return res.status(403)
186 .json({ error: 'Cannot get this private/internal or blacklisted video.' })
187 }
188
189 return next()
190 }
191
192 // Video is public, anyone can access it
193 if (video.privacy === VideoPrivacy.PUBLIC) return next()
194
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()
198
199 // Don't leak this unlisted video
200 return res.status(404).end()
201 }
202 }
203 ]
204 }
205
206 const videosGetValidator = videosCustomGetValidator('all')
207 const videosDownloadValidator = videosCustomGetValidator('all', true)
208
209 const 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
223 const videosRemoveValidator = [
224 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
225
226 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
227 logger.debug('Checking videosRemove parameters', { parameters: req.params })
228
229 if (areValidationErrors(req, res)) return
230 if (!await doesVideoExist(req.params.id, res)) return
231
232 // Check if the user who did the request is able to delete the video
233 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
234
235 return next()
236 }
237 ]
238
239 const 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
246 if (!await doesVideoExist(req.params.videoId, res)) return
247
248 // Check if the user who did the request is able to change the ownership of the video
249 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
250
251 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
252 if (!nextOwner) {
253 res.status(400)
254 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
255
256 return
257 }
258 res.locals.nextOwner = nextOwner
259
260 return next()
261 }
262 ]
263
264 const 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
276 const videoChangeOwnership = res.locals.videoChangeOwnership
277
278 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
279 res.status(403)
280 .json({ error: 'Ownership already accepted or refused' })
281 return
282 }
283
284 return next()
285 }
286 ]
287
288 const videosAcceptChangeOwnershipValidator = [
289 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
290 const body = req.body as VideoChangeOwnershipAccept
291 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
292
293 const user = res.locals.oauth.token.User
294 const videoChangeOwnership = res.locals.videoChangeOwnership
295 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
296 if (isAble === false) {
297 res.status(403)
298 .json({ error: 'The user video quota is exceeded with this video.' })
299
300 return
301 }
302
303 return next()
304 }
305 ]
306
307 const 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
320 function getCommonVideoEditAttributes () {
321 return [
322 body('thumbnailfile')
323 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
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 ),
327 body('previewfile')
328 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
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 ),
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()
347 .customSanitizer(toBooleanOrNull)
348 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
349 body('waitTranscoding')
350 .optional()
351 .customSanitizer(toBooleanOrNull)
352 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
353 body('privacy')
354 .optional()
355 .customSanitizer(toValueOrNull)
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()
371 .customSanitizer(toBooleanOrNull)
372 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
373 body('downloadEnabled')
374 .optional()
375 .customSanitizer(toBooleanOrNull)
376 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
377 body('originallyPublishedAt')
378 .optional()
379 .customSanitizer(toValueOrNull)
380 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
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()
389 .customSanitizer(toIntOrNull)
390 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
391 ] as (ValidationChain | express.Handler)[]
392 }
393
394 const 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'),
421 query('skipCount')
422 .optional()
423 .customSanitizer(toBooleanOrNull)
424 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
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
431 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
432 if (
433 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
434 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
435 ) {
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
446 // ---------------------------------------------------------------------------
447
448 export {
449 videosAddValidator,
450 videosUpdateValidator,
451 videosGetValidator,
452 videoFileMetadataGetValidator,
453 videosDownloadValidator,
454 checkVideoFollowConstraints,
455 videosCustomGetValidator,
456 videosRemoveValidator,
457
458 videosChangeOwnershipValidator,
459 videosTerminateChangeOwnershipValidator,
460 videosAcceptChangeOwnershipValidator,
461
462 getCommonVideoEditAttributes,
463
464 commonVideosFiltersValidator,
465
466 videosOverviewValidator
467 }
468
469 // ---------------------------------------------------------------------------
470
471 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
472 if (req.body.scheduleUpdate) {
473 if (!req.body.scheduleUpdate.updateAt) {
474 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
475
476 res.status(400)
477 .json({ error: 'Schedule update at is mandatory.' })
478
479 return true
480 }
481 }
482
483 return false
484 }
485
486 async 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 }
493 const acceptedResult = await Hooks.wrapFun(
494 isLocalVideoAccepted,
495 acceptParameters,
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 }