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