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