Add concept of video state, and add ability to wait transcoding before
[oweals/peertube.git] / server / middlewares / validators / videos.ts
1 import * as express from 'express'
2 import 'express-validator'
3 import { body, param, query } from 'express-validator/check'
4 import { UserRight, VideoPrivacy } from '../../../shared'
5 import { isBooleanValid, isIdOrUUIDValid, isIdValid, isUUIDValid, toIntOrNull, toValueOrNull } from '../../helpers/custom-validators/misc'
6 import {
7   isVideoAbuseReasonValid,
8   isVideoCategoryValid,
9   isVideoChannelOfAccountExist,
10   isVideoDescriptionValid,
11   isVideoExist,
12   isVideoFile,
13   isVideoImage,
14   isVideoLanguageValid,
15   isVideoLicenceValid,
16   isVideoNameValid,
17   isVideoPrivacyValid,
18   isVideoRatingTypeValid,
19   isVideoSupportValid,
20   isVideoTagsValid
21 } from '../../helpers/custom-validators/videos'
22 import { getDurationFromVideoFile } from '../../helpers/ffmpeg-utils'
23 import { logger } from '../../helpers/logger'
24 import { CONSTRAINTS_FIELDS } from '../../initializers'
25 import { UserModel } from '../../models/account/user'
26 import { VideoModel } from '../../models/video/video'
27 import { VideoShareModel } from '../../models/video/video-share'
28 import { authenticate } from '../oauth'
29 import { areValidationErrors } from './utils'
30
31 const videosAddValidator = [
32   body('videofile').custom((value, { req }) => isVideoFile(req.files)).withMessage(
33     'This file is not supported. Please, make sure it is of the following type : '
34     + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
35   ),
36   body('thumbnailfile').custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
37     'This thumbnail file is not supported. Please, make sure it is of the following type : '
38     + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
39   ),
40   body('previewfile').custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
41     'This preview file is not supported. Please, make sure it is of the following type : '
42     + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
43   ),
44   body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
45   body('category')
46     .optional()
47     .customSanitizer(toIntOrNull)
48     .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
49   body('licence')
50     .optional()
51     .customSanitizer(toIntOrNull)
52     .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
53   body('language')
54     .optional()
55     .customSanitizer(toValueOrNull)
56     .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
57   body('nsfw')
58     .optional()
59     .toBoolean()
60     .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
61   body('waitTranscoding')
62     .optional()
63     .toBoolean()
64     .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
65   body('description')
66     .optional()
67     .customSanitizer(toValueOrNull)
68     .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
69   body('support')
70     .optional()
71     .customSanitizer(toValueOrNull)
72     .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
73   body('tags')
74     .optional()
75     .customSanitizer(toValueOrNull)
76     .custom(isVideoTagsValid).withMessage('Should have correct tags'),
77   body('commentsEnabled')
78     .optional()
79     .toBoolean()
80     .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
81   body('privacy')
82     .optional()
83     .toInt()
84     .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
85   body('channelId')
86     .toInt()
87     .custom(isIdValid)
88     .withMessage('Should have correct video channel id'),
89
90   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
91     logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
92
93     if (areValidationErrors(req, res)) return
94     if (areErrorsInVideoImageFiles(req, res)) return
95
96     const videoFile: Express.Multer.File = req.files['videofile'][0]
97     const user = res.locals.oauth.token.User
98
99     if (!await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return
100
101     const isAble = await user.isAbleToUploadVideo(videoFile)
102     if (isAble === false) {
103       res.status(403)
104          .json({ error: 'The user video quota is exceeded with this video.' })
105          .end()
106
107       return
108     }
109
110     let duration: number
111
112     try {
113       duration = await getDurationFromVideoFile(videoFile.path)
114     } catch (err) {
115       logger.error('Invalid input file in videosAddValidator.', { err })
116       res.status(400)
117          .json({ error: 'Invalid input file.' })
118          .end()
119
120       return
121     }
122
123     videoFile['duration'] = duration
124
125     return next()
126   }
127 ]
128
129 const videosUpdateValidator = [
130   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
131   body('thumbnailfile').custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
132     'This thumbnail file is not supported. Please, make sure it is of the following type : '
133     + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
134   ),
135   body('previewfile').custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
136     'This preview file is not supported. Please, make sure it is of the following type : '
137     + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
138   ),
139   body('name')
140     .optional()
141     .custom(isVideoNameValid).withMessage('Should have a valid name'),
142   body('category')
143     .optional()
144     .customSanitizer(toIntOrNull)
145     .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
146   body('licence')
147     .optional()
148     .customSanitizer(toIntOrNull)
149     .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
150   body('language')
151     .optional()
152     .customSanitizer(toValueOrNull)
153     .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
154   body('nsfw')
155     .optional()
156     .toBoolean()
157     .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
158   body('waitTranscoding')
159     .optional()
160     .toBoolean()
161     .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
162   body('privacy')
163     .optional()
164     .toInt()
165     .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
166   body('description')
167     .optional()
168     .customSanitizer(toValueOrNull)
169     .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
170   body('support')
171     .optional()
172     .customSanitizer(toValueOrNull)
173     .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
174   body('tags')
175     .optional()
176     .customSanitizer(toValueOrNull)
177     .custom(isVideoTagsValid).withMessage('Should have correct tags'),
178   body('commentsEnabled')
179     .optional()
180     .toBoolean()
181     .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
182   body('channelId')
183     .optional()
184     .toInt()
185     .custom(isIdValid).withMessage('Should have correct video channel id'),
186
187   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
188     logger.debug('Checking videosUpdate parameters', { parameters: req.body })
189
190     if (areValidationErrors(req, res)) return
191     if (areErrorsInVideoImageFiles(req, res)) return
192     if (!await isVideoExist(req.params.id, res)) return
193
194     const video = res.locals.video
195
196     // Check if the user who did the request is able to update the video
197     const user = res.locals.oauth.token.User
198     if (!checkUserCanManageVideo(user, res.locals.video, UserRight.UPDATE_ANY_VIDEO, res)) return
199
200     if (video.privacy !== VideoPrivacy.PRIVATE && req.body.privacy === VideoPrivacy.PRIVATE) {
201       return res.status(409)
202         .json({ error: 'Cannot set "private" a video that was not private anymore.' })
203         .end()
204     }
205
206     if (req.body.channelId && !await isVideoChannelOfAccountExist(req.body.channelId, user, res)) return
207
208     return next()
209   }
210 ]
211
212 const videosGetValidator = [
213   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
214
215   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
216     logger.debug('Checking videosGet parameters', { parameters: req.params })
217
218     if (areValidationErrors(req, res)) return
219     if (!await isVideoExist(req.params.id, res)) return
220
221     const video = res.locals.video
222
223     // Video is public, anyone can access it
224     if (video.privacy === VideoPrivacy.PUBLIC) return next()
225
226     // Video is unlisted, check we used the uuid to fetch it
227     if (video.privacy === VideoPrivacy.UNLISTED) {
228       if (isUUIDValid(req.params.id)) return next()
229
230       // Don't leak this unlisted video
231       return res.status(404).end()
232     }
233
234     // Video is private, check the user
235     authenticate(req, res, () => {
236       if (video.VideoChannel.Account.userId !== res.locals.oauth.token.User.id) {
237         return res.status(403)
238           .json({ error: 'Cannot get this private video of another user' })
239           .end()
240       }
241
242       return next()
243     })
244   }
245 ]
246
247 const videosRemoveValidator = [
248   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
249
250   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
251     logger.debug('Checking videosRemove parameters', { parameters: req.params })
252
253     if (areValidationErrors(req, res)) return
254     if (!await isVideoExist(req.params.id, res)) return
255
256     // Check if the user who did the request is able to delete the video
257     if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.video, UserRight.REMOVE_ANY_VIDEO, res)) return
258
259     return next()
260   }
261 ]
262
263 const videosSearchValidator = [
264   query('search').not().isEmpty().withMessage('Should have a valid search'),
265
266   (req: express.Request, res: express.Response, next: express.NextFunction) => {
267     logger.debug('Checking videosSearch parameters', { parameters: req.params })
268
269     if (areValidationErrors(req, res)) return
270
271     return next()
272   }
273 ]
274
275 const videoAbuseReportValidator = [
276   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
277   body('reason').custom(isVideoAbuseReasonValid).withMessage('Should have a valid reason'),
278
279   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
280     logger.debug('Checking videoAbuseReport parameters', { parameters: req.body })
281
282     if (areValidationErrors(req, res)) return
283     if (!await isVideoExist(req.params.id, res)) return
284
285     return next()
286   }
287 ]
288
289 const videoRateValidator = [
290   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
291   body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
292
293   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
294     logger.debug('Checking videoRate parameters', { parameters: req.body })
295
296     if (areValidationErrors(req, res)) return
297     if (!await isVideoExist(req.params.id, res)) return
298
299     return next()
300   }
301 ]
302
303 const videosShareValidator = [
304   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
305   param('accountId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid account id'),
306
307   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
308     logger.debug('Checking videoShare parameters', { parameters: req.params })
309
310     if (areValidationErrors(req, res)) return
311     if (!await isVideoExist(req.params.id, res)) return
312
313     const share = await VideoShareModel.load(req.params.accountId, res.locals.video.id, undefined)
314     if (!share) {
315       return res.status(404)
316         .end()
317     }
318
319     res.locals.videoShare = share
320     return next()
321   }
322 ]
323
324 // ---------------------------------------------------------------------------
325
326 export {
327   videosAddValidator,
328   videosUpdateValidator,
329   videosGetValidator,
330   videosRemoveValidator,
331   videosSearchValidator,
332   videosShareValidator,
333
334   videoAbuseReportValidator,
335
336   videoRateValidator
337 }
338
339 // ---------------------------------------------------------------------------
340
341 function checkUserCanManageVideo (user: UserModel, video: VideoModel, right: UserRight, res: express.Response) {
342   // Retrieve the user who did the request
343   if (video.isOwned() === false) {
344     res.status(403)
345               .json({ error: 'Cannot manage a video of another server.' })
346               .end()
347     return false
348   }
349
350   // Check if the user can delete the video
351   // The user can delete it if he has the right
352   // Or if s/he is the video's account
353   const account = video.VideoChannel.Account
354   if (user.hasRight(right) === false && account.userId !== user.id) {
355     res.status(403)
356               .json({ error: 'Cannot manage a video of another user.' })
357               .end()
358     return false
359   }
360
361   return true
362 }
363
364 function areErrorsInVideoImageFiles (req: express.Request, res: express.Response) {
365   // Files are optional
366   if (!req.files) return false
367
368   for (const imageField of [ 'thumbnail', 'preview' ]) {
369     if (!req.files[ imageField ]) continue
370
371     const imageFile = req.files[ imageField ][ 0 ] as Express.Multer.File
372     if (imageFile.size > CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max) {
373       res.status(400)
374         .send({ error: `The size of the ${imageField} is too big (>${CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max}).` })
375         .end()
376       return true
377     }
378   }
379
380   return false
381 }