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:
parent
8681969c04
commit
d7c845266c
|
|
@ -14,3 +14,4 @@ velxio-deployer-firmware/managed_components/
|
|||
lms-c-precompiled.tar
|
||||
.browser-test/
|
||||
config/lms-tail.json
|
||||
assets-cache/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
19
config.py
19
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'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue