Remove resumable cache after upload success

pull/4548/head
Chocobozzz 2021-11-10 09:42:37 +01:00
parent 1868ff3db9
commit 020d3d3d79
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
5 changed files with 124 additions and 18 deletions

View File

@ -19,7 +19,7 @@ import { VideoPathManager } from '@server/lib/video-path-manager'
import { buildNextVideoState } from '@server/lib/video-state' import { buildNextVideoState } from '@server/lib/video-state'
import { openapiOperationDoc } from '@server/middlewares/doc' import { openapiOperationDoc } from '@server/middlewares/doc'
import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
import { uploadx } from '@uploadx/core' import { Uploadx } from '@uploadx/core'
import { VideoCreate, VideoState } from '../../../../shared' import { VideoCreate, VideoState } from '../../../../shared'
import { HttpStatusCode } from '../../../../shared/models' import { HttpStatusCode } from '../../../../shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
@ -41,6 +41,7 @@ import {
authenticate, authenticate,
videosAddLegacyValidator, videosAddLegacyValidator,
videosAddResumableInitValidator, videosAddResumableInitValidator,
videosResumableUploadIdValidator,
videosAddResumableValidator videosAddResumableValidator
} from '../../../middlewares' } from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
@ -50,7 +51,9 @@ import { VideoFileModel } from '../../../models/video/video-file'
const lTags = loggerTagsFactory('api', 'video') const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos') const auditLogger = auditLoggerFactory('videos')
const uploadRouter = express.Router() const uploadRouter = express.Router()
const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
const uploadx = new Uploadx({ directory: getResumableUploadPath() })
uploadx.getUserId = (_, res: express.Response) => res.locals.oauth?.token.user.id
const reqVideoFileAdd = createReqFiles( const reqVideoFileAdd = createReqFiles(
[ 'videofile', 'thumbnailfile', 'previewfile' ], [ 'videofile', 'thumbnailfile', 'previewfile' ],
@ -84,18 +87,21 @@ uploadRouter.post('/upload-resumable',
authenticate, authenticate,
reqVideoFileAddResumable, reqVideoFileAddResumable,
asyncMiddleware(videosAddResumableInitValidator), asyncMiddleware(videosAddResumableInitValidator),
uploadxMiddleware uploadx.upload
) )
uploadRouter.delete('/upload-resumable', uploadRouter.delete('/upload-resumable',
authenticate, authenticate,
uploadxMiddleware videosResumableUploadIdValidator,
asyncMiddleware(deleteUploadResumableCache),
uploadx.upload
) )
uploadRouter.put('/upload-resumable', uploadRouter.put('/upload-resumable',
openapiOperationDoc({ operationId: 'uploadResumable' }), openapiOperationDoc({ operationId: 'uploadResumable' }),
authenticate, authenticate,
uploadxMiddleware, // uploadx doesn't next() before the file upload completes videosResumableUploadIdValidator,
uploadx.upload, // uploadx doesn't next() before the file upload completes
asyncMiddleware(videosAddResumableValidator), asyncMiddleware(videosAddResumableValidator),
asyncMiddleware(addVideoResumable) asyncMiddleware(addVideoResumable)
) )
@ -108,7 +114,7 @@ export {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function addVideoLegacy (req: express.Request, res: express.Response) { async function addVideoLegacy (req: express.Request, res: express.Response) {
// Uploading the video could be long // Uploading the video could be long
// Set timeout to 10 minutes, as Express's default is 2 minutes // Set timeout to 10 minutes, as Express's default is 2 minutes
req.setTimeout(1000 * 60 * 10, () => { req.setTimeout(1000 * 60 * 10, () => {
@ -128,7 +134,7 @@ export async function addVideoLegacy (req: express.Request, res: express.Respons
return res.json(response) return res.json(response)
} }
export async function addVideoResumable (req: express.Request, res: express.Response) { async function addVideoResumable (req: express.Request, res: express.Response) {
const videoPhysicalFile = res.locals.videoFileResumable const videoPhysicalFile = res.locals.videoFileResumable
const videoInfo = videoPhysicalFile.metadata const videoInfo = videoPhysicalFile.metadata
const files = { previewfile: videoInfo.previewfile } const files = { previewfile: videoInfo.previewfile }
@ -291,3 +297,9 @@ function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile) {
}) })
.catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) })) .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
} }
async function deleteUploadResumableCache (req: express.Request, res: express.Response, next: express.NextFunction) {
await Redis.Instance.deleteUploadSession(req.query.upload_id)
return next()
}

View File

@ -271,6 +271,10 @@ class Redis {
: '' : ''
} }
deleteUploadSession (uploadId: string) {
return this.deleteKey('resumable-upload-' + uploadId)
}
/* ************ Keys generation ************ */ /* ************ Keys generation ************ */
generateCachedRouteKey (req: express.Request) { generateCachedRouteKey (req: express.Request) {

View File

@ -103,6 +103,22 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
} }
]) ])
const videosResumableUploadIdValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
const uploadId = req.query.upload_id
if (uploadId.startsWith(user.id + '-') !== true) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'You cannot send chunks in another user upload'
})
}
return next()
}
]
/** /**
* Gets called after the last PUT request * Gets called after the last PUT request
*/ */
@ -110,7 +126,7 @@ const videosAddResumableValidator = [
async (req: express.Request, res: express.Response, next: express.NextFunction) => { async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User const user = res.locals.oauth.token.User
const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body const body: express.CustomUploadXFile<express.UploadXFileMetadata> = req.body
const file = { ...body, duration: undefined, path: getResumableUploadPath(body.id), filename: body.metadata.filename } const file = { ...body, duration: undefined, path: getResumableUploadPath(body.name), filename: body.metadata.filename }
const cleanup = () => deleteFileAndCatch(file.path) const cleanup = () => deleteFileAndCatch(file.path)
const uploadId = req.query.upload_id const uploadId = req.query.upload_id
@ -552,6 +568,7 @@ export {
videosAddLegacyValidator, videosAddLegacyValidator,
videosAddResumableValidator, videosAddResumableValidator,
videosAddResumableInitValidator, videosAddResumableInitValidator,
videosResumableUploadIdValidator,
videosUpdateValidator, videosUpdateValidator,
videosGetValidator, videosGetValidator,

View File

@ -22,6 +22,8 @@ describe('Test resumable upload', function () {
const defaultFixture = 'video_short.mp4' const defaultFixture = 'video_short.mp4'
let server: PeerTubeServer let server: PeerTubeServer
let rootId: number let rootId: number
let userAccessToken: string
let userChannelId: number
async function buildSize (fixture: string, size?: number) { async function buildSize (fixture: string, size?: number) {
if (size !== undefined) return size if (size !== undefined) return size
@ -30,24 +32,33 @@ describe('Test resumable upload', function () {
return (await stat(baseFixture)).size return (await stat(baseFixture)).size
} }
async function prepareUpload (sizeArg?: number) { async function prepareUpload (options: {
const size = await buildSize(defaultFixture, sizeArg) channelId?: number
token?: string
size?: number
originalName?: string
lastModified?: number
} = {}) {
const { token, originalName, lastModified } = options
const size = await buildSize(defaultFixture, options.size)
const attributes = { const attributes = {
name: 'video', name: 'video',
channelId: server.store.channel.id, channelId: options.channelId ?? server.store.channel.id,
privacy: VideoPrivacy.PUBLIC, privacy: VideoPrivacy.PUBLIC,
fixture: defaultFixture fixture: defaultFixture
} }
const mimetype = 'video/mp4' const mimetype = 'video/mp4'
const res = await server.videos.prepareResumableUpload({ attributes, size, mimetype }) const res = await server.videos.prepareResumableUpload({ token, attributes, size, mimetype, originalName, lastModified })
return res.header['location'].split('?')[1] return res.header['location'].split('?')[1]
} }
async function sendChunks (options: { async function sendChunks (options: {
token?: string
pathUploadId: string pathUploadId: string
size?: number size?: number
expectedStatus?: HttpStatusCode expectedStatus?: HttpStatusCode
@ -55,12 +66,13 @@ describe('Test resumable upload', function () {
contentRange?: string contentRange?: string
contentRangeBuilder?: (start: number, chunk: any) => string contentRangeBuilder?: (start: number, chunk: any) => string
}) { }) {
const { pathUploadId, expectedStatus, contentLength, contentRangeBuilder } = options const { token, pathUploadId, expectedStatus, contentLength, contentRangeBuilder } = options
const size = await buildSize(defaultFixture, options.size) const size = await buildSize(defaultFixture, options.size)
const absoluteFilePath = buildAbsoluteFixturePath(defaultFixture) const absoluteFilePath = buildAbsoluteFixturePath(defaultFixture)
return server.videos.sendResumableChunks({ return server.videos.sendResumableChunks({
token,
pathUploadId, pathUploadId,
videoFilePath: absoluteFilePath, videoFilePath: absoluteFilePath,
size, size,
@ -105,6 +117,12 @@ describe('Test resumable upload', function () {
const body = await server.users.getMyInfo() const body = await server.users.getMyInfo()
rootId = body.id rootId = body.id
{
userAccessToken = await server.users.generateUserAndToken('user1')
const { videoChannels } = await server.users.getMyInfo({ token: userAccessToken })
userChannelId = videoChannels[0].id
}
await server.users.update({ userId: rootId, videoQuota: 10_000_000 }) await server.users.update({ userId: rootId, videoQuota: 10_000_000 })
}) })
@ -147,14 +165,14 @@ describe('Test resumable upload', function () {
}) })
it('Should not accept more chunks than expected', async function () { it('Should not accept more chunks than expected', async function () {
const uploadId = await prepareUpload(100) const uploadId = await prepareUpload({ size: 100 })
await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409 }) await sendChunks({ pathUploadId: uploadId, expectedStatus: HttpStatusCode.CONFLICT_409 })
await checkFileSize(uploadId, 0) await checkFileSize(uploadId, 0)
}) })
it('Should not accept more chunks than expected with an invalid content length/content range', async function () { it('Should not accept more chunks than expected with an invalid content length/content range', async function () {
const uploadId = await prepareUpload(1500) const uploadId = await prepareUpload({ size: 1500 })
// Content length check seems to have changed in v16 // Content length check seems to have changed in v16
if (process.version.startsWith('v16')) { if (process.version.startsWith('v16')) {
@ -167,7 +185,7 @@ describe('Test resumable upload', function () {
}) })
it('Should not accept more chunks than expected with an invalid content length', async function () { it('Should not accept more chunks than expected with an invalid content length', async function () {
const uploadId = await prepareUpload(500) const uploadId = await prepareUpload({ size: 500 })
const size = 1000 const size = 1000
@ -195,6 +213,51 @@ describe('Test resumable upload', function () {
await checkFileSize(uploadId, null) await checkFileSize(uploadId, null)
}) })
it('Should not have the same upload id with 2 different users', async function () {
const originalName = 'toto.mp4'
const lastModified = new Date().getTime()
const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
const uploadId2 = await prepareUpload({ originalName, lastModified, channelId: userChannelId, token: userAccessToken })
expect(uploadId1).to.not.equal(uploadId2)
})
it('Should have the same upload id with the same user', async function () {
const originalName = 'toto.mp4'
const lastModified = new Date().getTime()
const uploadId1 = await prepareUpload({ originalName, lastModified })
const uploadId2 = await prepareUpload({ originalName, lastModified })
expect(uploadId1).to.equal(uploadId2)
})
it('Should not cache a request with 2 different users', async function () {
const originalName = 'toto.mp4'
const lastModified = new Date().getTime()
const uploadId = await prepareUpload({ originalName, lastModified, token: server.accessToken })
await sendChunks({ pathUploadId: uploadId, token: server.accessToken })
await sendChunks({ pathUploadId: uploadId, token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should not cache a request after a delete', async function () {
const originalName = 'toto.mp4'
const lastModified = new Date().getTime()
const uploadId1 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
await sendChunks({ pathUploadId: uploadId1 })
await server.videos.endResumableUpload({ pathUploadId: uploadId1 })
const uploadId2 = await prepareUpload({ originalName, lastModified, token: server.accessToken })
expect(uploadId1).to.equal(uploadId2)
const result2 = await sendChunks({ pathUploadId: uploadId1 })
expect(result2.headers['x-resumable-upload-cached']).to.not.exist
})
}) })
after(async function () { after(async function () {

View File

@ -469,8 +469,11 @@ export class VideosCommand extends AbstractCommand {
attributes: VideoEdit attributes: VideoEdit
size: number size: number
mimetype: string mimetype: string
originalName?: string
lastModified?: number
}) { }) {
const { attributes, size, mimetype } = options const { attributes, originalName, lastModified, size, mimetype } = options
const path = '/api/v1/videos/upload-resumable' const path = '/api/v1/videos/upload-resumable'
@ -482,7 +485,14 @@ export class VideosCommand extends AbstractCommand {
'X-Upload-Content-Type': mimetype, 'X-Upload-Content-Type': mimetype,
'X-Upload-Content-Length': size.toString() 'X-Upload-Content-Length': size.toString()
}, },
fields: { filename: attributes.fixture, ...this.buildUploadFields(options.attributes) }, fields: {
filename: attributes.fixture,
originalName,
lastModified,
...this.buildUploadFields(options.attributes)
},
// Fixture will be sent later // Fixture will be sent later
attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')), attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
implicitToken: true, implicitToken: true,