mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
241 lines
7.7 KiB
TypeScript
241 lines
7.7 KiB
TypeScript
import { and, eq, like, or } from 'drizzle-orm'
|
|
import { Zip, ZipDeflate, ZipPassThrough, type Zippable, zipSync } from 'fflate'
|
|
import { DirType } from '../../shared/constants'
|
|
import { matters } from '../db/schema'
|
|
import type { Database } from '../platform/interface'
|
|
import type { Matter } from './matter'
|
|
|
|
export const ZIP_COMPRESS_LIMITS = {
|
|
totalInputBytes: 512 * 1024 * 1024,
|
|
singleFileBytes: 512 * 1024 * 1024,
|
|
fileCount: 1000,
|
|
directoryDepth: 10,
|
|
} as const
|
|
|
|
export interface CompressionSourceFile {
|
|
matter: Matter
|
|
archivePath: string
|
|
}
|
|
|
|
export interface CompressionSourceDirectory {
|
|
archivePath: string
|
|
}
|
|
|
|
export interface ZipSourceObject {
|
|
archivePath: string
|
|
bytes: Uint8Array
|
|
}
|
|
|
|
export interface ZipSourceStream {
|
|
archivePath: string
|
|
openStream: () => Promise<ReadableStream<Uint8Array>>
|
|
}
|
|
|
|
export interface CompressionPlan {
|
|
files: CompressionSourceFile[]
|
|
directories: CompressionSourceDirectory[]
|
|
inputBytes: number
|
|
outputName: string
|
|
targetFolder: string
|
|
}
|
|
|
|
export async function collectCompressionPlan(
|
|
db: Database,
|
|
orgId: string,
|
|
matterIds: string[],
|
|
opts: { targetFolder?: string; outputName?: string } = {},
|
|
): Promise<CompressionPlan> {
|
|
const uniqueIds = [...new Set(matterIds)]
|
|
if (uniqueIds.length > ZIP_COMPRESS_LIMITS.fileCount) {
|
|
throw new Error(`Compression file count exceeds ${ZIP_COMPRESS_LIMITS.fileCount}`)
|
|
}
|
|
const roots = await db
|
|
.select()
|
|
.from(matters)
|
|
.where(and(eq(matters.orgId, orgId), or(...uniqueIds.map((id) => eq(matters.id, id)))))
|
|
if (roots.length !== uniqueIds.length) throw new Error('Some archive source IDs do not belong to this organization')
|
|
if (roots.some((matter) => matter.status !== 'active')) throw new Error('Only active matters can be archived')
|
|
|
|
const files: CompressionSourceFile[] = []
|
|
const directories: CompressionSourceDirectory[] = []
|
|
for (const root of roots) {
|
|
const entries = await collectRootEntries(db, orgId, root)
|
|
files.push(...entries.files)
|
|
directories.push(...entries.directories)
|
|
}
|
|
validateCompressionEntries(files, directories)
|
|
|
|
return {
|
|
files,
|
|
directories,
|
|
inputBytes: files.reduce((sum, file) => sum + (file.matter.size ?? 0), 0),
|
|
outputName: archiveOutputName(roots, opts.outputName),
|
|
targetFolder: opts.targetFolder ?? roots[0].parent,
|
|
}
|
|
}
|
|
|
|
export function createZipArchive(
|
|
objects: ZipSourceObject[],
|
|
directories: CompressionSourceDirectory[] = [],
|
|
): Uint8Array {
|
|
const zippable: Zippable = {}
|
|
for (const directory of directories) zippable[`${directory.archivePath}/`] = new Uint8Array()
|
|
for (const object of objects) zippable[object.archivePath] = object.bytes
|
|
return zipSync(zippable, { level: 6 })
|
|
}
|
|
|
|
export function createZipArchiveStream(
|
|
sources: ZipSourceStream[],
|
|
directories: CompressionSourceDirectory[] = [],
|
|
): ReadableStream<Uint8Array> {
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const zip = new Zip()
|
|
zip.ondata = (error, chunk, final) => {
|
|
if (error) {
|
|
controller.error(error)
|
|
return
|
|
}
|
|
if (chunk) controller.enqueue(new Uint8Array(chunk))
|
|
if (final) controller.close()
|
|
}
|
|
|
|
void streamZipEntries(zip, sources, directories, async () => {}).catch((error) => {
|
|
zip.terminate()
|
|
controller.error(error)
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
async function streamZipEntries(
|
|
zip: Zip,
|
|
sources: ZipSourceStream[],
|
|
directories: CompressionSourceDirectory[],
|
|
waitForWrites: () => Promise<void>,
|
|
): Promise<void> {
|
|
for (const directory of directories) {
|
|
const entry = new ZipPassThrough(`${directory.archivePath}/`)
|
|
zip.add(entry)
|
|
entry.push(new Uint8Array(), true)
|
|
await waitForWrites()
|
|
}
|
|
|
|
for (const source of sources) {
|
|
const entry = new ZipDeflate(source.archivePath, { level: 6 })
|
|
zip.add(entry)
|
|
await pushStreamToZipEntry(await source.openStream(), entry, waitForWrites)
|
|
}
|
|
|
|
zip.end()
|
|
}
|
|
|
|
async function pushStreamToZipEntry(
|
|
stream: ReadableStream<Uint8Array>,
|
|
entry: ZipDeflate,
|
|
waitForWrites: () => Promise<void>,
|
|
): Promise<void> {
|
|
const reader = stream.getReader()
|
|
for (;;) {
|
|
const { done, value } = await reader.read()
|
|
if (done) {
|
|
entry.push(new Uint8Array(), true)
|
|
await waitForWrites()
|
|
return
|
|
}
|
|
entry.push(value, false)
|
|
await waitForWrites()
|
|
}
|
|
}
|
|
|
|
function validateCompressionEntries(files: CompressionSourceFile[], directories: CompressionSourceDirectory[]): void {
|
|
let totalBytes = 0
|
|
const paths = new Set<string>()
|
|
|
|
for (const directory of directories) {
|
|
if (directoryDepth(directory.archivePath) > ZIP_COMPRESS_LIMITS.directoryDepth) {
|
|
throw new Error(`Compression directory depth exceeds ${ZIP_COMPRESS_LIMITS.directoryDepth}`)
|
|
}
|
|
if (paths.has(directory.archivePath)) throw new Error(`Duplicate archive path: ${directory.archivePath}`)
|
|
paths.add(directory.archivePath)
|
|
}
|
|
|
|
for (const file of files) {
|
|
const bytes = file.matter.size ?? 0
|
|
totalBytes += bytes
|
|
if (bytes > ZIP_COMPRESS_LIMITS.singleFileBytes) {
|
|
throw new Error(`Compression source file exceeds ${ZIP_COMPRESS_LIMITS.singleFileBytes} bytes`)
|
|
}
|
|
if (totalBytes > ZIP_COMPRESS_LIMITS.totalInputBytes) {
|
|
throw new Error(`Compression input exceeds ${ZIP_COMPRESS_LIMITS.totalInputBytes} bytes`)
|
|
}
|
|
if (directoryDepth(file.archivePath) > ZIP_COMPRESS_LIMITS.directoryDepth) {
|
|
throw new Error(`Compression directory depth exceeds ${ZIP_COMPRESS_LIMITS.directoryDepth}`)
|
|
}
|
|
if (paths.has(file.archivePath)) throw new Error(`Duplicate archive path: ${file.archivePath}`)
|
|
paths.add(file.archivePath)
|
|
}
|
|
|
|
if (files.length > ZIP_COMPRESS_LIMITS.fileCount) {
|
|
throw new Error(`Compression file count exceeds ${ZIP_COMPRESS_LIMITS.fileCount}`)
|
|
}
|
|
}
|
|
|
|
async function collectRootEntries(
|
|
db: Database,
|
|
orgId: string,
|
|
root: Matter,
|
|
): Promise<{ files: CompressionSourceFile[]; directories: CompressionSourceDirectory[] }> {
|
|
if (root.dirtype === DirType.FILE) return { files: [{ matter: root, archivePath: root.name }], directories: [] }
|
|
|
|
const rootPath = buildPath(root.parent, root.name)
|
|
const rows = await db
|
|
.select()
|
|
.from(matters)
|
|
.where(
|
|
and(
|
|
eq(matters.orgId, orgId),
|
|
eq(matters.status, 'active'),
|
|
or(eq(matters.parent, rootPath), like(matters.parent, `${rootPath}/%`)),
|
|
),
|
|
)
|
|
const directories = [
|
|
{ archivePath: relativeArchivePath(rootPath, root) },
|
|
...rows
|
|
.filter((matter) => matter.dirtype !== DirType.FILE)
|
|
.map((matter) => ({ archivePath: relativeArchivePath(rootPath, matter) })),
|
|
].sort(
|
|
(a, b) =>
|
|
directoryDepth(a.archivePath) - directoryDepth(b.archivePath) || a.archivePath.localeCompare(b.archivePath),
|
|
)
|
|
const files = rows
|
|
.filter((matter) => matter.dirtype === DirType.FILE)
|
|
.map((matter) => ({ matter, archivePath: relativeArchivePath(rootPath, matter) }))
|
|
|
|
return { files, directories }
|
|
}
|
|
|
|
function relativeArchivePath(rootPath: string, matter: Matter): string {
|
|
const path = buildPath(matter.parent, matter.name)
|
|
const rootParent = rootPath.slice(0, Math.max(0, rootPath.lastIndexOf('/')))
|
|
return rootParent ? path.slice(rootParent.length + 1) : path
|
|
}
|
|
|
|
function archiveOutputName(roots: Matter[], requestedName?: string): string {
|
|
const name = requestedName ?? (roots.length === 1 ? `${baseZipName(roots[0].name)}.zip` : 'selection.zip')
|
|
return name.toLowerCase().endsWith('.zip') ? name : `${name}.zip`
|
|
}
|
|
|
|
function baseZipName(name: string): string {
|
|
return name.toLowerCase().endsWith('.zip') ? name.slice(0, -4) : name
|
|
}
|
|
|
|
function directoryDepth(path: string): number {
|
|
const parts = path.split('/').filter((part) => part.length > 0)
|
|
return Math.max(0, parts.length - 1)
|
|
}
|
|
|
|
function buildPath(parent: string, name: string): string {
|
|
return parent ? `${parent}/${name}` : name
|
|
}
|