Files
sori.studio/server/utils/zip-reader.js

124 lines
3.9 KiB
JavaScript

import { inflateRawSync } from 'node:zlib'
/**
* ZIP 엔트리 경로를 안전하게 정리한다.
* @param {string} value - ZIP 내부 경로
* @returns {string} 정리된 경로
*/
const normalizeZipEntryPath = (value) => String(value || '')
.replace(/\\/g, '/')
.replace(/^\/+/g, '')
.split('/')
.filter((part) => part && part !== '.' && part !== '..')
.join('/')
/**
* ZIP 종료 레코드 위치를 찾는다.
* @param {Buffer} buffer - ZIP 파일 버퍼
* @returns {number} 종료 레코드 위치
*/
const findEndOfCentralDirectoryOffset = (buffer) => {
const signature = 0x06054b50
const minOffset = Math.max(0, buffer.length - 65557)
for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) {
if (buffer.readUInt32LE(offset) === signature) {
return offset
}
}
return -1
}
/**
* ZIP 엔트리 데이터를 압축 해제한다.
* @param {Buffer} buffer - 전체 ZIP 버퍼
* @param {Object} entry - 중앙 디렉터리 엔트리
* @returns {Buffer} 압축 해제된 데이터
*/
const readZipEntryData = (buffer, entry) => {
const localHeaderOffset = entry.localHeaderOffset
if (buffer.readUInt32LE(localHeaderOffset) !== 0x04034b50) {
throw new Error('INVALID_ZIP_LOCAL_HEADER')
}
const localNameLength = buffer.readUInt16LE(localHeaderOffset + 26)
const localExtraLength = buffer.readUInt16LE(localHeaderOffset + 28)
const dataOffset = localHeaderOffset + 30 + localNameLength + localExtraLength
const compressedData = buffer.subarray(dataOffset, dataOffset + entry.compressedSize)
if (entry.compressionMethod === 0) {
return Buffer.from(compressedData)
}
if (entry.compressionMethod === 8) {
return inflateRawSync(compressedData)
}
throw new Error('UNSUPPORTED_ZIP_COMPRESSION')
}
/**
* ZIP 파일 버퍼를 파일 엔트리 목록으로 읽는다.
* @param {Buffer} buffer - ZIP 파일 버퍼
* @returns {Array<{ path: string, data: Buffer }>} 파일 엔트리 목록
*/
export const readZipBufferEntries = (buffer) => {
if (!Buffer.isBuffer(buffer) || buffer.length < 22) {
throw new Error('INVALID_ZIP_FILE')
}
const endOffset = findEndOfCentralDirectoryOffset(buffer)
if (endOffset < 0) {
throw new Error('INVALID_ZIP_FILE')
}
const entryCount = buffer.readUInt16LE(endOffset + 10)
const centralDirectorySize = buffer.readUInt32LE(endOffset + 12)
const centralDirectoryOffset = buffer.readUInt32LE(endOffset + 16)
const centralDirectoryEnd = centralDirectoryOffset + centralDirectorySize
if (centralDirectoryOffset < 0 || centralDirectoryEnd > buffer.length) {
throw new Error('INVALID_ZIP_FILE')
}
const entries = []
let offset = centralDirectoryOffset
for (let index = 0; index < entryCount; index += 1) {
if (buffer.readUInt32LE(offset) !== 0x02014b50) {
throw new Error('INVALID_ZIP_CENTRAL_DIRECTORY')
}
const compressionMethod = buffer.readUInt16LE(offset + 10)
const compressedSize = buffer.readUInt32LE(offset + 20)
const uncompressedSize = buffer.readUInt32LE(offset + 24)
const nameLength = buffer.readUInt16LE(offset + 28)
const extraLength = buffer.readUInt16LE(offset + 30)
const commentLength = buffer.readUInt16LE(offset + 32)
const localHeaderOffset = buffer.readUInt32LE(offset + 42)
const rawPath = buffer.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
const normalizedPath = normalizeZipEntryPath(rawPath)
const isDirectory = rawPath.replace(/\\/g, '/').endsWith('/')
if (normalizedPath && !isDirectory && !normalizedPath.startsWith('__MACOSX/')) {
entries.push({
path: normalizedPath,
compressionMethod,
compressedSize,
uncompressedSize,
localHeaderOffset
})
}
offset += 46 + nameLength + extraLength + commentLength
}
return entries.map((entry) => ({
path: entry.path,
data: readZipEntryData(buffer, entry)
}))
}