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