"""Editor konten (guru) — buffer draft di DB, publish tulis ke file .md. Endpoints (isi teks, per-file, model buffer→publish): GET /content/drafts?target_path=... ambil draft aktif (atau file asli bila belum ada draft) POST /content/drafts simpan buffer {target_path, body} POST /content/preview render {body} → HTML pakai parser asli POST /content/drafts//publish tulis draft.body ke file DELETE /content/drafts/ buang draft Endpoints tree (operasi struktural, langsung ke disk — lihat §3.3): GET /content/tree daftar rekursif file+folder POST /content/tree/folder buat folder POST /content/tree/file buat file .md baru PATCH /content/tree/rename rename/move file atau folder DELETE /content/tree/entry hapus file atau folder POST /content/assets/upload upload gambar Keamanan: pola sama seperti routes/student_management.py — cookie guru, origin check, path traversal protection ketat pada semua path yang diterima. """ import base64 import binascii import io import os import re as _re import shutil import zipfile from flask import Blueprint, current_app, jsonify, request, send_file from config import ( ASSETS_DIR, CONTENT_DIR, LESSON_DONE_MIN_PERCENT, LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT, ) from services import repositories from services.database import SessionLocal from services.lesson_registry import recompute_all_lessons, sync_lesson_registry from services.lesson_service import ( _render_markdown_string, get_evaluation_signature, invalidate_lesson_caches, ) from services.teacher_auth import check_origin, teacher_user_from_cookie content_editor_bp = Blueprint("content_editor", __name__, url_prefix="/content") _CONTENT_DIR_ABS = os.path.abspath(CONTENT_DIR) _ASSETS_DIR_ABS = os.path.abspath(ASSETS_DIR) _ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"} _MAX_ASSET_BYTES = 5 * 1024 * 1024 # 5 MiB — batas upload per asset. # SINKRONISASI: frontend hook (frontend/src/hooks.server.ts) mengembalikan # pesan 413 "maksimum 5 MiB per asset", dan podman-compose.yml menetapkan # BODY_SIZE_LIMIT=8M (5 MiB × 4/3 base64 + envelope). Ubah nilai di sini → # perbarui keduanya. _PROTECTED_BASENAMES = {"home.md", "sub-home.md"} def _is_protected(rel: str) -> bool: """True bila path relatif (terhadap root content) merujuk ke file navigasi kritis (home.md atau sub-home.md) yang kehilanganannya merusak seluruh navigasi situs.""" return os.path.basename(rel) in _PROTECTED_BASENAMES def _safe_target_path(raw: str) -> str | None: """Validasi & normalisasi target_path relatif CONTENT_DIR, wajib .md.""" if not raw or not raw.endswith(".md"): return None candidate = os.path.abspath(os.path.join(_CONTENT_DIR_ABS, raw)) if not candidate.startswith(_CONTENT_DIR_ABS + os.sep): return None return raw def _require_teacher(db): """Butuh db karena resolve user_id sungguhan lewat query token → User.""" if not check_origin(): return None, (jsonify({"success": False, "message": "Origin tidak diizinkan"}), 403) user = teacher_user_from_cookie(db) if not user: return None, (jsonify({"success": False, "message": "Butuh login guru"}), 401) return user, None # ── Draft endpoints (isi teks, per-file) ───────────────────────── @content_editor_bp.route("/drafts", methods=["GET"]) def get_draft(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err target_path = _safe_target_path(request.args.get("target_path", "")) if not target_path: return jsonify({"success": False, "message": "target_path tidak valid"}), 400 draft = repositories.get_active_draft(db, target_path=target_path) if draft: return jsonify({ "success": True, "source": "draft", "draft_id": draft.id, "body": draft.body, "base_mtime": draft.base_mtime, }) abs_path = os.path.join(_CONTENT_DIR_ABS, target_path) if not os.path.isfile(abs_path): return jsonify({"success": False, "message": "File tidak ditemukan"}), 404 with open(abs_path, "r", encoding="utf-8") as f: body = f.read() return jsonify({ "success": True, "source": "file", "draft_id": None, "body": body, "base_mtime": os.path.getmtime(abs_path), }) finally: db.close() @content_editor_bp.route("/drafts", methods=["POST"]) def save_draft(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err data = request.get_json(silent=True, force=True) or {} target_path = _safe_target_path(data.get("target_path", "")) if not target_path: return jsonify({"success": False, "message": "target_path tidak valid"}), 400 body = data.get("body", "") base_mtime = data.get("base_mtime") draft = repositories.upsert_draft( db, author_id=user.id, target_path=target_path, body=body, base_mtime=base_mtime, ) return jsonify({"success": True, "draft_id": draft.id, "updated_at": draft.updated_at.isoformat()}) finally: db.close() @content_editor_bp.route("/preview", methods=["POST"]) def preview(): # DEPRECATED: SvelteKit frontend now renders markdown preview locally. # Kept here as a fallback and for verification/testing. db = SessionLocal() try: user, err = _require_teacher(db) if err: return err data = request.get_json(silent=True, force=True) or {} body = data.get("body", "") try: parsed = _render_markdown_string(body) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 422 return jsonify({ "success": True, "lesson_content": parsed["lesson_html"], "exercise_content": parsed["exercise_html"], "quiz_data": parsed.get("quiz_data", []), "slides": parsed.get("slides", []), "active_tabs": parsed.get("active_tabs", []), }) finally: db.close() @content_editor_bp.route("/drafts//publish", methods=["POST"]) def publish(draft_id): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err draft = db.query(repositories.ContentDraft).filter( repositories.ContentDraft.id == draft_id ).first() if not draft or draft.status != "draft": return jsonify({"success": False, "message": "Draft tidak ditemukan"}), 404 target_path = _safe_target_path(draft.target_path) if not target_path: return jsonify({"success": False, "message": "target_path tidak valid"}), 400 abs_path = os.path.join(_CONTENT_DIR_ABS, target_path) # Deteksi konflik: file berubah di disk sejak draft dibuat/di-refresh. if os.path.exists(abs_path) and draft.base_mtime is not None: current_mtime = os.path.getmtime(abs_path) if abs(current_mtime - draft.base_mtime) > 0.001: return jsonify({ "success": False, "message": "conflict", "detail": "File berubah di server sejak draft ini dibuat. " "Muat ulang isi terbaru sebelum publish.", }), 409 # Baca isi lama SEBELUM ditimpa — untuk mendeteksi perubahan jenis # evaluasi (latihan/kuis) yang harus direkonsiliasi ke progress siswa. old_content = None if os.path.exists(abs_path): try: with open(abs_path, "r", encoding="utf-8") as f: old_content = f.read() except OSError: old_content = None # Validasi sebelum menulis — jangan pernah publish konten yang gagal parse. try: _render_markdown_string(draft.body) except ValueError as e: return jsonify({"success": False, "message": str(e)}), 422 # Guard khusus home.md/sub-home.md: marker navigasi wajib ada. base_name = os.path.basename(target_path) if base_name in _PROTECTED_BASENAMES and "---Available_Lessons---" not in draft.body: return jsonify({ "success": False, "message": f"Marker ---Available_Lessons--- hilang dari {base_name} — " "publish dibatalkan supaya navigasi tidak rusak.", }), 422 os.makedirs(os.path.dirname(abs_path), exist_ok=True) try: with open(abs_path, "w", encoding="utf-8") as f: f.write(draft.body) except PermissionError: return jsonify({ "success": False, "message": "Permission denied — tidak bisa menulis ke file di server. " "Pastikan folder content/ memiliki izin tulis untuk container.", }), 403 except OSError as e: return jsonify({ "success": False, "message": f"Gagal menulis file: {e}", }), 500 repositories.mark_draft_published(db, draft=draft) invalidate_lesson_caches(abs_path) # ── Sinkronisasi registry & progress (tanpa restart server) ── # Publish adalah momen "parsing ulang": daftar lesson di tabel DB dan # composite progress siswa direkonsiliasi dengan jenis evaluasi baru. # File konten sudah aman ditulis — kegagalan DB tidak membatalkan # publish, hanya dilaporkan sebagai warning. slug = os.path.splitext(os.path.basename(target_path))[0] is_lesson_file = os.path.basename(target_path) not in _PROTECTED_BASENAMES old_signature = None if old_content is not None: try: old_signature = get_evaluation_signature(old_content) except ValueError: old_signature = None # konten lama tidak valid → anggap berubah try: new_signature = get_evaluation_signature(draft.body) except ValueError: new_signature = None # sudah divalidasi di atas; defensive evaluation_changed = is_lesson_file and old_signature != new_signature progress_recomputed = 0 registry_result = None warning = None try: registry_result = sync_lesson_registry(db) if evaluation_changed: lesson = repositories.get_lesson_by_slug(db, slug) if lesson is not None: progress_recomputed = repositories.recompute_lesson_progress( db, lesson_id=lesson.id, has_exercise=bool(new_signature and new_signature["has_exercise"]), has_quiz=bool(new_signature and new_signature["has_quiz"]), exercise_weight=LESSON_EXERCISE_WEIGHT, quiz_weight=LESSON_QUIZ_WEIGHT, done_min_percent=LESSON_DONE_MIN_PERCENT, ) db.commit() except Exception as exc: # noqa: BLE001 — konten sudah tersimpan di disk db.rollback() current_app.logger.warning( "Publish %s: sinkronisasi registry/progress gagal: %s", target_path, exc ) warning = ( "Konten berhasil dipublikasikan, tetapi sinkronisasi registry/progress " f"gagal: {exc}. Gunakan tombol Sinkronisasi di halaman progress." ) payload = { "success": True, "published_path": target_path, "evaluation_changed": evaluation_changed, "lesson_slug": slug if is_lesson_file else None, "progress_recomputed": progress_recomputed, "registry": registry_result, } if warning: payload["warning"] = warning return jsonify(payload) finally: db.close() @content_editor_bp.route("/drafts/", methods=["DELETE"]) def delete_draft(draft_id): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err ok = repositories.discard_draft(db, draft_id=draft_id, author_id=user.id) return jsonify({"success": ok}) finally: db.close() @content_editor_bp.route("/sync-registry", methods=["POST"]) def sync_registry(): """Sinkronkan registry lesson + rekonsiliasi progress dari file Markdown. Dipakai tombol "Sinkronisasi" di halaman progress guru — menangkap perubahan konten yang terjadi di luar webapp (git pull, edit manual via SSH) tanpa perlu restart server. Idempotent. """ db = SessionLocal() try: user, err = _require_teacher(db) if err: return err try: registry_result = sync_lesson_registry(db) progress_result = recompute_all_lessons(db) except Exception as exc: # noqa: BLE001 db.rollback() current_app.logger.error("Sinkronisasi manual gagal: %s", exc) return jsonify({ "success": False, "message": f"Gagal sinkronisasi: {exc}", }), 500 return jsonify({ "success": True, "registry": registry_result, "progress": progress_result, }) finally: db.close() # ── Tree endpoints (operasi struktural) ────────────────────────── def _root_dir(root: str) -> str | None: if root == "content": return _CONTENT_DIR_ABS if root == "assets": return _ASSETS_DIR_ABS return None def _safe_rel_path(root_abs: str, raw: str) -> str | None: """Sama seperti _safe_target_path tapi generik untuk root manapun, tanpa syarat ekstensi .md (dipakai juga untuk folder & assets).""" if not raw or raw.startswith("/") or ".." in raw.split("/"): return None candidate = os.path.abspath(os.path.join(root_abs, raw)) if not candidate.startswith(root_abs + os.sep) and candidate != root_abs: return None return raw def _build_tree(root_abs: str, allowed_ext: set | None) -> list[dict]: """Tree rekursif. allowed_ext=None → semua file (Assets); allowed_ext={'.md'} → filter (Materi).""" def walk(dir_abs: str, rel_prefix: str) -> list[dict]: entries = [] try: items = sorted(os.scandir(dir_abs), key=lambda e: (e.is_file(), e.name.lower())) except OSError: return entries for entry in items: rel = f"{rel_prefix}{entry.name}" if entry.is_dir(): entries.append({ "type": "folder", "name": entry.name, "path": rel, "children": walk(entry.path, rel + "/"), }) elif entry.is_file(): ext = os.path.splitext(entry.name)[1].lower() if allowed_ext is not None and ext not in allowed_ext: continue entries.append({"type": "file", "name": entry.name, "path": rel, "ext": ext}) return entries return walk(root_abs, "") @content_editor_bp.route("/tree", methods=["GET"]) def get_tree(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err root = request.args.get("root", "content") root_abs = _root_dir(root) if not root_abs: return jsonify({"success": False, "message": "root tidak valid"}), 400 allowed = {".md"} if root == "content" else None return jsonify({"success": True, "root": root, "tree": _build_tree(root_abs, allowed)}) finally: db.close() @content_editor_bp.route("/tree/folder", methods=["POST"]) def create_folder(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err data = request.get_json(silent=True, force=True) or {} root = data.get("root", "content") root_abs = _root_dir(root) if not root_abs: return jsonify({"success": False, "message": "root tidak valid"}), 400 rel = _safe_rel_path(root_abs, data.get("path", "")) if not rel: return jsonify({"success": False, "message": "path tidak valid"}), 400 abs_path = os.path.join(root_abs, rel) if os.path.exists(abs_path): return jsonify({"success": False, "message": "Folder sudah ada"}), 409 try: os.makedirs(abs_path) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa membuat folder"}), 403 return jsonify({"success": True, "path": rel}) finally: db.close() @content_editor_bp.route("/tree/file", methods=["POST"]) def create_file(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err data = request.get_json(silent=True, force=True) or {} root = data.get("root", "content") if root != "content": return jsonify({"success": False, "message": "File baru hanya untuk Materi; Assets lewat upload"}), 400 rel = _safe_target_path(data.get("path", "")) # sudah wajibkan .md if not rel: return jsonify({"success": False, "message": "path tidak valid (harus .md)"}), 400 abs_path = os.path.join(_CONTENT_DIR_ABS, rel) if os.path.exists(abs_path): return jsonify({"success": False, "message": "File sudah ada"}), 409 os.makedirs(os.path.dirname(abs_path), exist_ok=True) template = data.get("template") or "# Judul Materi Baru\n\nTulis isi materi di sini.\n" try: with open(abs_path, "w", encoding="utf-8") as f: f.write(template) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa menulis file"}), 403 invalidate_lesson_caches(abs_path) return jsonify({"success": True, "path": rel}) finally: db.close() @content_editor_bp.route("/tree/rename", methods=["PATCH"]) def rename_entry(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err data = request.get_json(silent=True, force=True) or {} root = data.get("root", "content") root_abs = _root_dir(root) if not root_abs: return jsonify({"success": False, "message": "root tidak valid"}), 400 old_rel = _safe_rel_path(root_abs, data.get("old_path", "")) new_rel = _safe_rel_path(root_abs, data.get("new_path", "")) if not old_rel or not new_rel: return jsonify({"success": False, "message": "path tidak valid"}), 400 # P1-2: rename di root content wajib hasil & sumber berekstensi .md if root == "content" and not new_rel.endswith(".md"): return jsonify({"success": False, "message": "Materi hanya boleh berekstensi .md"}), 400 # P0-1: blokir rename file navigasi kritis tanpa konfirmasi eksplisit if root == "content" and _is_protected(old_rel) and not data.get("confirm_critical"): return jsonify({ "success": False, "message": "protected", "detail": f"'{os.path.basename(old_rel)}' adalah file navigasi penting. " "Konfirmasi ulang untuk melanjutkan.", }), 409 old_abs = os.path.join(root_abs, old_rel) new_abs = os.path.join(root_abs, new_rel) if not os.path.exists(old_abs): return jsonify({"success": False, "message": "Sumber tidak ditemukan"}), 404 if os.path.exists(new_abs): return jsonify({"success": False, "message": "Tujuan sudah ada"}), 409 os.makedirs(os.path.dirname(new_abs), exist_ok=True) try: shutil.move(old_abs, new_abs) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa rename"}), 403 if root == "content": repositories.retarget_drafts(db, old_prefix=old_rel, new_prefix=new_rel) invalidate_lesson_caches(old_abs) invalidate_lesson_caches(new_abs) return jsonify({"success": True, "path": new_rel}) finally: db.close() @content_editor_bp.route("/tree/entry", methods=["DELETE"]) def delete_entry(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err root = request.args.get("root", "content") root_abs = _root_dir(root) if not root_abs: return jsonify({"success": False, "message": "root tidak valid"}), 400 rel = _safe_rel_path(root_abs, request.args.get("path", "")) if not rel: return jsonify({"success": False, "message": "path tidak valid"}), 400 # P0-1: blokir delete file navigasi kritis (home.md, sub-home.md) # tanpa konfirmasi eksplisit dari user. if root == "content" and _is_protected(rel) and request.args.get("confirm_critical") != "true": return jsonify({ "success": False, "message": "protected", "detail": f"'{os.path.basename(rel)}' adalah file navigasi penting. " "Konfirmasi ulang untuk melanjutkan.", }), 409 abs_path = os.path.join(root_abs, rel) if not os.path.exists(abs_path): return jsonify({"success": False, "message": "Tidak ditemukan"}), 404 if os.path.isdir(abs_path): if os.listdir(abs_path) and request.args.get("force") != "true": return jsonify({"success": False, "message": "Folder tidak kosong", "needs_force": True}), 409 try: shutil.rmtree(abs_path) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa menghapus folder"}), 403 else: try: os.remove(abs_path) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa menghapus file"}), 403 if root == "content": repositories.drop_drafts_under(db, path=rel) invalidate_lesson_caches(abs_path) return jsonify({"success": True}) finally: 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" (_MAX_ASSET_BYTES * 4 // 3) + 1024: return jsonify({"success": False, "message": "Ukuran file melebihi 5 MiB"}), 400 try: raw = base64.b64decode(b64, validate=True) except (binascii.Error, ValueError): return jsonify({"success": False, "message": "content_base64 tidak valid"}), 400 if not raw: return jsonify({"success": False, "message": "File kosong"}), 400 if len(raw) > _MAX_ASSET_BYTES: return jsonify({"success": False, "message": "Ukuran file melebihi 5 MiB"}), 400 # Validasi isi benar-benar gambar (bukan cuma cek ekstensi) — SVG # dikecualikan (Pillow tidak parse SVG). # Pakai .load() (decode sungguhan) bukan .verify(): .verify() melakukan # CRC check strict pada IDAT yang sering FALSE-POSITIVE pada PNG valid # (terutama PNG sederhana / hasil re-encode) → backend 500. .load() # sudah cukup membuktikan file adalah gambar yang bisa di-decode. if ext != ".svg": try: from PIL import Image, UnidentifiedImageError im = Image.open(io.BytesIO(raw)) im.load() # raises jika bukan gambar yang bisa di-decode except ImportError: pass # Pillow tidak terinstal — skip validasi gambar 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) # 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) try: with open(abs_path, "wb") as f: f.write(raw) except PermissionError: return jsonify({"success": False, "message": "Permission denied — tidak bisa menulis file"}), 403 return jsonify({"success": True, "path": rel_path, "url": f"/assets/{rel_path}"}) finally: db.close() # ── Bundle export (import dilakukan client-side per-file) ──────── def _add_dir_to_zip(zf: zipfile.ZipFile, dir_abs: str, prefix: str) -> int: """Tambahkan semua file di dir_abs ke zip dengan prefix folder. Ditulis per-file (zf.write) — tidak ada batasan ukuran total.""" count = 0 for base, _dirs, files in os.walk(dir_abs): for fname in sorted(files): abs_path = os.path.join(base, fname) rel_inside = os.path.relpath(abs_path, dir_abs) zf.write(abs_path, arcname=f"{prefix}/{rel_inside}") count += 1 return count @content_editor_bp.route("/export", methods=["GET"]) def export_bundle(): db = SessionLocal() try: user, err = _require_teacher(db) if err: return err scope = request.args.get("scope", "all") if scope not in ("all", "content", "assets"): return jsonify({"success": False, "message": "scope tidak valid (all|content|assets)"}), 400 buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: file_count = 0 if scope in ("all", "content"): file_count += _add_dir_to_zip(zf, _CONTENT_DIR_ABS, "content") if scope in ("all", "assets"): file_count += _add_dir_to_zip(zf, _ASSETS_DIR_ABS, "assets") if file_count == 0: return jsonify({"success": False, "message": "Tidak ada file untuk diekspor"}), 404 from datetime import datetime stamp = datetime.now().strftime("%Y%m%d-%H%M%S") buf.seek(0) return send_file( buf, mimetype="application/zip", as_attachment=True, download_name=f"elemes-bundle-{scope}-{stamp}.zip", ) finally: db.close()