feat: extractBundle — unzip client-side via fflate dengan validasi entry

This commit is contained in:
a2nr 2026-10-01 12:09:12 +07:00
parent 796fd47800
commit 961d2a0ac2
2 changed files with 69 additions and 0 deletions

View File

@ -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();
});
});

View File

@ -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 };
}