867c05fc13f8152dca39732973f07adb3e9882ab
[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, OVERVIEWS } 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 { CONFIG } from '../../../initializers/config'
42 import { isLocalVideoAccepted } from '../../../lib/moderation'
43 import { Hooks } from '../../../lib/plugins/hooks'
44 import {
45   checkUserCanManageVideo,
46   doesVideoChannelOfAccountExist,
47   doesVideoExist,
48   doesVideoFileOfVideoExist
49 } from '../../../helpers/middlewares'
50 import { MVideoFullLight } from '@server/typings/models'
51 import { getVideoWithAttributes } from '../../../helpers/video'
52 import { getServerActor } from '@server/models/application/application'
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 const videosOverviewValidator = [
305   query('page')
306     .optional()
307     .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
308     .withMessage('Should have a valid pagination'),
309
310   (req: express.Request, res: express.Response, next: express.NextFunction) => {
311     if (areValidationErrors(req, res)) return
312
313     return next()
314   }
315 ]
316
317 function getCommonVideoEditAttributes () {
318   return [
319     body('thumbnailfile')
320       .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
321         'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
322         CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
323       ),
324     body('previewfile')
325       .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
326         'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
327         CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
328       ),
329
330     body('category')
331       .optional()
332       .customSanitizer(toIntOrNull)
333       .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
334     body('licence')
335       .optional()
336       .customSanitizer(toIntOrNull)
337       .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
338     body('language')
339       .optional()
340       .customSanitizer(toValueOrNull)
341       .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
342     body('nsfw')
343       .optional()
344       .customSanitizer(toBooleanOrNull)
345       .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
346     body('waitTranscoding')
347       .optional()
348       .customSanitizer(toBooleanOrNull)
349       .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
350     body('privacy')
351       .optional()
352       .customSanitizer(toValueOrNull)
353       .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
354     body('description')
355       .optional()
356       .customSanitizer(toValueOrNull)
357       .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
358     body('support')
359       .optional()
360       .customSanitizer(toValueOrNull)
361       .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
362     body('tags')
363       .optional()
364       .customSanitizer(toValueOrNull)
365       .custom(isVideoTagsValid).withMessage('Should have correct tags'),
366     body('commentsEnabled')
367       .optional()
368       .customSanitizer(toBooleanOrNull)
369       .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
370     body('downloadEnabled')
371       .optional()
372       .customSanitizer(toBooleanOrNull)
373       .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
374     body('originallyPublishedAt')
375       .optional()
376       .customSanitizer(toValueOrNull)
377       .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
378     body('scheduleUpdate')
379       .optional()
380       .customSanitizer(toValueOrNull),
381     body('scheduleUpdate.updateAt')
382       .optional()
383       .custom(isDateValid).withMessage('Should have a valid schedule update date'),
384     body('scheduleUpdate.privacy')
385       .optional()
386       .customSanitizer(toIntOrNull)
387       .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
388   ] as (ValidationChain | express.Handler)[]
389 }
390
391 const commonVideosFiltersValidator = [
392   query('categoryOneOf')
393     .optional()
394     .customSanitizer(toArray)
395     .custom(isNumberArray).withMessage('Should have a valid one of category array'),
396   query('licenceOneOf')
397     .optional()
398     .customSanitizer(toArray)
399     .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
400   query('languageOneOf')
401     .optional()
402     .customSanitizer(toArray)
403     .custom(isStringArray).withMessage('Should have a valid one of language array'),
404   query('tagsOneOf')
405     .optional()
406     .customSanitizer(toArray)
407     .custom(isStringArray).withMessage('Should have a valid one of tags array'),
408   query('tagsAllOf')
409     .optional()
410     .customSanitizer(toArray)
411     .custom(isStringArray).withMessage('Should have a valid all of tags array'),
412   query('nsfw')
413     .optional()
414     .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
415   query('filter')
416     .optional()
417     .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
418   query('skipCount')
419     .optional()
420     .customSanitizer(toBooleanOrNull)
421     .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
422
423   (req: express.Request, res: express.Response, next: express.NextFunction) => {
424     logger.debug('Checking commons video filters query', { parameters: req.query })
425
426     if (areValidationErrors(req, res)) return
427
428     const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
429     if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) {
430       res.status(401)
431          .json({ error: 'You are not allowed to see all local videos.' })
432
433       return
434     }
435
436     return next()
437   }
438 ]
439
440 // ---------------------------------------------------------------------------
441
442 export {
443   videosAddValidator,
444   videosUpdateValidator,
445   videosGetValidator,
446   videoFileMetadataGetValidator,
447   videosDownloadValidator,
448   checkVideoFollowConstraints,
449   videosCustomGetValidator,
450   videosRemoveValidator,
451
452   videosChangeOwnershipValidator,
453   videosTerminateChangeOwnershipValidator,
454   videosAcceptChangeOwnershipValidator,
455
456   getCommonVideoEditAttributes,
457
458   commonVideosFiltersValidator,
459
460   videosOverviewValidator
461 }
462
463 // ---------------------------------------------------------------------------
464
465 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
466   if (req.body.scheduleUpdate) {
467     if (!req.body.scheduleUpdate.updateAt) {
468       logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
469
470       res.status(400)
471          .json({ error: 'Schedule update at is mandatory.' })
472
473       return true
474     }
475   }
476
477   return false
478 }
479
480 async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
481   // Check we accept this video
482   const acceptParameters = {
483     videoBody: req.body,
484     videoFile,
485     user: res.locals.oauth.token.User
486   }
487   const acceptedResult = await Hooks.wrapFun(
488     isLocalVideoAccepted,
489     acceptParameters,
490     'filter:api.video.upload.accept.result'
491   )
492
493   if (!acceptedResult || acceptedResult.accepted !== true) {
494     logger.info('Refused local video.', { acceptedResult, acceptParameters })
495     res.status(403)
496        .json({ error: acceptedResult.errorMessage || 'Refused local video' })
497
498     return false
499   }
500
501   return true
502 }