Add video file metadata to download modal, via ffprobe (#2411)
[oweals/peertube.git] / server / middlewares / validators / videos / videos.ts
1 import * as express from 'express'
2 import { body, param, query, ValidationChain } from 'express-validator'
3 import { UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared'
4 import {
5   isBooleanValid,
6   isDateValid,
7   isIdOrUUIDValid,
8   isIdValid,
9   isUUIDValid,
10   toArray,
11   toBooleanOrNull,
12   toIntOrNull,
13   toValueOrNull
14 } from '../../../helpers/custom-validators/misc'
15 import {
16   isScheduleVideoUpdatePrivacyValid,
17   isVideoCategoryValid,
18   isVideoDescriptionValid,
19   isVideoFile,
20   isVideoFilterValid,
21   isVideoImage,
22   isVideoLanguageValid,
23   isVideoLicenceValid,
24   isVideoNameValid,
25   isVideoOriginallyPublishedAtValid,
26   isVideoPrivacyValid,
27   isVideoSupportValid,
28   isVideoTagsValid
29 } from '../../../helpers/custom-validators/videos'
30 import { getDurationFromVideoFile } from '../../../helpers/ffmpeg-utils'
31 import { logger } from '../../../helpers/logger'
32 import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
33 import { authenticatePromiseIfNeeded } from '../../oauth'
34 import { areValidationErrors } from '../utils'
35 import { cleanUpReqFiles } from '../../../helpers/express-utils'
36 import { VideoModel } from '../../../models/video/video'
37 import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
38 import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
39 import { AccountModel } from '../../../models/account/account'
40 import { isNSFWQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
41 import { getServerActor } from '../../../helpers/utils'
42 import { CONFIG } from '../../../initializers/config'
43 import { isLocalVideoAccepted } from '../../../lib/moderation'
44 import { Hooks } from '../../../lib/plugins/hooks'
45 import {
46   checkUserCanManageVideo,
47   doesVideoChannelOfAccountExist,
48   doesVideoExist,
49   doesVideoFileOfVideoExist
50 } from '../../../helpers/middlewares'
51 import { MVideoFullLight } from '@server/typings/models'
52 import { getVideoWithAttributes } from '../../../helpers/video'
53
54 const videosAddValidator = getCommonVideoEditAttributes().concat([
55   body('videofile')
56     .custom((value, { req }) => isVideoFile(req.files)).withMessage(
57       'This file is not supported or too large. Please, make sure it is of the following type: ' +
58       CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
59     ),
60   body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
61   body('channelId')
62     .customSanitizer(toIntOrNull)
63     .custom(isIdValid).withMessage('Should have correct video channel id'),
64
65   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
66     logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
67
68     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
69     if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
70
71     const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0]
72     const user = res.locals.oauth.token.User
73
74     if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
75
76     if (await user.isAbleToUploadVideo(videoFile) === false) {
77       res.status(403)
78          .json({ error: 'The user video quota is exceeded with this video.' })
79
80       return cleanUpReqFiles(req)
81     }
82
83     let duration: number
84
85     try {
86       duration = await getDurationFromVideoFile(videoFile.path)
87     } catch (err) {
88       logger.error('Invalid input file in videosAddValidator.', { err })
89       res.status(400)
90          .json({ error: 'Invalid input file.' })
91
92       return cleanUpReqFiles(req)
93     }
94
95     videoFile.duration = duration
96
97     if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
98
99     return next()
100   }
101 ])
102
103 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
104   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
105   body('name')
106     .optional()
107     .custom(isVideoNameValid).withMessage('Should have a valid name'),
108   body('channelId')
109     .optional()
110     .customSanitizer(toIntOrNull)
111     .custom(isIdValid).withMessage('Should have correct video channel id'),
112
113   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
114     logger.debug('Checking videosUpdate parameters', { parameters: req.body })
115
116     if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
117     if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
118     if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
119
120     // Check if the user who did the request is able to update the video
121     const user = res.locals.oauth.token.User
122     if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
123
124     if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
125
126     return next()
127   }
128 ])
129
130 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
131   const video = getVideoWithAttributes(res)
132
133   // Anybody can watch local videos
134   if (video.isOwned() === true) return next()
135
136   // Logged user
137   if (res.locals.oauth) {
138     // Users can search or watch remote videos
139     if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
140   }
141
142   // Anybody can search or watch remote videos
143   if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
144
145   // Check our instance follows an actor that shared this video
146   const serverActor = await getServerActor()
147   if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
148
149   return res.status(403)
150             .json({
151               error: 'Cannot get this video regarding follow constraints.'
152             })
153 }
154
155 const videosCustomGetValidator = (
156   fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
157   authenticateInQuery = false
158 ) => {
159   return [
160     param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
161
162     async (req: express.Request, res: express.Response, next: express.NextFunction) => {
163       logger.debug('Checking videosGet parameters', { parameters: req.params })
164
165       if (areValidationErrors(req, res)) return
166       if (!await doesVideoExist(req.params.id, res, fetchType)) return
167
168       // Controllers does not need to check video rights
169       if (fetchType === 'only-immutable-attributes') return next()
170
171       const video = getVideoWithAttributes(res)
172       const videoAll = video as MVideoFullLight
173
174       // Video private or blacklisted
175       if (videoAll.requiresAuth()) {
176         await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
177
178         const user = res.locals.oauth ? res.locals.oauth.token.User : null
179
180         // Only the owner or a user that have blacklist rights can see the video
181         if (!user || !user.canGetVideo(videoAll)) {
182           return res.status(403)
183                     .json({ error: 'Cannot get this private/internal or blacklisted video.' })
184         }
185
186         return next()
187       }
188
189       // Video is public, anyone can access it
190       if (video.privacy === VideoPrivacy.PUBLIC) return next()
191
192       // Video is unlisted, check we used the uuid to fetch it
193       if (video.privacy === VideoPrivacy.UNLISTED) {
194         if (isUUIDValid(req.params.id)) return next()
195
196         // Don't leak this unlisted video
197         return res.status(404).end()
198       }
199     }
200   ]
201 }
202
203 const videosGetValidator = videosCustomGetValidator('all')
204 const videosDownloadValidator = videosCustomGetValidator('all', true)
205
206 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
207   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
208   param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
209
210   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
211     logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
212
213     if (areValidationErrors(req, res)) return
214     if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
215
216     return next()
217   }
218 ])
219
220 const videosRemoveValidator = [
221   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
222
223   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
224     logger.debug('Checking videosRemove parameters', { parameters: req.params })
225
226     if (areValidationErrors(req, res)) return
227     if (!await doesVideoExist(req.params.id, res)) return
228
229     // Check if the user who did the request is able to delete the video
230     if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
231
232     return next()
233   }
234 ]
235
236 const videosChangeOwnershipValidator = [
237   param('videoId').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 changeOwnership parameters', { parameters: req.params })
241
242     if (areValidationErrors(req, res)) return
243     if (!await doesVideoExist(req.params.videoId, res)) return
244
245     // Check if the user who did the request is able to change the ownership of the video
246     if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
247
248     const nextOwner = await AccountModel.loadLocalByName(req.body.username)
249     if (!nextOwner) {
250       res.status(400)
251         .json({ error: 'Changing video ownership to a remote account is not supported yet' })
252
253       return
254     }
255     res.locals.nextOwner = nextOwner
256
257     return next()
258   }
259 ]
260
261 const videosTerminateChangeOwnershipValidator = [
262   param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
263
264   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
265     logger.debug('Checking changeOwnership parameters', { parameters: req.params })
266
267     if (areValidationErrors(req, res)) return
268     if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
269
270     // Check if the user who did the request is able to change the ownership of the video
271     if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
272
273     const videoChangeOwnership = res.locals.videoChangeOwnership
274
275     if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
276       res.status(403)
277          .json({ error: 'Ownership already accepted or refused' })
278       return
279     }
280
281     return next()
282   }
283 ]
284
285 const videosAcceptChangeOwnershipValidator = [
286   async (req: express.Request, res: express.Response, next: express.NextFunction) => {
287     const body = req.body as VideoChangeOwnershipAccept
288     if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
289
290     const user = res.locals.oauth.token.User
291     const videoChangeOwnership = res.locals.videoChangeOwnership
292     const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile())
293     if (isAble === false) {
294       res.status(403)
295         .json({ error: 'The user video quota is exceeded with this video.' })
296
297       return
298     }
299
300     return next()
301   }
302 ]
303
304 function getCommonVideoEditAttributes () {
305   return [
306     body('thumbnailfile')
307       .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
308         'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
309         CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
310       ),
311     body('previewfile')
312       .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
313         'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
314         CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
315       ),
316
317     body('category')
318       .optional()
319       .customSanitizer(toIntOrNull)
320       .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
321     body('licence')
322       .optional()
323       .customSanitizer(toIntOrNull)
324       .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
325     body('language')
326       .optional()
327       .customSanitizer(toValueOrNull)
328       .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
329     body('nsfw')
330       .optional()
331       .customSanitizer(toBooleanOrNull)
332       .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
333     body('waitTranscoding')
334       .optional()
335       .customSanitizer(toBooleanOrNull)
336       .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
337     body('privacy')
338       .optional()
339       .customSanitizer(toValueOrNull)
340       .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
341     body('description')
342       .optional()
343       .customSanitizer(toValueOrNull)
344       .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
345     body('support')
346       .optional()
347       .customSanitizer(toValueOrNull)
348       .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
349     body('tags')
350       .optional()
351       .customSanitizer(toValueOrNull)
352       .custom(isVideoTagsValid).withMessage('Should have correct tags'),
353     body('commentsEnabled')
354       .optional()
355       .customSanitizer(toBooleanOrNull)
356       .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
357     body('downloadEnabled')
358       .optional()
359       .customSanitizer(toBooleanOrNull)
360       .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
361     body('originallyPublishedAt')
362       .optional()
363       .customSanitizer(toValueOrNull)
364       .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
365     body('scheduleUpdate')
366       .optional()
367       .customSanitizer(toValueOrNull),
368     body('scheduleUpdate.updateAt')
369       .optional()
370       .custom(isDateValid).withMessage('Should have a valid schedule update date'),
371     body('scheduleUpdate.privacy')
372       .optional()
373       .customSanitizer(toIntOrNull)
374       .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
375   ] as (ValidationChain | express.Handler)[]
376 }
377
378 const commonVideosFiltersValidator = [
379   query('categoryOneOf')
380     .optional()
381     .customSanitizer(toArray)
382     .custom(isNumberArray).withMessage('Should have a valid one of category array'),
383   query('licenceOneOf')
384     .optional()
385     .customSanitizer(toArray)
386     .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
387   query('languageOneOf')
388     .optional()
389     .customSanitizer(toArray)
390     .custom(isStringArray).withMessage('Should have a valid one of language array'),
391   query('tagsOneOf')
392     .optional()
393     .customSanitizer(toArray)
394     .custom(isStringArray).withMessage('Should have a valid one of tags array'),
395   query('tagsAllOf')
396     .optional()
397     .customSanitizer(toArray)
398     .custom(isStringArray).withMessage('Should have a valid all of tags array'),
399   query('nsfw')
400     .optional()
401     .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
402   query('filter')
403     .optional()
404     .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
405   query('skipCount')
406     .optional()
407     .customSanitizer(toBooleanOrNull)
408     .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
409
410   (req: express.Request, res: express.Response, next: express.NextFunction) => {
411     logger.debug('Checking commons video filters query', { parameters: req.query })
412
413     if (areValidationErrors(req, res)) return
414
415     const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
416     if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) {
417       res.status(401)
418          .json({ error: 'You are not allowed to see all local videos.' })
419
420       return
421     }
422
423     return next()
424   }
425 ]
426
427 // ---------------------------------------------------------------------------
428
429 export {
430   videosAddValidator,
431   videosUpdateValidator,
432   videosGetValidator,
433   videoFileMetadataGetValidator,
434   videosDownloadValidator,
435   checkVideoFollowConstraints,
436   videosCustomGetValidator,
437   videosRemoveValidator,
438
439   videosChangeOwnershipValidator,
440   videosTerminateChangeOwnershipValidator,
441   videosAcceptChangeOwnershipValidator,
442
443   getCommonVideoEditAttributes,
444
445   commonVideosFiltersValidator
446 }
447
448 // ---------------------------------------------------------------------------
449
450 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
451   if (req.body.scheduleUpdate) {
452     if (!req.body.scheduleUpdate.updateAt) {
453       logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
454
455       res.status(400)
456          .json({ error: 'Schedule update at is mandatory.' })
457
458       return true
459     }
460   }
461
462   return false
463 }
464
465 async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
466   // Check we accept this video
467   const acceptParameters = {
468     videoBody: req.body,
469     videoFile,
470     user: res.locals.oauth.token.User
471   }
472   const acceptedResult = await Hooks.wrapFun(
473     isLocalVideoAccepted,
474     acceptParameters,
475     'filter:api.video.upload.accept.result'
476   )
477
478   if (!acceptedResult || acceptedResult.accepted !== true) {
479     logger.info('Refused local video.', { acceptedResult, acceptParameters })
480     res.status(403)
481        .json({ error: acceptedResult.errorMessage || 'Refused local video' })
482
483     return false
484   }
485
486   return true
487 }