mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
91 lines
3.1 KiB
TypeScript
91 lines
3.1 KiB
TypeScript
// ZIP archives in the file preview: the entries as a list, and one entry's
|
|
// bytes when it is opened. Nothing is extracted to disk under the archive's
|
|
// paths; an opened entry goes through the same preview as any attachment.
|
|
|
|
import type JSZip from 'jszip';
|
|
|
|
export interface ZipEntry {
|
|
/** Full path inside the archive, as stored. */
|
|
path: string;
|
|
/** Last path segment. */
|
|
name: string;
|
|
/** Folder path, '' at the top. */
|
|
folder: string;
|
|
/** Uncompressed size, when the archive records it. */
|
|
size?: number;
|
|
modified?: Date;
|
|
}
|
|
|
|
export interface ZipArchive {
|
|
entries: ZipEntry[];
|
|
/** True when the listing stopped at {@link MAX_ZIP_ENTRIES}. */
|
|
truncated: boolean;
|
|
/** One entry's bytes. */
|
|
read(path: string): Promise<Uint8Array>;
|
|
}
|
|
|
|
/** The archive is password-protected; JSZip can't decrypt it. */
|
|
export class ZipEncryptedError extends Error {}
|
|
|
|
// Metadata the archives of macOS and Windows carry and nobody wants to open.
|
|
const JUNK = /(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$|desktop\.ini$)/i;
|
|
|
|
/** Most entries listed; a larger archive lists the first ones. */
|
|
export const MAX_ZIP_ENTRIES = 2000;
|
|
|
|
// JSZip keeps the directory's sizes here without exposing them.
|
|
interface ZipObjectInternals {
|
|
_data?: { uncompressedSize?: number };
|
|
}
|
|
|
|
/**
|
|
* Read an archive's directory: its files (not folders), sorted by folder and
|
|
* then name, and a reader for their contents.
|
|
*/
|
|
export async function openZip(bytes: Uint8Array): Promise<ZipArchive> {
|
|
const { default: Zip } = await import('jszip');
|
|
let zip: JSZip;
|
|
try {
|
|
zip = await Zip.loadAsync(bytes, { createFolders: false });
|
|
} catch (e) {
|
|
if (/encrypt/i.test(e instanceof Error ? e.message : String(e))) throw new ZipEncryptedError('encrypted');
|
|
throw e;
|
|
}
|
|
const entries: ZipEntry[] = [];
|
|
let truncated = false;
|
|
for (const file of Object.values(zip.files)) {
|
|
if (file.dir || JUNK.test(file.name)) continue;
|
|
if (entries.length >= MAX_ZIP_ENTRIES) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
const path = file.name;
|
|
const slash = path.lastIndexOf('/');
|
|
const size = (file as unknown as ZipObjectInternals)._data?.uncompressedSize;
|
|
entries.push({
|
|
path,
|
|
name: slash >= 0 ? path.slice(slash + 1) : path,
|
|
folder: slash >= 0 ? path.slice(0, slash) : '',
|
|
size: typeof size === 'number' && size >= 0 ? size : undefined,
|
|
modified: file.date instanceof Date && !Number.isNaN(file.date.getTime()) ? file.date : undefined,
|
|
});
|
|
}
|
|
entries.sort((a, b) => a.folder.localeCompare(b.folder) || a.name.localeCompare(b.name));
|
|
return {
|
|
entries,
|
|
truncated,
|
|
read: async (path) => {
|
|
const file = zip.file(path);
|
|
if (!file) throw new Error(`${path} is not in the archive`);
|
|
return file.async('uint8array');
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Whether a file is a ZIP the preview lists (not one of the formats built on ZIP). */
|
|
export function isZipArchive(name: string, type?: string | null): boolean {
|
|
const mime = (type || '').split(';')[0].trim().toLowerCase();
|
|
if (mime === 'application/zip' || mime === 'application/x-zip-compressed' || mime === 'application/x-zip') return true;
|
|
return /\.zip$/i.test(name);
|
|
}
|