diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py index dec8cc0..22a0a4e 100644 --- a/apps/api/app/media_catalog.py +++ b/apps/api/app/media_catalog.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import os from pathlib import Path +from urllib.parse import urlsplit MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve() @@ -12,6 +13,22 @@ KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES} +def _source_system(source_page: object) -> str: + """Map only allowlisted hostnames; never promote an unknown URL to official.""" + try: + parsed = urlsplit(str(source_page or "")) + except ValueError: + return "unknown" + hostname = (parsed.hostname or "").lower().rstrip(".") + if hostname == "rf4db.com" or hostname.endswith(".rf4db.com"): + return "rf4db" + if hostname == "rf4map.ru" or hostname.endswith(".rf4map.ru"): + return "rf4map" + if hostname == "rf4-stat.ru" or hostname.endswith(".rf4-stat.ru"): + return "rf4stat" + return "unknown" + + def media_manifest_version() -> int: manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8")) return max(1, int(manifest.get("version", 1))) @@ -42,7 +59,7 @@ def published_assets(entity_type: str | None = None, media_role: str | None = No if not _public_role_allowed(item.get("entity_type"), item.get("media_role")): continue source_page = str(item.get("source_page") or "") - source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official" + source = _source_system(source_page) result.append({ "id": item["sha256"], "entity_type": item.get("entity_type"), @@ -117,7 +134,7 @@ def review_assets( if len(digest) != 64 or not item.get("local_path"): continue source_page = str(item.get("source_page") or "") - source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official" + source = _source_system(source_page) result.append({ "id": digest, "status": item_status, diff --git a/apps/api/tests/test_media_catalog.py b/apps/api/tests/test_media_catalog.py index 87f1fc0..3c8f839 100644 --- a/apps/api/tests/test_media_catalog.py +++ b/apps/api/tests/test_media_catalog.py @@ -18,3 +18,17 @@ def test_public_media_catalog_rejects_unknown_and_cross_entity_roles(tmp_path, m rows = media_catalog.published_assets() assert [row["id"] for row in rows] == ["d" * 64, "a" * 64] + + +def test_media_catalog_does_not_infer_official_source_from_url_substrings(tmp_path, monkeypatch) -> None: + manifest = {"assets": [ + {"status": "approved", "sha256": "e" * 64, "local_path": "asset.webp", "entity_type": "fish", "entity_key": "pike", "source_page": "https://rf4db.com.attacker.test/pike"}, + {"status": "approved", "sha256": "f" * 64, "local_path": "asset2.webp", "entity_type": "fish", "entity_key": "pike", "source_page": "https://rf4db.com/ru/fish/pike"}, + ]} + (tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + monkeypatch.setattr(media_catalog, "MEDIA_ROOT", tmp_path) + + rows = {row["id"]: row for row in media_catalog.published_assets()} + + assert rows["e" * 64]["source_system"] == "unknown" + assert rows["f" * 64]["source_system"] == "rf4db" diff --git a/apps/web/src/components/EntityMedia.astro b/apps/web/src/components/EntityMedia.astro index 5befe78..56e0686 100644 --- a/apps/web/src/components/EntityMedia.astro +++ b/apps/web/src/components/EntityMedia.astro @@ -4,6 +4,7 @@ import type { MediaAsset } from "../lib/api"; import { mediaRoleLabel } from "../lib/media"; const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean }; const isLowResolution = Math.min(asset.width, asset.height) < 256; +const hasCardVariant = (asset.variants ?? []).some(item => item.role === "card" && Math.min(item.width, item.height) >= 256); ---
@@ -13,5 +14,5 @@ const isLowResolution = Math.min(asset.width, asset.height) < 256; })} {asset.label -
{asset.label ?? "Справочный материал"}{asset.media_role && {mediaRoleLabel(asset.media_role)}}{isLowResolution && Низкое разрешение · {asset.width}×{asset.height}}
+
{asset.label ?? "Справочный материал"}{asset.media_role && {mediaRoleLabel(asset.media_role)}}{isLowResolution && {hasCardVariant ? "Миниатюра · доступна версия крупнее" : "Низкое разрешение · проверенная альтернатива не найдена"} · {asset.width}×{asset.height}}
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 05824bf..bbbefae 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,7 @@ R-пункты уточняют критерии существующих B/G/U/ - [x] **R09 · P1 · Полное удаление личных данных.** Retention и admin-delete загружают дочерние компоненты, очищают их `source_url/raw_payload` вместе с родительским report и сбрасывают upload-token; retention теперь обнаруживает старые reports даже при уже очищенных родительских полях, если provenance осталась в component. Повторная очистка остаётся безопасной; S3/БД failure drill — отдельный эксплуатационный gate. - [x] **R10 · P1 · Единый cooldown и стадии запроса.** Community CLI уже освобождал резерв до HTTP-ответа; media CLI теперь использует тот же контракт: DNS/connection/OSError освобождают только собственный резерв, HTTPError или ошибка валидированного тела сохраняют cooldown, batch освобождает окно только если ни один asset не получил ответ. Offline regression добавлен; scheduler сохраняет site-wide координацию. - [ ] **R18 · P1 · Границы локального и production запуска.** Loopback для dev-портов с явным opt-in LAN; проверить доверенные proxy/client-IP и rate-limit на двух клиентах. Критерий: default dev не открыт в LAN, production различает клиентов. Реальные серверные gates — A07. -- [ ] **R11 · P2 · Честное качество и происхождение media (B25).** Unknown вместо ложного official, hostname allowlist, размеры отдельно от наличия альтернатив, compact подписи и cache/revocation-политика. Критерий: длинные подписи читаемы, неизвестный источник не становится официальным. +- [x] **R11 · P2 · Честное качество и происхождение media (B25).** API использует точный hostname allowlist для `rf4db`/`rf4map`/`rf4-stat`, неизвестные и malformed URL получают `unknown`, а не ложный `rf4-official`. UI различает низкое разрешение и наличие проверенной card-версии, не утверждая отсутствие альтернатив без данных manifest. Cache/revocation и production visual acceptance остаются отдельными gates. - [ ] **R14 · P2 · Подтверждённый каталог и demo seed.** Отделить справочник от неподтверждённых описаний/точек, показывать loaded count и внешнюю полноту/unknown раздельно. Критерий: production без demo не создаёт вымышленных описаний, media count не подменяет canonical count. - [ ] **R15 · P2 · Восстановление пользовательских действий.** Сохранять draft при 422/429/timeout, объяснять конфликт повторов, undo плана и общий storage/clipboard helper. Критерий: повтор не требует заполнить весь улов заново. - [ ] **R16 · P2 · Производительность реальных endpoints.** Измерить ORM/RSS/SQL и чтение manifest, затем bounded aggregation и digest/mtime cache по результатам. Критерий: воспроизводимый endpoint budget на крупных fixtures; Redis заранее не добавлять.