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