elemes/routes/content_editor.py

613 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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/<id>/publish tulis draft.body ke file
DELETE /content/drafts/<id> 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
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 # 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/<draft_id>/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/<draft_id>", 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()
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):
{"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) 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()