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');
|
const { extractBundle } = await import('./contentEditor');
|
||||||
await expect(extractBundle(new File([new Uint8Array([1, 2, 3])], 'b.zip'))).rejects.toThrow();
|
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 content_base64 = await fileToBase64(file);
|
||||||
const res = await fetch('/api/content/assets/upload', {
|
const res = await fetch('/api/content/assets/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,9 +244,10 @@ export async function importBundle(options: ImportOptions): Promise<ImportSummar
|
||||||
const res = await uploadAsset(
|
const res = await uploadAsset(
|
||||||
new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'),
|
new File([bytes.slice().buffer as ArrayBuffer], spec.path.split('/').pop() ?? 'file'),
|
||||||
spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '',
|
spec.path.includes('/') ? spec.path.slice(0, spec.path.lastIndexOf('/')) : '',
|
||||||
|
mode === 'overwrite',
|
||||||
);
|
);
|
||||||
if (!res.success) {
|
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++;
|
imported++;
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import re as _re
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
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 config import ASSETS_DIR, CONTENT_DIR
|
||||||
from services import repositories
|
from services import repositories
|
||||||
|
|
@ -459,6 +459,30 @@ def delete_entry():
|
||||||
db.close()
|
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"])
|
@content_editor_bp.route("/assets/upload", methods=["POST"])
|
||||||
def upload_asset():
|
def upload_asset():
|
||||||
"""Body JSON (bukan multipart — lihat catatan proxy di hooks.server.ts):
|
"""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
|
im.load() # raises jika bukan gambar yang bisa di-decode
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass # Pillow tidak terinstal — skip validasi gambar
|
pass # Pillow tidak terinstal — skip validasi gambar
|
||||||
except (UnidentifiedImageError, OSError, SyntaxError):
|
except (UnidentifiedImageError, OSError, SyntaxError) as e:
|
||||||
return jsonify({"success": False, "message": "File bukan gambar yang valid"}), 400
|
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))
|
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
|
rel_path = f"{folder_rel}/{safe_name}" if folder_rel else safe_name
|
||||||
abs_path = os.path.join(_ASSETS_DIR_ABS, rel_path)
|
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
|
return jsonify({"success": False, "message": "Nama file sudah ada, ganti nama dulu"}), 409
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
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).
|
Menulis zip probe sendiri (content/imported/probe.md + assets/imported/probe.png).
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
@ -23,13 +24,20 @@ TEACHER_TOKEN = "141214"
|
||||||
PROBE_ZIP = Path("/tmp/elemes-probe-bundle.zip")
|
PROBE_ZIP = Path("/tmp/elemes-probe-bundle.zip")
|
||||||
DOWNLOAD_DIR = Path("/tmp/elemes-e2e-downloads")
|
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:
|
def make_probe_zip() -> Path:
|
||||||
if PROBE_ZIP.exists():
|
if PROBE_ZIP.exists():
|
||||||
return PROBE_ZIP
|
return PROBE_ZIP
|
||||||
with zipfile.ZipFile(PROBE_ZIP, "w", zipfile.ZIP_DEFLATED) as zf:
|
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("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
|
return PROBE_ZIP
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ Mengunci kontrak:
|
||||||
- regression: _render_markdown_string() == render_markdown_content() untuk file yang sama;
|
- regression: _render_markdown_string() == render_markdown_content() untuk file yang sama;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
@ -670,6 +671,97 @@ def test_export_scope_content_excludes_assets(client):
|
||||||
names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist()
|
names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist()
|
||||||
assert any(n.startswith("content/") for n in names)
|
assert any(n.startswith("content/") for n in names)
|
||||||
assert not any(n.startswith("assets/") 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)) ──────
|
# ── Boundary 5 MiB (cabang len(raw) — bukan pre-check len(b64)) ──────
|
||||||
# SVG dipakai karena dikecualikan dari validasi gambar PIL (Pillow tidak
|
# SVG dipakai karena dikecualikan dari validasi gambar PIL (Pillow tidak
|
||||||
# parse SVG), sehingga ukuran murni yang diuji, bukan validasi isi.
|
# parse SVG), sehingga ukuran murni yang diuji, bukan validasi isi.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue