feat: import F95 covers using personal encrypted sessions
This commit is contained in:
1 parent
3f51f06fec
commit
219005d16a
16 files changed
+763
-26
No files matched your search
@@ -363,6 +363,7 @@ function Report({ report }: { report: Record<string, any> | null }) {
|
||||
}
|
||||
|
||||
function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) {
|
||||
const [coverHash, setCoverHash] = useState<string | null>(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 && <div className="field">
|
||||
<span>Новая обложка из F95zone</span>
|
||||
<img src={`/blobs/${coverHash}`} alt="Превью новой обложки" style={{ maxWidth: 320, maxHeight: 200, objectFit: "contain" }} />
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setCoverHash(null)}>Оставить текущую обложку</button>
|
||||
</div>}
|
||||
<label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
|
||||
<label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
|
||||
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
|
||||
|
||||
@@ -13,6 +13,7 @@ export function NewGame() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [coverHash, setCoverHash] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [over, setOver] = useState(false);
|
||||
const { start } = useUploads();
|
||||
@@ -37,11 +38,12 @@ export function NewGame() {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setError("");
|
||||
start({ file, title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
|
||||
start({ file, title, description, coverHash, tags: tags.split(",").map((t) => t.trim()).filter(Boolean) });
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setTags("");
|
||||
setCoverHash(null);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -73,7 +75,13 @@ export function NewGame() {
|
||||
setTitle(m.title);
|
||||
setDescription(m.description);
|
||||
setTags(m.tags.join(", "));
|
||||
setCoverHash(m.cover_hash);
|
||||
}} />
|
||||
{coverHash && <div className="field">
|
||||
<span>Обложка из F95zone</span>
|
||||
<img src={`/blobs/${coverHash}`} alt="Превью обложки" style={{ maxWidth: 320, maxHeight: 200, objectFit: "contain" }} />
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setCoverHash(null)}>Убрать превью</button>
|
||||
</div>}
|
||||
<div className="form-row">
|
||||
<label className="field">
|
||||
<span>Название</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "../api";
|
||||
|
||||
export interface F95Meta {
|
||||
@@ -6,19 +7,66 @@ export interface F95Meta {
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
cover_hash: string | null;
|
||||
cover_warning: string | null;
|
||||
auth_required: boolean;
|
||||
metadata_available: boolean;
|
||||
}
|
||||
|
||||
/** Link to an F95zone thread + a button that pulls the title, description and tags from it. */
|
||||
/** Imports editable metadata and a locally cached cover from an F95zone thread. */
|
||||
export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [warning, setWarning] = useState("");
|
||||
const [needsAuth, setNeedsAuth] = useState(false);
|
||||
const [cookie, setCookie] = useState("");
|
||||
const [agent, setAgent] = useState("");
|
||||
|
||||
const decline = () => {
|
||||
setNeedsAuth(false);
|
||||
setCookie("");
|
||||
setAgent("");
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setWarning("");
|
||||
try {
|
||||
onLoaded(await api.post<F95Meta>("/api/metadata/f95", { url }));
|
||||
const metadata = await api.post<F95Meta>("/api/metadata/f95", { url });
|
||||
if (metadata.metadata_available) onLoaded(metadata);
|
||||
setWarning(metadata.cover_warning ?? "");
|
||||
setNeedsAuth(metadata.auth_required);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSession = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.put("/api/metadata/f95/session", { cookie, user_agent: agent });
|
||||
setCookie("");
|
||||
if (url.trim()) await load();
|
||||
else decline();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const forget = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.del("/api/metadata/f95/session");
|
||||
decline();
|
||||
setWarning("Ваши сохранённые cookie F95zone удалены.");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
@@ -30,15 +78,84 @@ export function F95Fetch({ onLoaded }: { onLoaded: (m: F95Meta) => void }) {
|
||||
<div className="field">
|
||||
<span>Ссылка на тему F95zone</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input value={url} onChange={(e) => setUrl(e.target.value)} style={{ flex: 1 }}
|
||||
<input value={url} disabled={busy} onChange={(e) => setUrl(e.target.value)} style={{ flex: 1 }}
|
||||
placeholder="https://f95zone.to/threads/…"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (url.trim() && !busy) void load(); } }} />
|
||||
<button type="button" className="btn" disabled={!url.trim() || busy} onClick={() => void load()}>
|
||||
{busy ? "Загрузка…" : "Подтянуть"}
|
||||
</button>
|
||||
</div>
|
||||
<span className="hint">Заполнит название, описание и теги. Результат можно поправить перед сохранением.</span>
|
||||
<span className="hint">Загрузит название, описание, теги и превью для обложки. Результат можно поправить перед сохранением.</span>
|
||||
{warning && <span className="hint" role="status">{warning}</span>}
|
||||
{error && <span className="hint" style={{ color: "var(--danger, #e5484d)" }}>{error}</span>}
|
||||
<div className="actions">
|
||||
<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>
|
||||
</div>
|
||||
{needsAuth && createPortal(<F95SessionDialog busy={busy} cookie={cookie} agent={agent}
|
||||
setCookie={setCookie} setAgent={setAgent} error={error} warning={warning}
|
||||
onDecline={decline} onSave={() => void saveSession()} />, document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
onDecline: () => void; onSave: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDivElement>(null);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
input.current?.focus();
|
||||
return () => previous?.focus();
|
||||
}, []);
|
||||
|
||||
return <div className="modal-backdrop" onMouseDown={(e) => {
|
||||
if (!busy && e.target === e.currentTarget) onDecline();
|
||||
}}>
|
||||
<div ref={dialog} className="modal" role="dialog" aria-modal="true" aria-label="Авторизация F95zone"
|
||||
onKeyDown={(e) => {
|
||||
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 (e.key === "Tab") {
|
||||
const controls = dialog.current?.querySelectorAll<HTMLElement>("input:not(:disabled), button:not(:disabled), summary");
|
||||
if (!controls?.length) return;
|
||||
const first = controls[0], last = controls[controls.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
}}>
|
||||
<h2>Авторизация F95zone</h2>
|
||||
<p className="muted">Для превью может потребоваться вход. Если cookie отсутствуют или истекли, вставьте новые.
|
||||
Можно отказаться и заполнить данные игры вручную; уже загруженный текст останется в форме.</p>
|
||||
<details>
|
||||
<summary>Где взять cookie</summary>
|
||||
<p className="small">Войдите на f95zone.to, откройте тему игры и инструменты разработчика → Network.
|
||||
Выберите запрос страницы темы → Request Headers и скопируйте значение Cookie целиком
|
||||
(включая xf_user, xf_session и cf_clearance, если они есть).
|
||||
User-Agent можно скопировать из того же запроса.</p>
|
||||
</details>
|
||||
<label className="field"><span>Cookie</span>
|
||||
<input ref={input} type="password" autoComplete="off" spellCheck={false} maxLength={16384}
|
||||
value={cookie} onChange={(e) => setCookie(e.target.value)} disabled={busy} placeholder="xf_user=…; xf_session=…; cf_clearance=…" />
|
||||
</label>
|
||||
<label className="field"><span>User-Agent (необязательно)</span>
|
||||
<input value={agent} onChange={(e) => setAgent(e.target.value)} disabled={busy} maxLength={2048} autoComplete="off" />
|
||||
</label>
|
||||
<p className="hint">Cookie сохраняются зашифрованными только в вашем профиле и используются сервером для запросов F95zone.</p>
|
||||
{warning && <p className="hint" role="status">{warning}</p>}
|
||||
{error && <p className="hint" role="alert">{error}</p>}
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" disabled={busy} onClick={onDecline}>Продолжить без авторизации</button>
|
||||
<button type="button" className="btn btn-primary" disabled={busy || !cookie.trim()} onClick={onSave}>
|
||||
{busy ? "Проверка…" : "Сохранить и повторить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface UploadInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
coverHash?: string | null;
|
||||
}
|
||||
|
||||
interface UploadContextValue {
|
||||
@@ -55,6 +56,7 @@ export function UploadProvider({ children }: { children: ReactNode }) {
|
||||
const game = await api.post<Game>("/api/games", {
|
||||
title: input.title?.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
|
||||
description: input.description ?? "", tags: input.tags ?? [],
|
||||
cover_hash: input.coverHash ?? null,
|
||||
});
|
||||
gameId = game.id;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { test } from "node:test";
|
||||
import { build } from "esbuild";
|
||||
|
||||
test("F95 personal cookies: decline, expired session, retry, delete and no parent form submission", {
|
||||
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
|
||||
}, async () => {
|
||||
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
|
||||
const compiled = await build({ stdin: { contents: `
|
||||
import {createElement as h, useState} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {F95Fetch} from './ui/F95Fetch';
|
||||
window.submits = 0; window.loaded = [];
|
||||
function App() { const [title, setTitle] = useState('Manual title');
|
||||
return h('form', {onSubmit: e => {e.preventDefault(); window.submits++;}},
|
||||
h(F95Fetch, {onLoaded: m => {window.loaded.push(m); setTitle(m.title);}}), h('output', null, title)); }
|
||||
createRoot(document.querySelector('main')).render(h(App));`,
|
||||
resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" },
|
||||
bundle: true, write: false, format: "iife", platform: "browser", logLevel: "silent" });
|
||||
let imports = 0, deleted = 0;
|
||||
const saved = [];
|
||||
const server = createServer(async (req, res) => {
|
||||
if (req.url === "/bundle.js") {
|
||||
res.setHeader("Content-Type", "application/javascript"); res.end(compiled.outputFiles[0].text); return;
|
||||
}
|
||||
if (req.url === "/api/metadata/f95/session") {
|
||||
if (req.method === "PUT") {
|
||||
let data = "";
|
||||
for await (const chunk of req) data += chunk;
|
||||
saved.push(JSON.parse(data));
|
||||
} else if (req.method === "DELETE") deleted++;
|
||||
res.writeHead(204); res.end(); return;
|
||||
}
|
||||
if (req.url === "/api/metadata/f95") {
|
||||
imports++;
|
||||
const authenticated = imports === 4;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({url: "https://f95zone.to/threads/example.123/", title: "Imported title",
|
||||
description: "Story", tags: [], cover_hash: authenticated ? "cover.webp" : null,
|
||||
cover_warning: authenticated ? null : "Нужна авторизация", auth_required: !authenticated,
|
||||
metadata_available: imports !== 5})); return;
|
||||
}
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end('<!doctype html><main></main><script src="/bundle.js"></script>');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE});
|
||||
const page = await browser.newPage();
|
||||
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.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.waitFor();
|
||||
assert.equal(await page.locator("output").textContent(), "Imported title");
|
||||
await dialog.getByRole("button", {name: "Продолжить без авторизации"}).press("Enter");
|
||||
await dialog.waitFor({state: "detached"});
|
||||
assert.equal(imports, 1);
|
||||
assert.equal(saved.length, 0);
|
||||
assert.equal(await page.locator("output").textContent(), "Imported title");
|
||||
|
||||
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
||||
await dialog.waitFor();
|
||||
await dialog.getByLabel("Cookie", {exact: true}).fill("xf_user=expired");
|
||||
await dialog.getByRole("button", {name: "Сохранить и повторить"}).click();
|
||||
await page.waitForFunction(() => window.loaded.length === 3);
|
||||
assert.equal(await dialog.count(), 1, "expired cookies keep the replacement dialog open");
|
||||
assert.equal(await dialog.getByLabel("Cookie", {exact: true}).inputValue(), "");
|
||||
await dialog.getByLabel("Cookie", {exact: true}).fill("xf_user=fresh; xf_session=example");
|
||||
await dialog.getByLabel("User-Agent (необязательно)").fill("Test browser");
|
||||
await dialog.getByLabel("Cookie", {exact: true}).press("Enter");
|
||||
await dialog.waitFor({state: "detached"});
|
||||
assert.deepEqual(saved, [{cookie: "xf_user=expired", user_agent: ""},
|
||||
{cookie: "xf_user=fresh; xf_session=example", user_agent: "Test browser"}]);
|
||||
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(() => localStorage.length), 0);
|
||||
|
||||
await page.getByRole("button", {name: "Забыть cookie F95zone"}).click();
|
||||
await page.getByText("Ваши сохранённые cookie F95zone удалены.").waitFor();
|
||||
assert.equal(deleted, 1);
|
||||
await page.reload();
|
||||
await page.getByPlaceholder("https://f95zone.to/threads/…").fill("https://f95zone.to/threads/example.123/");
|
||||
await page.getByRole("button", {name: "Подтянуть", exact: true}).click();
|
||||
await dialog.waitFor();
|
||||
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.waitFor({state: "detached"});
|
||||
assert.equal(imports, 5);
|
||||
assert.equal(saved.length, 2);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user