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 { describe, it, expect, vi } from 'vitest';
|
||||||
import { deleteEntry, renameEntry } from './contentEditor';
|
import { deleteEntry, renameEntry } from './contentEditor';
|
||||||
|
import { zipSync, strToU8 } from 'fflate';
|
||||||
|
|
||||||
// FileReader tidak tersedia di environment 'node' vitest; uploadAsset
|
// FileReader tidak tersedia di environment 'node' vitest; uploadAsset
|
||||||
// menggunakannya via fileToBase64. Polyfill minimal agar test jalan.
|
// menggunakannya via fileToBase64. Polyfill minimal agar test jalan.
|
||||||
|
|
@ -232,4 +233,26 @@ describe('importBundle — orkestrasi per-file', () => {
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.failedPath).toBe('lesson/a.md');
|
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 {
|
export interface TreeNode {
|
||||||
type: 'file' | 'folder';
|
type: 'file' | 'folder';
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -251,3 +253,47 @@ export async function importBundle(options: ImportOptions): Promise<ImportSummar
|
||||||
}
|
}
|
||||||
return { success: true, imported, skipped };
|
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