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:
parent
a1ccef2d71
commit
ffceb519be
|
|
@ -19,14 +19,8 @@
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
.teacher-link {
|
/* Progress link moved to navbar — style tidak lagi dipakai */
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.teacher-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.version-badge {
|
.version-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
|
|
|
||||||
|
|
@ -94,19 +94,24 @@ export async function createFile(path: string, template?: string) {
|
||||||
return res.json();
|
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', {
|
const res = await fetch('/api/content/tree/rename', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
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();
|
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(
|
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' },
|
{ method: 'DELETE', credentials: 'include' },
|
||||||
);
|
);
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,6 @@
|
||||||
type FeatureTemplate = { id: string; label: string; group: 'konten' | 'penilaian'; snippet: string };
|
type FeatureTemplate = { id: string; label: string; group: 'konten' | 'penilaian'; snippet: string };
|
||||||
|
|
||||||
let featureSidebarOpen = $state(true);
|
let featureSidebarOpen = $state(true);
|
||||||
let featureSidebarNarrow = $state(false);
|
|
||||||
|
|
||||||
// Desktop: panel fitur terbuka by default. Mobile: default tersembunyi (drawer).
|
// Desktop: panel fitur terbuka by default. Mobile: default tersembunyi (drawer).
|
||||||
$effect(() => {
|
$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) {
|
async function handleRename(oldPath: string) {
|
||||||
const oldName = oldPath.split('/').pop() || oldPath;
|
const oldName = oldPath.split('/').pop() || oldPath;
|
||||||
const newName = window.prompt('Nama baru:', oldName);
|
const newName = window.prompt('Nama baru:', oldName);
|
||||||
if (!newName || newName === oldName) return;
|
if (!newName || newName === oldName) return;
|
||||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/'));
|
||||||
const newPath = parentPath ? `${parentPath}/${newName}` : newName;
|
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) {
|
if (res.success) {
|
||||||
await loadTrees();
|
await loadTrees();
|
||||||
if (mgr.activePath === oldPath) {
|
if (mgr.activePath === oldPath) {
|
||||||
|
|
@ -497,8 +508,14 @@
|
||||||
|
|
||||||
async function handleDelete(path: string) {
|
async function handleDelete(path: string) {
|
||||||
const name = path.split('/').pop() || path;
|
const name = path.split('/').pop() || path;
|
||||||
if (!window.confirm(`Hapus "${name}"?`)) return;
|
// P0-1: konfirmasi kedua untuk file navigasi kritis
|
||||||
const res = await deleteEntry(mgr.treeRoot, path);
|
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) {
|
if (res.success) {
|
||||||
await loadTrees();
|
await loadTrees();
|
||||||
if (mgr.activePath === path) {
|
if (mgr.activePath === path) {
|
||||||
|
|
@ -857,9 +874,7 @@
|
||||||
</div>
|
</div>
|
||||||
{:else if dynTab.id === 'velxio'}
|
{:else if dynTab.id === 'velxio'}
|
||||||
<div class="velxio-tab-content">
|
<div class="velxio-tab-content">
|
||||||
<iframe
|
<iframe class="velxio-iframe" src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true" title="Velxio Arduino Editor"
|
||||||
class="velxio-iframe"
|
|
||||||
src="/velxio/editor?embed=true&hideEditor=true&lockComponents=true"
|
|
||||||
allow="cross-origin-isolated; fullscreen"
|
allow="cross-origin-isolated; fullscreen"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
></iframe>
|
></iframe>
|
||||||
|
|
@ -893,7 +908,7 @@
|
||||||
|
|
||||||
<!-- Feature sidebar (right, hide-able) -->
|
<!-- Feature sidebar (right, hide-able) -->
|
||||||
{#if featureSidebarOpen && mgr.activePath && !isAssetFile}
|
{#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">
|
<div class="feature-sidebar-header">
|
||||||
<span>Fitur</span>
|
<span>Fitur</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1078,17 +1093,6 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
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 {
|
.feature-sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1142,13 +1146,7 @@
|
||||||
box-shadow: none;
|
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 {
|
.btn-float-toggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|
@ -1252,29 +1250,6 @@
|
||||||
min-height: 0;
|
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 {
|
.editor-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1351,35 +1326,6 @@
|
||||||
font-size: 0.8rem;
|
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 */
|
/* Force CodeEditor in tab to fill parent */
|
||||||
.code-tab-content :global(.editor-wrapper) {
|
.code-tab-content :global(.editor-wrapper) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -1587,39 +1533,6 @@
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
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) {
|
@media (max-width: 768px) {
|
||||||
.content-editor-layout {
|
.content-editor-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ _CONTENT_DIR_ABS = os.path.abspath(CONTENT_DIR)
|
||||||
_ASSETS_DIR_ABS = os.path.abspath(ASSETS_DIR)
|
_ASSETS_DIR_ABS = os.path.abspath(ASSETS_DIR)
|
||||||
_ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
_ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
|
||||||
_MAX_ASSET_BYTES = 5 * 1024 * 1024
|
_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:
|
def _safe_target_path(raw: str) -> str | None:
|
||||||
|
|
@ -179,12 +187,12 @@ def publish(draft_id):
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"success": False, "message": str(e)}), 422
|
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)
|
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({
|
return jsonify({
|
||||||
"success": False,
|
"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.",
|
"publish dibatalkan supaya navigasi tidak rusak.",
|
||||||
}), 422
|
}), 422
|
||||||
|
|
||||||
|
|
@ -358,6 +366,19 @@ def rename_entry():
|
||||||
new_rel = _safe_rel_path(root_abs, data.get("new_path", ""))
|
new_rel = _safe_rel_path(root_abs, data.get("new_path", ""))
|
||||||
if not old_rel or not new_rel:
|
if not old_rel or not new_rel:
|
||||||
return jsonify({"success": False, "message": "path tidak valid"}), 400
|
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)
|
old_abs = os.path.join(root_abs, old_rel)
|
||||||
new_abs = os.path.join(root_abs, new_rel)
|
new_abs = os.path.join(root_abs, new_rel)
|
||||||
if not os.path.exists(old_abs):
|
if not os.path.exists(old_abs):
|
||||||
|
|
@ -392,6 +413,16 @@ def delete_entry():
|
||||||
rel = _safe_rel_path(root_abs, request.args.get("path", ""))
|
rel = _safe_rel_path(root_abs, request.args.get("path", ""))
|
||||||
if not rel:
|
if not rel:
|
||||||
return jsonify({"success": False, "message": "path tidak valid"}), 400
|
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)
|
abs_path = os.path.join(root_abs, rel)
|
||||||
if not os.path.exists(abs_path):
|
if not os.path.exists(abs_path):
|
||||||
return jsonify({"success": False, "message": "Tidak ditemukan"}), 404
|
return jsonify({"success": False, "message": "Tidak ditemukan"}), 404
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
"""Dipakai saat rename file/folder — pindahkan draft yang menunjuk ke path lama."""
|
||||||
from sqlalchemy import or_
|
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(
|
rows = db.query(ContentDraft).filter(
|
||||||
ContentDraft.status == "draft",
|
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()
|
).all()
|
||||||
count = 0
|
count = 0
|
||||||
for row in rows:
|
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."""
|
"""Dipakai saat delete file/folder — buang draft yang jadi yatim."""
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
# Escape % dan _ — lihat komentar di retarget_drafts (P2-3).
|
||||||
|
escaped = path.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
n = db.query(ContentDraft).filter(
|
n = db.query(ContentDraft).filter(
|
||||||
ContentDraft.status == "draft",
|
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)
|
).delete(synchronize_session=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
return n
|
return n
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,24 @@ def teacher_user_from_cookie(db):
|
||||||
|
|
||||||
|
|
||||||
def check_origin() -> bool:
|
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()
|
allowed = os.environ.get("ORIGIN", "").strip()
|
||||||
if not allowed or allowed == "*":
|
if not allowed or allowed == "*":
|
||||||
return True
|
return True
|
||||||
origin = request.headers.get("Origin", "")
|
origin = request.headers.get("Origin", "")
|
||||||
if not origin:
|
if not origin:
|
||||||
return True
|
return True # fail-open: rasa tidak adanya header Origin
|
||||||
return origin in {allowed, allowed.rstrip("/")}
|
return origin in {allowed, allowed.rstrip("/")}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ via repositories di test integrasi.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -77,6 +78,24 @@ def seed_demo_users():
|
||||||
db.close()
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def _isolate_database(request):
|
def _isolate_database(request):
|
||||||
"""Isolasi integration test: truncate semua tabel sebelum SETIAP test.
|
"""Isolasi integration test: truncate semua tabel sebelum SETIAP test.
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,7 @@ def test_preview_valid_quiz(client):
|
||||||
_seed_and_setup()
|
_seed_and_setup()
|
||||||
_login_teacher(client)
|
_login_teacher(client)
|
||||||
resp = client.post("/content/preview", json={
|
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
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
|
|
@ -181,7 +181,7 @@ def test_preview_invalid_quiz_two_correct(client):
|
||||||
_seed_and_setup()
|
_seed_and_setup()
|
||||||
_login_teacher(client)
|
_login_teacher(client)
|
||||||
resp = client.post("/content/preview", json={
|
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
|
assert resp.status_code == 422
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
|
|
@ -240,7 +240,7 @@ def test_publish_invalid_quiz_rejected(client):
|
||||||
|
|
||||||
resp = client.post("/content/drafts", json={
|
resp = client.post("/content/drafts", json={
|
||||||
"target_path": "test_editor/quiz.md",
|
"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")),
|
"base_mtime": os.path.getmtime(str(TEST_CONTENT / "test_editor/quiz.md")),
|
||||||
})
|
})
|
||||||
draft_id = resp.get_json()["draft_id"]
|
draft_id = resp.get_json()["draft_id"]
|
||||||
|
|
@ -254,12 +254,12 @@ def test_publish_home_without_marker_rejected(client):
|
||||||
_seed_and_setup()
|
_seed_and_setup()
|
||||||
_login_teacher(client)
|
_login_teacher(client)
|
||||||
|
|
||||||
# Create home without marker
|
# gunakan home.md asli yang sudah di-seed oleh _seed_and_setup
|
||||||
home = TEST_CONTENT / "test_home.md"
|
home = TEST_CONTENT / "home.md"
|
||||||
home.write_text("# Home\n\nNo marker here.\n", encoding="utf-8")
|
assert home.exists(), "home.md harus ada dari _seed_and_setup"
|
||||||
|
|
||||||
resp = client.post("/content/drafts", json={
|
resp = client.post("/content/drafts", json={
|
||||||
"target_path": "test_home.md",
|
"target_path": "home.md",
|
||||||
"body": "# Home\n\nNo marker here.\n",
|
"body": "# Home\n\nNo marker here.\n",
|
||||||
"base_mtime": os.path.getmtime(str(home)),
|
"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"]
|
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 ──────────────────────────────────────────────────────────
|
# ── Tree ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_get_tree_content(client):
|
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
|
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 ──────────────────────────────────────────────────
|
# ── Draft delete ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_discard_draft(client):
|
def test_discard_draft(client):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue