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