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