feat: import F95 covers using personal encrypted sessions
CI / frontend (push) Failing after 16s
CI / backend (push) Failing after 36s

This commit is contained in:
mrleo1nid committed 2026-10-26 15:39:41 +03:00
1 parent 3f51f06fec
commit 219005d16a
16 files changed
+763 -26

No files matched your search

+103 -10
View File
@@ -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}
+29
View File
@@ -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 "", ""
+1
View File
@@ -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")
+8 -1
View File
@@ -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:
+75 -9
View File
@@ -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))
+2
View File
@@ -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):
@@ -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")
+1
View File
@@ -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
+247
View File
@@ -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'<html data-template="thread_view"><head><meta property="og:title" '
f'content="VN - Example [v1] [Author]">{meta}</head><body>'
f'<div class="bbWrapper">{post}<br>Overview: A story<br>Download: link</div>'
f'<div class="bbWrapper">{reply}</div></body></html>')
def test_og_preview_and_existing_metadata():
result = f95.parse(page(f'<meta property="og:image" content="{IMAGE}">',
'<img class="bbImage" src="/attachments/other.jpg">'))
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('<meta property="og:image" content="/styles/logo.png">',
'<img class="avatar" src="/avatar.jpg">'
'<div class="bbCodeSpoiler"><img class="bbImage" src="/hidden.jpg"></div>'
'<img class="bbImage" src="data:image/gif;base64,AA" '
'data-src="//attachments.f95zone.to/preview.jpg">',
'<img class="bbImage" src="/reply.jpg">')
assert f95.parse(html)["image_url"] == IMAGE
assert f95.parse(page(reply='<img class="bbImage" src="/reply.jpg">'))["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'<meta property="og:image" content="{IMAGE}">')), "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('<html data-template="login"></html>')
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'<meta property="og:image" content="{IMAGE}">')), "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()