384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { createReadStream } from 'fs'
|
||
import { mkdir, open, rm } from 'fs/promises'
|
||
import { randomUUID } from 'crypto'
|
||
import path from 'path'
|
||
import { pipeline } from 'stream/promises'
|
||
import { createInflateRaw } from 'zlib'
|
||
|
||
/**
|
||
* 任务-详情编辑用的流式 ZIP 读取器。
|
||
*
|
||
* 只依赖 Node 自带 zlib:读中央目录定位每个条目,再按条目把解压结果
|
||
* 流式写到磁盘。全程不把整个压缩包读进内存,历史素材的分组 ZIP
|
||
* (主图/SKU图/详情图)可能数百 MB 甚至更大。
|
||
*
|
||
* 只支持本系统自己打包出来的常规布局:
|
||
* - method 0(store)/ method 8(deflate)
|
||
* - UTF-8 文件名、数据描述符(flag bit 3)、ZIP64 扩展
|
||
* 不支持加密与其它压缩方法,遇到即报错。
|
||
*/
|
||
|
||
const EOCD_SIGNATURE = 0x06054b50
|
||
const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50
|
||
const ZIP64_EOCD_SIGNATURE = 0x06064b50
|
||
const CENTRAL_HEADER_SIGNATURE = 0x02014b50
|
||
const LOCAL_HEADER_SIGNATURE = 0x04034b50
|
||
|
||
export interface ZipEntryInfo {
|
||
/** 中央目录中的原始文件名 */
|
||
name: string
|
||
compressedSize: number
|
||
uncompressedSize: number
|
||
compressionMethod: number
|
||
localHeaderOffset: number
|
||
flags: number
|
||
}
|
||
|
||
interface ZipCentralDirectory {
|
||
entries: ZipEntryInfo[]
|
||
}
|
||
|
||
function readUInt16LE(buffer: Buffer, offset: number): number {
|
||
return buffer.readUInt16LE(offset)
|
||
}
|
||
|
||
function readUInt32LE(buffer: Buffer, offset: number): number {
|
||
return buffer.readUInt32LE(offset)
|
||
}
|
||
|
||
function readBigUInt64LE(buffer: Buffer, offset: number): number {
|
||
const value = buffer.readBigUInt64LE(offset)
|
||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||
throw new Error('ZIP 内的偏移或大小超出安全范围')
|
||
}
|
||
return Number(value)
|
||
}
|
||
|
||
async function readSlice(
|
||
handle: Awaited<ReturnType<typeof open>>,
|
||
position: number,
|
||
length: number,
|
||
): Promise<Buffer> {
|
||
const buffer = Buffer.alloc(length)
|
||
const { bytesRead } = await handle.read(buffer, 0, length, position)
|
||
if (bytesRead !== length) {
|
||
throw new Error('压缩包已损坏:读取内容不完整')
|
||
}
|
||
return buffer
|
||
}
|
||
|
||
function findEocdSignature(tail: Buffer): number {
|
||
// EOCD 固定 22 字节起,注释最长 65535,从尾部向前扫签名。
|
||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||
if (readUInt32LE(tail, offset) === EOCD_SIGNATURE) {
|
||
return offset
|
||
}
|
||
}
|
||
throw new Error('文件不是有效的 ZIP 压缩包')
|
||
}
|
||
|
||
async function locateZip64Eocd(
|
||
handle: Awaited<ReturnType<typeof open>>,
|
||
eocdOffsetInTail: number,
|
||
tailStart: number,
|
||
fileSize: number,
|
||
): Promise<{ entryCount: number; cdSize: number; cdOffset: number } | null> {
|
||
if (eocdOffsetInTail < 20) {
|
||
return null
|
||
}
|
||
const locatorPosition = tailStart + eocdOffsetInTail - 20
|
||
if (locatorPosition < 0 || locatorPosition + 20 > fileSize) {
|
||
return null
|
||
}
|
||
const locator = await readSlice(handle, locatorPosition, 20)
|
||
if (readUInt32LE(locator, 0) !== ZIP64_EOCD_LOCATOR_SIGNATURE) {
|
||
return null
|
||
}
|
||
const zip64EocdOffset = readBigUInt64LE(locator, 8)
|
||
const zip64Eocd = await readSlice(handle, zip64EocdOffset, 56)
|
||
if (readUInt32LE(zip64Eocd, 0) !== ZIP64_EOCD_SIGNATURE) {
|
||
throw new Error('压缩包已损坏:ZIP64 目录定位失败')
|
||
}
|
||
const entryCount = readBigUInt64LE(zip64Eocd, 32)
|
||
const cdSize = readBigUInt64LE(zip64Eocd, 40)
|
||
const cdOffset = readBigUInt64LE(zip64Eocd, 48)
|
||
return { entryCount, cdSize, cdOffset }
|
||
}
|
||
|
||
async function readCentralDirectory(
|
||
zipPath: string,
|
||
): Promise<ZipCentralDirectory> {
|
||
const handle = await open(zipPath, 'r')
|
||
try {
|
||
const stats = await handle.stat()
|
||
if (!stats.isFile() || stats.size < 22) {
|
||
throw new Error('文件不是有效的 ZIP 压缩包')
|
||
}
|
||
|
||
const tailLength = Math.min(stats.size, 22 + 65535 + 20 + 56)
|
||
const tailStart = stats.size - tailLength
|
||
const tail = await readSlice(handle, tailStart, tailLength)
|
||
const eocdOffsetInTail = findEocdSignature(tail)
|
||
const eocdAbsolute = tailStart + eocdOffsetInTail
|
||
|
||
let entryCount = readUInt16LE(tail, eocdOffsetInTail + 10)
|
||
let cdSize = readUInt32LE(tail, eocdOffsetInTail + 12)
|
||
let cdOffset = readUInt32LE(tail, eocdOffsetInTail + 16)
|
||
|
||
if (
|
||
entryCount === 0xffff
|
||
|| cdSize === 0xffffffff
|
||
|| cdOffset === 0xffffffff
|
||
) {
|
||
const zip64 = await locateZip64Eocd(
|
||
handle,
|
||
eocdOffsetInTail,
|
||
tailStart,
|
||
stats.size,
|
||
)
|
||
if (!zip64) {
|
||
throw new Error('压缩包已损坏:ZIP64 目录缺失')
|
||
}
|
||
entryCount = zip64.entryCount
|
||
cdSize = zip64.cdSize
|
||
cdOffset = zip64.cdOffset
|
||
}
|
||
|
||
if (entryCount <= 0) {
|
||
return { entries: [] }
|
||
}
|
||
|
||
const directoryBuffer = await readSlice(handle, cdOffset, cdSize)
|
||
const entries: ZipEntryInfo[] = []
|
||
let cursor = 0
|
||
|
||
for (let index = 0; index < entryCount; index += 1) {
|
||
if (cursor + 46 > directoryBuffer.length) {
|
||
throw new Error('压缩包已损坏:中央目录不完整')
|
||
}
|
||
if (readUInt32LE(directoryBuffer, cursor) !== CENTRAL_HEADER_SIGNATURE) {
|
||
throw new Error('压缩包已损坏:中央目录条目签名异常')
|
||
}
|
||
|
||
const flags = readUInt16LE(directoryBuffer, cursor + 8)
|
||
const compressionMethod = readUInt16LE(directoryBuffer, cursor + 10)
|
||
let compressedSize = readUInt32LE(directoryBuffer, cursor + 20)
|
||
let uncompressedSize = readUInt32LE(directoryBuffer, cursor + 24)
|
||
const nameLength = readUInt16LE(directoryBuffer, cursor + 28)
|
||
const extraLength = readUInt16LE(directoryBuffer, cursor + 30)
|
||
const commentLength = readUInt16LE(directoryBuffer, cursor + 32)
|
||
let localHeaderOffset = readUInt32LE(directoryBuffer, cursor + 42)
|
||
|
||
const extraStart = cursor + 46 + nameLength
|
||
const extraEnd = extraStart + extraLength
|
||
if (extraEnd > directoryBuffer.length) {
|
||
throw new Error('压缩包已损坏:中央目录扩展字段不完整')
|
||
}
|
||
|
||
let extraCursor = extraStart
|
||
while (extraCursor + 4 <= extraEnd) {
|
||
const headerId = readUInt16LE(directoryBuffer, extraCursor)
|
||
const dataSize = readUInt16LE(directoryBuffer, extraCursor + 2)
|
||
if (headerId === 0x0001) {
|
||
let fieldCursor = extraCursor + 4
|
||
const fieldEnd = extraCursor + 4 + dataSize
|
||
if (uncompressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
|
||
uncompressedSize = readBigUInt64LE(directoryBuffer, fieldCursor)
|
||
fieldCursor += 8
|
||
}
|
||
if (compressedSize === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
|
||
compressedSize = readBigUInt64LE(directoryBuffer, fieldCursor)
|
||
fieldCursor += 8
|
||
}
|
||
if (localHeaderOffset === 0xffffffff && fieldCursor + 8 <= fieldEnd) {
|
||
localHeaderOffset = readBigUInt64LE(directoryBuffer, fieldCursor)
|
||
fieldCursor += 8
|
||
}
|
||
}
|
||
extraCursor += 4 + dataSize
|
||
}
|
||
|
||
const name = directoryBuffer.toString('utf8', cursor + 46, extraStart)
|
||
entries.push({
|
||
name,
|
||
compressedSize,
|
||
uncompressedSize,
|
||
compressionMethod,
|
||
localHeaderOffset,
|
||
flags,
|
||
})
|
||
|
||
cursor = extraEnd + commentLength
|
||
}
|
||
|
||
return { entries }
|
||
} finally {
|
||
await handle.close()
|
||
}
|
||
}
|
||
|
||
/** 列出 ZIP 内的条目(按中央目录顺序,跳过目录项)。 */
|
||
export async function listZipEntries(zipPath: string): Promise<ZipEntryInfo[]> {
|
||
const { entries } = await readCentralDirectory(zipPath)
|
||
return entries.filter((entry) => !entry.name.endsWith('/'))
|
||
}
|
||
|
||
/**
|
||
* 把 ZIP 条目解压到目标目录。
|
||
*
|
||
* 返回值保持中央目录顺序;文件名只保留最后一段路径,
|
||
* 拒绝空名和穿越命名,避免历史数据里的异常条目写出目录树。
|
||
*/
|
||
export async function extractZipEntriesToDirectory(
|
||
zipPath: string,
|
||
destinationDir: string,
|
||
): Promise<Array<{ entryName: string; filePath: string; size: number }>> {
|
||
const { entries } = await readCentralDirectory(zipPath)
|
||
await mkdir(destinationDir, { recursive: true })
|
||
|
||
const extracted: Array<{ entryName: string; filePath: string; size: number }> = []
|
||
for (const entry of entries) {
|
||
if (entry.name.endsWith('/')) {
|
||
continue
|
||
}
|
||
|
||
const normalized = entry.name.replace(/\\/g, '/')
|
||
const baseName = normalized.split('/').pop() || ''
|
||
if (!baseName || baseName === '.' || baseName === '..') {
|
||
throw new Error(`压缩包含异常文件名,拒绝处理: ${entry.name}`)
|
||
}
|
||
if ((entry.flags & 0x1) !== 0) {
|
||
throw new Error(`压缩包含加密文件,不支持编辑: ${baseName}`)
|
||
}
|
||
|
||
const outputPath = path.join(destinationDir, `${extracted.length}-${baseName}`)
|
||
const written = await extractSingleEntry(handle => extractByEntry(handle, entry, outputPath))
|
||
if (written !== entry.uncompressedSize) {
|
||
throw new Error(`压缩包已损坏:条目 ${baseName} 解压后大小不一致`)
|
||
}
|
||
|
||
extracted.push({
|
||
entryName: baseName,
|
||
filePath: outputPath,
|
||
size: entry.uncompressedSize,
|
||
})
|
||
}
|
||
|
||
return extracted
|
||
|
||
async function extractSingleEntry(
|
||
worker: (handle: Awaited<ReturnType<typeof open>>) => Promise<number>,
|
||
): Promise<number> {
|
||
const handle = await open(zipPath, 'r')
|
||
try {
|
||
return await worker(handle)
|
||
} finally {
|
||
await handle.close()
|
||
}
|
||
}
|
||
}
|
||
|
||
async function extractByEntry(
|
||
handle: Awaited<ReturnType<typeof open>>,
|
||
entry: ZipEntryInfo,
|
||
outputPath: string,
|
||
): Promise<number> {
|
||
const localHeader = await readSlice(handle, entry.localHeaderOffset, 30)
|
||
if (readUInt32LE(localHeader, 0) !== LOCAL_HEADER_SIGNATURE) {
|
||
throw new Error(`压缩包已损坏:条目 ${entry.name} 本地头异常`)
|
||
}
|
||
const nameLength = readUInt16LE(localHeader, 26)
|
||
const extraLength = readUInt16LE(localHeader, 28)
|
||
const dataStart = entry.localHeaderOffset + 30 + nameLength + extraLength
|
||
|
||
if (entry.compressionMethod === 0) {
|
||
// store:直接按范围拷贝原始字节
|
||
return copyRangeToFile(handle, dataStart, entry.compressedSize, outputPath)
|
||
}
|
||
|
||
if (entry.compressionMethod === 8) {
|
||
const source = createRangeReadStream(handle, dataStart, entry.compressedSize)
|
||
const inflater = createInflateRaw()
|
||
let inflatedBytes = 0
|
||
inflater.on('data', (chunk: Buffer) => {
|
||
inflatedBytes += chunk.length
|
||
})
|
||
const output = await createFileWriteStream(outputPath)
|
||
try {
|
||
await pipeline(source, inflater, output)
|
||
} catch (error) {
|
||
output.destroy()
|
||
throw error
|
||
}
|
||
return inflatedBytes
|
||
}
|
||
|
||
throw new Error(`压缩包含不支持的压缩方式(method ${entry.compressionMethod}),无法编辑`)
|
||
}
|
||
|
||
// ---- 以下为内部辅助,避免引入额外依赖 ----
|
||
|
||
async function copyRangeToFile(
|
||
handle: Awaited<ReturnType<typeof open>>,
|
||
start: number,
|
||
length: number,
|
||
outputPath: string,
|
||
): Promise<number> {
|
||
const CHUNK = 1024 * 1024
|
||
const output = await createFileWriteStream(outputPath)
|
||
let written = 0
|
||
try {
|
||
let position = start
|
||
while (written < length) {
|
||
const size = Math.min(CHUNK, length - written)
|
||
const buffer = await readSlice(handle, position, size)
|
||
await new Promise<void>((resolve, reject) => {
|
||
output.write(buffer, (error) => (error ? reject(error) : resolve()))
|
||
})
|
||
written += size
|
||
position += size
|
||
}
|
||
await new Promise<void>((resolve, reject) => {
|
||
output.end(() => resolve())
|
||
output.on('error', reject)
|
||
})
|
||
return written
|
||
} catch (error) {
|
||
output.destroy()
|
||
throw error
|
||
}
|
||
}
|
||
|
||
function createRangeReadStream(
|
||
handle: Awaited<ReturnType<typeof open>>,
|
||
start: number,
|
||
length: number,
|
||
) {
|
||
// fd 由外层 close;createReadStream 仅负责按窗口读取
|
||
return createReadStream('', {
|
||
fd: handle.fd,
|
||
autoClose: false,
|
||
start,
|
||
end: start + length - 1,
|
||
})
|
||
}
|
||
|
||
async function createFileWriteStream(outputPath: string) {
|
||
const { createWriteStream } = await import('fs')
|
||
return createWriteStream(outputPath, { flags: 'wx' })
|
||
}
|
||
|
||
/** 创建任务-详情编辑用的临时工作目录,返回路径与清理函数。 */
|
||
export async function createTaskDetailEditWorkspace(): Promise<{
|
||
dir: string
|
||
cleanup: () => Promise<void>
|
||
}> {
|
||
const { getUploadRootDir } = await import('@/lib/upload-paths')
|
||
const dir = path.join(getUploadRootDir(), 'temp', `task-detail-edit-${randomUUID()}`)
|
||
await mkdir(dir, { recursive: true })
|
||
return {
|
||
dir,
|
||
cleanup: () => rm(dir, { recursive: true, force: true }),
|
||
}
|
||
}
|