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