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