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