Merge branch 'master' into develop
[oweals/peertube.git] / server / middlewares / validators / videos / videos.ts
1 import * as express from 'express'
2 import 'express-validator'
3 import { body, param, query, ValidationChain } from 'express-validator/check'
4 import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
5 import {
6   isBooleanValid,
7   isDateValid,
8   isIdOrUUIDValid,
9   isIdValid,
10   isUUIDValid,
11   toArray,
12   toIntOrNull,
13   toValueOrNull
14 } from '../../../helpers/custom-validators/misc'
15 import {
16   checkUserCanManageVideo,
17   isScheduleVideoUpdatePrivacyValid,
18   isVideoCategoryValid,
19   isVideoChannelOfAccountExist,
20   isVideoDescriptionValid,
21   isVideoExist,
22   isVideoFile,
23   isVideoFilterValid,
24   isVideoImage,
25   isVideoLanguageValid,
26   isVideoLicenceValid,
27   isVideoNameValid,
28   isVideoPrivacyValid,
29   isVideoRatingTypeValid,
30   isVideoSupportValid,
31   isVideoTagsValid
32 } from '../../../helpers/custom-validators/videos'
33 import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
34 import { logger } from '../../../helpers/logger'
35 import { CONSTRAINTS_FIELDS } from '../../../initializers'
36 import { VideoShareModel } from '../../../models/video/video-share'
37 import { authenticate } from '../../oauth'
38 import { areValidationErrors } from '../utils'
39 import { cleanUpReqFiles } from '../../../helpers/express-utils'
40 import { VideoModel } from '../../../models/video/video'
41 import { UserModel } from '../../../models/account/user'
42 import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
43 import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
44 import { VideoChangeOwnershipModel } from '../../../models/video/video-change-ownership'
45 import { AccountModel } from '../../../models/account/account'
46 import { VideoFetchType } from '../../../helpers/video'
47 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
48
49 const videosAddValidator = getCommonVideoAttributes().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     .toInt()
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 = req.files['videofile'][0]
67     const user = res.locals.oauth.token.User
68
69     if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
70
71     const isAble = await user.isAbleToUploadVideo(videoFile)
72     if (isAble === false) {
73       res.status(403)
74          .json({ error: 'The user video quota is exceeded with this video.' })
75
76       return cleanUpReqFiles(req)
77     }
78
79     let duration: number
80
81     try {
82       duration = await getDurationFromVideoFile(videoFile.path)
83     } catch (err) {
84       logger.error('Invalid input file in videosAddValidator.', { err })
85       res.status(400)
86          .json({ error: 'Invalid input file.' })
87
88       return cleanUpReqFiles(req)
89     }
90
91     videoFile['duration'] = duration
92
93     return next()
94   }
95 ])
96
97 const videosUpdateValidator = getCommonVideoAttributes().concat([
98   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
99   body('name')
100     .optional()
101     .custom(isVideoNameValid).withMessage('Should have a valid name'),
102   body('channelId')
103     .optional()
104     .toInt()
105     .custom(isIdValid).withMessage('Should have correct video channel id'),
106
107   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
108     logger.debug('Checking videosUpdate parameters', { parameters: req.body })
109
110     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
111     if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
112     if (!await isVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
113
114     const video = res.locals.video
115
116     // Check if the user who did the request is able to update the video
117     const user = res.locals.oauth.token.User
118     if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
119
120     if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) {
121       cleanUpReqFiles(req)
122       return res.status(409)
123         .json({ error: 'Cannot set "private" a video that was not private.' })
124     }
125
126     if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
127
128     return next()
129   }
130 ])
131
132 const videosCustomGetValidator = (fetchType: VideoFetchType) => {
133   return [
134     param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
135
136     async (req: express.Request, res: express.Response, next: express.NextFunction) => {
137       logger.debug('Checking videosGet parameters', { parameters: req.params })
138
139       if (areValidationErrors(req, res)) return
140       if (!await isVideoExist(req.params.id, res, fetchType)) return
141
142       const video: VideoModel = res.locals.video
143
144       // Video private or blacklisted
145       if (video.privacy === VideoPrivacy.PRIVATE || video.VideoBlacklist) {
146         return authenticate(req, res, () => {
147           const user: UserModel = res.locals.oauth.token.User
148
149           // Only the owner or a user that have blacklist rights can see the video
150           if (video.VideoChannel.Account.userId !== user.id && !user.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
151             return res.status(403)
152                       .json({ error: 'Cannot get this private or blacklisted video.' })
153           }
154
155           return next()
156         })
157       }
158
159       // Video is public, anyone can access it
160       if (video.privacy === VideoPrivacy.PUBLIC) return next()
161
162       // Video is unlisted, check we used the uuid to fetch it
163       if (video.privacy === VideoPrivacy.UNLISTED) {
164         if (isUUIDValid(req.params.id)) return next()
165
166         // Don't leak this unlisted video
167         return res.status(404).end()
168       }
169     }
170   ]
171 }
172
173 const videosGetValidator = videosCustomGetValidator('all')
174
175 const videosRemoveValidator = [
176   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
177
178   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
179     logger.debug('Checking videosRemove parameters', { parameters: req.params })
180
181     if (areValidationErrors(req, res)) return
182     if (!await isVideoExist(req.params.id, res)) return
183
184     // Check if the user who did the request is able to delete the video
185     if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.REMOVE_ANY_VIDEO, res)) return
186
187     return next()
188   }
189 ]
190
191 const videoRateValidator = [
192   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
193   body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
194
195   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
196     logger.debug('Checking videoRate parameters', { parameters: req.body })
197
198     if (areValidationErrors(req, res)) return
199     if (!await isVideoExist(req.params.id, res)) return
200
201     return next()
202   }
203 ]
204
205 const videosShareValidator = [
206   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
207   param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'),
208
209   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
210     logger.debug('Checking videoShare parameters', { parameters: req.params })
211
212     if (areValidationErrors(req, res)) return
213     if (!await isVideoExist(req.params.id, res)) return
214
215     const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined)
216     if (!share) {
217       return res.status(404)
218         .end()
219     }
220
221     res.locals.videoShare = share
222     return next()
223   }
224 ]
225
226 const videosChangeOwnershipValidator = [
227   param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
228
229   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
230     logger.debug('Checking changeOwnership parameters', { parameters: req.params })
231
232     if (areValidationErrors(req, res)) return
233     if (!await isVideoExist(req.params.videoId, res)) return
234
235     // Check if the user who did the request is able to change the ownership of the video
236     if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
237
238     const nextOwner = await AccountModel.loadLocalByName(req.body.username)
239     if (!nextOwner) {
240       res.status(400)
241         .json({ error: 'Changing video ownership to a remote account is not supported yet' })
242
243       return
244     }
245     res.locals.nextOwner = nextOwner
246
247     return next()
248   }
249 ]
250
251 const videosTerminateChangeOwnershipValidator = [
252   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
253
254   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
255     logger.debug('Checking changeOwnership parameters', { parameters: req.params })
256
257     if (areValidationErrors(req, res)) return
258     if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
259
260     // Check if the user who did the request is able to change the ownership of the video
261     if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
262
263     return next()
264   },
265   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
266     const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel
267
268     if (videoChangeOwnership.status === VideoChangeOwnershipStatus.WAITING) {
269       return next()
270     } else {
271       res.status(403)
272         .json({ error: 'Ownership already accepted or refused' })
273
274       return
275     }
276   }
277 ]
278
279 const videosAcceptChangeOwnershipValidator = [
280   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
281     const body = req.body as VideoChangeOwnershipAccept
282     if (!await isVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
283
284     const user = res.locals.oauth.token.User
285     const videoChangeOwnership = res.locals.videoChangeOwnership as VideoChangeOwnershipModel
286     const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getOriginalFile())
287     if (isAble === false) {
288       res.status(403)
289         .json({ error: 'The user video quota is exceeded with this video.' })
290
291       return
292     }
293
294     return next()
295   }
296 ]
297
298 function getCommonVideoAttributes () {
299   return [
300     body('thumbnailfile')
301       .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
302       'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
303       + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
304     ),
305     body('previewfile')
306       .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
307       'This preview file is not supported or too large. Please, make sure it is of the following type: '
308       + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
309     ),
310
311     body('category')
312       .optional()
313       .customSanitizer(toIntOrNull)
314       .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
315     body('licence')
316       .optional()
317       .customSanitizer(toIntOrNull)
318       .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
319     body('language')
320       .optional()
321       .customSanitizer(toValueOrNull)
322       .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
323     body('nsfw')
324       .optional()
325       .toBoolean()
326       .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
327     body('waitTranscoding')
328       .optional()
329       .toBoolean()
330       .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
331     body('privacy')
332       .optional()
333       .toInt()
334       .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
335     body('description')
336       .optional()
337       .customSanitizer(toValueOrNull)
338       .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
339     body('support')
340       .optional()
341       .customSanitizer(toValueOrNull)
342       .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
343     body('tags')
344       .optional()
345       .customSanitizer(toValueOrNull)
346       .custom(isVideoTagsValid).withMessage('Should have correct tags'),
347     body('commentsEnabled')
348       .optional()
349       .toBoolean()
350       .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
351
352     body('scheduleUpdate')
353       .optional()
354       .customSanitizer(toValueOrNull),
355     body('scheduleUpdate.updateAt')
356       .optional()
357       .custom(isDateValid).withMessage('Should have a valid schedule update date'),
358     body('scheduleUpdate.privacy')
359       .optional()
360       .toInt()
361       .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
362   ] as (ValidationChain | express.Handler)[]
363 }
364
365 const commonVideosFiltersValidator = [
366   query('categoryOneOf')
367     .optional()
368     .customSanitizer(toArray)
369     .custom(isNumberArray).withMessage('Should have a valid one of category array'),
370   query('licenceOneOf')
371     .optional()
372     .customSanitizer(toArray)
373     .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
374   query('languageOneOf')
375     .optional()
376     .customSanitizer(toArray)
377     .custom(isStringArray).withMessage('Should have a valid one of language array'),
378   query('tagsOneOf')
379     .optional()
380     .customSanitizer(toArray)
381     .custom(isStringArray).withMessage('Should have a valid one of tags array'),
382   query('tagsAllOf')
383     .optional()
384     .customSanitizer(toArray)
385     .custom(isStringArray).withMessage('Should have a valid all of tags array'),
386   query('nsfw')
387     .optional()
388     .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
389   query('filter')
390     .optional()
391     .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
392
393   (req: express.Request, res: express.Response, next: express.NextFunction) => {
394     logger.debug('Checking commons video filters query', { parameters: req.query })
395
396     if (areValidationErrors(req, res)) return
397
398     const user: UserModel = res.locals.oauth ? res.locals.oauth.token.User : undefined
399     if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) {
400       res.status(401)
401          .json({ error: 'You are not allowed to see all local videos.' })
402
403       return
404     }
405
406     return next()
407   }
408 ]
409
410 // ---------------------------------------------------------------------------
411
412 export {
413   videosAddValidator,
414   videosUpdateValidator,
415   videosGetValidator,
416   videosCustomGetValidator,
417   videosRemoveValidator,
418   videosShareValidator,
419
420   videoRateValidator,
421
422   videosChangeOwnershipValidator,
423   videosTerminateChangeOwnershipValidator,
424   videosAcceptChangeOwnershipValidator,
425
426   getCommonVideoAttributes,
427
428   commonVideosFiltersValidator
429 }
430
431 // ---------------------------------------------------------------------------
432
433 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
434   if (req.body.scheduleUpdate) {
435     if (!req.body.scheduleUpdate.updateAt) {
436       res.status(400)
437          .json({ error: 'Schedule update at is mandatory.' })
438
439       return true
440     }
441   }
442
443   return false
444 }