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