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