diff --git a/README.md b/README.md index 814dceb..daeb586 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Naninovel описаны в [docs/IMPORT.md](docs/IMPORT.md). кэш в браузере для плеера и пакетная замена картинок в модах с предпросмотром и откатом. Настройка и ограничения — в [docs/IMAGE_ENHANCE.md](docs/IMAGE_ENHANCE.md). +Импорт обложек из F95zone и настройка авторизованной сессии — в [docs/F95.md](docs/F95.md). + ## Установка на VM ```bash diff --git a/backend/app/f95.py b/backend/app/f95.py index 5020c7a..e19e3c5 100644 --- a/backend/app/f95.py +++ b/backend/app/f95.py @@ -1,24 +1,100 @@ -"""Reads title, description and tags from a public F95zone thread (no login, first post only).""" +"""Reads F95zone metadata with an optional personal session cookie.""" import re import urllib.error import urllib.request from html import unescape from html.parser import HTMLParser -from urllib.parse import urlparse +from urllib.parse import urljoin, urlparse THREAD_RE = re.compile(r"^/threads/[^/]+\.\d+/?|^/threads/\d+/?") UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36" MAX_BYTES = 3 * 1024 * 1024 +MAX_IMAGE_BYTES = 20 * 1024 * 1024 # first-post fields that are not part of the description itself STOP_LABELS = ("thread updated", "release date", "developer", "censored", "version", "os", "language", "store", "genre", "installation", "changelog", "developer notes", "download", "animations") +def _request(url: str, referer: str | None = None, cookie: str = "", user_agent: str = "") -> urllib.request.Request: + headers = {"User-Agent": user_agent or UA, "Accept-Language": "en-US,en;q=0.8"} + if referer: + headers["Referer"] = referer + request = urllib.request.Request(url, headers=headers) + cookie = cookie.strip() + if "\r" in cookie or "\n" in cookie: + raise F95Error("Некорректные cookie F95zone") + if cookie: + # Redirect handlers explicitly reapply the cookie after validating the next host. + request.add_unredirected_header("Cookie", cookie) + return request + + +def image_url(value: str) -> str | None: + """Only download images hosted by F95; never follow arbitrary post links.""" + try: + url = urljoin("https://f95zone.to/", value.strip()) + p = urlparse(url) + port = p.port + except ValueError: + return None + host = (p.hostname or "").lower() + if (value.strip() and p.scheme in ("http", "https") + and (host == "f95zone.to" or host.endswith(".f95zone.to")) + and not p.username and not p.password and port in (None, 80, 443) + and not p.path.startswith("/styles/") and "logo" not in p.path.lower()): + return p._replace(scheme="https", fragment="").geturl() + return None + + +class _F95Redirect(urllib.request.HTTPRedirectHandler): + images = False + + def redirect_request(self, req, fp, code, msg, headers, newurl): + target = image_url(newurl) + if not target or (not self.images and urlparse(target).hostname not in ("f95zone.to", "www.f95zone.to")): + raise F95Error("Неожиданный редирект F95zone") + redirected = super().redirect_request(req, fp, code, msg, headers, target) + if redirected is not None: + cookie = req.get_header("Cookie", "") + if cookie: + redirected.add_unredirected_header("Cookie", cookie) + return redirected + + +class _ImageRedirect(_F95Redirect): + images = True + + +def fetch_image(url: str, thread_url: str, cookie: str = "", user_agent: str = "") -> bytes: + target = image_url(url) + if not target: + raise F95Error("Недопустимая ссылка на превью F95zone") + request = _request(target, thread_url, cookie, user_agent) + try: + with urllib.request.build_opener(_ImageRedirect()).open(request, timeout=15) as response: + if response.headers.get_content_type() == "text/html": + raise F95AuthError("F95zone запрашивает авторизацию для превью") + data = response.read(MAX_IMAGE_BYTES + 1) + if len(data) > MAX_IMAGE_BYTES: + raise F95Error("Превью F95zone слишком большое") + return data + except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise F95AuthError("F95zone не принял cookie или ограничил доступ к превью") from e + raise F95Error("Не удалось скачать превью F95zone") from e + except OSError as e: + raise F95Error("Не удалось скачать превью F95zone") from e + + class F95Error(Exception): pass +class F95AuthError(F95Error): + pass + + def validate_url(url: str) -> str: p = urlparse(url.strip()) if p.scheme not in ("http", "https") or (p.hostname or "").lower() not in ("f95zone.to", "www.f95zone.to"): @@ -28,10 +104,10 @@ def validate_url(url: str) -> str: return f"https://f95zone.to{p.path}" -def fetch(url: str) -> str: - req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept-Language": "en-US,en;q=0.8"}) +def fetch(url: str, cookie: str = "", user_agent: str = "") -> str: + req = _request(url, cookie=cookie, user_agent=user_agent) try: - with urllib.request.urlopen(req, timeout=15) as r: # noqa: S310 - host is validated + with urllib.request.build_opener(_F95Redirect()).open(req, timeout=15) as r: final = urlparse(r.geturl()) if (final.hostname or "") not in ("f95zone.to", "www.f95zone.to"): raise F95Error("Неожиданный редирект") @@ -39,6 +115,8 @@ def fetch(url: str) -> str: except F95Error: raise except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise F95AuthError("F95zone не принял cookie или ограничил доступ к теме") from e if e.code >= 500: raise F95Error(f"F95zone сейчас недоступен (ответил {e.code}), попробуйте позже") raise F95Error(f"F95zone ответил {e.code} (тема недоступна или сайт блокирует запрос)") @@ -52,9 +130,12 @@ class _Parser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.og_title = "" + self.og_image = None + self.post_image = None self.tags: list[str] = [] self.post: list[str] = [] self.template = "" + self.logged_in = None self._in_tag = False self._tag_buf = "" self._depth = 0 # div depth inside the first bbWrapper; 0 = outside @@ -66,8 +147,11 @@ class _Parser(HTMLParser): cls = (a.get("class") or "").split() if tag == "html": self.template = a.get("data-template") or "" + self.logged_in = a.get("data-logged-in") elif tag == "meta" and a.get("property") == "og:title": self.og_title = a.get("content") or "" + elif tag == "meta" and a.get("property") == "og:image": + self.og_image = image_url(a.get("content") or "") elif tag == "a" and "tagItem" in cls: self._in_tag, self._tag_buf = True, "" if self._done: @@ -80,6 +164,11 @@ class _Parser(HTMLParser): self._depth += 1 if self._skip or {"bbCodeSpoiler", "messageHide"} & set(cls): self._skip += 1 + elif tag == "img" and "bbImage" in cls and not self._skip and not self.post_image: + for key in ("data-src", "src"): + if candidate := image_url(a.get(key) or ""): + self.post_image = candidate + break elif tag == "br" and not self._skip: self.post.append("\n") elif tag in ("script", "style"): @@ -109,13 +198,17 @@ class _Parser(HTMLParser): def parse(html: str) -> dict: p = _Parser() p.feed(html) - if p.template == "login" or not p.post: - raise F95Error("Тема не найдена или закрыта для гостей") + if p.template == "login": + raise F95AuthError("F95zone запрашивает авторизацию") + if not p.post: + raise F95Error("Тема не найдена или недоступна") title = unescape(p.og_title) # "VN - Ren'Py - Hooked! [v0.6.0] [BlenderKnight]" -> "Hooked!" title = re.sub(r"\s*\[[^\]]*\]", "", title) title = re.sub(r"^(?:[A-Za-z0-9'’ ]{1,20} - )+", "", title).strip() - return {"title": title, "description": _description("".join(p.post)), "tags": sorted(set(p.tags))} + return {"title": title, "description": _description("".join(p.post)), + "tags": sorted(set(p.tags)), "image_url": p.og_image or p.post_image, + "logged_in": p.logged_in} def _description(text: str) -> str: @@ -139,6 +232,6 @@ def _description(text: str) -> str: return re.sub(r"\n{3,}", "\n\n", "\n".join(out)).strip() -def load(url: str) -> dict: +def load(url: str, cookie: str = "", user_agent: str = "") -> dict: url = validate_url(url) - return {**parse(fetch(url)), "url": url} + return {**parse(fetch(url, cookie, user_agent)), "url": url} diff --git a/backend/app/f95_session.py b/backend/app/f95_session.py new file mode 100644 index 0000000..ec4fb9a --- /dev/null +++ b/backend/app/f95_session.py @@ -0,0 +1,29 @@ +"""Encrypted, user-bound storage for third-party credentials.""" +import base64 +import hashlib +import json + +from cryptography.fernet import Fernet, InvalidToken + +from app.config import get_settings + + +def _cipher() -> Fernet: + key = hashlib.sha256(("f95-session:" + get_settings().secret_key).encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + + +def seal(user_id: int, cookie: str, user_agent: str) -> str: + data = json.dumps({"user_id": user_id, "cookie": cookie, "user_agent": user_agent}) + return _cipher().encrypt(data.encode()).decode() + + +def open_session(user_id: int, token: str | None) -> tuple[str, str]: + if token: + try: + data = json.loads(_cipher().decrypt(token.encode())) + if data["user_id"] == user_id: + return data["cookie"], data["user_agent"] + except (InvalidToken, ValueError, KeyError, TypeError): + pass + return "", "" diff --git a/backend/app/models.py b/backend/app/models.py index 9fbb67e..43fdb70 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -39,6 +39,7 @@ class User(Base): must_change_password: Mapped[bool] = mapped_column(Boolean, default=True) must_change_username: Mapped[bool] = mapped_column(Boolean, default=False) token_version: Mapped[int] = mapped_column(Integer, default=0) + f95_session: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) role: Mapped[Role] = relationship(lazy="joined") diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py index 0283390..a29cfb9 100644 --- a/backend/app/routers/games.py +++ b/backend/app/routers/games.py @@ -6,7 +6,7 @@ from PIL import Image, UnidentifiedImageError from sqlalchemy import and_, exists, func, or_, select, true from starlette.concurrency import run_in_threadpool -from app.blobs import put_bytes +from app.blobs import blob_path, put_bytes from app.deps import DB, CurrentUser, has_perm, require from app.models import Game, GameVersion, User from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut @@ -87,11 +87,14 @@ async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[d @router.post("", response_model=GameOut, status_code=201) async def create_game(body: GameIn, user: Uploader, db: DB) -> Game: + if body.cover_hash and not blob_path(body.cover_hash).is_file(): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова") game = Game( title=body.title.strip(), description=body.description, tags=sorted({t.strip().lower() for t in body.tags if t.strip()}), created_by_id=user.id, + cover_hash=body.cover_hash, ) db.add(game) await db.commit() @@ -118,6 +121,10 @@ async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: D game = await get_game(db, game_id) if not can_edit_game(user, game): raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") + if body.cover_hash: + if not blob_path(body.cover_hash).is_file(): + raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Обложка не найдена, загрузите превью снова") + game.cover_hash = body.cover_hash if body.title is not None: game.title = body.title.strip() if body.description is not None: diff --git a/backend/app/routers/metadata.py b/backend/app/routers/metadata.py index 4f584b9..505c631 100644 --- a/backend/app/routers/metadata.py +++ b/backend/app/routers/metadata.py @@ -1,9 +1,12 @@ from fastapi import APIRouter, HTTPException, status -from pydantic import BaseModel +from PIL import Image, UnidentifiedImageError +from pydantic import BaseModel, SecretStr from starlette.concurrency import run_in_threadpool from app import f95 -from app.deps import CurrentUser, has_perm +from app.deps import DB, CurrentUser, has_perm +from app.f95_session import open_session, seal +from app.routers.games import make_cover router = APIRouter(prefix="/api/metadata", tags=["metadata"]) @@ -12,18 +15,81 @@ class F95In(BaseModel): url: str +class F95SessionIn(BaseModel): + cookie: SecretStr + user_agent: str = "" + + class MetaOut(BaseModel): url: str - title: str - description: str - tags: list[str] + title: str = "" + description: str = "" + tags: list[str] = [] + cover_hash: str | None = None + cover_warning: str | None = None + auth_required: bool = False + metadata_available: bool = True + + +def load_metadata(url: str, cookie: str = "", user_agent: str = "") -> dict: + url = f95.validate_url(url) + try: + metadata = f95.load(url, cookie, user_agent) + except f95.F95AuthError as e: + return {"url": url, "auth_required": True, "metadata_available": False, "cover_warning": str(e)} + logged_in = metadata.pop("logged_in", None) + metadata["cover_hash"] = None + if image := metadata.pop("image_url", None): + try: + metadata["cover_hash"] = make_cover(f95.fetch_image(image, metadata["url"], cookie, user_agent)) + except f95.F95AuthError as e: + metadata["auth_required"] = True + metadata["cover_warning"] = f"{e}. Остальные данные загружены." + except f95.F95Error as e: + metadata["cover_warning"] = f"{e}. Остальные данные загружены." + except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError): + metadata["cover_warning"] = "Не удалось загрузить превью F95zone. Остальные данные загружены." + else: + metadata["auth_required"] = not cookie or logged_in in ("false", "0") + metadata["cover_warning"] = "В теме не найдено доступное превью. Остальные данные загружены." + return metadata + + +def require_import(user: CurrentUser) -> None: + if not (has_perm(user, "games.upload") or has_perm(user, "games.edit")): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав") + + +@router.put("/f95/session", status_code=204) +async def save_f95_session(body: F95SessionIn, user: CurrentUser, db: DB) -> None: + require_import(user) + cookie = body.cookie.get_secret_value().strip() + if cookie.lower().startswith("cookie:"): + cookie = cookie[7:].strip() + agent = body.user_agent.strip() + if (not cookie or len(cookie) > 16384 or len(agent) > 2048 + or any(ord(c) < 32 or ord(c) > 126 for c in cookie + agent) + or not all("=" in part and part.strip().split("=", 1)[0] for part in cookie.split(";"))): + raise HTTPException(422, "Вставьте значение заголовка Cookie из запроса F95zone одной строкой") + user.f95_session = seal(user.id, cookie, agent) + await db.commit() + + +@router.delete("/f95/session", status_code=204) +async def delete_f95_session(user: CurrentUser, db: DB) -> None: + user.f95_session = None + await db.commit() @router.post("/f95", response_model=MetaOut) -async def from_f95(body: F95In, user: CurrentUser) -> dict: - if not (has_perm(user, "games.upload") or has_perm(user, "games.edit")): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав") +async def from_f95(body: F95In, user: CurrentUser, db: DB) -> dict: + require_import(user) + cookie, agent = open_session(user.id, user.f95_session) try: - return await run_in_threadpool(f95.load, body.url) + result = await run_in_threadpool(load_metadata, body.url, cookie, agent) + if result.get("auth_required") and user.f95_session: + user.f95_session = None + await db.commit() + return result except f95.F95Error as e: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(e)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2cbf7bd..252a72c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -113,12 +113,14 @@ class GameIn(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" tags: list[str] = [] + cover_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}\.webp$") class GameUpdateIn(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = None tags: list[str] | None = None + cover_hash: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}\.webp$") class VersionIn(BaseModel): diff --git a/backend/migrations/versions/0005_f95_sessions.py b/backend/migrations/versions/0005_f95_sessions.py new file mode 100644 index 0000000..6b0d2f9 --- /dev/null +++ b/backend/migrations/versions/0005_f95_sessions.py @@ -0,0 +1,15 @@ +"""Personal encrypted F95zone sessions.""" +import sqlalchemy as sa +from alembic import op + +revision = "0005" +down_revision = "0004" +branch_labels = depends_on = None + + +def upgrade(): + op.add_column("users", sa.Column("f95_session", sa.Text(), nullable=True)) + + +def downgrade(): + op.drop_column("users", "f95_session") diff --git a/backend/requirements.txt b/backend/requirements.txt index d436eab..a8c6f96 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,6 +6,7 @@ alembic==1.15.2 pydantic-settings==2.9.1 argon2-cffi==23.1.0 pyjwt==2.10.1 +cryptography==50.0.2 pillow==11.2.1 UnityPy==1.25.4 TypeTreeGeneratorAPI==0.0.10 diff --git a/backend/tests/test_f95.py b/backend/tests/test_f95.py new file mode 100644 index 0000000..e47bbef --- /dev/null +++ b/backend/tests/test_f95.py @@ -0,0 +1,247 @@ +import asyncio +import io +import urllib.error +from email.message import Message +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from PIL import Image +from pydantic import SecretStr, ValidationError + +from app import f95 +from app.blobs import blob_path +from app.config import get_settings +from app.f95_session import open_session, seal +from app.routers import games, metadata +from app.schemas import GameIn, GameUpdateIn + +THREAD = "https://f95zone.to/threads/example.123/" +IMAGE = "https://attachments.f95zone.to/preview.jpg" + + +def page(meta="", post="", reply=""): + return (f'{meta}' + f'
{post}
Overview: A story
Download: link
' + f'
{reply}
') + + +def test_og_preview_and_existing_metadata(): + result = f95.parse(page(f'', + '')) + assert result == {"title": "Example", "description": "A story", "tags": [], "image_url": IMAGE, "logged_in": None} + + +def test_first_post_lazy_image_fallback_ignores_logo_spoilers_and_replies(): + html = page('', + '' + '
' + '', + '') + assert f95.parse(html)["image_url"] == IMAGE + assert f95.parse(page(reply=''))["image_url"] is None + + +@pytest.mark.parametrize("url", ["", "http://127.0.0.1/a", "https://f95zone.to.evil.test/a", + "https://evil.test/a", "file:///tmp/a", "data:image/png,AA", + "https://user:pass@f95zone.to/a", "https://f95zone.to:bad/a"]) +def test_untrusted_image_urls_are_rejected(url): + assert f95.image_url(url) is None + + +def test_external_redirect_is_rejected_before_request(): + request = f95.urllib.request.Request(IMAGE) + with pytest.raises(f95.F95Error): + f95._ImageRedirect().redirect_request(request, None, 302, "Found", {}, "http://127.0.0.1/a") + + +def test_session_cookie_used_for_thread_and_image_but_never_external_redirects(monkeypatch): + cookie = "xf_user=test; xf_session=example" + requests = [] + + def open_request(request, timeout): + requests.append(request) + response = io.BytesIO(page().encode() if len(requests) == 1 else b"image") + response.geturl = lambda: request.full_url + response.headers = Message() + return response + + monkeypatch.setattr(f95.urllib.request, "build_opener", + lambda *_: SimpleNamespace(open=open_request)) + assert f95.load(THREAD, cookie, "Test browser")["title"] == "Example" + assert f95.fetch_image(IMAGE, THREAD, cookie, "Test browser") == b"image" + for request in requests: + assert request.get_header("Cookie") == "xf_user=test; xf_session=example" + assert request.get_header("User-agent") == "Test browser" + assert "Cookie" not in request.headers # not copied automatically on redirects + assert requests[1].get_header("Referer") == THREAD + redirected = f95._ImageRedirect().redirect_request(requests[1], None, 302, "Found", {}, + "https://f95zone.to/attachments/preview.jpg") + assert redirected.get_header("Cookie") == "xf_user=test; xf_session=example" + for handler in (f95._F95Redirect(), f95._ImageRedirect()): + with pytest.raises(f95.F95Error): + handler.redirect_request(requests[0], None, 302, "Found", {}, "https://evil.test/a") + + +def test_missing_session_sends_no_cookie(monkeypatch): + assert not f95._request(THREAD).has_header("Cookie") + + +def test_auth_failure_reports_session_requirement_without_cookie(monkeypatch): + def denied(*args, **kwargs): + raise urllib.error.HTTPError(IMAGE, 403, "Forbidden", {}, None) + + monkeypatch.setattr(f95.urllib.request, "build_opener", lambda *_: SimpleNamespace(open=denied)) + with pytest.raises(f95.F95AuthError, match="не принял cookie") as error: + f95.fetch_image(IMAGE, THREAD, "xf_user=secret") + assert "secret" not in str(error.value) + + +def test_image_download_is_bounded(monkeypatch): + monkeypatch.setattr(f95, "MAX_IMAGE_BYTES", 8) + response = io.BytesIO(b"x" * 9) + response.headers = Message() + monkeypatch.setattr(f95.urllib.request, "build_opener", + lambda *_: SimpleNamespace(open=lambda *a, **kw: response)) + with pytest.raises(f95.F95Error, match="слишком большое"): + f95.fetch_image(IMAGE, THREAD) + + +def test_import_stores_local_webp_and_keeps_metadata_on_image_failure(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + get_settings.cache_clear() + monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page( + f'')), "url": THREAD}) + image = io.BytesIO() + Image.new("RGB", (1200, 800), "red").save(image, "PNG") + monkeypatch.setattr(f95, "fetch_image", lambda *_: image.getvalue()) + try: + result = metadata.load_metadata(THREAD) + with Image.open(blob_path(result["cover_hash"])) as cover: + assert cover.format == "WEBP" + assert cover.size == (640, 427) + assert "image_url" not in result + assert metadata.MetaOut(**result).cover_hash == result["cover_hash"] + monkeypatch.setattr(f95, "fetch_image", lambda *_: b"not an image") + failed = metadata.load_metadata(THREAD) + assert failed["title"] == "Example" + assert failed["cover_hash"] is None + assert failed["cover_warning"] + finally: + get_settings.cache_clear() + + +def test_missing_preview_is_nonfatal(monkeypatch): + monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page()), "url": THREAD}) + result = metadata.load_metadata(THREAD) + assert result["cover_hash"] is None + assert result["cover_warning"] + assert result["description"] == "A story" + assert result["auth_required"] is True + + +def test_personal_sessions_encrypted_and_bound_to_user(): + token = seal(1, "xf_user=private", "Browser") + assert "private" not in token + assert open_session(1, token) == ("xf_user=private", "Browser") + assert open_session(2, token) == ("", "") + assert open_session(1, "corrupted") == ("", "") + + +def test_login_page_requests_cookie_instead_of_discarding_form(monkeypatch): + with pytest.raises(f95.F95AuthError): + f95.parse('') + + def need_login(*_): + raise f95.F95AuthError("Нужна авторизация") + + monkeypatch.setattr(f95, "load", need_login) + result = metadata.MetaOut(**metadata.load_metadata(THREAD)) + assert result.auth_required is True + assert result.metadata_available is False + + +def test_expired_cookie_image_preserves_text_and_requests_replacement(monkeypatch): + monkeypatch.setattr(f95, "load", lambda *_: {**f95.parse(page( + f'')), "url": THREAD}) + + def expired(*_): + raise f95.F95AuthError("Cookie истекли") + + monkeypatch.setattr(f95, "fetch_image", expired) + result = metadata.load_metadata(THREAD, "xf_user=expired") + assert result["title"] == "Example" + assert result["auth_required"] is True + assert result["cover_hash"] is None + + +def test_user_session_endpoints_and_isolated_import(monkeypatch): + db = SimpleNamespace(commit=AsyncMock()) + first = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None) + second = SimpleNamespace(id=2, permissions=["games.upload"], f95_session=None) + asyncio.run(metadata.save_f95_session(metadata.F95SessionIn( + cookie=SecretStr("Cookie: xf_user=first"), user_agent="Browser"), first, db)) + assert first.f95_session and "xf_user" not in first.f95_session + assert second.f95_session is None + calls = [] + + def load(url, cookie, agent): + calls.append((cookie, agent)) + return {"url": url, "title": "Example", "auth_required": False} + + monkeypatch.setattr(metadata, "load_metadata", load) + asyncio.run(metadata.from_f95(metadata.F95In(url=THREAD), first, db)) + asyncio.run(metadata.from_f95(metadata.F95In(url=THREAD), second, db)) + assert calls == [("xf_user=first", "Browser"), ("", "")] + monkeypatch.setattr(metadata, "load_metadata", lambda *args: {"url": THREAD, "auth_required": True}) + asyncio.run(metadata.from_f95(metadata.F95In(url=THREAD), first, db)) + assert first.f95_session is None + asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(cookie=SecretStr("xf_user=new")), first, db)) + asyncio.run(metadata.delete_f95_session(first, db)) + assert first.f95_session is None + + +@pytest.mark.parametrize("cookie", ["", "secret", "xf_user=one\r\nOther: two", "x=" + "a" * 17000]) +def test_invalid_session_not_saved_or_echoed(cookie): + user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None) + db = SimpleNamespace(commit=AsyncMock()) + with pytest.raises(metadata.HTTPException) as error: + asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(cookie=SecretStr(cookie)), user, db)) + assert user.f95_session is None + assert not db.commit.called + assert "xf_user" not in error.value.detail + + +@pytest.mark.parametrize("schema", [GameIn, GameUpdateIn]) +def test_cover_hash_cannot_be_a_path_or_arbitrary_blob(schema): + for value in ("../cover.webp", "a" * 64 + ".html", "invalid"): + with pytest.raises(ValidationError): + schema(title="Example", cover_hash=value) + + +def test_cover_applied_only_when_saving_and_missing_blob_rejected(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path)) + get_settings.cache_clear() + cover_hash = "a" * 64 + ".webp" + db = SimpleNamespace(add=lambda _: None, commit=AsyncMock()) + user = SimpleNamespace(id=1) + game = SimpleNamespace(title="Example", cover_hash="old.webp") + monkeypatch.setattr(games, "get_game", AsyncMock(return_value=game)) + monkeypatch.setattr(games, "can_edit_game", lambda *_: True) + try: + with pytest.raises(games.HTTPException) as error: + asyncio.run(games.create_game(GameIn(title="Example", cover_hash=cover_hash), user, db)) + assert error.value.status_code == 422 + path = blob_path(cover_hash) + path.parent.mkdir(parents=True) + path.write_bytes(b"cover") + created = asyncio.run(games.create_game(GameIn(title="Example", cover_hash=cover_hash), user, db)) + assert created.cover_hash == cover_hash + asyncio.run(games.update_game(1, GameUpdateIn(title="Renamed"), user, db)) + assert game.cover_hash == "old.webp" + asyncio.run(games.update_game(1, GameUpdateIn(cover_hash=cover_hash), user, db)) + assert game.cover_hash == cover_hash + finally: + get_settings.cache_clear() diff --git a/docs/F95.md b/docs/F95.md new file mode 100644 index 0000000..eb2aca7 --- /dev/null +++ b/docs/F95.md @@ -0,0 +1,41 @@ +# Импорт данных и обложек F95zone + +Кнопка «Подтянуть» в формах создания и редактирования игры загружает название, +описание, теги и превью. Изображение сохраняется на сервере в WebP; обложка игры +меняется только после сохранения формы. Перед сохранением превью можно убрать. + +Cookie привязаны к текущему пользователю приложения. Если они отсутствуют, +истекли или F95zone запросил вход, открывается окно авторизации с возможностью +отказаться. Уже полученные название, описание и теги остаются в форме. +При отказе автоматических повторов нет; можно заполнить данные вручную. + +Чтобы добавить сессию: + +1. Войдите в свой аккаунт F95zone в браузере и откройте тему игры. +2. В инструментах разработчика, на вкладке Network, выберите запрос страницы + темы и скопируйте значение заголовка запроса `Cookie`. +3. В окне «Авторизация F95zone» вставьте значение в поле Cookie целиком, + включая `xf_user`, `xf_session`, `cf_clearance` и остальные cookie, если они есть. + При необходимости вставьте `User-Agent` из того же запроса в соседнее поле. +4. Нажмите «Сохранить и повторить». Импорт повторится с вашими cookie. + +Cookie используется для запроса темы и скачивания картинки. Сессия хранится +зашифрованной в записи вашего пользователя; другие пользователи её не используют. +Значения не возвращаются через API и не сохраняются в localStorage. +Кнопка «Забыть cookie F95zone» удаляет вашу сессию. При повторном отказе в доступе +сохранённая сессия сбрасывается и окно предлагает ввести новые cookie либо отказаться. +Успешный доступ к F95zone зависит также от защиты сайта; ответ 403 может означать +блокировку автоматических запросов, а не только истёкшую сессию. + +После обновления приложения примените миграции до `0005` обычным способом +(Docker-образ применяет их при запуске backend). Установите обновлённые зависимости +backend. Шифрование использует `SECRET_KEY`; при смене ключа пользователям +потребуется заново добавить cookie. Общие `F95_COOKIE` и `F95_USER_AGENT` из `.env` +больше не используются; перезапуск сервера для изменения личной сессии не требуется. + +Импорт выбирает `og:image`, а при его отсутствии — первую доступную `bbImage` +из первого сообщения вне скрытых блоков. Поддерживаются изображения на +`f95zone.to` и его поддоменах; внешние хостинги пропускаются. Скачивание ограничено +20 МиБ, переходы за пределы F95zone блокируются до отправки cookie. +Если изображение скачать не удалось, текстовые данные импортируются с сообщением +об ошибке превью; текущая обложка игры сохраняется. diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 538abe9..cc5f29c 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -363,6 +363,7 @@ function Report({ report }: { report: Record | null }) { } function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) { + const [coverHash, setCoverHash] = useState(null); const [title, setTitle] = useState(game.title); const [description, setDescription] = useState(game.description); const [tags, setTags] = useState(game.tags.join(", ")); @@ -371,6 +372,7 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool try { await api.patch(`/api/games/${game.id}`, { title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), + ...(coverHash ? { cover_hash: coverHash } : {}), }); onDone(true); } catch (e) { @@ -383,7 +385,13 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: bool setTitle(m.title); setDescription(m.description); setTags(m.tags.join(", ")); + setCoverHash(m.cover_hash); }} /> + {coverHash &&
+ Новая обложка из F95zone + Превью новой обложки + +
}