]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/middlewares/validators/videos/videos.ts
Refactor auth flow
[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 { MVideoWithRights } 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 '../../auth'
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) as MVideoWithRights
201
202 // Video private or blacklisted
203 if (video.requiresAuth()) {
204 await authenticatePromiseIfNeeded(req, res, authenticateInQuery)
205
206 const user = res.locals.oauth ? res.locals.oauth.token.User : null
207
208 // Only the owner or a user that have blacklist rights can see the video
209 if (!user || !user.canGetVideo(video)) {
210 return res.status(HttpStatusCode.FORBIDDEN_403)
211 .json({ error: 'Cannot get this private/internal or blacklisted video.' })
212 }
213
214 return next()
215 }
216
217 // Video is public, anyone can access it
218 if (video.privacy === VideoPrivacy.PUBLIC) return next()
219
220 // Video is unlisted, check we used the uuid to fetch it
221 if (video.privacy === VideoPrivacy.UNLISTED) {
222 if (isUUIDValid(req.params.id)) return next()
223
224 // Don't leak this unlisted video
225 return res.status(HttpStatusCode.NOT_FOUND_404).end()
226 }
227 }
228 ]
229 }
230
231 const videosGetValidator = videosCustomGetValidator('all')
232 const videosDownloadValidator = videosCustomGetValidator('all', true)
233
234 const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
235 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
236 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
237
238 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
239 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
240
241 if (areValidationErrors(req, res)) return
242 if (!await doesVideoFileOfVideoExist(+req.params.videoFileId, req.params.id, res)) return
243
244 return next()
245 }
246 ])
247
248 const videosRemoveValidator = [
249 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
250
251 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
252 logger.debug('Checking videosRemove parameters', { parameters: req.params })
253
254 if (areValidationErrors(req, res)) return
255 if (!await doesVideoExist(req.params.id, res)) return
256
257 // Check if the user who did the request is able to delete the video
258 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.REMOVE_ANY_VIDEO, res)) return
259
260 return next()
261 }
262 ]
263
264 const videosChangeOwnershipValidator = [
265 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
266
267 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
268 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
269
270 if (areValidationErrors(req, res)) return
271 if (!await doesVideoExist(req.params.videoId, res)) return
272
273 // Check if the user who did the request is able to change the ownership of the video
274 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
275
276 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
277 if (!nextOwner) {
278 res.status(HttpStatusCode.BAD_REQUEST_400)
279 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
280
281 return
282 }
283 res.locals.nextOwner = nextOwner
284
285 return next()
286 }
287 ]
288
289 const videosTerminateChangeOwnershipValidator = [
290 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
291
292 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
293 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
294
295 if (areValidationErrors(req, res)) return
296 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
297
298 // Check if the user who did the request is able to change the ownership of the video
299 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
300
301 const videoChangeOwnership = res.locals.videoChangeOwnership
302
303 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
304 res.status(HttpStatusCode.FORBIDDEN_403)
305 .json({ error: 'Ownership already accepted or refused' })
306 return
307 }
308
309 return next()
310 }
311 ]
312
313 const videosAcceptChangeOwnershipValidator = [
314 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
315 const body = req.body as VideoChangeOwnershipAccept
316 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
317
318 const user = res.locals.oauth.token.User
319 const videoChangeOwnership = res.locals.videoChangeOwnership
320 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
321 if (isAble === false) {
322 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
323 .json({ error: 'The user video quota is exceeded with this video.' })
324
325 return
326 }
327
328 return next()
329 }
330 ]
331
332 const videosOverviewValidator = [
333 query('page')
334 .optional()
335 .isInt({ min: 1, max: OVERVIEWS.VIDEOS.SAMPLES_COUNT })
336 .withMessage('Should have a valid pagination'),
337
338 (req: express.Request, res: express.Response, next: express.NextFunction) => {
339 if (areValidationErrors(req, res)) return
340
341 return next()
342 }
343 ]
344
345 function getCommonVideoEditAttributes () {
346 return [
347 body('thumbnailfile')
348 .custom((value, { req }) => isVideoImage(req.files, 'thumbnailfile')).withMessage(
349 'This thumbnail file is not supported or too large. Please, make sure it is of the following type: ' +
350 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
351 ),
352 body('previewfile')
353 .custom((value, { req }) => isVideoImage(req.files, 'previewfile')).withMessage(
354 'This preview file is not supported or too large. Please, make sure it is of the following type: ' +
355 CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME.join(', ')
356 ),
357
358 body('category')
359 .optional()
360 .customSanitizer(toIntOrNull)
361 .custom(isVideoCategoryValid).withMessage('Should have a valid category'),
362 body('licence')
363 .optional()
364 .customSanitizer(toIntOrNull)
365 .custom(isVideoLicenceValid).withMessage('Should have a valid licence'),
366 body('language')
367 .optional()
368 .customSanitizer(toValueOrNull)
369 .custom(isVideoLanguageValid).withMessage('Should have a valid language'),
370 body('nsfw')
371 .optional()
372 .customSanitizer(toBooleanOrNull)
373 .custom(isBooleanValid).withMessage('Should have a valid NSFW attribute'),
374 body('waitTranscoding')
375 .optional()
376 .customSanitizer(toBooleanOrNull)
377 .custom(isBooleanValid).withMessage('Should have a valid wait transcoding attribute'),
378 body('privacy')
379 .optional()
380 .customSanitizer(toValueOrNull)
381 .custom(isVideoPrivacyValid).withMessage('Should have correct video privacy'),
382 body('description')
383 .optional()
384 .customSanitizer(toValueOrNull)
385 .custom(isVideoDescriptionValid).withMessage('Should have a valid description'),
386 body('support')
387 .optional()
388 .customSanitizer(toValueOrNull)
389 .custom(isVideoSupportValid).withMessage('Should have a valid support text'),
390 body('tags')
391 .optional()
392 .customSanitizer(toValueOrNull)
393 .custom(isVideoTagsValid).withMessage('Should have correct tags'),
394 body('commentsEnabled')
395 .optional()
396 .customSanitizer(toBooleanOrNull)
397 .custom(isBooleanValid).withMessage('Should have comments enabled boolean'),
398 body('downloadEnabled')
399 .optional()
400 .customSanitizer(toBooleanOrNull)
401 .custom(isBooleanValid).withMessage('Should have downloading enabled boolean'),
402 body('originallyPublishedAt')
403 .optional()
404 .customSanitizer(toValueOrNull)
405 .custom(isVideoOriginallyPublishedAtValid).withMessage('Should have a valid original publication date'),
406 body('scheduleUpdate')
407 .optional()
408 .customSanitizer(toValueOrNull),
409 body('scheduleUpdate.updateAt')
410 .optional()
411 .custom(isDateValid).withMessage('Should have a valid schedule update date'),
412 body('scheduleUpdate.privacy')
413 .optional()
414 .customSanitizer(toIntOrNull)
415 .custom(isScheduleVideoUpdatePrivacyValid).withMessage('Should have correct schedule update privacy')
416 ] as (ValidationChain | ExpressPromiseHandler)[]
417 }
418
419 const commonVideosFiltersValidator = [
420 query('categoryOneOf')
421 .optional()
422 .customSanitizer(toArray)
423 .custom(isNumberArray).withMessage('Should have a valid one of category array'),
424 query('licenceOneOf')
425 .optional()
426 .customSanitizer(toArray)
427 .custom(isNumberArray).withMessage('Should have a valid one of licence array'),
428 query('languageOneOf')
429 .optional()
430 .customSanitizer(toArray)
431 .custom(isStringArray).withMessage('Should have a valid one of language array'),
432 query('tagsOneOf')
433 .optional()
434 .customSanitizer(toArray)
435 .custom(isStringArray).withMessage('Should have a valid one of tags array'),
436 query('tagsAllOf')
437 .optional()
438 .customSanitizer(toArray)
439 .custom(isStringArray).withMessage('Should have a valid all of tags array'),
440 query('nsfw')
441 .optional()
442 .custom(isNSFWQueryValid).withMessage('Should have a valid NSFW attribute'),
443 query('filter')
444 .optional()
445 .custom(isVideoFilterValid).withMessage('Should have a valid filter attribute'),
446 query('skipCount')
447 .optional()
448 .customSanitizer(toBooleanOrNull)
449 .custom(isBooleanValid).withMessage('Should have a valid skip count boolean'),
450 query('search')
451 .optional()
452 .custom(exists).withMessage('Should have a valid search'),
453
454 (req: express.Request, res: express.Response, next: express.NextFunction) => {
455 logger.debug('Checking commons video filters query', { parameters: req.query })
456
457 if (areValidationErrors(req, res)) return
458
459 const user = res.locals.oauth ? res.locals.oauth.token.User : undefined
460 if (
461 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
462 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
463 ) {
464 res.status(HttpStatusCode.UNAUTHORIZED_401)
465 .json({ error: 'You are not allowed to see all local videos.' })
466
467 return
468 }
469
470 return next()
471 }
472 ]
473
474 // ---------------------------------------------------------------------------
475
476 export {
477 videosAddValidator,
478 videosUpdateValidator,
479 videosGetValidator,
480 videoFileMetadataGetValidator,
481 videosDownloadValidator,
482 checkVideoFollowConstraints,
483 videosCustomGetValidator,
484 videosRemoveValidator,
485
486 videosChangeOwnershipValidator,
487 videosTerminateChangeOwnershipValidator,
488 videosAcceptChangeOwnershipValidator,
489
490 getCommonVideoEditAttributes,
491
492 commonVideosFiltersValidator,
493
494 videosOverviewValidator
495 }
496
497 // ---------------------------------------------------------------------------
498
499 function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) {
500 if (req.body.scheduleUpdate) {
501 if (!req.body.scheduleUpdate.updateAt) {
502 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
503
504 res.status(HttpStatusCode.BAD_REQUEST_400)
505 .json({ error: 'Schedule update at is mandatory.' })
506
507 return true
508 }
509 }
510
511 return false
512 }
513
514 async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) {
515 // Check we accept this video
516 const acceptParameters = {
517 videoBody: req.body,
518 videoFile,
519 user: res.locals.oauth.token.User
520 }
521 const acceptedResult = await Hooks.wrapFun(
522 isLocalVideoAccepted,
523 acceptParameters,
524 'filter:api.video.upload.accept.result'
525 )
526
527 if (!acceptedResult || acceptedResult.accepted !== true) {
528 logger.info('Refused local video.', { acceptedResult, acceptParameters })
529 res.status(HttpStatusCode.FORBIDDEN_403)
530 .json({ error: acceptedResult.errorMessage || 'Refused local video' })
531
532 return false
533 }
534
535 return true
536 }