]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
Add model cache for video
[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 = (
151 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes',
152 authenticateInQuery = false
153 ) => {
154 return [
155 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
156
157 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
158 logger.debug('Checking videosGet parameters', { parameters: req.params })
159
160 if (areValidationErrors(req, res)) return
161 if (!await doesVideoExist(req.params.id, res, fetchType)) return
162
163 const video = getVideoWithAttributes(res)
164 const videoAll = video as MVideoFullLight
165
166 // Video private or blacklisted
167 if (videoAll.requiresAuth()) {
168 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
169
170 const user = res.locals.oauth ? res.locals.oauth.token.User : null
171
172 // Only the owner or a user that have blacklist rights can see the video
173 if (!user || !user.canGetVideo(videoAll)) {
174 return res.status(403)
175 .json({ error: 'Cannot get this private/internal 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 const videoChangeOwnership = res.locals.videoChangeOwnership
252
253 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
254 res.status(403)
255 .json({ error: 'Ownership already accepted or refused' })
256 return
257 }
258
259 return next()
260 }
261 ]
262
263 const videosAcceptChangeOwnershipValidator = [
264 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
265 const body = req.body as VideoChangeOwnershipAccept
266 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
267
268 const user = res.locals.oauth.token.User
269 const videoChangeOwnership = res.locals.videoChangeOwnership
270 const isAble = await user.isAbleToUploadVideo(videoChangeOwnership.Video.getMaxQualityFile())
271 if (isAble === false) {
272 res.status(403)
273 .json({ error: 'The user video quota is exceeded with this video.' })
274
275 return
276 }
277
278 return next()
279 }
280 ]
281
282 function getCommonVideoEditAttributes () {
283 return [
284 body('thumbnailfile')
285 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
286 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
287 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
288 ),
289 body('previewfile')
290 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
291 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
292 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
293 ),
294
295 body('category')
296 .optional()
297 .customSanitizer(toIntOrNull)
298 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
299 body('licence')
300 .optional()
301 .customSanitizer(toIntOrNull)
302 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
303 body('language')
304 .optional()
305 .customSanitizer(toValueOrNull)
306 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
307 body('nsfw')
308 .optional()
309 .customSanitizer(toBooleanOrNull)
310 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
311 body('waitTranscoding')
312 .optional()
313 .customSanitizer(toBooleanOrNull)
314 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
315 body('privacy')
316 .optional()
317 .customSanitizer(toValueOrNull)
318 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
319 body('description')
320 .optional()
321 .customSanitizer(toValueOrNull)
322 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
323 body('support')
324 .optional()
325 .customSanitizer(toValueOrNull)
326 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
327 body('tags')
328 .optional()
329 .customSanitizer(toValueOrNull)
330 .custom(isVideoTagsValid).withMessage('Should have correct tags'),
331 body('commentsEnabled')
332 .optional()
333 .customSanitizer(toBooleanOrNull)
334 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
335 body('downloadEnabled')
336 .optional()
337 .customSanitizer(toBooleanOrNull)
338 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
339 body('originallyPublishedAt')
340 .optional()
341 .customSanitizer(toValueOrNull)
342 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
343 body('scheduleUpdate')
344 .optional()
345 .customSanitizer(toValueOrNull),
346 body('scheduleUpdate.updateAt')
347 .optional()
348 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
349 body('scheduleUpdate.privacy')
350 .optional()
351 .customSanitizer(toIntOrNull)
352 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
353 ] as (ValidationChain | express.Handler)[]
354 }
355
356 const commonVideosFiltersValidator = [
357 query('categoryOneOf')
358 .optional()
359 .customSanitizer(toArray)
360 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
361 query('licenceOneOf')
362 .optional()
363 .customSanitizer(toArray)
364 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
365 query('languageOneOf')
366 .optional()
367 .customSanitizer(toArray)
368 .custom(isStringArray).withMessage('Should have a valid one of language array'),
369 query('tagsOneOf')
370 .optional()
371 .customSanitizer(toArray)
372 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
373 query('tagsAllOf')
374 .optional()
375 .customSanitizer(toArray)
376 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
377 query('nsfw')
378 .optional()
379 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
380 query('filter')
381 .optional()
382 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
383 query('skipCount')
384 .optional()
385 .customSanitizer(toBooleanOrNull)
386 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
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 }