feat: extractBundle — unzip client-side via fflate dengan validasi entry
This commit is contained in:
parent
796fd47800
commit
961d2a0ac2
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { deleteEntry, renameEntry } from './contentEditor';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
|
||||
// FileReader tidak tersedia di environment 'node' vitest; uploadAsset
|
||||
// menggunakannya via fileToBase64. Polyfill minimal agar test jalan.
|
||||
|
|
@ -232,4 +233,26 @@ describe('importBundle — orkestrasi per-file', () => {
|
|||
expect(result.success).toBe(false);
|
||||
expect(result.failedPath).toBe('lesson/a.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBundle', () => {
|
||||
it('memisahkan content .md dan asset biner, menandai entry tidak valid', async () => {
|
||||
const { extractBundle } = await import('./contentEditor');
|
||||
const zipBytes = zipSync({
|
||||
'content/lesson/a.md': strToU8('# A\n'),
|
||||
'assets/img/diag.png': new Uint8Array([1, 2, 3]),
|
||||
'evil.sh': strToU8('#!/bin/sh\n'),
|
||||
'content/bad.txt': strToU8('x'),
|
||||
'content/../../evil.md': strToU8('# evil\n'),
|
||||
});
|
||||
const result = await extractBundle(new File([zipBytes], 'b.zip'));
|
||||
expect(result.contentFiles.map((f) => f.path)).toEqual(['lesson/a.md']);
|
||||
expect(result.assetFiles.map((f) => f.path)).toEqual(['img/diag.png']);
|
||||
expect(result.invalidEntries.sort()).toEqual(['content/../../evil.md', 'content/bad.txt', 'evil.sh']);
|
||||
});
|
||||
|
||||
it('menolak bytes zip yang tidak valid', async () => {
|
||||
const { extractBundle } = await import('./contentEditor');
|
||||
await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { unzip, strFromU8 } from 'fflate';
|
||||
|
||||
export interface TreeNode {
|
||||
type: 'file' | 'folder';
|
||||
name: string;
|
||||
|
|
@ -251,3 +253,47 @@ export async function importBundle(options: ImportOptions): Promise<ImportSummar
|
|||
}
|
||||
return { success: true, imported, skipped };
|
||||
}
|
||||
|
||||
export interface ExtractedBundle {
|
||||
contentFiles: ImportFileSpec[];
|
||||
assetFiles: ImportFileSpec[];
|
||||
invalidEntries: string[]; // entry di luar content/|assets/ | bukan .md di content/ | path mencurigakan
|
||||
}
|
||||
|
||||
/** Extract zip di browser (pola zip = output /content/export:
|
||||
* top-level `content/**.md` dan `assets/**`). Entry tidak valid
|
||||
* dikumpulkan di invalidEntries — tidak diimport, dilaporkan ke user. */
|
||||
export async function extractBundle(file: File): Promise<ExtractedBundle> {
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
const entries = await new Promise<Record<string, Uint8Array>>((resolve, reject) =>
|
||||
unzip(buf, (err, data) => (err ? reject(new Error('File bukan zip yang valid')) : resolve(data))),
|
||||
);
|
||||
|
||||
const contentFiles: ImportFileSpec[] = [];
|
||||
const assetFiles: ImportFileSpec[] = [];
|
||||
const invalidEntries: string[] = [];
|
||||
|
||||
for (const [name, bytes] of Object.entries(entries)) {
|
||||
if (name.endsWith('/')) continue; // directory entry
|
||||
const parts = name.split('/');
|
||||
if ((parts[0] !== 'content' && parts[0] !== 'assets') || parts.length < 2) {
|
||||
invalidEntries.push(name);
|
||||
continue;
|
||||
}
|
||||
const rel = parts.slice(1).join('/');
|
||||
if (!rel || rel.startsWith('/') || rel.split('/').includes('..') || rel.split('/').includes('')) {
|
||||
invalidEntries.push(name);
|
||||
continue;
|
||||
}
|
||||
if (parts[0] === 'content') {
|
||||
if (!rel.endsWith('.md')) {
|
||||
invalidEntries.push(name);
|
||||
continue;
|
||||
}
|
||||
contentFiles.push({ path: rel, body: strFromU8(bytes) });
|
||||
} else {
|
||||
assetFiles.push({ path: rel, bytes });
|
||||
}
|
||||
}
|
||||
return { contentFiles, assetFiles, invalidEntries };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue