게시물 Export ZIP 생성 연결 v1.5.22
This commit is contained in:
167
server/utils/zip-writer.js
Normal file
167
server/utils/zip-writer.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { deflateRawSync } from 'node:zlib'
|
||||
|
||||
const crcTable = Array.from({ length: 256 }, (_, index) => {
|
||||
let value = index
|
||||
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
|
||||
}
|
||||
|
||||
return value >>> 0
|
||||
})
|
||||
|
||||
/**
|
||||
* CRC32 값을 계산한다.
|
||||
* @param {Buffer} input - 원본 데이터
|
||||
* @returns {number} CRC32
|
||||
*/
|
||||
const calculateCrc32 = (input) => {
|
||||
let crc = 0xffffffff
|
||||
|
||||
for (const byte of input) {
|
||||
crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP DOS 날짜/시간 값을 만든다.
|
||||
* @param {Date} date - 기준 날짜
|
||||
* @returns {{ dosDate: number, dosTime: number }} DOS 날짜/시간
|
||||
*/
|
||||
const createDosDateTime = (date) => {
|
||||
const year = Math.max(date.getFullYear(), 1980)
|
||||
|
||||
return {
|
||||
dosTime: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
||||
dosDate: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 엔트리 경로를 정리한다.
|
||||
* @param {string} pathValue - 엔트리 경로
|
||||
* @returns {string} 정리된 경로
|
||||
*/
|
||||
const normalizeZipPath = (pathValue) => String(pathValue || '')
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^\/+/g, '')
|
||||
.replace(/\.\.+/g, '.')
|
||||
|
||||
/**
|
||||
* ZIP 로컬 파일 헤더를 만든다.
|
||||
* @param {Object} entry - 엔트리 정보
|
||||
* @returns {Buffer} 로컬 파일 헤더
|
||||
*/
|
||||
const createLocalFileHeader = (entry) => {
|
||||
const header = Buffer.alloc(30)
|
||||
header.writeUInt32LE(0x04034b50, 0)
|
||||
header.writeUInt16LE(20, 4)
|
||||
header.writeUInt16LE(0x0800, 6)
|
||||
header.writeUInt16LE(8, 8)
|
||||
header.writeUInt16LE(entry.dosTime, 10)
|
||||
header.writeUInt16LE(entry.dosDate, 12)
|
||||
header.writeUInt32LE(entry.crc32, 14)
|
||||
header.writeUInt32LE(entry.compressedSize, 18)
|
||||
header.writeUInt32LE(entry.uncompressedSize, 22)
|
||||
header.writeUInt16LE(entry.name.length, 26)
|
||||
header.writeUInt16LE(0, 28)
|
||||
|
||||
return Buffer.concat([header, entry.name])
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 중앙 디렉터리 헤더를 만든다.
|
||||
* @param {Object} entry - 엔트리 정보
|
||||
* @returns {Buffer} 중앙 디렉터리 헤더
|
||||
*/
|
||||
const createCentralDirectoryHeader = (entry) => {
|
||||
const header = Buffer.alloc(46)
|
||||
header.writeUInt32LE(0x02014b50, 0)
|
||||
header.writeUInt16LE(20, 4)
|
||||
header.writeUInt16LE(20, 6)
|
||||
header.writeUInt16LE(0x0800, 8)
|
||||
header.writeUInt16LE(8, 10)
|
||||
header.writeUInt16LE(entry.dosTime, 12)
|
||||
header.writeUInt16LE(entry.dosDate, 14)
|
||||
header.writeUInt32LE(entry.crc32, 16)
|
||||
header.writeUInt32LE(entry.compressedSize, 20)
|
||||
header.writeUInt32LE(entry.uncompressedSize, 24)
|
||||
header.writeUInt16LE(entry.name.length, 28)
|
||||
header.writeUInt16LE(0, 30)
|
||||
header.writeUInt16LE(0, 32)
|
||||
header.writeUInt16LE(0, 34)
|
||||
header.writeUInt16LE(0, 36)
|
||||
header.writeUInt32LE(0, 38)
|
||||
header.writeUInt32LE(entry.offset, 42)
|
||||
|
||||
return Buffer.concat([header, entry.name])
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP 종료 레코드를 만든다.
|
||||
* @param {number} entryCount - 엔트리 개수
|
||||
* @param {number} centralDirectorySize - 중앙 디렉터리 크기
|
||||
* @param {number} centralDirectoryOffset - 중앙 디렉터리 시작 위치
|
||||
* @returns {Buffer} 종료 레코드
|
||||
*/
|
||||
const createEndOfCentralDirectory = (entryCount, centralDirectorySize, centralDirectoryOffset) => {
|
||||
const endRecord = Buffer.alloc(22)
|
||||
endRecord.writeUInt32LE(0x06054b50, 0)
|
||||
endRecord.writeUInt16LE(0, 4)
|
||||
endRecord.writeUInt16LE(0, 6)
|
||||
endRecord.writeUInt16LE(entryCount, 8)
|
||||
endRecord.writeUInt16LE(entryCount, 10)
|
||||
endRecord.writeUInt32LE(centralDirectorySize, 12)
|
||||
endRecord.writeUInt32LE(centralDirectoryOffset, 16)
|
||||
endRecord.writeUInt16LE(0, 20)
|
||||
|
||||
return endRecord
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리에서 ZIP 파일 버퍼를 만든다.
|
||||
* @param {Array<{ path: string, data: Buffer | string }>} files - ZIP 파일 목록
|
||||
* @returns {Buffer} ZIP 버퍼
|
||||
*/
|
||||
export const createZipBuffer = (files) => {
|
||||
const localFileParts = []
|
||||
const centralDirectoryParts = []
|
||||
const entries = []
|
||||
let offset = 0
|
||||
|
||||
for (const file of files) {
|
||||
const normalizedPath = normalizeZipPath(file.path)
|
||||
if (!normalizedPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rawData = Buffer.isBuffer(file.data) ? file.data : Buffer.from(String(file.data), 'utf8')
|
||||
const compressedData = deflateRawSync(rawData)
|
||||
const { dosDate, dosTime } = createDosDateTime(new Date())
|
||||
const entry = {
|
||||
name: Buffer.from(normalizedPath, 'utf8'),
|
||||
crc32: calculateCrc32(rawData),
|
||||
compressedSize: compressedData.length,
|
||||
uncompressedSize: rawData.length,
|
||||
dosDate,
|
||||
dosTime,
|
||||
offset
|
||||
}
|
||||
const localHeader = createLocalFileHeader(entry)
|
||||
|
||||
localFileParts.push(localHeader, compressedData)
|
||||
offset += localHeader.length + compressedData.length
|
||||
entries.push(entry)
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
centralDirectoryParts.push(createCentralDirectoryHeader(entry))
|
||||
}
|
||||
|
||||
const centralDirectory = Buffer.concat(centralDirectoryParts)
|
||||
const endRecord = createEndOfCentralDirectory(entries.length, centralDirectory.length, offset)
|
||||
|
||||
return Buffer.concat([...localFileParts, centralDirectory, endRecord])
|
||||
}
|
||||
Reference in New Issue
Block a user