From d7c845266c26365741235f6d88ce6f6d0ea2132e Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 25 Oct 2026 19:19:07 +0700 Subject: [PATCH] feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + Dockerfile | 4 + config.py | 19 ++ routes/lessons.py | 59 +++++- services/image_cache.py | 163 ++++++++++++++ services/tests/conftest.py | 5 +- services/tests/test_asset_cache.py | 329 +++++++++++++++++++++++++++++ 7 files changed, 574 insertions(+), 6 deletions(-) create mode 100644 services/image_cache.py create mode 100644 services/tests/test_asset_cache.py diff --git a/.gitignore b/.gitignore index 55b1ec0..17f0196 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ velxio-deployer-firmware/managed_components/ lms-c-precompiled.tar .browser-test/ config/lms-tail.json +assets-cache/ diff --git a/Dockerfile b/Dockerfile index 86e3bcd..234fe13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/config.py b/config.py index 29a1e50..f3c25f9 100644 --- a/config.py +++ b/config.py @@ -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')) diff --git a/routes/lessons.py b/routes/lessons.py index 29de782..4b46506 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -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/') 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 " + _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