fix: simplify F95 cookie setup and constrain auth dialog layout
This commit is contained in:
1 parent
219005d16a
commit
ad622389bd
6 files changed
+169
-41
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
from pydantic import BaseModel, SecretStr
|
from pydantic import BaseModel, SecretStr
|
||||||
@@ -16,7 +18,10 @@ class F95In(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class F95SessionIn(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 = ""
|
user_agent: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +68,18 @@ def require_import(user: CurrentUser) -> None:
|
|||||||
@router.put("/f95/session", status_code=204)
|
@router.put("/f95/session", status_code=204)
|
||||||
async def save_f95_session(body: F95SessionIn, user: CurrentUser, db: DB) -> None:
|
async def save_f95_session(body: F95SessionIn, user: CurrentUser, db: DB) -> None:
|
||||||
require_import(user)
|
require_import(user)
|
||||||
|
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()
|
cookie = body.cookie.get_secret_value().strip()
|
||||||
if cookie.lower().startswith("cookie:"):
|
if cookie.lower().startswith("cookie:"):
|
||||||
cookie = cookie[7:].strip()
|
cookie = cookie[7:].strip()
|
||||||
|
|||||||
@@ -214,6 +214,33 @@ def test_invalid_session_not_saved_or_echoed(cookie):
|
|||||||
assert "xf_user" not in error.value.detail
|
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])
|
@pytest.mark.parametrize("schema", [GameIn, GameUpdateIn])
|
||||||
def test_cover_hash_cannot_be_a_path_or_arbitrary_blob(schema):
|
def test_cover_hash_cannot_be_a_path_or_arbitrary_blob(schema):
|
||||||
for value in ("../cover.webp", "a" * 64 + ".html", "invalid"):
|
for value in ("../cover.webp", "a" * 64 + ".html", "invalid"):
|
||||||
|
|||||||
+18
-5
@@ -12,13 +12,26 @@ Cookie привязаны к текущему пользователю прил
|
|||||||
Чтобы добавить сессию:
|
Чтобы добавить сессию:
|
||||||
|
|
||||||
1. Войдите в свой аккаунт F95zone в браузере и откройте тему игры.
|
1. Войдите в свой аккаунт F95zone в браузере и откройте тему игры.
|
||||||
2. В инструментах разработчика, на вкладке Network, выберите запрос страницы
|
2. Нажмите F12 → «Приложение» (Application) → «Хранилище» → «Файлы cookie» →
|
||||||
темы и скопируйте значение заголовка запроса `Cookie`.
|
`https://f95zone.to`.
|
||||||
3. В окне «Авторизация F95zone» вставьте значение в поле Cookie целиком,
|
3. Выберите строку `xf_user` и скопируйте только содержимое колонки «Значение»
|
||||||
включая `xf_user`, `xf_session`, `cf_clearance` и остальные cookie, если они есть.
|
в одноимённое поле окна авторизации. Если копируете из нижней панели Cookie Value,
|
||||||
При необходимости вставьте `User-Agent` из того же запроса в соседнее поле.
|
отключите «Показать декодированные параметры URL». При необходимости добавьте
|
||||||
|
`xf_session` таким же способом. Имена cookie, домен, путь и срок действия копировать не нужно.
|
||||||
4. Нажмите «Сохранить и повторить». Импорт повторится с вашими 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 используется для запроса темы и скачивания картинки. Сессия хранится
|
Cookie используется для запроса темы и скачивания картинки. Сессия хранится
|
||||||
зашифрованной в записи вашего пользователя; другие пользователи её не используют.
|
зашифрованной в записи вашего пользователя; другие пользователи её не используют.
|
||||||
Значения не возвращаются через API и не сохраняются в localStorage.
|
Значения не возвращаются через API и не сохраняются в localStorage.
|
||||||
|
|||||||
@@ -708,6 +708,18 @@ input[type="file"] { padding: 0.4rem; }
|
|||||||
animation: pop 0.15s ease-out;
|
animation: pop 0.15s ease-out;
|
||||||
}
|
}
|
||||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: var(--space-2); }
|
.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 {
|
.toasts {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
|
||||||
@@ -13,6 +13,9 @@ export interface F95Meta {
|
|||||||
metadata_available: boolean;
|
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. */
|
/** Imports editable metadata and a locally cached cover from an F95zone thread. */
|
||||||
export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
@@ -20,12 +23,12 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [warning, setWarning] = useState("");
|
const [warning, setWarning] = useState("");
|
||||||
const [needsAuth, setNeedsAuth] = useState(false);
|
const [needsAuth, setNeedsAuth] = useState(false);
|
||||||
const [cookie, setCookie] = useState("");
|
const [cookies, setCookies] = useState<CookieValues>(EMPTY_COOKIES);
|
||||||
const [agent, setAgent] = useState("");
|
const [agent, setAgent] = useState("");
|
||||||
|
|
||||||
const decline = () => {
|
const decline = () => {
|
||||||
setNeedsAuth(false);
|
setNeedsAuth(false);
|
||||||
setCookie("");
|
setCookies(EMPTY_COOKIES);
|
||||||
setAgent("");
|
setAgent("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,8 +52,8 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await api.put("/api/metadata/f95/session", { cookie, user_agent: agent });
|
await api.put("/api/metadata/f95/session", { ...cookies, user_agent: agent });
|
||||||
setCookie("");
|
setCookies(EMPTY_COOKIES);
|
||||||
if (url.trim()) await load();
|
if (url.trim()) await load();
|
||||||
else decline();
|
else decline();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -92,38 +95,43 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
|||||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setNeedsAuth(true)}>Cookie F95zone</button>
|
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setNeedsAuth(true)}>Cookie F95zone</button>
|
||||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => void forget()}>Забыть cookie F95zone</button>
|
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => void forget()}>Забыть cookie F95zone</button>
|
||||||
</div>
|
</div>
|
||||||
{needsAuth && createPortal(<F95SessionDialog busy={busy} cookie={cookie} agent={agent}
|
{needsAuth && createPortal(<F95SessionDialog busy={busy} cookies={cookies} agent={agent}
|
||||||
setCookie={setCookie} setAgent={setAgent} error={error} warning={warning}
|
setCookies={setCookies} setAgent={setAgent} error={error} warning={warning}
|
||||||
onDecline={decline} onSave={() => void saveSession()} />, document.body)}
|
onDecline={decline} onSave={() => void saveSession()} />, document.body)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function F95SessionDialog({ busy, cookie, agent, setCookie, setAgent, error, warning, onDecline, onSave }: {
|
function F95SessionDialog({ busy, cookies, agent, setCookies, setAgent, error, warning, onDecline, onSave }: {
|
||||||
busy: boolean; cookie: string; agent: string; error: string; warning: string;
|
busy: boolean; cookies: CookieValues; agent: string; error: string; warning: string;
|
||||||
setCookie: (value: string) => void; setAgent: (value: string) => void;
|
setCookies: (value: CookieValues) => void; setAgent: (value: string) => void;
|
||||||
onDecline: () => void; onSave: () => void;
|
onDecline: () => void; onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
const dialog = useRef<HTMLDivElement>(null);
|
const dialog = useRef<HTMLDivElement>(null);
|
||||||
const input = useRef<HTMLInputElement>(null);
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const userLabel = useId();
|
||||||
|
const canSave = !!(cookies.xf_user.trim() || cookies.xf_session.trim());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
const overflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
input.current?.focus();
|
input.current?.focus();
|
||||||
return () => previous?.focus();
|
return () => { document.body.style.overflow = overflow; previous?.focus(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <div className="modal-backdrop" onMouseDown={(e) => {
|
return <div className="modal-backdrop" onMouseDown={(e) => {
|
||||||
if (!busy && e.target === e.currentTarget) onDecline();
|
if (!busy && e.target === e.currentTarget) onDecline();
|
||||||
}}>
|
}}>
|
||||||
<div ref={dialog} className="modal" role="dialog" aria-modal="true" aria-label="Авторизация F95zone"
|
<div ref={dialog} className="modal f95-modal" role="dialog" aria-modal="true" aria-label="Авторизация F95zone"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape" && !busy) { e.stopPropagation(); onDecline(); }
|
if (e.key === "Escape" && !busy) { e.stopPropagation(); onDecline(); }
|
||||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault(); e.stopPropagation();
|
||||||
if (!busy && cookie.trim()) onSave();
|
if (!busy && canSave) onSave();
|
||||||
}
|
}
|
||||||
if (e.key === "Tab") {
|
if (e.key === "Tab") {
|
||||||
const controls = dialog.current?.querySelectorAll<HTMLElement>("input:not(:disabled), button:not(:disabled), summary");
|
const controls = Array.from(dialog.current?.querySelectorAll<HTMLElement>("input:not(:disabled), button:not(:disabled), summary") ?? [])
|
||||||
|
.filter((element) => element.getClientRects().length > 0);
|
||||||
if (!controls?.length) return;
|
if (!controls?.length) return;
|
||||||
const first = controls[0], last = controls[controls.length - 1];
|
const first = controls[0], last = controls[controls.length - 1];
|
||||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||||
@@ -131,28 +139,50 @@ function F95SessionDialog({ busy, cookie, agent, setCookie, setAgent, error, war
|
|||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<h2>Авторизация F95zone</h2>
|
<h2>Авторизация F95zone</h2>
|
||||||
<p className="muted">Для превью может потребоваться вход. Если cookie отсутствуют или истекли, вставьте новые.
|
<p className="muted small">Добавьте cookie своего аккаунта для загрузки превью.
|
||||||
Можно отказаться и заполнить данные игры вручную; уже загруженный текст останется в форме.</p>
|
Можно пропустить: уже загруженные данные останутся в форме.</p>
|
||||||
<details>
|
<details>
|
||||||
<summary>Где взять cookie</summary>
|
<summary>Где взять cookie</summary>
|
||||||
<p className="small">Войдите на f95zone.to, откройте тему игры и инструменты разработчика → Network.
|
<ol className="small">
|
||||||
Выберите запрос страницы темы → Request Headers и скопируйте значение Cookie целиком
|
<li>Войдите на f95zone.to. Нажмите F12 → «Приложение» (Application).</li>
|
||||||
(включая xf_user, xf_session и cf_clearance, если они есть).
|
<li>Слева откройте «Хранилище → Файлы cookie → https://f95zone.to».</li>
|
||||||
User-Agent можно скопировать из того же запроса.</p>
|
<li>Выберите строку <b>xf_user</b> и скопируйте только её «Значение» в поле ниже.
|
||||||
|
При необходимости так же скопируйте <b>xf_session</b>.</li>
|
||||||
|
</ol>
|
||||||
|
<p className="hint">Если копируете из нижней панели Cookie Value, отключите «Показать декодированные параметры URL».
|
||||||
|
TSVB_UID и xf_csrf для импорта не нужны.</p>
|
||||||
</details>
|
</details>
|
||||||
<label className="field"><span>Cookie</span>
|
<label className="field"><span id={userLabel}>xf_user</span>
|
||||||
<input ref={input} type="password" autoComplete="off" spellCheck={false} maxLength={16384}
|
<input ref={input} type="password" autoComplete="off" spellCheck={false} maxLength={8192}
|
||||||
value={cookie} onChange={(e) => setCookie(e.target.value)} disabled={busy} placeholder="xf_user=…; xf_session=…; cf_clearance=…" />
|
aria-labelledby={userLabel} aria-describedby={`${userLabel}-hint`}
|
||||||
|
value={cookies.xf_user} onChange={(e) => setCookies({ ...cookies, xf_user: e.target.value })}
|
||||||
|
disabled={busy} placeholder="Только значение xf_user" />
|
||||||
|
<span id={`${userLabel}-hint`} className="hint">Начните с этой cookie. Если её нет, используйте xf_session.</span>
|
||||||
|
</label>
|
||||||
|
<label className="field"><span>xf_session (необязательно)</span>
|
||||||
|
<input type="password" autoComplete="off" spellCheck={false} maxLength={8192}
|
||||||
|
value={cookies.xf_session} onChange={(e) => setCookies({ ...cookies, xf_session: e.target.value })}
|
||||||
|
disabled={busy} placeholder="Только значение xf_session" />
|
||||||
|
</label>
|
||||||
|
<details>
|
||||||
|
<summary>Если F95zone блокирует запрос</summary>
|
||||||
|
<p className="hint">При проверке Cloudflare можно добавить cf_clearance из той же таблицы.
|
||||||
|
Cookie браузера может не подойти серверу; повторный отказ не всегда означает истёкшую сессию.</p>
|
||||||
|
<label className="field"><span>cf_clearance (необязательно)</span>
|
||||||
|
<input type="password" value={cookies.cf_clearance} disabled={busy} maxLength={8192} autoComplete="off" spellCheck={false}
|
||||||
|
onChange={(e) => setCookies({ ...cookies, cf_clearance: e.target.value })} placeholder="Только значение cf_clearance" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field"><span>User-Agent (необязательно)</span>
|
<label className="field"><span>User-Agent (необязательно)</span>
|
||||||
<input value={agent} onChange={(e) => setAgent(e.target.value)} disabled={busy} maxLength={2048} autoComplete="off" />
|
<input value={agent} onChange={(e) => setAgent(e.target.value)} disabled={busy} maxLength={2048} autoComplete="off" />
|
||||||
|
<span className="hint">Его можно скопировать в Network → запрос страницы F95zone → Request Headers → User-Agent.</span>
|
||||||
</label>
|
</label>
|
||||||
|
</details>
|
||||||
<p className="hint">Cookie сохраняются зашифрованными только в вашем профиле и используются сервером для запросов F95zone.</p>
|
<p className="hint">Cookie сохраняются зашифрованными только в вашем профиле и используются сервером для запросов F95zone.</p>
|
||||||
{warning && <p className="hint" role="status">{warning}</p>}
|
{warning && <p className="hint" role="status">{warning}</p>}
|
||||||
{error && <p className="hint" role="alert">{error}</p>}
|
{error && <p className="hint" role="alert">{error}</p>}
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button type="button" className="btn" disabled={busy} onClick={onDecline}>Продолжить без авторизации</button>
|
<button type="button" className="btn" disabled={busy} onClick={onDecline}>Пропустить</button>
|
||||||
<button type="button" className="btn btn-primary" disabled={busy || !cookie.trim()} onClick={onSave}>
|
<button type="button" className="btn btn-primary" disabled={busy || !canSave} onClick={onSave}>
|
||||||
{busy ? "Проверка…" : "Сохранить и повторить"}
|
{busy ? "Проверка…" : "Сохранить и повторить"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -21,7 +22,9 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
|
|||||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
||||||
let imports = 0, deleted = 0;
|
let imports = 0, deleted = 0;
|
||||||
const saved = [];
|
const saved = [];
|
||||||
|
const css = readFileSync(new URL("../src/styles.css", import.meta.url), "utf8");
|
||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
|
if (req.url === "/styles.css") { res.setHeader("Content-Type", "text/css"); res.end(css); return; }
|
||||||
if (req.url === "/bundle.js") {
|
if (req.url === "/bundle.js") {
|
||||||
res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return;
|
res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return;
|
||||||
}
|
}
|
||||||
@@ -43,20 +46,44 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
|
|||||||
metadata_available: imports !== 5})); return;
|
metadata_available: imports !== 5})); return;
|
||||||
}
|
}
|
||||||
res.setHeader("Content-Type", "text/html");
|
res.setHeader("Content-Type", "text/html");
|
||||||
res.end('<!doctype html><main></main><script src="/bundle.js"></script>');
|
res.end('<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><link rel="stylesheet" href="/styles.css"><main></main><script src="/bundle.js"></script>');
|
||||||
});
|
});
|
||||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||||
let browser;
|
let browser;
|
||||||
try {
|
try {
|
||||||
browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE});
|
browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE});
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage({colorScheme: "dark"});
|
||||||
|
page.setDefaultTimeout(10000);
|
||||||
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
await page.goto(`http://127.0.0.1:${server.address().port}`);
|
||||||
await page.getByPlaceholder("https://f95zone.to/threads/…").fill("https://f95zone.to/threads/example.123/");
|
await page.getByPlaceholder("https://f95zone.to/threads/…").fill("https://f95zone.to/threads/example.123/");
|
||||||
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
await dialog.waitFor();
|
await dialog.waitFor();
|
||||||
assert.equal(await page.locator("output").textContent(), "Imported title");
|
assert.equal(await page.locator("output").textContent(), "Imported title");
|
||||||
await dialog.getByRole("button", {name: "Продолжить без авторизации"}).press("Enter");
|
if (process.env.PGAMES_F95_SCREENSHOT_DIR) {
|
||||||
|
await page.setViewportSize({width: 676, height: 743});
|
||||||
|
await page.screenshot({path: `${process.env.PGAMES_F95_SCREENSHOT_DIR}/f95-collapsed.png`});
|
||||||
|
}
|
||||||
|
// Exercise the actual stylesheet, expanded help and the overflow shown in the report.
|
||||||
|
await dialog.getByText("Где взять cookie", {exact: true}).click();
|
||||||
|
await dialog.getByText("Если F95zone блокирует запрос", {exact: true}).click();
|
||||||
|
for (const viewport of [{width: 676, height: 743}, {width: 320, height: 568}]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
const layout = await dialog.evaluate(element => {
|
||||||
|
const box = element.getBoundingClientRect();
|
||||||
|
const buttons = [...element.querySelectorAll('.modal-actions button')].map(button => button.getBoundingClientRect());
|
||||||
|
return {insideViewport: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
|
||||||
|
noHorizontalOverflow: element.scrollWidth <= element.clientWidth + 1,
|
||||||
|
buttonsInside: buttons.every(button => button.left >= box.left && button.right <= box.right)};
|
||||||
|
});
|
||||||
|
assert.deepEqual(layout, {insideViewport: true, noHorizontalOverflow: true, buttonsInside: true});
|
||||||
|
await dialog.getByRole("button", {name: "Пропустить", exact: true}).scrollIntoViewIfNeeded();
|
||||||
|
if (process.env.PGAMES_F95_SCREENSHOT_DIR) {
|
||||||
|
await page.screenshot({path: `${process.env.PGAMES_F95_SCREENSHOT_DIR}/f95-${viewport.width}.png`});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await page.setViewportSize({width: 1000, height: 900});
|
||||||
|
await dialog.getByRole("button", {name: "Пропустить", exact: true}).press("Enter");
|
||||||
await dialog.waitFor({state: "detached"});
|
await dialog.waitFor({state: "detached"});
|
||||||
assert.equal(imports, 1);
|
assert.equal(imports, 1);
|
||||||
assert.equal(saved.length, 0);
|
assert.equal(saved.length, 0);
|
||||||
@@ -64,17 +91,19 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
|
|||||||
|
|
||||||
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
||||||
await dialog.waitFor();
|
await dialog.waitFor();
|
||||||
await dialog.getByLabel("Cookie", {exact: true}).fill("xf_user=expired");
|
await dialog.getByLabel("xf_user", {exact: true}).fill("expired");
|
||||||
await dialog.getByRole("button", {name: "Сохранить и повторить"}).click();
|
await dialog.getByRole("button", {name: "Сохранить и повторить"}).click();
|
||||||
await page.waitForFunction(() => window.loaded.length === 3);
|
await page.waitForFunction(() => window.loaded.length === 3);
|
||||||
assert.equal(await dialog.count(), 1, "expired cookies keep the replacement dialog open");
|
assert.equal(await dialog.count(), 1, "expired cookies keep the replacement dialog open");
|
||||||
assert.equal(await dialog.getByLabel("Cookie", {exact: true}).inputValue(), "");
|
assert.equal(await dialog.getByLabel("xf_user", {exact: true}).inputValue(), "");
|
||||||
await dialog.getByLabel("Cookie", {exact: true}).fill("xf_user=fresh; xf_session=example");
|
await dialog.getByLabel("xf_user", {exact: true}).fill("fresh");
|
||||||
|
await dialog.getByLabel("xf_session (необязательно)").fill("example");
|
||||||
|
await dialog.getByText("Если F95zone блокирует запрос", {exact: true}).click();
|
||||||
await dialog.getByLabel("User-Agent (необязательно)").fill("Test browser");
|
await dialog.getByLabel("User-Agent (необязательно)").fill("Test browser");
|
||||||
await dialog.getByLabel("Cookie", {exact: true}).press("Enter");
|
await dialog.getByLabel("xf_user", {exact: true}).press("Enter");
|
||||||
await dialog.waitFor({state: "detached"});
|
await dialog.waitFor({state: "detached"});
|
||||||
assert.deepEqual(saved, [{cookie: "xf_user=expired", user_agent: ""},
|
assert.deepEqual(saved, [{xf_user: "expired", xf_session: "", cf_clearance: "", user_agent: ""},
|
||||||
{cookie: "xf_user=fresh; xf_session=example", user_agent: "Test browser"}]);
|
{xf_user: "fresh", xf_session: "example", cf_clearance: "", user_agent: "Test browser"}]);
|
||||||
assert.equal(await page.evaluate(() => window.loaded.at(-1).cover_hash), "cover.webp");
|
assert.equal(await page.evaluate(() => window.loaded.at(-1).cover_hash), "cover.webp");
|
||||||
assert.equal(await page.evaluate(() => window.submits), 0);
|
assert.equal(await page.evaluate(() => window.submits), 0);
|
||||||
assert.equal(await page.evaluate(() => localStorage.length), 0);
|
assert.equal(await page.evaluate(() => localStorage.length), 0);
|
||||||
@@ -87,7 +116,7 @@ test("F95 personal cookies: decline, expired session, retry, delete and no paren
|
|||||||
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
||||||
await dialog.waitFor();
|
await dialog.waitFor();
|
||||||
assert.equal(await page.locator("output").textContent(), "Manual title", "login-only response cannot erase manual fields");
|
assert.equal(await page.locator("output").textContent(), "Manual title", "login-only response cannot erase manual fields");
|
||||||
await dialog.getByLabel("Cookie", {exact: true}).press("Escape");
|
await dialog.getByLabel("xf_user", {exact: true}).press("Escape");
|
||||||
await dialog.waitFor({state: "detached"});
|
await dialog.waitFor({state: "detached"});
|
||||||
assert.equal(imports, 5);
|
assert.equal(imports, 5);
|
||||||
assert.equal(saved.length, 2);
|
assert.equal(saved.length, 2);
|
||||||
|
|||||||
Reference in new issue
Block a user