]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
Fix private video download
[github/Chocobozzz/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 { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '../../../helpers/middlewares'
46 import { MVideoFullLight } from '@server/typings/models'
47 import { getVideoWithAttributes } from '../../../helpers/video'
48
49 const videosAddValidator = getCommonVideoEditAttributes().concat([
50 body('videofile')
51 .custom((value, { req }) => isVideoFile(req.files)).withMessage(
52 'This file is not supported or too large. Please, make sure it is of the following type: '
53 + CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
54 ),
55 body('name').custom(isVideoNameValid).withMessage('Should have a valid name'),
56 body('channelId')
57 .customSanitizer(toIntOrNull)
58 .custom(isIdValid).withMessage('Should have correct video channel id'),
59
60 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
61 logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files })
62
63 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
64 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
65
66 const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0]
67 const user = res.locals.oauth.token.User
68
69 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
70
71 if (await user.isAbleToUploadVideo(videoFile) === 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 if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req)
93
94 return next()
95 }
96 ])
97
98 const videosUpdateValidator = getCommonVideoEditAttributes().concat([
99 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
100 body('name')
101 .optional()
102 .custom(isVideoNameValid).withMessage('Should have a valid name'),
103 body('channelId')
104 .optional()
105 .customSanitizer(toIntOrNull)
106 .custom(isIdValid).withMessage('Should have correct video channel id'),
107
108 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
109 logger.debug('Checking videosUpdate parameters', { parameters: req.body })
110
111 if (areValidationErrors(req, res)) return cleanUpReqFiles(req)
112 if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req)
113 if (!await doesVideoExist(req.params.id, res)) return cleanUpReqFiles(req)
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.videoAll, UserRight.UPDATE_ANY_VIDEO, res)) return cleanUpReqFiles(req)
118
119 if (req.body.channelId && !await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
120
121 return next()
122 }
123 ])
124
125 async function checkVideoFollowConstraints (req: express.Request, res: express.Response, next: express.NextFunction) {
126 const video = getVideoWithAttributes(res)
127
128 // Anybody can watch local videos
129 if (video.isOwned() === true) return next()
130
131 // Logged user
132 if (res.locals.oauth) {
133 // Users can search or watch remote videos
134 if (CONFIG.SEARCH.REMOTE_URI.USERS === true) return next()
135 }
136
137 // Anybody can search or watch remote videos
138 if (CONFIG.SEARCH.REMOTE_URI.ANONYMOUS === true) return next()
139
140 // Check our instance follows an actor that shared this video
141 const serverActor = await getServerActor()
142 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
143
144 return res.status(403)
145 .json({
146 error: 'Cannot get this video regarding follow constraints.'
147 })
148 }
149
150 const videosCustomGetValidator = (fetchType: 'all' | 'only-video' | 'only-video-with-rights', authenticateInQuery = false) => {
151 return [
152 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
153
154 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
155 logger.debug('Checking videosGet parameters', { parameters: req.params })
156
157 if (areValidationErrors(req, res)) return
158 if (!await doesVideoExist(req.params.id, res, fetchType)) return
159
160 const video = getVideoWithAttributes(res)
161 const videoAll = video as MVideoFullLight
162
163 // Video private or blacklisted
164 if (video.privacy === VideoPrivacy.PRIVATE || videoAll.VideoBlacklist) {
165 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
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 (videoAll.VideoChannel && videoAll.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 const videosDownloadValidator = videosCustomGetValidator('all', true)
197
198 const videosRemoveValidator = [
199 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
200
201 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
202 logger.debug('Checking videosRemove parameters', { parameters: req.params })
203
204 if (areValidationErrors(req, res)) return
205 if (!await doesVideoExist(req.params.id, res)) return
206
207 // Check if the user who did the request is able to delete the video
208 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
209
210 return next()
211 }
212 ]
213
214 const videosChangeOwnershipValidator = [
215 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
216
217 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
218 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
219
220 if (areValidationErrors(req, res)) return
221 if (!await doesVideoExist(req.params.videoId, res)) return
222
223 // Check if the user who did the request is able to change the ownership of the video
224 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
225
226 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
227 if (!nextOwner) {
228 res.status(400)
229 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
230
231 return
232 }
233 res.locals.nextOwner = nextOwner
234
235 return next()
236 }
237 ]
238
239 const videosTerminateChangeOwnershipValidator = [
240 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
241
242 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
243 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
244
245 if (areValidationErrors(req, res)) return
246 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
247
248 // Check if the user who did the request is able to change the ownership of the video
249 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
250
251 return next()
252 },
253 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
254 const videoChangeOwnership = res.locals.videoChangeOwnership
255
256 if (videoChangeOwnership.status === VideoChangeOwnershipStatus.WAITING) {
257 return next()
258 } else {
259 res.status(403)
260 .json({ error: 'Ownership already accepted or refused' })
261
262 return
263 }
264 }
265 ]
266
267 const videosAcceptChangeOwnershipValidator = [
268 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
269 const body = req.body as VideoChangeOwnershipAccept
270 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
271
272 const user = res.locals.oauth.token.User
273 const videoChangeOwnership = res.locals.videoChangeOwnership
274 const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile())
275 if (isAble === false) {
276 res.status(403)
277 .json({ error: 'The user video quota is exceeded with this video.' })
278
279 return
280 }
281
282 return next()
283 }
284 ]
285
286 function getCommonVideoEditAttributes () {
287 return [
288 body('thumbnailfile')
289 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
290 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: '
291 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
292 ),
293 body('previewfile')
294 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
295 'This preview file is not supported or too large. Please, make sure it is of the following type: '
296 + CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
297 ),
298
299 body('category')
300 .optional()
301 .customSanitizer(toIntOrNull)
302 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
303 body('licence')
304 .optional()
305 .customSanitizer(toIntOrNull)
306 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
307 body('language')
308 .optional()
309 .customSanitizer(toValueOrNull)
310 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
311 body('nsfw')
312 .optional()
313 .customSanitizer(toBooleanOrNull)
314 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
315 body('waitTranscoding')
316 .optional()
317 .customSanitizer(toBooleanOrNull)
318 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
319 body('privacy')
320 .optional()
321 .customSanitizer(toValueOrNull)
322 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
323 body('description')
324 .optional()
325 .customSanitizer(toValueOrNull)
326 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
327 body('support')
328 .optional()
329 .customSanitizer(toValueOrNull)
330 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
331 body('tags')
332 .optional()
333 .customSanitizer(toValueOrNull)
334 .custom(isVideoTagsValid).withMessage('Should have correct tags'),
335 body('commentsEnabled')
336 .optional()
337 .customSanitizer(toBooleanOrNull)
338 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
339 body('downloadEnabled')
340 .optional()
341 .customSanitizer(toBooleanOrNull)
342 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
343 body('originallyPublishedAt')
344 .optional()
345 .customSanitizer(toValueOrNull)
346 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
347 body('scheduleUpdate')
348 .optional()
349 .customSanitizer(toValueOrNull),
350 body('scheduleUpdate.updateAt')
351 .optional()
352 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
353 body('scheduleUpdate.privacy')
354 .optional()
355 .customSanitizer(toIntOrNull)
356 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
357 ] as (ValidationChain | express.Handler)[]
358 }
359
360 const commonVideosFiltersValidator = [
361 query('categoryOneOf')
362 .optional()
363 .customSanitizer(toArray)
364 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
365 query('licenceOneOf')
366 .optional()
367 .customSanitizer(toArray)
368 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
369 query('languageOneOf')
370 .optional()
371 .customSanitizer(toArray)
372 .custom(isStringArray).withMessage('Should have a valid one of language array'),
373 query('tagsOneOf')
374 .optional()
375 .customSanitizer(toArray)
376 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
377 query('tagsAllOf')
378 .optional()
379 .customSanitizer(toArray)
380 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
381 query('nsfw')
382 .optional()
383 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
384 query('filter')
385 .optional()
386 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
387
388 (req: express.Request, res: express.Response, next: express.NextFunction) => {
389 logger.debug('Checking commons video filters query', { parameters: req.query })
390
391 if (areValidationErrors(req, res)) return
392
393 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
394 if (req.query.filter === 'all-local' && (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)) {
395 res.status(401)
396 .json({ error: 'You are not allowed to see all local videos.' })
397
398 return
399 }
400
401 return next()
402 }
403 ]
404
405 // ---------------------------------------------------------------------------
406
407 export {
408 videosAddValidator,
409 videosUpdateValidator,
410 videosGetValidator,
411 videosDownloadValidator,
412 checkVideoFollowConstraints,
413 videosCustomGetValidator,
414 videosRemoveValidator,
415
416 videosChangeOwnershipValidator,
417 videosTerminateChangeOwnershipValidator,
418 videosAcceptChangeOwnershipValidator,
419
420 getCommonVideoEditAttributes,
421
422 commonVideosFiltersValidator
423 }
424
425 // ---------------------------------------------------------------------------
426
427 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
428 if (req.body.scheduleUpdate) {
429 if (!req.body.scheduleUpdate.updateAt) {
430 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
431
432 res.status(400)
433 .json({ error: 'Schedule update at is mandatory.' })
434
435 return true
436 }
437 }
438
439 return false
440 }
441
442 async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
443 // Check we accept this video
444 const acceptParameters = {
445 videoBody: req.body,
446 videoFile,
447 user: res.locals.oauth.token.User
448 }
449 const acceptedResult = await Hooks.wrapFun(
450 isLocalVideoAccepted,
451 acceptParameters,
452 'filter:api.video.upload.accept.result'
453 )
454
455 if (!acceptedResult || acceptedResult.accepted !== true) {
456 logger.info('Refused local video.', { acceptedResult, acceptParameters })
457 res.status(403)
458 .json({ error: acceptedResult.errorMessage || 'Refused local video' })
459
460 return false
461 }
462
463 return true
464 }