From 961d2a0ac2580ce193d1c7514f78c436c5cab0f8 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 12:09:12 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20extractBundle=20=E2=80=94=20unzip=20cli?= =?UTF-8?q?ent-side=20via=20fflate=20dengan=20validasi=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/services/contentEditor.test.ts | 23 ++++++++++ frontend/src/lib/services/contentEditor.ts | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/frontend/src/lib/services/contentEditor.test.ts b/frontend/src/lib/services/contentEditor.test.ts index 9d09de2..9a078e2 100644 --- a/frontend/src/lib/services/contentEditor.test.ts +++ b/frontend/src/lib/services/contentEditor.test.ts @@ -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(); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/services/contentEditor.ts b/frontend/src/lib/services/contentEditor.ts index bbc77f2..ae064ca 100644 --- a/frontend/src/lib/services/contentEditor.ts +++ b/frontend/src/lib/services/contentEditor.ts @@ -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 { + const buf = new Uint8Array(await file.arrayBuffer()); + const entries = await new Promise>((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 }; +}