mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
Split each uploaded file into 5 MB chunks posted sequentially to a new /api/upload/chunk endpoint, then assemble them on the server via /api/upload/finalize. The client queries /api/upload/chunk/:uploadId/:fileIndex to skip chunks already received, so an interrupted upload resumes instead of restarting. Each chunk is retried with backoff. Path components are sanitized and the same canUpload/library-access checks as /api/upload are enforced. The legacy /api/upload endpoint is left intact. The chunk staging directory defaults to <metadata>/uploads and can be relocated with UPLOAD_TEMP_DIR so large in-progress uploads can be staged off the metadata volume.
171 lines
5.9 KiB
JavaScript
171 lines
5.9 KiB
JavaScript
const { expect } = require('chai')
|
|
const sinon = require('sinon')
|
|
const Path = require('path')
|
|
const os = require('os')
|
|
|
|
const fs = require('../../../server/libs/fsExtra')
|
|
const Database = require('../../../server/Database')
|
|
const Logger = require('../../../server/Logger')
|
|
const MiscController = require('../../../server/controllers/MiscController')
|
|
|
|
function mockResponse() {
|
|
const res = {}
|
|
res.statusCode = 200
|
|
res.status = (code) => {
|
|
res.statusCode = code
|
|
return res
|
|
}
|
|
res.send = (body) => {
|
|
res.body = body
|
|
return res
|
|
}
|
|
res.sendStatus = (code) => {
|
|
res.statusCode = code
|
|
return res
|
|
}
|
|
res.json = (obj) => {
|
|
res.jsonBody = obj
|
|
return res
|
|
}
|
|
return res
|
|
}
|
|
|
|
function chunkRequest(uploadId, fileIndex, chunkIndex, numChunks, buffer) {
|
|
return {
|
|
user: { canUpload: true, username: 'admin', checkCanAccessLibrary: () => true },
|
|
body: { uploadId, fileIndex: `${fileIndex}`, chunkIndex: `${chunkIndex}`, numChunks: `${numChunks}` },
|
|
files: {
|
|
chunk: {
|
|
mv: async (dest) => fs.writeFile(dest, buffer)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('MiscController - chunked resumable upload', () => {
|
|
let metadataRoot
|
|
let libraryRoot
|
|
|
|
beforeEach(async () => {
|
|
metadataRoot = await fs.mkdtemp(Path.join(os.tmpdir(), 'abs-meta-'))
|
|
libraryRoot = await fs.mkdtemp(Path.join(os.tmpdir(), 'abs-lib-'))
|
|
global.MetadataPath = metadataRoot
|
|
|
|
sinon.stub(Database, 'libraryModel').get(() => ({
|
|
findByIdWithFolders: async (id) => (id === 'lib1' ? { id: 'lib1', isPodcast: false, libraryFolders: [{ id: 'fold1', path: libraryRoot }] } : null)
|
|
}))
|
|
|
|
sinon.stub(Logger, 'info')
|
|
sinon.stub(Logger, 'warn')
|
|
sinon.stub(Logger, 'error')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
sinon.restore()
|
|
await fs.remove(metadataRoot)
|
|
await fs.remove(libraryRoot)
|
|
delete global.MetadataPath
|
|
})
|
|
|
|
it('stores chunks, lists them for resume, then assembles them in order', async () => {
|
|
const uploadId = 'session-abc'
|
|
|
|
let res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest(uploadId, 0, 0, 2, Buffer.from('hello ')), res)
|
|
expect(res.statusCode).to.equal(200)
|
|
|
|
res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest(uploadId, 0, 1, 2, Buffer.from('world')), res)
|
|
expect(res.statusCode).to.equal(200)
|
|
|
|
res = mockResponse()
|
|
await MiscController.getUploadChunks({ user: { canUpload: true }, params: { uploadId, fileIndex: '0' } }, res)
|
|
expect(res.jsonBody.chunks.sort()).to.deep.equal([0, 1])
|
|
|
|
res = mockResponse()
|
|
await MiscController.handleUploadFinalize(
|
|
{
|
|
user: { canUpload: true, username: 'admin', checkCanAccessLibrary: () => true },
|
|
body: { uploadId, title: 'My Book', author: 'Author', library: 'lib1', folder: 'fold1', files: [{ index: 0, name: 'audio.mp3', numChunks: 2 }] }
|
|
},
|
|
res
|
|
)
|
|
expect(res.statusCode).to.equal(200)
|
|
|
|
const assembled = await fs.readFile(Path.join(libraryRoot, 'Author', 'My Book', 'audio.mp3'))
|
|
expect(assembled.toString()).to.equal('hello world')
|
|
|
|
expect(await fs.pathExists(Path.join(metadataRoot, 'uploads', uploadId))).to.equal(false)
|
|
})
|
|
|
|
it('only re-sends the missing chunk on resume', async () => {
|
|
const uploadId = 'session-resume'
|
|
|
|
let res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest(uploadId, 0, 0, 2, Buffer.from('AAAA')), res)
|
|
|
|
res = mockResponse()
|
|
await MiscController.getUploadChunks({ user: { canUpload: true }, params: { uploadId, fileIndex: '0' } }, res)
|
|
expect(res.jsonBody.chunks).to.deep.equal([0])
|
|
|
|
res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest(uploadId, 0, 1, 2, Buffer.from('BBBB')), res)
|
|
|
|
res = mockResponse()
|
|
await MiscController.handleUploadFinalize(
|
|
{
|
|
user: { canUpload: true, username: 'admin', checkCanAccessLibrary: () => true },
|
|
body: { uploadId, title: 'Resumed', library: 'lib1', folder: 'fold1', files: [{ index: 0, name: 'audio.mp3', numChunks: 2 }] }
|
|
},
|
|
res
|
|
)
|
|
expect(res.statusCode).to.equal(200)
|
|
|
|
const assembled = await fs.readFile(Path.join(libraryRoot, 'Resumed', 'audio.mp3'))
|
|
expect(assembled.toString()).to.equal('AAAABBBB')
|
|
})
|
|
|
|
it('fails finalize when a chunk is missing', async () => {
|
|
const uploadId = 'session-missing'
|
|
|
|
let res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest(uploadId, 0, 0, 2, Buffer.from('only-first')), res)
|
|
|
|
res = mockResponse()
|
|
await MiscController.handleUploadFinalize(
|
|
{
|
|
user: { canUpload: true, username: 'admin', checkCanAccessLibrary: () => true },
|
|
body: { uploadId, title: 'Broken', library: 'lib1', folder: 'fold1', files: [{ index: 0, name: 'audio.mp3', numChunks: 2 }] }
|
|
},
|
|
res
|
|
)
|
|
expect(res.statusCode).to.equal(400)
|
|
})
|
|
|
|
it('rejects an upload id that escapes the uploads directory', async () => {
|
|
const res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest('../escape', 0, 0, 1, Buffer.from('x')), res)
|
|
expect(res.statusCode).to.equal(400)
|
|
})
|
|
|
|
it('rejects a chunk upload without permission', async () => {
|
|
const res = mockResponse()
|
|
await MiscController.handleUploadChunk({ user: { canUpload: false, username: 'reader' }, body: {}, files: {} }, res)
|
|
expect(res.statusCode).to.equal(403)
|
|
})
|
|
|
|
it('stages chunks under UPLOAD_TEMP_DIR when set', async () => {
|
|
const altRoot = await fs.mkdtemp(Path.join(os.tmpdir(), 'abs-up-'))
|
|
process.env.UPLOAD_TEMP_DIR = altRoot
|
|
try {
|
|
const uploadId = 'session-env'
|
|
const res = mockResponse()
|
|
await MiscController.handleUploadChunk(chunkRequest(uploadId, 0, 0, 1, Buffer.from('Z')), res)
|
|
expect(res.statusCode).to.equal(200)
|
|
expect(await fs.pathExists(Path.join(altRoot, uploadId, '0', '0'))).to.equal(true)
|
|
} finally {
|
|
delete process.env.UPLOAD_TEMP_DIR
|
|
await fs.remove(altRoot)
|
|
}
|
|
})
|
|
})
|