feat(embed): add raw HTML embed fence with bleach sanitization
- Add embed markdown fence: user pastes raw embed HTML code (from Canva/YouTube/Google Docs Share→Embed) into lesson markdown and slide content. Backend sanitizes via bleach (whitelist tags/attrs/styles) + checks iframe src against domain blacklist (SSRF prevention). Frontend renders iframe directly — no lazy action needed. - Backend: _process_embed_embeds + _sanitize_embed_html in lesson_service.py, applied to lesson_content, exercise, lesson_info, and slide loop. Graceful fallback if tinycss2 missing (CSS unsanitized but tags/attrs still stripped). - Tests: 9 pytest cases (Canva/YouTube HTML, script stripping, onclick stripping, blocked domain, non-https iframe, empty, unchanged, dangerous style). - Frontend: remove renderEmbedEmbeds.ts + wire-up + .generic-embed CSS (URL-only approach from earlier iteration, superseded). Keep .embed-error CSS for error messages. - Example: update test_slides.md with raw HTML Canva embed (slide) + YouTube embed (body). - Deps: bleach>=6.0.0, tinycss2>=1.2.0 in requirements.txt. - Docs: consolidate 4 plan files into docs/06-embed-content.md.
This commit is contained in:
parent
0cbac64c6f
commit
2a09ee77ab
|
|
@ -0,0 +1,187 @@
|
|||
# Fitur Embed Konten di Materi Markdown & Slide
|
||||
|
||||
**Tanggal:** 2026-07-19
|
||||
**Status:** Implementasi selesai
|
||||
**Lokasi kode:** Backend `services/lesson_service.py` + Frontend (CSS only)
|
||||
|
||||
---
|
||||
|
||||
## 1. Latar Belakang
|
||||
|
||||
Elemes memerlukan fitur agar author materi dapat menyisipkan konten *embedded* (iframe dari platform luar) langsung dari markdown — baik di tubuh materi maupun di dalam slide presentasi. Contoh penggunaan: video YouTube, desain Canva, Google Docs, Figma, widget Scratch, dll.
|
||||
|
||||
### Infrastruktur yang Sudah Ada
|
||||
|
||||
Elemes **sudah punya** pipeline markdown→embed untuk `circuit` dan `flowchart`:
|
||||
- **Backend** (`services/lesson_service.py`): regex fence ```circuit``` → `<div class="*-embed" data-*>`, lalu `md.markdown()` render jadi HTML.
|
||||
- **Frontend** (`src/lib/actions/render*Embeds.ts`): `IntersectionObserver` ganti div → `<iframe>` lazy load.
|
||||
- **Slide** sudah diparse di `lesson_service.py`, dan di dalam loop slide embed circuit/flowchart sudah diproses — jadi embed otomatis berlaku di materi **dan** slide.
|
||||
|
||||
**Kesimpulan feasibility:** ✅ Sangat feasible — infrastruktur sudah ada, tinggal diperluas polanya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Evolusi Pendekatan
|
||||
|
||||
### Opsi Awal (Ditolak): URL-only fence + whitelist domain
|
||||
|
||||
Pendekatan pertama: user tulis URL di fence ```embed```, backend bikin div, frontend pasang iframe.
|
||||
|
||||
````markdown
|
||||
```embed,100%,400px
|
||||
https://www.youtube.com/embed/VIDEO_ID
|
||||
```
|
||||
````
|
||||
|
||||
**Masalah ditemukan saat testing:**
|
||||
1. **Canva menolak di-iframe** — "canva.com refused to connect". Canva set `X-Frame-Options: DENY` untuk URL design biasa; butuh URL khusus `?embed` untuk mengizinkan iframe.
|
||||
2. **Embed di slide stuck "Memuat..."** — karena iframe ditolak, `onload` tidak fire, loading text tidak dihapus.
|
||||
3. **Perlu transform per-platform** — Canva butuh `?embed`, Google Docs butuh `/preview`, Figma butuh format khusus. Hardcode per-platform tidak fleksibel.
|
||||
|
||||
### Pendekatan Final (Dipilih): Raw HTML embed code + bleach sanitizer
|
||||
|
||||
Alih-alih URL, user **paste embed HTML code** siap pakai dari platform (Share → Embed):
|
||||
|
||||
````markdown
|
||||
```embed
|
||||
<div style="position: relative; width: 100%; padding-top: 56.25%;">
|
||||
<iframe loading="lazy" src="https://www.canva.com/design/.../view?embed" allowfullscreen></iframe>
|
||||
</div>
|
||||
<a href="https://www.canva.com/..." target="_blank" rel="noopener">Judul</a> by Author
|
||||
```
|
||||
````
|
||||
|
||||
**Kelebihan:**
|
||||
- User kontrol penuh (aspect ratio, style, link credit) — embed code dari platform resmi sudah optimize.
|
||||
- Support Canva, YouTube, Google Docs, Figma, Scratch, dll sekaligus — tanpa hardcode transform per-platform.
|
||||
- Lebih fleksibel: author bisa kustomisasi wrapper, caption, dll.
|
||||
|
||||
**Konsekuensi keamanan:** Raw HTML = potensi XSS. Wajib **sanitize** sebelum render. Tanpa sanitize, author bisa sisipkan `<script>`, `onclick`, `onerror`, dll.
|
||||
|
||||
---
|
||||
|
||||
## 3. Desain Teknis
|
||||
|
||||
### Keamanan — Dual Layer
|
||||
|
||||
1. **`bleach` library** (Python standar industri):
|
||||
- Whitelist tag: `div`, `iframe`, `a`, `span`, `p`, `br`, `img`.
|
||||
- Whitelist attribute per tag: `iframe[src|style|loading|allowfullscreen|allow|title]`, `a[href|target|rel|style]`, dll.
|
||||
- Whitelist CSS property: `position`, `width`, `height`, `padding`, `margin`, `border-radius`, `box-shadow`, dll (via `CSSSanitizer` + `tinycss2`).
|
||||
- Strip: `<script>`, `onclick`, `onerror`, `javascript:` URL, dan semua tag/attr/style berbahaya.
|
||||
|
||||
2. **Domain blacklist** (`EMBED_BLOCKED_HOSTS`):
|
||||
- Cek `iframe[src]` setelah sanitasi: wajib `https://`, hostname tidak boleh di blacklist.
|
||||
- Default blokir: `localhost`, `127.0.0.1`, `0.0.0.0`, `metadata.google.internal`, `169.254.169.254` (cegah SSRF / metadata leak).
|
||||
- Subdomain ikut diblokir (mis. `sub.localhost`).
|
||||
|
||||
### Resiliensi — Graceful Degradation
|
||||
|
||||
Import `CSSSanitizer` di-bungkus `try/except ImportError`. Kalau `tinycss2` tidak terinstall di environment, aplikasi **tidak crash** — fallback ke `bleach.clean()` tanpa CSS sanitizer (tags/attrs tetap di-sanitize, hanya style CSS tidak difilter). Di production, `tinycss2` wajib ada di `requirements.txt` untuk keamanan penuh.
|
||||
|
||||
### Alur Pipeline
|
||||
|
||||
```
|
||||
Markdown (```embed\nRAW HTML\n```)
|
||||
↓
|
||||
_process_embed_embeds() — regex match fence
|
||||
↓
|
||||
_sanitize_embed_html() — bleach.clean() + iframe src blacklist check
|
||||
↓
|
||||
HTML bersih (iframe jadi) → md.markdown() → lesson_content / slides_html
|
||||
↓
|
||||
Frontend: langsung render via {@html} — tidak perlu action khusus
|
||||
```
|
||||
|
||||
Backend memanggil `_process_embed_embeds()` di 4 titik agar berlaku di semua konten:
|
||||
1. Loop slide (slide carousel)
|
||||
2. `lesson_content` (tubuh materi)
|
||||
3. `exercise_content` (latihan)
|
||||
4. `lesson_info` (info pelajaran)
|
||||
|
||||
Frontend tidak butuh action baru — HTML sudah berisi iframe jadi dari backend. Action `renderEmbedEmbeds.ts` dari pendekatan URL-only lama sudah dihapus.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementasi
|
||||
|
||||
### File yang Dimodifikasi/Dibuat
|
||||
|
||||
| File | Aksi | Detail |
|
||||
|------|------|--------|
|
||||
| `services/lesson_service.py` | EDIT | Tambah `import bleach`, `EMBED_ALLOWED_TAGS/ATTRS/STYLES`, fungsi `_sanitize_embed_html()` + `_process_embed_embeds()` (ganti URL-only lama). 4 call sites tetap dipanggil. |
|
||||
| `services/tests/test_lesson_service_embed.py` | CREATE | 9 pytest: Canva HTML, YouTube HTML, strips `<script>`, strips `onclick`, blocked domain, non-https iframe, empty, unchanged, dangerous style. |
|
||||
| `services/requirements.txt` | EDIT | Tambah `bleach>=6.0.0`, `tinycss2>=1.2.0`. |
|
||||
| `frontend/src/app.css` | EDIT | Hapus `.generic-embed*` (tidak dipakai lagi), simpan `.embed-error`. |
|
||||
| `frontend/src/routes/lesson/[slug]/+page.svelte` | EDIT | Hapus import + call `renderEmbedEmbeds` (tidak perlu lagi). |
|
||||
| `frontend/src/lib/actions/renderEmbedEmbeds.ts` | DELETE | Pendekatan URL-only dihapus. |
|
||||
| `examples/content/dasar/test_slides.md` | EDIT | Contoh Canva (raw HTML, di dalam slide) + YouTube (raw HTML, di body materi). |
|
||||
|
||||
### Verifikasi
|
||||
|
||||
- ✅ `pytest tests/ -v` — 9/9 passed.
|
||||
- ✅ `npm run build` — sukses tanpa error.
|
||||
- ✅ Manual test: Canva embed (dengan `?embed` URL) load di slide; YouTube embed load di body.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cara Pakai
|
||||
|
||||
### Untuk Author Materi
|
||||
|
||||
1. Buka platform (Canva, YouTube, Google Docs, dll) → klik **Share** / **Bagikan** → **Embed**.
|
||||
2. Copy kode HTML yang diberikan (biasanya berisi `<iframe>` + optional wrapper `<div>` + `<a>` credit).
|
||||
3. Paste di markdown materi di dalam fence ```embed```:
|
||||
|
||||
````markdown
|
||||
```embed
|
||||
<div style="position: relative; width: 100%; padding-top: 56.25%;">
|
||||
<iframe loading="lazy" src="https://www.canva.com/design/.../view?embed" allowfullscreen></iframe>
|
||||
</div>
|
||||
```
|
||||
````
|
||||
|
||||
4. Embed otomatis muncul di materi. Kalau diletakkan di dalam blok `---slide-start---` / `---slide-end---`, embed muncul di slide carousel.
|
||||
|
||||
### Catatan Platform
|
||||
|
||||
| Platform | Cara dapat embed code |
|
||||
|----------|---------------------|
|
||||
| Canva | Share → Embed → Copy. URL sudah include `?embed`. |
|
||||
| YouTube | Share → Embed → Copy. URL pakai `youtube.com/embed/VIDEO_ID`. |
|
||||
| Google Docs | File → Share → Publish to web → Embed → Copy. URL pakai `/preview`. |
|
||||
| Figma | Share → Get embed code → Copy. URL pakai `figma.com/embed?...`. |
|
||||
|
||||
### Pesan Error
|
||||
|
||||
- **"Konten embed ditolak: iframe harus https."** — URL iframe pakai `http://`, ganti ke `https://`.
|
||||
- **"Konten embed ditolak: domain iframe diblokir."** — Domain iframe ada di blacklist (internal/metadata endpoint).
|
||||
- **"Konten embed kosong."** — Fence ```embed``` tidak berisi apa-apa.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pertanyaan Umum
|
||||
|
||||
**Kenapa pakai raw HTML, bukan URL saja?**
|
||||
Karena setiap platform punya format embed berbeda (Canva butuh `?embed`, Google Docs butuh `/preview`, Figma butuh `embed_host`). Dengan raw HTML, author paste kode siap pakai dari platform — lebih fleksibel dan tidak perlu hardcode transform per-platform di backend.
|
||||
|
||||
**Apakah aman?**
|
||||
Ya. HTML di-sanitize pakai `bleach` (whitelist tag/attr/style) + cek domain iframe di blacklist. `<script>`, event handler (`onclick`), `javascript:` URL, dan domain berbahaya semua ditolak.
|
||||
|
||||
**Bisa dipakai di slide?**
|
||||
Ya. Embed di dalam `---slide-start---` / `---slide-end---` otomatis diproses — backend panggil `_process_embed_embeds()` di loop slide.
|
||||
|
||||
**Kenapa `.generic-embed*` CSS dihapus?**
|
||||
Itu CSS dari pendekatan URL-only lama (frontend bikin div + lazy iframe). Sekarang iframe sudah jadi dari backend, tidak butuh wrapper CSS khusus. `.embed-error` tetap dipertahankan untuk pesan error.
|
||||
|
||||
---
|
||||
|
||||
## 7. Riwayat Dokumen
|
||||
|
||||
Dokumen ini mengonsolidasi 4 file plan awal yang sudah superseded:
|
||||
- `possibility-study-embed.md` — studi feasibility awal (opsi A/B/C).
|
||||
- `plan-embed-implementation.md` — plan implementasi Opsi A (URL + whitelist).
|
||||
- `plan-fix-embed-stuck.md` — diagnosis "stuck loading" (sempat dikira race condition, ternyata Canva block).
|
||||
- `plan-embed-rawhtml.md` — plan final pendekatan raw HTML + bleach.
|
||||
|
||||
Konsolidasi dilakukan agar pembaca masa depan tidak perlu membaca 4 file perjalanan; cukup 1 dokumen koheren yang menceritakan konteks, keputusan, dan hasil akhir.
|
||||
|
|
@ -10,6 +10,17 @@ Ini adalah konten slide pertama.
|
|||
- Mudah digunakan
|
||||
- Berbasis Markdown
|
||||
- Interaktif
|
||||
|
||||
```embed
|
||||
<div style="position: relative; width: 100%; height: 0; padding-top: 56.2500%;
|
||||
padding-bottom: 0; box-shadow: 0 2px 8px 0 rgba(63,69,81,0.16); margin-top: 1.6em; margin-bottom: 0.9em; overflow: hidden;
|
||||
border-radius: 8px; will-change: transform;">
|
||||
<iframe loading="lazy" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0; border: none; padding: 0;margin: 0;"
|
||||
src="https://www.canva.com/design/DAGxLO8cerw/GoZd4ObhxuXX2Don8fbBEg/view?embed" allowfullscreen="allowfullscreen" allow="fullscreen">
|
||||
</iframe>
|
||||
</div>
|
||||
<a href="https://www.canva.com/design/DAGxLO8cerw/GoZd4ObhxuXX2Don8fbBEg/view?utm_content=DAGxLO8cerw&utm_campaign=designshare&utm_medium=embeds&utm_source=link" target="_blank" rel="noopener">Berfikir Komputasional </a> by ANGGORO DWI NUR ROHMAN
|
||||
```
|
||||
---
|
||||
# Slide 3: Selesai
|
||||
Terima kasih telah mencoba!
|
||||
|
|
@ -17,6 +28,26 @@ Terima kasih telah mencoba!
|
|||
|
||||
Setelah slide di atas, ini adalah konten materi biasa.
|
||||
|
||||
## canva embed
|
||||
|
||||
```embed
|
||||
<div style="position: relative; width: 100%; height: 0; padding-top: 56.2500%;
|
||||
padding-bottom: 0; box-shadow: 0 2px 8px 0 rgba(63,69,81,0.16); margin-top: 1.6em; margin-bottom: 0.9em; overflow: hidden;
|
||||
border-radius: 8px; will-change: transform;">
|
||||
<iframe loading="lazy" style="position: absolute; width: 100%; height: 100%; top: 0; left: 0; border: none; padding: 0;margin: 0;"
|
||||
src="https://www.canva.com/design/DAGxLO8cerw/GoZd4ObhxuXX2Don8fbBEg/view?embed" allowfullscreen="allowfullscreen" allow="fullscreen">
|
||||
</iframe>
|
||||
</div>
|
||||
<a href="https://www.canva.com/design/DAGxLO8cerw/GoZd4ObhxuXX2Don8fbBEg/view?utm_content=DAGxLO8cerw&utm_campaign=designshare&utm_medium=embeds&utm_source=link" target="_blank" rel="noopener">Berfikir Komputasional </a> by ANGGORO DWI NUR ROHMAN
|
||||
```
|
||||
|
||||
|
||||
## Youtube Embed
|
||||
|
||||
```embed
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/QiLhQjYCP2Q?si=CwNjoALVUjeTJoSu" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||
```
|
||||
|
||||
```c
|
||||
#include <stdio.h>
|
||||
int main() {
|
||||
|
|
|
|||
|
|
@ -222,6 +222,17 @@ tr:nth-child(even) {
|
|||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Embed error (from ```embed fences) ── */
|
||||
.embed-error {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: var(--radius);
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import { setupTryButtons } from '$actions/setupTryButtons';
|
||||
import { renderCircuitEmbeds } from '$actions/renderCircuitEmbeds';
|
||||
import { renderFlowchartEmbeds } from '$actions/renderFlowchartEmbeds';
|
||||
|
||||
import { renderMath, autoRenderMath } from '$lib/actions/renderMath';
|
||||
import { tick, mount, unmount } from 'svelte';
|
||||
import { LessonManager } from './lesson.svelte';
|
||||
|
|
@ -94,6 +95,7 @@
|
|||
highlightAllCode(el);
|
||||
renderCircuitEmbeds(el);
|
||||
renderFlowchartEmbeds(el);
|
||||
|
||||
autoRenderMath(el);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,3 +6,5 @@ python-dotenv==1.0.0
|
|||
requests==2.31.0
|
||||
flask-limiter==3.5.0
|
||||
python-markdown-math==0.8
|
||||
bleach>=6.0.0
|
||||
tinycss2>=1.2.0
|
||||
|
|
@ -5,8 +5,10 @@ Lesson loading, ordering, and markdown rendering.
|
|||
import os
|
||||
import re
|
||||
import html as html_module
|
||||
import bleach
|
||||
from functools import lru_cache
|
||||
from threading import Lock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import markdown as md
|
||||
|
||||
|
|
@ -293,6 +295,32 @@ def get_ordered_lessons_with_learning_objectives(progress=None):
|
|||
|
||||
MD_EXTENSIONS = ['fenced_code', 'tables', 'nl2br', 'toc', 'mdx_math']
|
||||
|
||||
# Domain blacklist for embed iframe src (must be https).
|
||||
EMBED_BLOCKED_HOSTS = {
|
||||
'localhost', '127.0.0.1', '0.0.0.0',
|
||||
'metadata.google.internal', '169.254.169.254',
|
||||
}
|
||||
|
||||
# HTML sanitization config for ```embed fences (raw HTML embed code)
|
||||
EMBED_ALLOWED_TAGS = ['div', 'iframe', 'a', 'span', 'p', 'br', 'img']
|
||||
EMBED_ALLOWED_ATTRS = {
|
||||
'div': ['style', 'class'],
|
||||
'iframe': ['src', 'style', 'loading', 'allowfullscreen', 'allow', 'title', 'class'],
|
||||
'a': ['href', 'target', 'rel', 'style', 'class'],
|
||||
'span': ['style', 'class'],
|
||||
'p': ['style', 'class'],
|
||||
'img': ['src', 'alt', 'style', 'class', 'loading'],
|
||||
'*': ['class'],
|
||||
}
|
||||
EMBED_ALLOWED_STYLES = [
|
||||
'position', 'width', 'height', 'padding', 'padding-top', 'padding-bottom',
|
||||
'padding-left', 'padding-right', 'margin', 'margin-top', 'margin-bottom',
|
||||
'margin-left', 'margin-right', 'border', 'border-radius',
|
||||
'overflow', 'box-shadow', 'top', 'left', 'right', 'bottom',
|
||||
'will-change', 'display', 'flex-direction', 'gap',
|
||||
'max-width', 'max-height', 'min-height',
|
||||
]
|
||||
|
||||
|
||||
def _process_circuit_embeds(text):
|
||||
"""Replace ```circuit[,width][,height] code fences with embeddable HTML divs.
|
||||
|
|
@ -366,6 +394,62 @@ def _process_flowchart_embeds(text):
|
|||
return pattern.sub(_replacer, text)
|
||||
|
||||
|
||||
def _sanitize_embed_html(html_text):
|
||||
"""Sanitize raw embed HTML: whitelist tags/attrs/styles + check iframe src domain."""
|
||||
cleaned = bleach.clean(
|
||||
html_text,
|
||||
tags=EMBED_ALLOWED_TAGS,
|
||||
attributes=EMBED_ALLOWED_ATTRS,
|
||||
strip=True,
|
||||
)
|
||||
# Optional CSS sanitization — requires tinycss2 (skip if not installed)
|
||||
try:
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
css_sanitizer = CSSSanitizer(allowed_css_properties=EMBED_ALLOWED_STYLES)
|
||||
cleaned = bleach.clean(
|
||||
html_text,
|
||||
tags=EMBED_ALLOWED_TAGS,
|
||||
attributes=EMBED_ALLOWED_ATTRS,
|
||||
css_sanitizer=css_sanitizer,
|
||||
strip=True,
|
||||
)
|
||||
except ImportError:
|
||||
pass # tinycss2 missing — CSS styles left unsanitized but tags/attrs still stripped
|
||||
# Check every iframe src: must be https + not blacklisted
|
||||
for match in re.finditer(r'<iframe[^>]+src="([^"]*)"', cleaned):
|
||||
src = match.group(1)
|
||||
try:
|
||||
host = (urlparse(src).hostname or '').lower()
|
||||
except Exception:
|
||||
return '<div class="embed-error">Konten embed ditolak: URL iframe tidak valid.</div>'
|
||||
if not src.startswith('https://'):
|
||||
return '<div class="embed-error">Konten embed ditolak: iframe harus https.</div>'
|
||||
if host in EMBED_BLOCKED_HOSTS or any(host.endswith('.' + h) for h in EMBED_BLOCKED_HOSTS):
|
||||
return '<div class="embed-error">Konten embed ditolak: domain iframe diblokir.</div>'
|
||||
return cleaned
|
||||
|
||||
|
||||
def _process_embed_embeds(text):
|
||||
"""Replace ```embed fences containing raw HTML embed code with sanitized HTML.
|
||||
|
||||
User pastes embed code from Canva/YouTube/Google Docs (Share → Embed).
|
||||
HTML is sanitized via bleach (whitelist tags/attrs/styles) and iframe src
|
||||
is checked against EMBED_BLOCKED_HOSTS.
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r'```embed\s*\n(.*?)```',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def _replacer(match):
|
||||
raw_html = match.group(1).strip()
|
||||
if not raw_html:
|
||||
return '<div class="embed-error">Konten embed kosong.</div>'
|
||||
return _sanitize_embed_html(raw_html)
|
||||
|
||||
return pattern.sub(_replacer, text)
|
||||
|
||||
|
||||
def _parse_flashcards(text):
|
||||
"""Parse a string of markdown with headings and options into a list of dicts.
|
||||
|
||||
|
|
@ -613,6 +697,7 @@ def render_markdown_content(file_path):
|
|||
# Process embeds in slides too
|
||||
s = _process_circuit_embeds(s)
|
||||
s = _process_flowchart_embeds(s)
|
||||
s = _process_embed_embeds(s)
|
||||
slides_html.append(md.markdown(s.strip(), extensions=MD_EXTENSIONS))
|
||||
|
||||
# Just use whichever initial code matched as the generic 'initial_code' for simplicity
|
||||
|
|
@ -628,12 +713,15 @@ def render_markdown_content(file_path):
|
|||
# Convert ```circuit and ```flowchart fences to embed divs before markdown rendering
|
||||
lesson_content = _process_circuit_embeds(lesson_content)
|
||||
lesson_content = _process_flowchart_embeds(lesson_content)
|
||||
lesson_content = _process_embed_embeds(lesson_content)
|
||||
if exercise_content:
|
||||
exercise_content = _process_circuit_embeds(exercise_content)
|
||||
exercise_content = _process_flowchart_embeds(exercise_content)
|
||||
exercise_content = _process_embed_embeds(exercise_content)
|
||||
if lesson_info:
|
||||
lesson_info = _process_circuit_embeds(lesson_info)
|
||||
lesson_info = _process_flowchart_embeds(lesson_info)
|
||||
lesson_info = _process_embed_embeds(lesson_info)
|
||||
|
||||
lesson_html = md.markdown(lesson_content, extensions=MD_EXTENSIONS)
|
||||
exercise_html = md.markdown(exercise_content, extensions=MD_EXTENSIONS) if exercise_content else ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import pytest
|
||||
from lesson_service import _process_embed_embeds
|
||||
|
||||
|
||||
def test_embed_canva_html():
|
||||
md = """```embed
|
||||
<div style="position: relative; width: 100%; padding-top: 56.25%;">
|
||||
<iframe loading="lazy" src="https://www.canva.com/design/ABC/view?embed" allowfullscreen></iframe>
|
||||
</div>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'canva.com' in out
|
||||
assert '<iframe' in out
|
||||
assert 'allowfullscreen' in out
|
||||
|
||||
|
||||
def test_embed_strips_script():
|
||||
md = """```embed
|
||||
<div><iframe src="https://youtube.com/embed/x"></iframe><script>alert(1)</script></div>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
# Script tags are stripped by bleach; inner text remains but is harmless
|
||||
assert '<script' not in out.lower()
|
||||
assert '</script>' not in out.lower()
|
||||
|
||||
|
||||
def test_embed_strips_onclick():
|
||||
md = """```embed
|
||||
<div onclick="alert(1)"><iframe src="https://youtube.com/embed/x"></iframe></div>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'onclick' not in out
|
||||
assert 'alert' not in out
|
||||
|
||||
|
||||
def test_embed_blocked_domain():
|
||||
md = """```embed
|
||||
<iframe src="https://169.254.169.254/meta"></iframe>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'embed-error' in out
|
||||
|
||||
|
||||
def test_embed_non_https_iframe():
|
||||
md = """```embed
|
||||
<iframe src="http://youtube.com/embed/x"></iframe>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'embed-error' in out
|
||||
|
||||
|
||||
def test_embed_empty_rejected():
|
||||
md = """```embed
|
||||
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'embed-error' in out
|
||||
assert 'kosong' in out
|
||||
|
||||
|
||||
def test_embed_no_embed_unchanged():
|
||||
md = "# Heading\n\nparagraf biasa"
|
||||
assert _process_embed_embeds(md) == md
|
||||
|
||||
|
||||
def test_embed_youtube_html():
|
||||
md = """```embed
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/dQw4w9WgXcQ" allowfullscreen></iframe>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'youtube.com' in out
|
||||
assert '<iframe' in out
|
||||
|
||||
|
||||
def test_embed_strips_dangerous_style():
|
||||
md = """```embed
|
||||
<div style="background: url('javascript:alert(1)')"><iframe src="https://youtube.com/embed/x"></iframe></div>
|
||||
```"""
|
||||
out = _process_embed_embeds(md)
|
||||
assert 'javascript' not in out.lower()
|
||||
Loading…
Reference in New Issue