diff options
author | kontrollanten <6680299+kontrollanten@users.noreply.github.com> | 2021-05-10 11:13:41 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-10 11:13:41 +0200 |
commit | f6d6e7f861189a4446f406efb775a29688764b48 (patch) | |
tree | c3dda9958c3f189d4c39e8743c738d8c1fef4c2d /server/middlewares/validators | |
parent | d29ced1a8582d99b776f664475a157adcf555d98 (diff) | |
download | PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.tar.gz PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.tar.zst PeerTube-f6d6e7f861189a4446f406efb775a29688764b48.zip |
Resumable video uploads (#3933)
* WIP: resumable video uploads
relates to #324
* fix review comments
* video upload: error handling
* fix audio upload
* fixes after self review
* Update server/controllers/api/videos/index.ts
Co-authored-by: Rigel Kent <par@rigelk.eu>
* Update server/middlewares/validators/videos/videos.ts
Co-authored-by: Rigel Kent <par@rigelk.eu>
* Update server/controllers/api/videos/index.ts
Co-authored-by: Rigel Kent <par@rigelk.eu>
* update after code review
* refactor upload route
- restore multipart upload route
- move resumable to dedicated upload-resumable route
- move checks to middleware
- do not leak internal fs structure in response
* fix yarn.lock upon rebase
* factorize addVideo for reuse in both endpoints
* add resumable upload API to openapi spec
* add initial test and test helper for resumable upload
* typings for videoAddResumable middleware
* avoid including aws and google packages via node-uploadx, by only including uploadx/core
* rename ex-isAudioBg to more explicit name mentioning it is a preview file for audio
* add video-upload-tmp-folder-cleaner job
* stronger typing of video upload middleware
* reduce dependency to @uploadx/core
* add audio upload test
* refactor resumable uploads cleanup from job to scheduler
* refactor resumable uploads scheduler to compare to last execution time
* make resumable upload validator to always cleanup on failure
* move legacy upload request building outside of uploadVideo test helper
* filter upload-resumable middlewares down to POST, PUT, DELETE
also begin to type metadata
* merge add duration functions
* stronger typings and documentation for uploadx behaviour, move init validator up
* refactor(client/video-edit): options > uploadxOptions
* refactor(client/video-edit): remove obsolete else
* scheduler/remove-dangling-resum: rename tag
* refactor(server/video): add UploadVideoFiles type
* refactor(mw/validators): restructure eslint disable
* refactor(mw/validators/videos): rename import
* refactor(client/vid-upload): rename html elem id
* refactor(sched/remove-dangl): move fn to method
* refactor(mw/async): add method typing
* refactor(mw/vali/video): double quote > single
* refactor(server/upload-resum): express use > all
* proper http methud enum server/middlewares/async.ts
* properly type http methods
* factorize common video upload validation steps
* add check for maximum partially uploaded file size
* fix audioBg use
* fix extname(filename) in addVideo
* document parameters for uploadx's resumable protocol
* clear META files in scheduler
* last audio refactor before cramming preview in the initial POST form data
* refactor as mulitpart/form-data initial post request
this allows preview/thumbnail uploads alongside the initial request,
and cleans up the upload form
* Add more tests for resumable uploads
* Refactor remove dangling resumable uploads
* Prepare changelog
* Add more resumable upload tests
* Remove user quota check for resumable uploads
* Fix upload error handler
* Update nginx template for upload-resumable
* Cleanup comment
* Remove unused express methods
* Prefer to use got instead of raw http
* Don't retry on error 500
Co-authored-by: Rigel Kent <par@rigelk.eu>
Co-authored-by: Rigel Kent <sendmemail@rigelk.eu>
Co-authored-by: Chocobozzz <me@florianbigard.com>
Diffstat (limited to 'server/middlewares/validators')
-rw-r--r-- | server/middlewares/validators/videos/videos.ts | 184 |
1 files changed, 152 insertions, 32 deletions
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts index bb617d77c..d26bcd4a6 100644 --- a/server/middlewares/validators/videos/videos.ts +++ b/server/middlewares/validators/videos/videos.ts | |||
@@ -1,9 +1,10 @@ | |||
1 | import * as express from 'express' | 1 | import * as express from 'express' |
2 | import { body, param, query, ValidationChain } from 'express-validator' | 2 | import { body, header, param, query, ValidationChain } from 'express-validator' |
3 | import { getResumableUploadPath } from '@server/helpers/upload' | ||
3 | import { isAbleToUploadVideo } from '@server/lib/user' | 4 | import { isAbleToUploadVideo } from '@server/lib/user' |
4 | import { getServerActor } from '@server/models/application/application' | 5 | import { getServerActor } from '@server/models/application/application' |
5 | import { ExpressPromiseHandler } from '@server/types/express' | 6 | import { ExpressPromiseHandler } from '@server/types/express' |
6 | import { MVideoWithRights } from '@server/types/models' | 7 | import { MUserAccountId, MVideoWithRights } from '@server/types/models' |
7 | import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' | 8 | import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' |
8 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' | 9 | import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' |
9 | import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' | 10 | import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model' |
@@ -47,6 +48,7 @@ import { | |||
47 | doesVideoExist, | 48 | doesVideoExist, |
48 | doesVideoFileOfVideoExist | 49 | doesVideoFileOfVideoExist |
49 | } from '../../../helpers/middlewares' | 50 | } from '../../../helpers/middlewares' |
51 | import { deleteFileAndCatch } from '../../../helpers/utils' | ||
50 | import { getVideoWithAttributes } from '../../../helpers/video' | 52 | import { getVideoWithAttributes } from '../../../helpers/video' |
51 | import { CONFIG } from '../../../initializers/config' | 53 | import { CONFIG } from '../../../initializers/config' |
52 | import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' | 54 | import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' |
@@ -57,7 +59,7 @@ import { VideoModel } from '../../../models/video/video' | |||
57 | import { authenticatePromiseIfNeeded } from '../../auth' | 59 | import { authenticatePromiseIfNeeded } from '../../auth' |
58 | import { areValidationErrors } from '../utils' | 60 | import { areValidationErrors } from '../utils' |
59 | 61 | ||
60 | const videosAddValidator = getCommonVideoEditAttributes().concat([ | 62 | const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ |
61 | body('videofile') | 63 | body('videofile') |
62 | .custom((value, { req }) => isFileFieldValid(req.files, 'videofile')) | 64 | .custom((value, { req }) => isFileFieldValid(req.files, 'videofile')) |
63 | .withMessage('Should have a file'), | 65 | .withMessage('Should have a file'), |
@@ -73,54 +75,117 @@ const videosAddValidator = getCommonVideoEditAttributes().concat([ | |||
73 | logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) | 75 | logger.debug('Checking videosAdd parameters', { parameters: req.body, files: req.files }) |
74 | 76 | ||
75 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) | 77 | if (areValidationErrors(req, res)) return cleanUpReqFiles(req) |
76 | if (areErrorsInScheduleUpdate(req, res)) return cleanUpReqFiles(req) | ||
77 | 78 | ||
78 | const videoFile: Express.Multer.File & { duration?: number } = req.files['videofile'][0] | 79 | const videoFile: express.VideoUploadFile = req.files['videofile'][0] |
79 | const user = res.locals.oauth.token.User | 80 | const user = res.locals.oauth.token.User |
80 | 81 | ||
81 | if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) | 82 | if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFile.size, files: req.files })) { |
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) | 83 | return cleanUpReqFiles(req) |
91 | } | 84 | } |
92 | 85 | ||
93 | if (!isVideoFileSizeValid(videoFile.size.toString())) { | 86 | try { |
94 | res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) | 87 | if (!videoFile.duration) await addDurationToVideo(videoFile) |
95 | .json({ | 88 | } catch (err) { |
96 | error: 'This file is too large.' | 89 | logger.error('Invalid input file in videosAddLegacyValidator.', { err }) |
97 | }) | 90 | res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422) |
91 | .json({ error: 'Video file unreadable.' }) | ||
98 | 92 | ||
99 | return cleanUpReqFiles(req) | 93 | return cleanUpReqFiles(req) |
100 | } | 94 | } |
101 | 95 | ||
102 | if (await isAbleToUploadVideo(user.id, videoFile.size) === false) { | 96 | if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req) |
103 | res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) | ||
104 | .json({ error: 'The user video quota is exceeded with this video.' }) | ||
105 | 97 | ||
106 | return cleanUpReqFiles(req) | 98 | return next() |
107 | } | 99 | } |
100 | ]) | ||
101 | |||
102 | /** | ||
103 | * Gets called after the last PUT request | ||
104 | */ | ||
105 | const videosAddResumableValidator = [ | ||
106 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
107 | const user = res.locals.oauth.token.User | ||
108 | |||
109 | const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body | ||
110 | const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename } | ||
111 | |||
112 | const cleanup = () => deleteFileAndCatch(file.path) | ||
108 | 113 | ||
109 | let duration: number | 114 | if (!await doesVideoChannelOfAccountExist(file.metadata.channelId, user, res)) return cleanup() |
110 | 115 | ||
111 | try { | 116 | try { |
112 | duration = await getDurationFromVideoFile(videoFile.path) | 117 | if (!file.duration) await addDurationToVideo(file) |
113 | } catch (err) { | 118 | } catch (err) { |
114 | logger.error('Invalid input file in videosAddValidator.', { err }) | 119 | logger.error('Invalid input file in videosAddResumableValidator.', { err }) |
115 | res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422) | 120 | res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422) |
116 | .json({ error: 'Video file unreadable.' }) | 121 | .json({ error: 'Video file unreadable.' }) |
117 | 122 | ||
118 | return cleanUpReqFiles(req) | 123 | return cleanup() |
119 | } | 124 | } |
120 | 125 | ||
121 | videoFile.duration = duration | 126 | if (!await isVideoAccepted(req, res, file)) return cleanup() |
122 | 127 | ||
123 | if (!await isVideoAccepted(req, res, videoFile)) return cleanUpReqFiles(req) | 128 | res.locals.videoFileResumable = file |
129 | |||
130 | return next() | ||
131 | } | ||
132 | ] | ||
133 | |||
134 | /** | ||
135 | * File is created in POST initialisation, and its body is saved as a 'metadata' field is saved by uploadx for later use. | ||
136 | * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/uploadx.ts | ||
137 | * | ||
138 | * Uploadx doesn't use next() until the upload completes, so this middleware has to be placed before uploadx | ||
139 | * see https://github.com/kukhariev/node-uploadx/blob/dc9fb4a8ac5a6f481902588e93062f591ec6ef03/packages/core/src/handlers/base-handler.ts | ||
140 | * | ||
141 | */ | ||
142 | const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([ | ||
143 | body('filename') | ||
144 | .isString() | ||
145 | .exists() | ||
146 | .withMessage('Should have a valid filename'), | ||
147 | body('name') | ||
148 | .trim() | ||
149 | .custom(isVideoNameValid) | ||
150 | .withMessage('Should have a valid name'), | ||
151 | body('channelId') | ||
152 | .customSanitizer(toIntOrNull) | ||
153 | .custom(isIdValid).withMessage('Should have correct video channel id'), | ||
154 | |||
155 | header('x-upload-content-length') | ||
156 | .isNumeric() | ||
157 | .exists() | ||
158 | .withMessage('Should specify the file length'), | ||
159 | header('x-upload-content-type') | ||
160 | .isString() | ||
161 | .exists() | ||
162 | .withMessage('Should specify the file mimetype'), | ||
163 | |||
164 | async (req: express.Request, res: express.Response, next: express.NextFunction) => { | ||
165 | const videoFileMetadata = { | ||
166 | mimetype: req.headers['x-upload-content-type'] as string, | ||
167 | size: +req.headers['x-upload-content-length'], | ||
168 | originalname: req.body.name | ||
169 | } | ||
170 | |||
171 | const user = res.locals.oauth.token.User | ||
172 | const cleanup = () => cleanUpReqFiles(req) | ||
173 | |||
174 | logger.debug('Checking videosAddResumableInitValidator parameters and headers', { | ||
175 | parameters: req.body, | ||
176 | headers: req.headers, | ||
177 | files: req.files | ||
178 | }) | ||
179 | |||
180 | if (areValidationErrors(req, res)) return cleanup() | ||
181 | |||
182 | const files = { videofile: [ videoFileMetadata ] } | ||
183 | if (!await commonVideoChecksPass({ req, res, user, videoFileSize: videoFileMetadata.size, files })) return cleanup() | ||
184 | |||
185 | // multer required unsetting the Content-Type, now we can set it for node-uploadx | ||
186 | req.headers['content-type'] = 'application/json; charset=utf-8' | ||
187 | // place previewfile in metadata so that uploadx saves it in .META | ||
188 | if (req.files['previewfile']) req.body.previewfile = req.files['previewfile'] | ||
124 | 189 | ||
125 | return next() | 190 | return next() |
126 | } | 191 | } |
@@ -478,7 +543,10 @@ const commonVideosFiltersValidator = [ | |||
478 | // --------------------------------------------------------------------------- | 543 | // --------------------------------------------------------------------------- |
479 | 544 | ||
480 | export { | 545 | export { |
481 | videosAddValidator, | 546 | videosAddLegacyValidator, |
547 | videosAddResumableValidator, | ||
548 | videosAddResumableInitValidator, | ||
549 | |||
482 | videosUpdateValidator, | 550 | videosUpdateValidator, |
483 | videosGetValidator, | 551 | videosGetValidator, |
484 | videoFileMetadataGetValidator, | 552 | videoFileMetadataGetValidator, |
@@ -515,7 +583,51 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response) | |||
515 | return false | 583 | return false |
516 | } | 584 | } |
517 | 585 | ||
518 | async function isVideoAccepted (req: express.Request, res: express.Response, videoFile: Express.Multer.File & { duration?: number }) { | 586 | async function commonVideoChecksPass (parameters: { |
587 | req: express.Request | ||
588 | res: express.Response | ||
589 | user: MUserAccountId | ||
590 | videoFileSize: number | ||
591 | files: express.UploadFilesForCheck | ||
592 | }): Promise<boolean> { | ||
593 | const { req, res, user, videoFileSize, files } = parameters | ||
594 | |||
595 | if (areErrorsInScheduleUpdate(req, res)) return false | ||
596 | |||
597 | if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false | ||
598 | |||
599 | if (!isVideoFileMimeTypeValid(files)) { | ||
600 | res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) | ||
601 | .json({ | ||
602 | error: 'This file is not supported. Please, make sure it is of the following type: ' + | ||
603 | CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') | ||
604 | }) | ||
605 | |||
606 | return false | ||
607 | } | ||
608 | |||
609 | if (!isVideoFileSizeValid(videoFileSize.toString())) { | ||
610 | res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) | ||
611 | .json({ error: 'This file is too large. It exceeds the maximum file size authorized.' }) | ||
612 | |||
613 | return false | ||
614 | } | ||
615 | |||
616 | if (await isAbleToUploadVideo(user.id, videoFileSize) === false) { | ||
617 | res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) | ||
618 | .json({ error: 'The user video quota is exceeded with this video.' }) | ||
619 | |||
620 | return false | ||
621 | } | ||
622 | |||
623 | return true | ||
624 | } | ||
625 | |||
626 | export async function isVideoAccepted ( | ||
627 | req: express.Request, | ||
628 | res: express.Response, | ||
629 | videoFile: express.VideoUploadFile | ||
630 | ) { | ||
519 | // Check we accept this video | 631 | // Check we accept this video |
520 | const acceptParameters = { | 632 | const acceptParameters = { |
521 | videoBody: req.body, | 633 | videoBody: req.body, |
@@ -538,3 +650,11 @@ async function isVideoAccepted (req: express.Request, res: express.Response, vid | |||
538 | 650 | ||
539 | return true | 651 | return true |
540 | } | 652 | } |
653 | |||
654 | async function addDurationToVideo (videoFile: { path: string, duration?: number }) { | ||
655 | const duration: number = await getDurationFromVideoFile(videoFile.path) | ||
656 | |||
657 | if (isNaN(duration)) throw new Error(`Couldn't get video duration`) | ||
658 | |||
659 | videoFile.duration = duration | ||
660 | } | ||