feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk)

Arsitektur: upload tetap menyimpan original apa adanya; optimasi terjadi
saat serving — gambar raster di-resize (maks 1600px) + encode WebP
(quality 80), di-generate on-the-fly lalu di-cache di disk.

- config.CACHE_DIR = sibling ASSETS_DIR ('assets-cache'), WAJIB di luar
  ASSETS_DIR supaya export_bundle (os.walk tanpa filter) tetap lossless
- services/image_cache.py: varian WebP, cache key path+mtime+size+width,
  invalidasi otomatis via mtime, atomic write, fallback original bila gagal
- routes/lessons.py::send_assets: handler (bukan static serve) —
  .png/.jpg/.jpeg/.webp/.gif -> varian WebP; .svg & GIF animasi pass-through;
  ?orig=1 untuk original; ?w= untuk lebar alternatif; ETag + Cache-Control
- Dockerfile: buat /app/assets-cache writable

Test (services/tests/test_asset_cache.py, 20 test): lokasi cache di luar
ASSETS_DIR, cache reuse, invalidasi mtime, passthrough SVG/GIF animasi,
fallback file korup, ETag/304, dan acceptance test — upload -> request ->
export bundle berisi HANYA original tanpa .webp turunan.
This commit is contained in:
opencode 2026-10-25 19:19:07 +07:00
parent 8681969c04
commit d7c845266c
7 changed files with 574 additions and 6 deletions

1
.gitignore vendored
View File

@ -14,3 +14,4 @@ velxio-deployer-firmware/managed_components/
lms-c-precompiled.tar
.browser-test/
config/lms-tail.json
assets-cache/

View File

@ -11,6 +11,10 @@ RUN apt-get update && \
# Set working directory
WORKDIR /app
# Cache varian WebP asset gambar — WAJIB di luar /app/assets supaya tidak
# ikut ter-zip saat export bundle (lihat config.py CACHE_DIR).
RUN mkdir -p /app/assets-cache && chown -R app:app /app/assets-cache
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

View File

@ -13,6 +13,25 @@ CONTENT_DIR = os.environ.get('CONTENT_DIR', 'content')
# CONTENT_DIR='/app/content' → ASSETS_DIR='/app/assets'
ASSETS_DIR = os.path.join(os.path.dirname(CONTENT_DIR.rstrip(os.sep)), 'assets')
# Cache varian ringan (WebP) untuk asset gambar raster.
#
# WAJIB di luar ASSETS_DIR — sibling, bukan `assets/.cache/`. Alasan:
# `export_bundle` (routes/content_editor.py) men-zip SELURUH isi ASSETS_DIR
# tanpa filter, jadi file cache di dalam ASSETS_DIR akan ikut ter-zip lalu
# ter-import ulang sebagai asset biasa (menggandakan file & merusak bundel).
# Dengan cache di luar, export 100% lossless — hanya original yang ikut.
#
# E.g. CONTENT_DIR='content' → CACHE_DIR='assets-cache'
# CONTENT_DIR='/app/content' → CACHE_DIR='/app/assets-cache'
CACHE_DIR = os.path.join(os.path.dirname(CONTENT_DIR.rstrip(os.sep)), 'assets-cache')
# Dimensi maksimum (sisi terpanjang, px) untuk varian WebP yang di-generate.
ASSET_VARIANT_MAX_DIM = int(os.environ.get('ASSET_VARIANT_MAX_DIM', '1600'))
# Kualitas WebP (1-100).
ASSET_VARIANT_QUALITY = int(os.environ.get('ASSET_VARIANT_QUALITY', '80'))
# Cache-Control max-age untuk respons asset (detik).
ASSET_CACHE_MAX_AGE = int(os.environ.get('ASSET_CACHE_MAX_AGE', '3600'))
# ── Evaluation weights & done threshold ───────────────────────────
# Bobot exercise (0-100) dan quiz (0-100) untuk composite score.
LESSON_EXERCISE_WEIGHT = float(os.environ.get('LESSON_EXERCISE_WEIGHT', '70'))

View File

@ -4,11 +4,12 @@ Lesson JSON API routes consumed by the SvelteKit frontend.
import os
from flask import Blueprint, request, jsonify, send_from_directory
from werkzeug.utils import secure_filename
from flask import Blueprint, Response, abort, request, jsonify, send_from_directory
from werkzeug.utils import safe_join, secure_filename
from compiler import compiler_factory
from config import ASSETS_DIR
from config import ASSET_CACHE_MAX_AGE, ASSETS_DIR
from services import image_cache
from services.lesson_service import (
get_missing_prerequisites,
get_ordered_lessons_with_learning_objectives,
@ -24,6 +25,22 @@ from services.token_service import get_student_progress, has_quiz_attempt, is_qu
lessons_bp = Blueprint('lessons', __name__)
def _parse_variant_width(raw: str | None) -> int | None:
"""Parse parameter `?w=` (lebar alternatif, px).
Nilai tidak valid / di luar rentang aman → None (pakai lebar default).
"""
if not raw:
return None
try:
width = int(raw)
except (TypeError, ValueError):
return None
if width < 1 or width > 4096:
return None
return width
def _prereq_specs(lesson):
"""Spec prasyarat untuk pengecekan kunci.
@ -296,7 +313,39 @@ def get_key_text(filename):
@lessons_bp.route('/assets/<path:path>')
def send_assets(path):
"""Serve asset files (images, etc.) from ASSETS_DIR."""
"""Serve asset files (images, etc.) from ASSETS_DIR.
Gambar raster (png/jpg/jpeg/webp/gif) di-serve sebagai versi ringan:
resize (maks dimensi) + WebP, di-generate on-the-fly lalu di-cache di
`CACHE_DIR` (di luar ASSETS_DIR — lihat config.py). Original tetap utuh
di storage dan bisa diakses lewat `?orig=1`.
SVG & GIF animasi di-serve apa adanya. File korrupt / Pillow gagal →
fallback ke original.
"""
abs_path = safe_join(ASSETS_DIR, path)
if abs_path is None or not os.path.isfile(abs_path):
abort(404)
want_original = request.args.get('orig') in ('1', 'true', 'yes')
width = _parse_variant_width(request.args.get('w'))
if not want_original:
variant = image_cache.get_variant(abs_path, path, width)
if variant is not None:
data, etag = variant
if request.if_none_match and etag in request.if_none_match:
resp = Response(status=304)
else:
resp = Response(data, mimetype='image/webp')
resp.headers['Content-Length'] = str(len(data))
resp.headers['ETag'] = f'"{etag}"'
resp.headers['Cache-Control'] = f'public, max-age={ASSET_CACHE_MAX_AGE}'
resp.headers['X-Asset-Variant'] = 'webp'
return resp
# Pass-through: original apa adanya (SVG, GIF animasi, ?orig=1, raster
# yang gagal di-variant-kan).
response = send_from_directory(ASSETS_DIR, path)
# Mitigasi stored-XSS: SVG bisa memuat <script>. Route ini publik
# (same-origin) — kalau SVG dibuka langsung di tab browser, script-nya
@ -304,4 +353,6 @@ def send_assets(path):
# semua eksekusi di dokumen tersebut; sandbox melarang navigasi/form.
if path.lower().endswith(".svg"):
response.headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
response.headers['Cache-Control'] = f'public, max-age={ASSET_CACHE_MAX_AGE}'
response.headers['X-Asset-Variant'] = 'original'
return response

163
services/image_cache.py Normal file
View File

@ -0,0 +1,163 @@
"""
Cache varian ringan (WebP) untuk asset gambar raster.
Arsitektur (Fitur D — optimasi kecepatan load gambar):
- **Upload menyimpan file original apa adanya.** Tidak ada konversi saat
upload — original tetap utuh di storage dan bisa diakses kapan saja.
- **Optimasi terjadi saat serving.** Saat browser meminta `/assets/<path>`,
server mengirim versi ringan: resize (maks dimensi) + encode WebP, yang
di-generate on-the-fly lalu di-cache di disk.
- **Semua asset lama otomatis ikut teroptimasi** — tanpa migrasi, tanpa
tooling konversi, tanpa menyentuh konten markdown (path referensi tidak
berubah).
Batas keras: cache WAJIB berada di luar `ASSETS_DIR` (lihat `config.CACHE_DIR`).
`export_bundle` men-zip seluruh isi `ASSETS_DIR` tanpa filter — kalau cache
berada di dalam, file turunan ikut ter-zip lalu ter-import ulang sebagai asset
biasa. Dengan cache di luar, export tetap 100% lossless.
Invalidasi: berdasarkan `mtime` + `size` file original. File diganti →
mtime berubah → key cache berubah → entry lama menjadi yatim (dibiarkan,
dibersihkan lazy).
"""
from __future__ import annotations
import hashlib
import io
import logging
import os
from config import (
ASSET_VARIANT_MAX_DIM,
ASSET_VARIANT_QUALITY,
CACHE_DIR,
)
logger = logging.getLogger(__name__)
# Ekstensi gambar raster yang boleh di-generate varian WebP-nya.
RASTER_EXTS = frozenset({'.png', '.jpg', '.jpeg', '.webp', '.gif'})
# Ekstensi yang selalu di-serve apa adanya (vektor / format khusus).
PASSTHROUGH_EXTS = frozenset({'.svg'})
def is_raster(rel_path: str) -> bool:
"""True bila path adalah gambar raster yang boleh di-generate variannya."""
return os.path.splitext(rel_path.lower())[1] in RASTER_EXTS
def is_passthrough(rel_path: str) -> bool:
"""True bila file harus selalu di-serve original (mis. SVG)."""
return os.path.splitext(rel_path.lower())[1] in PASSTHROUGH_EXTS
def cache_key(rel_path: str, mtime_ns: int, size: int, width: int | None) -> str:
"""Kunci cache deterministik: path + mtime + ukuran + lebar varian.
mtime membuat cache otomatis invalidasi saat file asli diganti.
"""
raw = f'{rel_path}|{mtime_ns}|{size}|{width or 0}'.encode('utf-8')
return hashlib.sha1(raw).hexdigest()
def _cache_file_for(key: str) -> str:
return os.path.join(CACHE_DIR, f'{key}.webp')
def _build_webp(abs_path: str, width: int | None) -> bytes | None:
"""Buka original, resize (bila perlu), encode WebP.
Return None bila file tidak bisa diproses (corrupt, format tak didukung,
GIF animasi, dsb) — pemanggil lalu fallback serve original.
"""
try:
from PIL import Image, ImageOps
except ImportError: # pragma: no cover - Pillow adalah dependency wajib
logger.warning("Pillow tidak tersedia — varian WebP dilewati")
return None
try:
with Image.open(abs_path) as im:
# GIF/WebP animasi: konversi WebP animasi kompleks & berisiko —
# biarkan apa adanya (pass-through).
if getattr(im, 'is_animated', False):
return None
# Hormati orientasi EXIF (foto HP) sebelum resize.
try:
im = ImageOps.exif_transpose(im)
except Exception: # pragma: no cover - EXIF rusak tidak fatal
pass
if im.mode not in ('RGB', 'RGBA'):
im = im.convert('RGBA' if 'transparency' in im.info else 'RGB')
target = width or ASSET_VARIANT_MAX_DIM
longest = max(im.size)
if longest > target:
scale = target / longest
new_size = (
max(1, round(im.width * scale)),
max(1, round(im.height * scale)),
)
im = im.resize(new_size, Image.LANCZOS)
buf = io.BytesIO()
im.save(buf, format='WEBP', quality=ASSET_VARIANT_QUALITY, method=4)
return buf.getvalue()
except Exception as exc:
logger.warning("Gagal membuat varian WebP untuk %s: %r", abs_path, exc)
return None
def get_variant(
abs_path: str,
rel_path: str,
width: int | None = None,
) -> tuple[bytes, str] | None:
"""Ambil (atau buat) varian WebP untuk asset gambar raster.
Return `(bytes, etag)` bila varian berhasil dibuat/diambil,
atau `None` bila file tidak boleh/berhasil di-variant-kan
(SVG, GIF animasi, file korrupt, Pillow gagal) — pemanggil harus
fallback ke original.
"""
if not is_raster(rel_path):
return None
try:
st = os.stat(abs_path)
except OSError:
return None
key = cache_key(rel_path, st.st_mtime_ns, st.st_size, width)
cache_file = _cache_file_for(key)
try:
with open(cache_file, 'rb') as fh:
data = fh.read()
if data:
return data, key
except FileNotFoundError:
pass
except OSError as exc: # pragma: no cover - I/O error tak terduga
logger.warning("Gagal baca cache %s: %r", cache_file, exc)
data = _build_webp(abs_path, width)
if not data:
return None
# Tulis atomik supaya request paralel tidak membaca file setengah jadi.
try:
os.makedirs(CACHE_DIR, exist_ok=True)
tmp_file = f'{cache_file}.{os.getpid()}.tmp'
with open(tmp_file, 'wb') as fh:
fh.write(data)
os.replace(tmp_file, cache_file)
except OSError as exc: # pragma: no cover - mis. cache dir tidak writable
logger.warning("Gagal menyimpan cache %s: %r", cache_file, exc)
return data, key

View File

@ -80,7 +80,7 @@ def seed_demo_users():
@pytest.fixture(autouse=True)
def _isolate_filesystem():
"""Bersihkan CONTENT_DIR/ASSETS_DIR test sebelum tiap test — filesystem
"""Bersihkan CONTENT_DIR/ASSETS_DIR/CACHE_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
@ -89,7 +89,8 @@ def _isolate_filesystem():
"""
content_dir = pathlib.Path(_TEST_CONTENT)
assets_dir = content_dir.parent / "assets"
for d in (content_dir, assets_dir):
cache_dir = content_dir.parent / "assets-cache"
for d in (content_dir, assets_dir, cache_dir):
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,329 @@
"""
Test Fitur D — optimasi kecepatan load gambar asset.
Arsitektur: original disimpan apa adanya; optimasi terjadi saat serving
(resize + WebP on-the-fly, cache di `CACHE_DIR`).
Batas keras yang diuji di sini: cache WAJIB di luar `ASSETS_DIR` supaya
export bundle tetap lossless (hanya original yang ter-zip).
"""
import base64
import io
import os
import pathlib
import zipfile
import pytest
from config import ASSETS_DIR, CACHE_DIR
TEST_ASSETS = pathlib.Path(ASSETS_DIR)
TEST_CACHE = pathlib.Path(CACHE_DIR)
DB_REQUIRED = os.environ.get("DATABASE_URL", "").strip()
# ── Helpers ────────────────────────────────────────────────────────
def _make_png(width: int = 800, height: int = 600) -> bytes:
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (width, height), (30, 120, 200)).save(buf, format="PNG")
return buf.getvalue()
def _make_gif(animated: bool = False) -> bytes:
from PIL import Image
frames = []
for color in [(255, 0, 0), (0, 255, 0)]:
frames.append(Image.new("RGB", (10, 10), color))
buf = io.BytesIO()
if animated:
frames[0].save(
buf, format="GIF", save_all=True, append_images=frames[1:], duration=100, loop=0
)
else:
frames[0].save(buf, format="GIF")
return buf.getvalue()
def _write(rel_path: str, raw: bytes) -> pathlib.Path:
target = TEST_ASSETS / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
return target
# ── Unit: lokasi cache & varian ────────────────────────────────────
def test_cache_dir_is_outside_assets_dir():
"""Cache WAJIB sibling, bukan di dalam ASSETS_DIR.
Kalau cache di dalam ASSETS_DIR, `export_bundle` (os.walk tanpa filter)
akan ikut men-zip file turunan → bundel ter-import ulang berisi duplikat.
"""
assets_abs = os.path.abspath(ASSETS_DIR)
cache_abs = os.path.abspath(CACHE_DIR)
assert cache_abs != assets_abs
assert not cache_abs.startswith(assets_abs + os.sep)
assert os.path.basename(cache_abs) == "assets-cache"
def test_variant_generated_and_reused_from_cache():
raw = _make_png()
path = _write("big.png", raw)
from services import image_cache
first = image_cache.get_variant(str(path), "big.png")
assert first is not None
data, key = first
assert data[:4] == b"RIFF" and data[8:12] == b"WEBP"
cache_files = list(TEST_CACHE.glob("*.webp"))
assert len(cache_files) == 1
assert cache_files[0].read_bytes() == data
mtime_before = cache_files[0].stat().st_mtime_ns
# Kunjungan kedua: HARUS pakai cache (tidak regenerate).
second = image_cache.get_variant(str(path), "big.png")
assert second is not None
assert second[1] == key
assert second[0] == data
assert cache_files[0].stat().st_mtime_ns == mtime_before
assert len(list(TEST_CACHE.glob("*.webp"))) == 1
def test_variant_invalidated_when_source_replaced():
path = _write("swap.png", _make_png(400, 300))
from services import image_cache
first = image_cache.get_variant(str(path), "swap.png")
assert first is not None
# File asli diganti → mtime berubah → key baru.
path.write_bytes(_make_png(410, 310))
second = image_cache.get_variant(str(path), "swap.png")
assert second is not None
assert second[1] != first[1]
def test_svg_is_not_converted():
path = _write("icon.svg", b"<svg xmlns='http://www.w3.org/2000/svg'></svg>")
from services import image_cache
assert image_cache.get_variant(str(path), "icon.svg") is None
def test_animated_gif_is_not_converted():
path = _write("anim.gif", _make_gif(animated=True))
from services import image_cache
assert image_cache.get_variant(str(path), "anim.gif") is None
def test_corrupt_image_returns_none_for_original_fallback():
path = _write("broken.png", b"not-a-real-png")
from services import image_cache
assert image_cache.get_variant(str(path), "broken.png") is None
def test_variant_respects_width_parameter():
from PIL import Image
path = _write("wide.png", _make_png(1200, 800))
from services import image_cache
variant = image_cache.get_variant(str(path), "wide.png", width=300)
assert variant is not None
with Image.open(io.BytesIO(variant[0])) as im:
assert im.width == 300
assert im.height == 200
def test_variant_respects_max_dimension():
from PIL import Image
path = _write("huge.png", _make_png(3000, 2000))
from services import image_cache
variant = image_cache.get_variant(str(path), "huge.png")
assert variant is not None
with Image.open(io.BytesIO(variant[0])) as im:
assert max(im.size) == 1600 # ASSET_VARIANT_MAX_DIM default
# ── Route: /assets/<path> (publik, tanpa DB) ───────────────────────
def test_asset_route_serves_webp_variant(client):
_write("foto.png", _make_png())
resp = client.get("/assets/foto.png")
assert resp.status_code == 200
assert resp.mimetype == "image/webp"
assert resp.data[:4] == b"RIFF" and resp.data[8:12] == b"WEBP"
assert resp.headers.get("ETag")
assert "public" in resp.headers.get("Cache-Control", "")
assert resp.headers.get("X-Asset-Variant") == "webp"
def test_asset_route_second_request_hits_cache(client):
_write("foto2.png", _make_png())
first = client.get("/assets/foto2.png")
cache_files = list(TEST_CACHE.glob("*.webp"))
assert len(cache_files) == 1
mtime_before = cache_files[0].stat().st_mtime_ns
second = client.get("/assets/foto2.png")
assert second.status_code == 200
assert second.headers.get("ETag") == first.headers.get("ETag")
assert cache_files[0].stat().st_mtime_ns == mtime_before
assert len(list(TEST_CACHE.glob("*.webp"))) == 1
def test_asset_route_etag_returns_304(client):
_write("foto3.png", _make_png())
first = client.get("/assets/foto3.png")
etag = first.headers.get("ETag")
assert etag
resp = client.get("/assets/foto3.png", headers={"If-None-Match": etag})
assert resp.status_code == 304
def test_asset_route_orig_param_serves_original(client):
raw = _make_png()
_write("orig.png", raw)
resp = client.get("/assets/orig.png?orig=1")
assert resp.status_code == 200
assert resp.data == raw
assert resp.mimetype == "image/png"
assert resp.headers.get("X-Asset-Variant") == "original"
def test_asset_route_width_param(client):
_write("w.png", _make_png(1200, 800))
resp = client.get("/assets/w.png?w=400")
assert resp.status_code == 200
from PIL import Image
with Image.open(io.BytesIO(resp.data)) as im:
assert im.width == 400
def test_asset_route_svg_passthrough_with_csp(client):
raw = b"<svg xmlns='http://www.w3.org/2000/svg'><script>x</script></svg>"
_write("icon.svg", raw)
resp = client.get("/assets/icon.svg")
assert resp.status_code == 200
assert resp.data == raw
assert resp.mimetype == "image/svg+xml"
assert "sandbox" in resp.headers.get("Content-Security-Policy", "")
def test_asset_route_corrupt_file_falls_back_to_original(client):
raw = b"not-a-real-png"
_write("bad.png", raw)
resp = client.get("/assets/bad.png")
assert resp.status_code == 200
assert resp.data == raw
assert resp.headers.get("X-Asset-Variant") == "original"
def test_asset_route_missing_file_404(client):
resp = client.get("/assets/tidak-ada.png")
assert resp.status_code == 404
def test_asset_route_rejects_path_traversal(client):
resp = client.get("/assets/../../etc/passwd")
assert resp.status_code in (400, 404)
def test_asset_route_static_gif_converted(client):
_write("statis.gif", _make_gif(animated=False))
resp = client.get("/assets/statis.gif")
assert resp.status_code == 200
assert resp.mimetype == "image/webp"
# ── Integrasi: export bundle tetap lossless ───────────────────────
@pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)")
@pytest.mark.integration
def test_export_bundle_contains_only_originals_after_serving(client):
"""Acceptance test wajib: cache terisi → export tetap hanya original."""
from services import repositories as repo
from services.database import SessionLocal
from services.tests.conftest import TEACHER_TOKEN
db = SessionLocal()
try:
teacher = repo.create_user(db, display_name="Pak Guru", role="teacher")
repo.create_access_token(db, user_id=teacher.id, raw_token=TEACHER_TOKEN)
db.commit()
finally:
db.close()
raw = _make_png()
_write("bundle.png", raw)
client.set_cookie("student_token", TEACHER_TOKEN)
# 1. Request gambar → varian WebP dibuat & di-cache.
resp = client.get("/assets/bundle.png")
assert resp.status_code == 200
assert resp.mimetype == "image/webp"
assert list(TEST_CACHE.glob("*.webp")), "cache varian harus terisi"
# 2. Export bundle → zip harus berisi ORIGINAL saja.
export = client.get("/content/export")
assert export.status_code == 200
names = zipfile.ZipFile(io.BytesIO(export.data)).namelist()
assert "assets/bundle.png" in names
assert not any(n.endswith(".webp") for n in names), (
f"file turunan WebP ikut ter-zip: {[n for n in names if n.endswith('.webp')]}"
)
# Tidak ada folder cache di dalam assets.
assert not any(n.startswith("assets-cache") for n in names)
assert not any("assets-cache" in n for n in names)
@pytest.mark.skipif(not DB_REQUIRED, reason="butuh DATABASE_URL (PostgreSQL nyata)")
@pytest.mark.integration
def test_assets_tree_does_not_expose_cache_folder(client):
"""getTree('assets') tidak boleh menampilkan folder cache."""
from services import repositories as repo
from services.database import SessionLocal
from services.tests.conftest import TEACHER_TOKEN
db = SessionLocal()
try:
teacher = repo.create_user(db, display_name="Pak Guru", role="teacher")
repo.create_access_token(db, user_id=teacher.id, raw_token=TEACHER_TOKEN)
db.commit()
finally:
db.close()
_write("terlihat.png", _make_png())
client.set_cookie("student_token", TEACHER_TOKEN)
# Pastikan cache benar-benar terisi (di luar ASSETS_DIR).
client.get("/assets/terlihat.png")
assert list(TEST_CACHE.glob("*.webp"))
resp = client.get("/content/tree?root=assets")
assert resp.status_code == 200
tree = resp.get_json()["tree"]
names = [n.get("name") for n in tree]
assert "terlihat.png" in names
assert "assets-cache" not in names