mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
b2859ec944
* refactor(storage): remove custom host downloads * test(download): cover private signed URLs end to end
228 lines
5.9 KiB
JavaScript
228 lines
5.9 KiB
JavaScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { createServer } from 'node:http'
|
|
|
|
const port = Number(process.env.E2E_S3_MOCK_PORT ?? 9191)
|
|
const objects = new Map()
|
|
const uploads = new Map()
|
|
|
|
const server = createServer(async (req, res) => {
|
|
setCors(res)
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
try {
|
|
await handleRequest(req, res)
|
|
} catch (error) {
|
|
console.error('[s3-mock] request failed:', error)
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
res.end('Internal Server Error')
|
|
}
|
|
})
|
|
|
|
server.listen(port, '127.0.0.1', () => {
|
|
console.log(`[s3-mock] listening on http://127.0.0.1:${port}`)
|
|
})
|
|
|
|
async function handleRequest(req, res) {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
const { bucket, key } = parsePath(url.pathname)
|
|
|
|
if (url.pathname === '/health') {
|
|
res.writeHead(200)
|
|
res.end('ok')
|
|
return
|
|
}
|
|
|
|
if (!bucket) {
|
|
res.writeHead(200)
|
|
res.end('')
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && url.searchParams.has('uploads')) {
|
|
createMultipartUpload(res, bucket, key)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'PUT' && url.searchParams.has('uploadId') && url.searchParams.has('partNumber')) {
|
|
await uploadPart(req, res, url)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'POST' && url.searchParams.has('uploadId')) {
|
|
completeMultipartUpload(res, url, bucket, key)
|
|
return
|
|
}
|
|
|
|
if (req.method === 'DELETE' && url.searchParams.has('uploadId')) {
|
|
uploads.delete(url.searchParams.get('uploadId'))
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const objectKey = storageKey(bucket, key)
|
|
if (req.method === 'PUT') {
|
|
const body = await readBody(req)
|
|
objects.set(objectKey, {
|
|
body,
|
|
contentType: req.headers['content-type'] ?? 'application/octet-stream',
|
|
})
|
|
res.writeHead(200, { etag: etag(body) })
|
|
res.end('')
|
|
return
|
|
}
|
|
|
|
if (req.method === 'HEAD') {
|
|
const object = objects.get(objectKey)
|
|
if (!object) {
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
res.writeHead(200, {
|
|
'Content-Length': object.body.byteLength,
|
|
'Content-Type': object.contentType,
|
|
etag: etag(object.body),
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
if (req.method === 'GET') {
|
|
const object = objects.get(objectKey)
|
|
if (!object) {
|
|
res.writeHead(404)
|
|
res.end('Not found')
|
|
return
|
|
}
|
|
writeObject(res, object, req.headers.range, {
|
|
contentDisposition: url.searchParams.get('response-content-disposition'),
|
|
contentType: url.searchParams.get('response-content-type'),
|
|
})
|
|
return
|
|
}
|
|
|
|
if (req.method === 'DELETE') {
|
|
objects.delete(objectKey)
|
|
res.writeHead(204)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
res.writeHead(405)
|
|
res.end('Method not allowed')
|
|
}
|
|
|
|
function createMultipartUpload(res, bucket, key) {
|
|
const uploadId = randomUUID()
|
|
uploads.set(uploadId, { bucket, key, parts: new Map() })
|
|
res.writeHead(200, { 'Content-Type': 'application/xml' })
|
|
res.end(`<CreateMultipartUploadResult><UploadId>${uploadId}</UploadId></CreateMultipartUploadResult>`)
|
|
}
|
|
|
|
async function uploadPart(req, res, url) {
|
|
const uploadId = url.searchParams.get('uploadId')
|
|
const upload = uploads.get(uploadId)
|
|
if (!upload) {
|
|
res.writeHead(404)
|
|
res.end('Upload not found')
|
|
return
|
|
}
|
|
const partNumber = Number(url.searchParams.get('partNumber'))
|
|
const body = await readBody(req)
|
|
upload.parts.set(partNumber, body)
|
|
res.writeHead(200, { etag: etag(body) })
|
|
res.end('')
|
|
}
|
|
|
|
function completeMultipartUpload(res, url, bucket, key) {
|
|
const uploadId = url.searchParams.get('uploadId')
|
|
const upload = uploads.get(uploadId)
|
|
if (!upload) {
|
|
res.writeHead(404)
|
|
res.end('Upload not found')
|
|
return
|
|
}
|
|
|
|
const parts = [...upload.parts.entries()].sort(([left], [right]) => left - right)
|
|
const total = parts.reduce((sum, [, part]) => sum + part.byteLength, 0)
|
|
const body = new Uint8Array(total)
|
|
let offset = 0
|
|
for (const [, part] of parts) {
|
|
body.set(part, offset)
|
|
offset += part.byteLength
|
|
}
|
|
|
|
objects.set(storageKey(bucket, key), { body, contentType: 'application/octet-stream' })
|
|
uploads.delete(uploadId)
|
|
res.writeHead(200, { 'Content-Type': 'application/xml' })
|
|
res.end('<CompleteMultipartUploadResult />')
|
|
}
|
|
|
|
function writeObject(res, object, rangeHeader, overrides) {
|
|
const headers = {
|
|
'Content-Type': overrides.contentType ?? object.contentType,
|
|
...(overrides.contentDisposition ? { 'Content-Disposition': overrides.contentDisposition } : {}),
|
|
}
|
|
if (!rangeHeader) {
|
|
res.writeHead(200, {
|
|
'Content-Length': object.body.byteLength,
|
|
etag: etag(object.body),
|
|
...headers,
|
|
})
|
|
res.end(object.body)
|
|
return
|
|
}
|
|
|
|
const match = /^bytes=(\d+)-(\d+)?$/.exec(rangeHeader)
|
|
if (!match) {
|
|
res.writeHead(416)
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
const start = Number(match[1])
|
|
const end = match[2] ? Number(match[2]) : object.body.byteLength - 1
|
|
const slice = object.body.slice(start, end + 1)
|
|
res.writeHead(206, {
|
|
'Content-Length': slice.byteLength,
|
|
'Content-Range': `bytes ${start}-${end}/${object.body.byteLength}`,
|
|
etag: etag(object.body),
|
|
...headers,
|
|
})
|
|
res.end(slice)
|
|
}
|
|
|
|
function parsePath(pathname) {
|
|
const parts = pathname.split('/').filter(Boolean).map(decodeURIComponent)
|
|
return {
|
|
bucket: parts[0] ?? '',
|
|
key: parts.slice(1).join('/'),
|
|
}
|
|
}
|
|
|
|
function storageKey(bucket, key) {
|
|
return `${bucket}/${key}`
|
|
}
|
|
|
|
function setCors(res) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,PUT,POST,DELETE,OPTIONS')
|
|
res.setHeader('Access-Control-Allow-Headers', '*')
|
|
res.setHeader('Access-Control-Expose-Headers', 'ETag,Content-Disposition,Content-Length,Content-Range,Content-Type')
|
|
}
|
|
|
|
function etag(body) {
|
|
return `"${createHash('md5').update(body).digest('hex')}"`
|
|
}
|
|
|
|
async function readBody(req) {
|
|
const chunks = []
|
|
for await (const chunk of req) chunks.push(chunk)
|
|
return new Uint8Array(Buffer.concat(chunks))
|
|
}
|