"""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, jsonify, request, send_file from config import ASSETS_DIR, CONTENT_DIR from services import repositories from services.database import SessionLocal from services.lesson_service import _render_markdown_string, 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 _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 # 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) return jsonify({"success": True, "published_path": target_path}) 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() # ── 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() @content_editor_bp.route("/assets/upload", methods=["POST"]) def upload_asset(): """Body JSON (bukan multipart — lihat catatan proxy di hooks.server.ts): {"filename": "resistor.png", "folder": "diagrams", "content_base64": "..."} """ db = SessionLocal() try: user, err = _require_teacher(db) if err: return err data = request.get_json(silent=True, force=True) or {} filename = (data.get("filename") or "").strip() if not filename: return jsonify({"success": False, "message": "filename wajib disertakan"}), 400 ext = os.path.splitext(filename)[1].lower() if ext not in _ALLOWED_IMAGE_EXT: return jsonify({"success": False, "message": f"Ekstensi {ext} tidak diizinkan"}), 400 folder = (data.get("folder") or "").strip() folder_rel = _safe_rel_path(_ASSETS_DIR_ABS, folder) if folder else "" if folder and folder_rel is None: return jsonify({"success": False, "message": "folder tidak valid"}), 400 b64 = data.get("content_base64") or "" if len(b64) > (_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): return jsonify({"success": False, "message": "File bukan gambar yang valid"}), 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): 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()