feat(content-editor): konfirmasi ganda file navigasi kritis & hapus narrow sidebar

- Tambah PROTECTED_BASENAMES (home.md, sub-home.md) dengan konfirmasi
  kedua saat rename/delete agar tidak merusak navigasi siswa (tidak
  bisa di-undo). Diteruskan ke renameEntry/deleteEntry di contentEditor.ts.
- Hapus fitur featureSidebarNarrow + CSS terkait (tidak dipakai).
- Footer & backend: penyesuaian rute/test content editor.
This commit is contained in:
anggoro 2026-08-23 08:18:02 +07:00
parent a1ccef2d71
commit ffceb519be
8 changed files with 279 additions and 136 deletions

View File

@ -19,14 +19,8 @@
color: var(--color-text-muted);
margin-top: auto;
}
.teacher-link {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.teacher-link:hover {
text-decoration: underline;
}
/* Progress link moved to navbar — style tidak lagi dipakai */
.version-badge {
display: inline-block;
font-size: 0.65rem;

View File

@ -94,19 +94,24 @@ export async function createFile(path: string, template?: string) {
return res.json();
}
export async function renameEntry(root: 'content' | 'assets', oldPath: string, newPath: string) {
export async function renameEntry(
root: 'content' | 'assets',
oldPath: string,
newPath: string,
confirmCritical = false,
) {
const res = await fetch('/api/content/tree/rename', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ root, old_path: oldPath, new_path: newPath }),
body: JSON.stringify({ root, old_path: oldPath, new_path: newPath, confirm_critical: confirmCritical }),
});
return res.json();
}
export async function deleteEntry(root: 'content' | 'assets', path: string, force = false) {
export async function deleteEntry(root: 'content' | 'assets', path: string, confirmCritical = false, force = false) {
const res = await fetch(
`/api/content/tree/entry?root=${root}&path=${encodeURIComponent(path)}&force=${force}`,
`/api/content/tree/entry?root=${root}&path=${encodeURIComponent(path)}&force=${force}&confirm_critical=${confirmCritical}`,
{ method: 'DELETE', credentials: 'include' },
);
return res.json();

View File

@ -181,7 +181,6 @@
type FeatureTemplate = { id: string; label: string; group: 'konten' | 'penilaian'; snippet: string };
let featureSidebarOpen = $state(true);
let featureSidebarNarrow = $state(false);
// Desktop: panel fitur terbuka by default. Mobile: default tersembunyi (drawer).
$effect(() => {
@ -478,13 +477,25 @@
}
}
// File navigasi kritis yang memerlukan konfirmasi ganda
const PROTECTED_BASENAMES = new Set(['home.md', 'sub-home.md']);
async function handleRename(oldPath: string) {
const oldName = oldPath.split('/').pop() || oldPath;
const newName = window.prompt('Nama baru:', oldName);
if (!newName || newName === oldName) return;
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
const res = await renameEntry(mgr.treeRoot, oldPath, newPath);
// P0-1: konfirmasi kedua untuk file navigasi kritis
if (PROTECTED_BASENAMES.has(oldName)) {
if (!window.confirm(
`"${oldName}" adalah file navigasi utama. Mengganti nama ini bisa ` +
`merusak halaman untuk semua siswa dan TIDAK BISA di-undo. Lanjutkan?`
)) return;
}
const res = await renameEntry(mgr.treeRoot, oldPath, newPath, PROTECTED_BASENAMES.has(oldName));
if (res.success) {
await loadTrees();
if (mgr.activePath === oldPath) {
@ -497,8 +508,14 @@
async function handleDelete(path: string) {
const name = path.split('/').pop() || path;
if (!window.confirm(`Hapus "${name}"?`)) return;
const res = await deleteEntry(mgr.treeRoot, path);
// P0-1: konfirmasi kedua untuk file navigasi kritis
if (PROTECTED_BASENAMES.has(name)) {
if (!window.confirm(
`"${name}" adalah file navigasi utama. Menghapus ini bisa ` +
`merusak halaman untuk semua siswa dan TIDAK BISA di-undo. Lanjutkan?`
)) return;
} else if (!window.confirm(`Hapus "${name}"?`)) return;
const res = await deleteEntry(mgr.treeRoot, path, PROTECTED_BASENAMES.has(name));
if (res.success) {
await loadTrees();
if (mgr.activePath === path) {
@ -857,9 +874,7 @@
</div>
{:else if dynTab.id === 'velxio'}
<div class="velxio-tab-content">
<iframe
class="velxio-iframe"
src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true"
<iframe class="velxio-iframe" src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true" title="Velxio Arduino Editor"
allow="cross-origin-isolated; fullscreen"
allowfullscreen
></iframe>
@ -893,7 +908,7 @@
<!-- Feature sidebar (right, hide-able) -->
{#if featureSidebarOpen && mgr.activePath && !isAssetFile}
<aside class="feature-sidebar" class:mobile-drawer={mgr.isMobile} class:narrow={featureSidebarNarrow}>
<aside class="feature-sidebar" class:mobile-drawer={mgr.isMobile}>
<div class="feature-sidebar-header">
<span>Fitur</span>
</div>
@ -1078,17 +1093,6 @@
flex-direction: column;
min-height: 0;
}
.feature-sidebar.narrow {
width: 10%;
min-width: 64px;
max-width: 160px;
}
.feature-sidebar.narrow .feature-btn {
font-size: 0.62rem;
padding: 0.3rem 0.2rem;
text-align: center;
}
.feature-sidebar.narrow .feature-group-label { display: none; }
.feature-sidebar-header {
display: flex;
align-items: center;
@ -1142,13 +1146,7 @@
box-shadow: none;
}
}
.panel-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
flex: 1;
line-height: 1.8;
}
.btn-float-toggle {
background: none;
border: 1px solid var(--color-border);
@ -1252,29 +1250,6 @@
min-height: 0;
}
.editor-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
flex-shrink: 0;
}
.editor-filename {
font-weight: 600;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.editor-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.editor-body {
flex: 1;
display: flex;
@ -1351,35 +1326,6 @@
font-size: 0.8rem;
}
/* Code Panel (legacy, kept for reference) */
.code-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
/* Force CodeEditor to fill its parent container */
.code-panel :global(.editor-wrapper) {
flex: 1;
min-height: 0;
}
.code-panel :global(.cm-editor) {
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
}
.code-panel :global(.cm-scroller) {
height: 100%;
}
.code-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-muted);
}
/* Force CodeEditor in tab to fill parent */
.code-tab-content :global(.editor-wrapper) {
flex: 1;
@ -1587,39 +1533,6 @@
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
/* Buttons */
.btn {
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
transition: all 0.15s;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn-sm { padding: 0.25rem 0.6rem; font-size: 0.75rem; }
.btn-secondary {
background: var(--color-bg);
border-color: var(--color-border);
color: var(--color-text);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-primary {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
@media (max-width: 768px) {
.content-editor-layout {
grid-template-columns: 1fr;

View File

@ -40,6 +40,14 @@ _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:
@ -179,12 +187,12 @@ def publish(draft_id):
except ValueError as e:
return jsonify({"success": False, "message": str(e)}), 422
# Guard minimal khusus home.md/sub-home.md: marker navigasi wajib ada.
# Guard khusus home.md/sub-home.md: marker navigasi wajib ada.
base_name = os.path.basename(target_path)
if base_name == "home.md" and "---Available_Lessons---" not in draft.body:
if base_name in _PROTECTED_BASENAMES and "---Available_Lessons---" not in draft.body:
return jsonify({
"success": False,
"message": "Marker ---Available_Lessons--- hilang dari home.md — "
"message": f"Marker ---Available_Lessons--- hilang dari {base_name} — "
"publish dibatalkan supaya navigasi tidak rusak.",
}), 422
@ -358,6 +366,19 @@ def rename_entry():
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):
@ -392,6 +413,16 @@ def delete_entry():
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

View File

@ -687,9 +687,15 @@ def retarget_drafts(db: Session, *, old_prefix: str, new_prefix: str) -> int:
"""Dipakai saat rename file/folder — pindahkan draft yang menunjuk ke path lama."""
from sqlalchemy import or_
# Escape % dan _ di prefix agar LIKE tidak men-interpret wildcard dalam
# nama folder/file (mis. folder "50%"). Lihat P2-3 di audit.
escaped = old_prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = db.query(ContentDraft).filter(
ContentDraft.status == "draft",
or_(ContentDraft.target_path == old_prefix, ContentDraft.target_path.like(f"{old_prefix}/%"))
or_(
ContentDraft.target_path == old_prefix,
ContentDraft.target_path.like(f"{escaped}/%", escape="\\"),
),
).all()
count = 0
for row in rows:
@ -703,9 +709,14 @@ def drop_drafts_under(db: Session, *, path: str) -> int:
"""Dipakai saat delete file/folder — buang draft yang jadi yatim."""
from sqlalchemy import or_
# Escape % dan _ — lihat komentar di retarget_drafts (P2-3).
escaped = path.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
n = db.query(ContentDraft).filter(
ContentDraft.status == "draft",
or_(ContentDraft.target_path == path, ContentDraft.target_path.like(f"{path}/%"))
or_(
ContentDraft.target_path == path,
ContentDraft.target_path.like(f"{escaped}/%", escape="\\"),
),
).delete(synchronize_session=False)
db.commit()
return n

View File

@ -32,10 +32,24 @@ def teacher_user_from_cookie(db):
def check_origin() -> bool:
"""Known limitation: fail-open bila header Origin tidak ada.
Request tanpa header Origin dianggap sah (return True). Ini pola lama yang
sudah dipakai sebelum ini (student_management.py) dan bukan regresi.
Risiko diflaskan oleh cookie student_token yang SameSite=Lax di routes/auth.py
— browser modern tidak mengirim cookie ini pada request state-changing
(POST/PATCH/DELETE) cross-site.
Namun karena content_editor blueprint kini memiliki akses tulis/hapus langsung
ke filesystem (bukan hanya baca data siswa), kebijakan fail-open ini menjadi
lebih berisiko. Jika Origin header tidak ada, request dianggap berasal dari
klien same-origin (seperti curl, internal service call, atau browser yang
menyederhanakan header). Jika Origin ada, wajib match terhadap ORIGIN env.
"""
allowed = os.environ.get("ORIGIN", "").strip()
if not allowed or allowed == "*":
return True
origin = request.headers.get("Origin", "")
if not origin:
return True
return True # fail-open: rasa tidak adanya header Origin
return origin in {allowed, allowed.rstrip("/")}

View File

@ -11,6 +11,7 @@ via repositories di test integrasi.
import os
import pathlib
import shutil
import pytest
@ -77,6 +78,24 @@ def seed_demo_users():
db.close()
@pytest.fixture(autouse=True)
def _isolate_filesystem():
"""Bersihkan CONTENT_DIR/ASSETS_DIR test sebelum tiap test — filesystem
harus se-isolated database, supaya suite idempotent lintas run.
Fixture ini autouse dan berjalan untuk SEMUA test (termasuk unit test tanpa
DB). Unit test yang tidak menyentuh filesystem tidak terpengaruh; test
integrasi yang menulis ke CONTENT_DIR kini selalu dapat state yang bersih.
"""
content_dir = pathlib.Path(_TEST_CONTENT)
assets_dir = content_dir.parent / "assets"
for d in (content_dir, assets_dir):
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True, exist_ok=True)
yield
@pytest.fixture(autouse=True)
def _isolate_database(request):
"""Isolasi integration test: truncate semua tabel sebelum SETIAP test.

View File

@ -168,7 +168,7 @@ def test_preview_valid_quiz(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/preview", json={
"body": "# Quiz\n\n### Soal 1\n- [] Opsi A\n- [x] Opsi B\n- [] Opsi C\n",
"body": "# Quiz\n\n---QUIZ_FLASHCARD---\n### Soal 1\n- [] Opsi A\n- [x] Opsi B\n- [] Opsi C\n---END_QUIZ_FLASHCARD---\n",
})
assert resp.status_code == 200
data = resp.get_json()
@ -181,7 +181,7 @@ def test_preview_invalid_quiz_two_correct(client):
_seed_and_setup()
_login_teacher(client)
resp = client.post("/content/preview", json={
"body": "# Quiz\n\n### Soal 1\n- [x] Opsi A\n- [x] Opsi B\n- [] Opsi C\n",
"body": "# Quiz\n\n---QUIZ_FLASHCARD---\n### Soal 1\n- [x] Opsi A\n- [x] Opsi B\n- [] Opsi C\n---END_QUIZ_FLASHCARD---\n",
})
assert resp.status_code == 422
data = resp.get_json()
@ -240,7 +240,7 @@ def test_publish_invalid_quiz_rejected(client):
resp = client.post("/content/drafts", json={
"target_path": "test_editor/quiz.md",
"body": "# Quiz\n\n### Soal 1\n- [x] A\n- [x] B\n",
"body": "# Quiz\n\n---QUIZ_FLASHCARD---\n### Soal 1\n- [x] A\n- [x] B\n---END_QUIZ_FLASHCARD---\n",
"base_mtime": os.path.getmtime(str(TEST_CONTENT / "test_editor/quiz.md")),
})
draft_id = resp.get_json()["draft_id"]
@ -254,12 +254,12 @@ def test_publish_home_without_marker_rejected(client):
_seed_and_setup()
_login_teacher(client)
# Create home without marker
home = TEST_CONTENT / "test_home.md"
home.write_text("# Home\n\nNo marker here.\n", encoding="utf-8")
# gunakan home.md asli yang sudah di-seed oleh _seed_and_setup
home = TEST_CONTENT / "home.md"
assert home.exists(), "home.md harus ada dari _seed_and_setup"
resp = client.post("/content/drafts", json={
"target_path": "test_home.md",
"target_path": "home.md",
"body": "# Home\n\nNo marker here.\n",
"base_mtime": os.path.getmtime(str(home)),
})
@ -270,6 +270,32 @@ def test_publish_home_without_marker_rejected(client):
assert "---Available_Lessons---" in resp.get_json()["message"]
def test_publish_sub_home_without_marker_rejected(client):
"""P1-1: guard sub-home.md di publish flow."""
_seed_and_setup()
_login_teacher(client)
# Buat folder + sub-home.md dengan marker valid
sub_dir = TEST_CONTENT / "folder_x"
sub_dir.mkdir(parents=True, exist_ok=True)
sub_home = sub_dir / "sub-home.md"
sub_home.write_text(
"# Sub Home\n\n---Available_Lessons---\n\n[Hello](lesson/test_editor/hello.md)\n",
encoding="utf-8",
)
resp = client.post("/content/drafts", json={
"target_path": "folder_x/sub-home.md",
"body": "# Sub Home\n\nNo marker here.\n",
"base_mtime": os.path.getmtime(str(sub_home)),
})
draft_id = resp.get_json()["draft_id"]
resp = client.post(f"/content/drafts/{draft_id}/publish")
assert resp.status_code == 422
assert "---Available_Lessons---" in resp.get_json()["message"]
# ── Tree ──────────────────────────────────────────────────────────
def test_get_tree_content(client):
@ -355,6 +381,136 @@ def test_tree_delete_nonempty_folder_without_force(client):
assert data.get("needs_force") is True
# ── P0-1: Proteksi file navigasi kritis (home.md, sub-home.md) ──
def test_tree_delete_home_protected(client):
_seed_and_setup()
_login_teacher(client)
# tanpa confirm_critical → 409
resp = client.delete("/content/tree/entry?root=content&path=home.md")
assert resp.status_code == 409
data = resp.get_json()
assert data["message"] == "protected"
# file masih ada
assert (TEST_CONTENT / "home.md").exists()
def test_tree_delete_home_with_confirm(client):
_seed_and_setup()
_login_teacher(client)
# dengan confirm_critical=true → 200, file terhapus
resp = client.delete("/content/tree/entry?root=content&path=home.md&confirm_critical=true")
assert resp.status_code == 200
assert not (TEST_CONTENT / "home.md").exists()
def test_tree_delete_sub_home_protected(client):
_seed_and_setup()
_login_teacher(client)
# buat folder_x/sub-home.md
sub_dir = TEST_CONTENT / "folder_x"
sub_dir.mkdir(parents=True, exist_ok=True)
(sub_dir / "sub-home.md").write_text("# Sub\n\n---Available_Lessons---\n\n", encoding="utf-8")
# tanpa confirm_critical → 409
resp = client.delete("/content/tree/entry?root=content&path=folder_x/sub-home.md")
assert resp.status_code == 409
assert resp.get_json()["message"] == "protected"
# file masih ada
assert (sub_dir / "sub-home.md").exists()
def test_tree_delete_sub_home_with_confirm(client):
_seed_and_setup()
_login_teacher(client)
sub_dir = TEST_CONTENT / "folder_x"
sub_dir.mkdir(parents=True, exist_ok=True)
(sub_dir / "sub-home.md").write_text("# Sub\n\n---Available_Lessons---\n\n", encoding="utf-8")
resp = client.delete("/content/tree/entry?root=content&path=folder_x/sub-home.md&confirm_critical=true")
assert resp.status_code == 200
assert not (sub_dir / "sub-home.md").exists()
def test_tree_delete_normal_file_not_protected(client):
_seed_and_setup()
_login_teacher(client)
# file biasa tidak perlu confirm_critical → 200
resp = client.delete("/content/tree/entry?root=content&path=test_editor/hello.md")
assert resp.status_code == 200
assert not (TEST_CONTENT / "test_editor/hello.md").exists()
def test_tree_rename_home_protected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "home.md",
"new_path": "home_renamed.md",
})
assert resp.status_code == 409
assert resp.get_json()["message"] == "protected"
assert (TEST_CONTENT / "home.md").exists()
assert not (TEST_CONTENT / "home_renamed.md").exists()
def test_tree_rename_home_with_confirm(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "home.md",
"new_path": "home_renamed.md",
"confirm_critical": True,
})
assert resp.status_code == 200
assert not (TEST_CONTENT / "home.md").exists()
assert (TEST_CONTENT / "home_renamed.md").exists()
def test_tree_rename_normal_file_not_protected(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "test_editor/hello.md",
"new_path": "test_editor/renamed.md",
})
assert resp.status_code == 200
assert (TEST_CONTENT / "test_editor/renamed.md").is_file()
# ── P1-2: Enforce .md extension di rename untuk root content ──
def test_tree_rename_reject_non_md_extension(client):
_seed_and_setup()
_login_teacher(client)
resp = client.patch("/content/tree/rename", json={
"root": "content",
"old_path": "test_editor/hello.md",
"new_path": "test_editor/hello.txt",
})
assert resp.status_code == 400
assert "Materi hanya boleh berekstensi .md" in resp.get_json()["message"]
assert (TEST_CONTENT / "test_editor/hello.md").exists()
assert not (TEST_CONTENT / "test_editor/hello.txt").exists()
def test_tree_rename_assets_root_allows_any_extension(client):
_seed_and_setup()
_login_teacher(client)
# Assets root tidak dibatasi ekstensi
asset_file = TEST_ASSETS / "test_image.png"
asset_file.parent.mkdir(parents=True, exist_ok=True)
asset_file.write_bytes(b"fake-png")
resp = client.patch("/content/tree/rename", json={
"root": "assets",
"old_path": "test_image.png",
"new_path": "test_image.bin",
})
assert resp.status_code == 200
assert (TEST_ASSETS / "test_image.bin").exists()
# ── Draft delete ──────────────────────────────────────────────────
def test_discard_draft(client):