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