fix(content-editor): diagnostik upload asset + kelengkapan bundle
- upload_asset: deteksi format dari magic bytes saat PIL menolak, log exception asli - contentEditor.ts: import bundle stop di failure pertama (informasi utuh tetap tersedia); guard frontend - verify_import_export.py: probe PNG 1x1 valid (PNG palsu ditolak PIL) - test_content_editor_routes.py: coverage pesan error + AVIF - bundle: elemes_content_fixed.zip (electron_flow_sparkfun.png 404 nginx dibuang)
This commit is contained in:
parent
bb8ca50650
commit
3c2546c818
|
|
@ -255,4 +255,75 @@ describe('extractBundle', () => {
|
|||
const { extractBundle } = await import('./contentEditor');
|
||||
await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('importBundle — overwrite asset & pesan fallback', () => {
|
||||
it('mode overwrite mengirim overwrite:true untuk asset', async () => {
|
||||
const { importBundle } = await import('./contentEditor');
|
||||
const bodies: any[] = [];
|
||||
global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url.includes('/api/content/tree/folder')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
}
|
||||
if (url.includes('/api/content/assets/upload')) {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
});
|
||||
const result = await importBundle({
|
||||
contentFiles: [],
|
||||
assetFiles: [{ path: 'ovw.png', bytes: new Uint8Array([1]) }],
|
||||
mode: 'overwrite',
|
||||
});
|
||||
expect(bodies[0].overwrite).toBe(true);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('mode skip mengirim overwrite:false untuk asset', async () => {
|
||||
const { importBundle } = await import('./contentEditor');
|
||||
const bodies: any[] = [];
|
||||
global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url.includes('/api/content/assets/upload')) {
|
||||
bodies.push(JSON.parse(String(init?.body)));
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
});
|
||||
await importBundle({
|
||||
contentFiles: [],
|
||||
assetFiles: [{ path: 's.png', bytes: new Uint8Array([1]) }],
|
||||
mode: 'skip',
|
||||
existingPaths: new Set<string>(),
|
||||
});
|
||||
expect(bodies[0].overwrite).toBe(false);
|
||||
});
|
||||
|
||||
it('respons tanpa message → fallback "HTTP <status>", bukan undefined', async () => {
|
||||
const { importBundle } = await import('./contentEditor');
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes('/api/content/assets/upload')) {
|
||||
return Promise.resolve(new Response(JSON.stringify({ error: 'Backend unavailable' }), { status: 502 }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
});
|
||||
const result = await importBundle({
|
||||
contentFiles: [],
|
||||
assetFiles: [{ path: 'x.png', bytes: new Uint8Array([1]) }],
|
||||
mode: 'overwrite',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('502');
|
||||
expect(result.message).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('uploadAsset merespons JSON sukses apa adanya (res.ok true)', async () => {
|
||||
const { uploadAsset } = await import('./contentEditor');
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ success: true, url: '/assets/x.png' }), { status: 200 }),
|
||||
);
|
||||
const res = await uploadAsset(new File(['x'], 'x.png'), '');
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.url).toBe('/assets/x.png');
|
||||
});
|
||||
});
|
||||
|
|
@ -135,14 +135,21 @@ function fileToBase64(file: File): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
export async function uploadAsset(file: File, folder = '') {
|
||||
export async function uploadAsset(file: File, folder = '', overwrite = false) {
|
||||
const content_base64 = await fileToBase64(file);
|
||||
const res = await fetch('/api/content/assets/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ filename: file.name, folder, content_base64 }),
|
||||
body: JSON.stringify({ filename: file.name, folder, content_base64, overwrite }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Respons error bisa berupa JSON {message, ...}, JSON lain (mis. 502
|
||||
// proxy {"error": ...}), atau HTML (Flask default 404/500). Pastikan
|
||||
// selalu menghasilkan string — jangan pernah 'undefined'.
|
||||
const data = await res.json().catch(() => null);
|
||||
return { success: false, message: data?.message ?? `HTTP ${res.status}` };
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
|
|
@ -237,9 +244,10 @@ export async function importBundle(options: ImportOptions): Promise<ImportSummar
|
|||
const res = await uploadAsset(
|
||||
new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'),
|
||||
spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '',
|
||||
mode === 'overwrite',
|
||||
);
|
||||
if (!res.success) {
|
||||
return { success: false, imported, skipped, failedPath: spec.path, message: res.message };
|
||||
return { success: false, imported, skipped, failedPath: spec.path, message: res.message ?? 'Gagal mengunggah asset' };
|
||||
}
|
||||
}
|
||||
imported++;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import re as _re
|
|||
import shutil
|
||||
import zipfile
|
||||
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from flask import Blueprint, current_app, jsonify, request, send_file
|
||||
|
||||
from config import ASSETS_DIR, CONTENT_DIR
|
||||
from services import repositories
|
||||
|
|
@ -459,6 +459,30 @@ def delete_entry():
|
|||
db.close()
|
||||
|
||||
|
||||
def _sniff_image_format(raw: bytes) -> str:
|
||||
"""Deteksi format aktual dari magic bytes — dipakai untuk pesan error
|
||||
diagnostik saat PIL menolak file (PIL tidak menyebut format aslinya;
|
||||
kasus nyata: halaman 404 HTML tersamar sebagai .png di bundle zip)."""
|
||||
if raw.startswith(b"\x89PNG"):
|
||||
return "PNG"
|
||||
if raw.startswith(b"\xff\xd8\xff"):
|
||||
return "JPEG"
|
||||
if raw.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "GIF"
|
||||
if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
||||
return "WebP"
|
||||
if raw[4:8] == b"ftyp" and raw[8:12] in (b"avif", b"avis"):
|
||||
return "AVIF"
|
||||
head = raw[:512].lstrip().lower()
|
||||
if head.startswith((b"<html", b"<!doctype", b"<?xml", b"<svg")):
|
||||
return "HTML/XML"
|
||||
try:
|
||||
raw[:64].decode("utf-8")
|
||||
return "teks"
|
||||
except UnicodeDecodeError:
|
||||
return "tidak dikenal"
|
||||
|
||||
|
||||
@content_editor_bp.route("/assets/upload", methods=["POST"])
|
||||
def upload_asset():
|
||||
"""Body JSON (bukan multipart — lihat catatan proxy di hooks.server.ts):
|
||||
|
|
@ -508,13 +532,23 @@ def upload_asset():
|
|||
im.load() # raises jika bukan gambar yang bisa di-decode
|
||||
except ImportError:
|
||||
pass # Pillow tidak terinstal — skip validasi gambar
|
||||
except (UnidentifiedImageError, OSError, SyntaxError):
|
||||
return jsonify({"success": False, "message": "File bukan gambar yang valid"}), 400
|
||||
except (UnidentifiedImageError, OSError, SyntaxError) as e:
|
||||
fmt = _sniff_image_format(raw)
|
||||
hint = " — konversi ke PNG/JPEG dulu" if fmt == "AVIF" else ""
|
||||
current_app.logger.warning(
|
||||
"upload_asset: PIL menolak %s (terdeteksi: %s): %r", filename, fmt, e)
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"File bukan gambar yang valid (terdeteksi: {fmt}){hint}",
|
||||
}), 400
|
||||
|
||||
safe_name = _re.sub(r"[^a-zA-Z0-9_.-]", "_", os.path.basename(filename))
|
||||
rel_path = f"{folder_rel}/{safe_name}" if folder_rel else safe_name
|
||||
abs_path = os.path.join(_ASSETS_DIR_ABS, rel_path)
|
||||
if os.path.exists(abs_path):
|
||||
# overwrite=True hanya dikirim eksplisit (jalur import bundle mode
|
||||
# "timpa"). Upload manual UI tidak mengirim flag → tetap 409, jadi
|
||||
# file lama tidak pernah tertimpa tanpa niat.
|
||||
if os.path.exists(abs_path) and not data.get("overwrite"):
|
||||
return jsonify({"success": False, "message": "Nama file sudah ada, ganti nama dulu"}), 409
|
||||
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Langkah:
|
|||
|
||||
Menulis zip probe sendiri (content/imported/probe.md + assets/imported/probe.png).
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import sys
|
||||
import zipfile
|
||||
|
|
@ -23,13 +24,20 @@ TEACHER_TOKEN = "141214"
|
|||
PROBE_ZIP = Path("/tmp/elemes-probe-bundle.zip")
|
||||
DOWNLOAD_DIR = Path("/tmp/elemes-e2e-downloads")
|
||||
|
||||
# PNG 1x1 valid — backend memvalidasi isi gambar via Pillow (bukan cuma
|
||||
# ekstensi), jadi probe harus gambar yang benar-benar bisa di-decode.
|
||||
PNG_1PX = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def make_probe_zip() -> Path:
|
||||
if PROBE_ZIP.exists():
|
||||
return PROBE_ZIP
|
||||
with zipfile.ZipFile(PROBE_ZIP, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("content/imported/probe.md", "# Probe Import\n\nMateri test.\n")
|
||||
zf.writestr("assets/imported/probe.png", b"\x89PNG\r\n\x1a\nfake-png")
|
||||
zf.writestr("assets/imported/probe.png", PNG_1PX)
|
||||
return PROBE_ZIP
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ Mengunci kontrak:
|
|||
- regression: _render_markdown_string() == render_markdown_content() untuk file yang sama;
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
|
|
@ -670,6 +671,97 @@ def test_export_scope_content_excludes_assets(client):
|
|||
names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist()
|
||||
assert any(n.startswith("content/") for n in names)
|
||||
assert not any(n.startswith("assets/") for n in names)
|
||||
|
||||
|
||||
# ── Upload asset: GIF diterima, overwrite eksplisit, kontrak pesan error ──
|
||||
|
||||
# GIF89a 1x1 hitam — GIF valid minimal
|
||||
GIF_1PX = base64.b64decode(
|
||||
"R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==")
|
||||
|
||||
|
||||
def _upload(client, filename, raw, folder="", overwrite=None):
|
||||
payload = {
|
||||
"filename": filename,
|
||||
"folder": folder,
|
||||
"content_base64": base64.b64encode(raw).decode(),
|
||||
}
|
||||
if overwrite is not None:
|
||||
payload["overwrite"] = overwrite
|
||||
return client.post("/content/assets/upload", json=payload)
|
||||
|
||||
|
||||
def test_upload_gif_accepted(client):
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
resp = _upload(client, "anim.gif", GIF_1PX)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
body = resp.get_json()
|
||||
assert body["success"] is True
|
||||
assert body["url"] == "/assets/anim.gif"
|
||||
assert (TEST_ASSETS / "anim.gif").read_bytes() == GIF_1PX
|
||||
|
||||
|
||||
def test_upload_existing_name_rejected_without_overwrite(client):
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
_upload(client, "dup.gif", GIF_1PX)
|
||||
resp = _upload(client, "dup.gif", GIF_1PX)
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()["message"] # pesan tidak boleh kosong
|
||||
|
||||
|
||||
def test_upload_existing_name_overwritten_with_flag(client):
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
_upload(client, "dup.gif", GIF_1PX)
|
||||
resp = _upload(client, "dup.gif", GIF_1PX, overwrite=True)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()["success"] is True
|
||||
|
||||
|
||||
def test_upload_corrupt_gif_rejected_with_message(client):
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
resp = _upload(client, "bad.gif", b"GIF89a\x00garbage")
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()["message"] # "File bukan gambar yang valid"
|
||||
|
||||
|
||||
def test_upload_oversize_rejected_with_message(client):
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
big = GIF_1PX + b"\x00" * (5 * 1024 * 1024)
|
||||
resp = _upload(client, "big.gif", big)
|
||||
assert resp.status_code == 400
|
||||
assert "mib" in resp.get_json()["message"].lower()
|
||||
|
||||
|
||||
def test_upload_invalid_image_message_includes_detected_format(client):
|
||||
"""Pesan penolakan PIL harus menyebut format terdeteksi — file 404 HTML
|
||||
tersamar .png (bug nyata: electron_flow_sparkfun.png 153 byte) harus
|
||||
terbaca penyebabnya langsung dari toast."""
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
resp = _upload(client, "fake.png", b"<html>\r\n<head><title>404 Not Found</title></head></html>\r\n")
|
||||
assert resp.status_code == 400
|
||||
msg = resp.get_json()["message"]
|
||||
assert "File bukan gambar yang valid" in msg
|
||||
assert "HTML" in msg # format terdeteksi disebut eksplisit
|
||||
|
||||
|
||||
def test_upload_avif_reports_avif(client):
|
||||
"""AVIF dinamai .png ditolak dengan format terdeteksi + hint konversi."""
|
||||
_seed_and_setup()
|
||||
_login_teacher(client)
|
||||
avif = b"\x00\x00\x00\x1cftypavif" + b"\x00" * 16
|
||||
resp = _upload(client, "photo.png", avif)
|
||||
assert resp.status_code == 400
|
||||
msg = resp.get_json()["message"]
|
||||
assert "AVIF" in msg
|
||||
assert "PNG/JPEG" in msg # hint konversi ikut disertakan
|
||||
|
||||
|
||||
# ── Boundary 5 MiB (cabang len(raw) — bukan pre-check len(b64)) ──────
|
||||
# SVG dipakai karena dikecualikan dari validasi gambar PIL (Pillow tidak
|
||||
# parse SVG), sehingga ukuran murni yang diuji, bukan validasi isi.
|
||||
|
|
|
|||
Loading…
Reference in New Issue