diff --git a/backend/app/routers/metadata.py b/backend/app/routers/metadata.py index 505c631..10de0f1 100644 --- a/backend/app/routers/metadata.py +++ b/backend/app/routers/metadata.py @@ -1,3 +1,5 @@ +from urllib.parse import quote + from fastapi import APIRouter, HTTPException, status from PIL import Image, UnidentifiedImageError from pydantic import BaseModel, SecretStr @@ -16,7 +18,10 @@ class F95In(BaseModel): class F95SessionIn(BaseModel): - cookie: SecretStr + cookie: SecretStr = SecretStr("") # Compatibility with previously saved clients. + xf_user: SecretStr = SecretStr("") + xf_session: SecretStr = SecretStr("") + cf_clearance: SecretStr = SecretStr("") user_agent: str = "" @@ -63,7 +68,19 @@ def require_import(user: CurrentUser) -> None: @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() + values = {key: getattr(body, key).get_secret_value().strip() + for key in ("xf_user", "xf_session", "cf_clearance")} + if any(values.values()): + if not (values["xf_user"] or values["xf_session"]): + raise HTTPException(422, "Добавьте значение xf_user или xf_session для входа в F95zone") + if any(len(value) > 8192 or any(ord(c) < 33 or ord(c) > 126 or c == ";" for c in value) + for value in values.values()): + raise HTTPException(422, "Скопируйте только значение cookie, без имени и дополнительных строк") + # DevTools can display decoded values. Preserve existing escapes and encode delimiters. + cookie = "; ".join(f"{key}={quote(value, safe='%') if key.startswith('xf_') else value}" + for key, value in values.items() if value) + else: + cookie = body.cookie.get_secret_value().strip() if cookie.lower().startswith("cookie:"): cookie = cookie[7:].strip() agent = body.user_agent.strip() diff --git a/backend/tests/test_f95.py b/backend/tests/test_f95.py index e47bbef..fbe688d 100644 --- a/backend/tests/test_f95.py +++ b/backend/tests/test_f95.py @@ -214,6 +214,33 @@ def test_invalid_session_not_saved_or_echoed(cookie): assert "xf_user" not in error.value.detail +@pytest.mark.parametrize(("values", "expected"), [ + ({"xf_user": "123,remember"}, "xf_user=123%2Cremember"), + ({"xf_user": "123%2Cremember"}, "xf_user=123%2Cremember"), + ({"xf_session": "session-token"}, "xf_session=session-token"), + ({"xf_user": "remember", "xf_session": "session", "cf_clearance": "cf.signature=="}, + "xf_user=remember; xf_session=session; cf_clearance=cf.signature=="), +]) +def test_individual_cookie_values_preserve_encoded_and_decoded_devtools_values(values, expected): + user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None) + db = SimpleNamespace(commit=AsyncMock()) + asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(**values), user, db)) + assert open_session(1, user.f95_session) == (expected, "") + + +@pytest.mark.parametrize("values", [ + {"cf_clearance": "only-cloudflare"}, + {"xf_user": "one; xf_session=two"}, + {"xf_user": "one\nsecond-line"}, +]) +def test_individual_cookie_fields_reject_missing_login_and_multiple_values(values): + user = SimpleNamespace(id=1, permissions=["games.upload"], f95_session=None) + db = SimpleNamespace(commit=AsyncMock()) + with pytest.raises(metadata.HTTPException): + asyncio.run(metadata.save_f95_session(metadata.F95SessionIn(**values), user, db)) + assert user.f95_session is None + + @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"): diff --git a/docs/F95.md b/docs/F95.md index eb2aca7..2a7010e 100644 --- a/docs/F95.md +++ b/docs/F95.md @@ -12,13 +12,26 @@ Cookie привязаны к текущему пользователю прил Чтобы добавить сессию: 1. Войдите в свой аккаунт F95zone в браузере и откройте тему игры. -2. В инструментах разработчика, на вкладке Network, выберите запрос страницы - темы и скопируйте значение заголовка запроса `Cookie`. -3. В окне «Авторизация F95zone» вставьте значение в поле Cookie целиком, - включая `xf_user`, `xf_session`, `cf_clearance` и остальные cookie, если они есть. - При необходимости вставьте `User-Agent` из того же запроса в соседнее поле. +2. Нажмите F12 → «Приложение» (Application) → «Хранилище» → «Файлы cookie» → + `https://f95zone.to`. +3. Выберите строку `xf_user` и скопируйте только содержимое колонки «Значение» + в одноимённое поле окна авторизации. Если копируете из нижней панели Cookie Value, + отключите «Показать декодированные параметры URL». При необходимости добавьте + `xf_session` таким же способом. Имена cookie, домен, путь и срок действия копировать не нужно. 4. Нажмите «Сохранить и повторить». Импорт повторится с вашими cookie. +`xf_user` — токен «запомнить меня», `xf_session` — идентификатор текущей сессии. +В XenForo вход может подтверждаться одним из них; начните с `xf_user`, либо используйте +`xf_session`, если `xf_user` отсутствует. Это не гарантирует доступ через защиту F95zone. +См. [пояснение разработчика XenForo](https://xenforo.com/community/threads/authentication-from-a-node-application.189858/). +`xf_csrf` и `TSVB_UID` для этого импорта не запрашиваются. + +Если сайт блокирует запрос, откройте «Если F95zone блокирует запрос» и добавьте +`cf_clearance` из той же таблицы. Необязательный User-Agent находится в Network → +запрос страницы темы → Request Headers → User-Agent. +`cf_clearance` привязан к посетителю и устройству; перенос cookie из браузера на сервер +может не сработать ([документация Cloudflare](https://developers.cloudflare.com/cloudflare-challenges/concepts/clearance/)). + Cookie используется для запроса темы и скачивания картинки. Сессия хранится зашифрованной в записи вашего пользователя; другие пользователи её не используют. Значения не возвращаются через API и не сохраняются в localStorage. diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 53eb3ae..84d0d9d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -708,6 +708,18 @@ input[type="file"] { padding: 0.4rem; } animation: pop 0.15s ease-out; } .modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: var(--space-2); } +.f95-modal { + width: min(520px, 100%); + min-width: 0; + max-height: calc(100dvh - 2 * var(--space-4)); + overflow-y: auto; + overflow-wrap: anywhere; +} +.f95-modal > * { flex-shrink: 0; min-width: 0; } +.f95-modal .modal-actions { flex-wrap: wrap; } +.f95-modal .modal-actions .btn { flex: 1 1 180px; min-width: 0; white-space: normal; } +.f95-modal details .field { margin-top: var(--space-3); } +.f95-modal ol { padding-left: 1.25rem; margin-block: var(--space-2); } .toasts { position: fixed; diff --git a/frontend/src/ui/F95Fetch.tsx b/frontend/src/ui/F95Fetch.tsx index 327b8c1..afa936a 100644 --- a/frontend/src/ui/F95Fetch.tsx +++ b/frontend/src/ui/F95Fetch.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { api } from "../api"; @@ -13,6 +13,9 @@ export interface F95Meta { metadata_available: boolean; } +interface CookieValues { xf_user: string; xf_session: string; cf_clearance: string } +const EMPTY_COOKIES: CookieValues = { xf_user: "", xf_session: "", cf_clearance: "" }; + /** Imports editable metadata and a locally cached cover from an F95zone thread. */ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { const [url, setUrl] = useState(""); @@ -20,12 +23,12 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { const [error, setError] = useState(""); const [warning, setWarning] = useState(""); const [needsAuth, setNeedsAuth] = useState(false); - const [cookie, setCookie] = useState(""); + const [cookies, setCookies] = useState(EMPTY_COOKIES); const [agent, setAgent] = useState(""); const decline = () => { setNeedsAuth(false); - setCookie(""); + setCookies(EMPTY_COOKIES); setAgent(""); }; @@ -49,8 +52,8 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { setBusy(true); setError(""); try { - await api.put("/api/metadata/f95/session", { cookie, user_agent: agent }); - setCookie(""); + await api.put("/api/metadata/f95/session", { ...cookies, user_agent: agent }); + setCookies(EMPTY_COOKIES); if (url.trim()) await load(); else decline(); } catch (e) { @@ -92,38 +95,43 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) { - {needsAuth && createPortal( void saveSession()} />, document.body)} ); } -function F95SessionDialog({ busy, cookie, agent, setCookie, setAgent, error, warning, onDecline, onSave }: { - busy: boolean; cookie: string; agent: string; error: string; warning: string; - setCookie: (value: string) => void; setAgent: (value: string) => void; +function F95SessionDialog({ busy, cookies, agent, setCookies, setAgent, error, warning, onDecline, onSave }: { + busy: boolean; cookies: CookieValues; agent: string; error: string; warning: string; + setCookies: (value: CookieValues) => void; setAgent: (value: string) => void; onDecline: () => void; onSave: () => void; }) { const dialog = useRef(null); const input = useRef(null); + const userLabel = useId(); + const canSave = !!(cookies.xf_user.trim() || cookies.xf_session.trim()); useEffect(() => { const previous = document.activeElement as HTMLElement | null; + const overflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; input.current?.focus(); - return () => previous?.focus(); + return () => { document.body.style.overflow = overflow; previous?.focus(); }; }, []); return
{ if (!busy && e.target === e.currentTarget) onDecline(); }}> -
{ if (e.key === "Escape" && !busy) { e.stopPropagation(); onDecline(); } if (e.key === "Enter" && e.target instanceof HTMLInputElement) { e.preventDefault(); e.stopPropagation(); - if (!busy && cookie.trim()) onSave(); + if (!busy && canSave) onSave(); } if (e.key === "Tab") { - const controls = dialog.current?.querySelectorAll("input:not(:disabled), button:not(:disabled), summary"); + const controls = Array.from(dialog.current?.querySelectorAll("input:not(:disabled), button:not(:disabled), summary") ?? []) + .filter((element) => element.getClientRects().length > 0); if (!controls?.length) return; const first = controls[0], last = controls[controls.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } @@ -131,28 +139,50 @@ function F95SessionDialog({ busy, cookie, agent, setCookie, setAgent, error, war } }}>

Авторизация F95zone

-

Для превью может потребоваться вход. Если cookie отсутствуют или истекли, вставьте новые. - Можно отказаться и заполнить данные игры вручную; уже загруженный текст останется в форме.

+

Добавьте cookie своего аккаунта для загрузки превью. + Можно пропустить: уже загруженные данные останутся в форме.

Где взять cookie -

Войдите на f95zone.to, откройте тему игры и инструменты разработчика → Network. - Выберите запрос страницы темы → Request Headers и скопируйте значение Cookie целиком - (включая xf_user, xf_session и cf_clearance, если они есть). - User-Agent можно скопировать из того же запроса.

+
    +
  1. Войдите на f95zone.to. Нажмите F12 → «Приложение» (Application).
  2. +
  3. Слева откройте «Хранилище → Файлы cookie → https://f95zone.to».
  4. +
  5. Выберите строку xf_user и скопируйте только её «Значение» в поле ниже. + При необходимости так же скопируйте xf_session.
  6. +
+

Если копируете из нижней панели Cookie Value, отключите «Показать декодированные параметры URL». + TSVB_UID и xf_csrf для импорта не нужны.

-