]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
867c05fc13f8152dca39732973f07adb3e9882ab
[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 { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
4 import {
5 isBooleanValid,
6 isDateValid,
7 isIdOrUUIDValid,
8 isIdValid,
9 isUUIDValid,
10 toArray,
11 toBooleanOrNull,
12 toIntOrNull,
13 toValueOrNull
14 } from '../../../helpers/custom-validators/misc'
15 import {
16 isScheduleVideoUpdatePrivacyValid,
17 isVideoCategoryValid,
18 isVideoDescriptionValid,
19 isVideoFile,
20 isVideoFilterValid,
21 isVideoImage,
22 isVideoLanguageValid,
23 isVideoLicenceValid,
24 isVideoNameValid,
25 isVideoOriginallyPublishedAtValid,
26 isVideoPrivacyValid,
27 isVideoSupportValid,
28 isVideoTagsValid
29 } from '../../../helpers/custom-validators/videos'
30 import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
31 import { logger } from '../../../helpers/logger'
32 import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
33 import { authenticatePromiseIfNeeded } from '../../oauth'
34 import { areValidationErrors } from '../utils'
35 import { cleanUpReqFiles } from '../../../helpers/express-utils'
36 import { VideoModel } from '../../../models/video/video'
37 import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
38 import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
39 import { AccountModel } from '../../../models/account/account'
40 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
41 import { CONFIG } from '../../../initializers/config'
42 import { isLocalVideoAccepted } from '../../../lib/moderation'
43 import { Hooks } from '../../../lib/plugins/hooks'
44 import {
45 checkUserCanManageVideo,
46 doesVideoChannelOfAccountExist,
47 doesVideoExist,
48 doesVideoFileOfVideoExist
49 } from '../../../helpers/middlewares'
50 import { MVideoFullLight } from '@server/typings/models'
51 import { getVideoWithAttributes } from '../../../helpers/video'
52 import { getServerActor } from '@server/models/application/application'
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 error: 'Cannot get this video regarding follow constraints.'
152 })
153 }
154
155 const videosCustomGetValidator = (
156 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
157 authenticateInQuery = false
158 ) => {
159 return [
160 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
161
162 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
163 logger.debug('Checking videosGet parameters', { parameters: req.params })
164
165 if (areValidationErrors(req, res)) return
166 if (!await doesVideoExist(req.params.id, res, fetchType)) return
167
168 // Controllers does not need to check video rights
169 if (fetchType === 'only-immutable-attributes') return next()
170
171 const video = getVideoWithAttributes(res)
172 const videoAll = video as MVideoFullLight
173
174 // Video private or blacklisted
175 if (videoAll.requiresAuth()) {
176 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
177
178 const user = res.locals.oauth ? res.locals.oauth.token.User : null
179
180 // Only the owner or a user that have blacklist rights can see the video
181 if (!user || !user.canGetVideo(videoAll)) {
182 return res.status(403)
183 .json({ error: 'Cannot get this private/internal or blacklisted video.' })
184 }
185
186 return next()
187 }
188
189 // Video is public, anyone can access it
190 if (video.privacy === VideoPrivacy.PUBLIC) return next()
191
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()
195
196 // Don't leak this unlisted video
197 return res.status(404).end()
198 }
199 }
200 ]
201 }
202
203 const videosGetValidator = videosCustomGetValidator('all')
204 const videosDownloadValidator = videosCustomGetValidator('all', true)
205
206 const 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
220 const videosRemoveValidator = [
221 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
222
223 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
224 logger.debug('Checking videosRemove parameters', { parameters: req.params })
225
226 if (areValidationErrors(req, res)) return
227 if (!await doesVideoExist(req.params.id, res)) return
228
229 // Check if the user who did the request is able to delete the video
230 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
231
232 return next()
233 }
234 ]
235
236 const 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
243 if (!await doesVideoExist(req.params.videoId, res)) return
244
245 // Check if the user who did the request is able to change the ownership of the video
246 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
247
248 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
249 if (!nextOwner) {
250 res.status(400)
251 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
252
253 return
254 }
255 res.locals.nextOwner = nextOwner
256
257 return next()
258 }
259 ]
260
261 const 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
273 const videoChangeOwnership = res.locals.videoChangeOwnership
274
275 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
276 res.status(403)
277 .json({ error: 'Ownership already accepted or refused' })
278 return
279 }
280
281 return next()
282 }
283 ]
284
285 const videosAcceptChangeOwnershipValidator = [
286 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
287 const body = req.body as VideoChangeOwnershipAccept
288 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
289
290 const user = res.locals.oauth.token.User
291 const videoChangeOwnership = res.locals.videoChangeOwnership
292 const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile())
293 if (isAble === false) {
294 res.status(403)
295 .json({ error: 'The user video quota is exceeded with this video.' })
296
297 return
298 }
299
300 return next()
301 }
302 ]
303
304 const 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
317 function getCommonVideoEditAttributes () {
318 return [
319 body('thumbnailfile')
320 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
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 ),
324 body('previewfile')
325 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
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 ),
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()
344 .customSanitizer(toBooleanOrNull)
345 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
346 body('waitTranscoding')
347 .optional()
348 .customSanitizer(toBooleanOrNull)
349 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
350 body('privacy')
351 .optional()
352 .customSanitizer(toValueOrNull)
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()
368 .customSanitizer(toBooleanOrNull)
369 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
370 body('downloadEnabled')
371 .optional()
372 .customSanitizer(toBooleanOrNull)
373 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
374 body('originallyPublishedAt')
375 .optional()
376 .customSanitizer(toValueOrNull)
377 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
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()
386 .customSanitizer(toIntOrNull)
387 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
388 ] as (ValidationChain | express.Handler)[]
389 }
390
391 const 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'),
418 query('skipCount')
419 .optional()
420 .customSanitizer(toBooleanOrNull)
421 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
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
428 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
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
440 // ---------------------------------------------------------------------------
441
442 export {
443 videosAddValidator,
444 videosUpdateValidator,
445 videosGetValidator,
446 videoFileMetadataGetValidator,
447 videosDownloadValidator,
448 checkVideoFollowConstraints,
449 videosCustomGetValidator,
450 videosRemoveValidator,
451
452 videosChangeOwnershipValidator,
453 videosTerminateChangeOwnershipValidator,
454 videosAcceptChangeOwnershipValidator,
455
456 getCommonVideoEditAttributes,
457
458 commonVideosFiltersValidator,
459
460 videosOverviewValidator
461 }
462
463 // ---------------------------------------------------------------------------
464
465 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
466 if (req.body.scheduleUpdate) {
467 if (!req.body.scheduleUpdate.updateAt) {
468 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
469
470 res.status(400)
471 .json({ error: 'Schedule update at is mandatory.' })
472
473 return true
474 }
475 }
476
477 return false
478 }
479
480 async 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 }
487 const acceptedResult = await Hooks.wrapFun(
488 isLocalVideoAccepted,
489 acceptParameters,
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 }