import { buildNextVideoState } from '@server/lib/video-state'
import { openapiOperationDoc } from '@server/middlewares/doc'
import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
-import { uploadx } from '@uploadx/core'
+import { Uploadx } from '@uploadx/core'
import { VideoCreate, VideoState } from '../../../../shared'
import { HttpStatusCode } from '../../../../shared/models'
import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
authenticate,
videosAddLegacyValidator,
videosAddResumableInitValidator,
+ videosResumableUploadIdValidator,
videosAddResumableValidator
} from '../../../middlewares'
import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
const lTags = loggerTagsFactory('api', 'video')
const auditLogger = auditLoggerFactory('videos')
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(
[ 'videofile', 'thumbnailfile', 'previewfile' ],
authenticate,
reqVideoFileAddResumable,
asyncMiddleware(videosAddResumableInitValidator),
- uploadxMiddleware
+ uploadx.upload
)
uploadRouter.delete('/upload-resumable',
authenticate,
- uploadxMiddleware
+ videosResumableUploadIdValidator,
+ asyncMiddleware(deleteUploadResumableCache),
+ uploadx.upload
)
uploadRouter.put('/upload-resumable',
openapiOperationDoc({ operationId: 'uploadResumable' }),
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(addVideoResumable)
)
// ---------------------------------------------------------------------------
-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
// Set timeout to 10 minutes, as Express's default is 2 minutes
req.setTimeout(1000 * 60 * 10, () => {
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 videoInfo = videoPhysicalFile.metadata
const files = { previewfile: videoInfo.previewfile }
})
.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()
+}
: ''
}
+ deleteUploadSession (uploadId: string) {
+ return this.deleteKey('resumable-upload-' + uploadId)
+ }
+
/* ************ Keys generation ************ */
generateCachedRouteKey (req: express.Request) {
}
])
+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
*/
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
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 uploadId = req.query.upload_id
videosAddLegacyValidator,
videosAddResumableValidator,
videosAddResumableInitValidator,
+ videosResumableUploadIdValidator,
videosUpdateValidator,
videosGetValidator,
const defaultFixture = 'video_short.mp4'
let server: PeerTubeServer
let rootId: number
+ let userAccessToken: string
+ let userChannelId: number
async function buildSize (fixture: string, size?: number) {
if (size !== undefined) return size
return (await stat(baseFixture)).size
}
- async function prepareUpload (sizeArg?: number) {
- const size = await buildSize(defaultFixture, sizeArg)
+ async function prepareUpload (options: {
+ channelId?: number
+ token?: string
+ size?: number
+ originalName?: string
+ lastModified?: number
+ } = {}) {
+ const { token, originalName, lastModified } = options
+
+ const size = await buildSize(defaultFixture, options.size)
const attributes = {
name: 'video',
- channelId: server.store.channel.id,
+ channelId: options.channelId ?? server.store.channel.id,
privacy: VideoPrivacy.PUBLIC,
fixture: defaultFixture
}
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]
}
async function sendChunks (options: {
+ token?: string
pathUploadId: string
size?: number
expectedStatus?: HttpStatusCode
contentRange?: 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 absoluteFilePath = buildAbsoluteFixturePath(defaultFixture)
return server.videos.sendResumableChunks({
+ token,
pathUploadId,
videoFilePath: absoluteFilePath,
size,
const body = await server.users.getMyInfo()
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 })
})
})
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 checkFileSize(uploadId, 0)
})
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
if (process.version.startsWith('v16')) {
})
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
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 () {
attributes: VideoEdit
size: number
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'
'X-Upload-Content-Type': mimetype,
'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
attaches: this.buildUploadAttaches(omit(options.attributes, 'fixture')),
implicitToken: true,