Skip to content

Commit b5b1c9e

Browse files
committed
Harden PGN import error handling on client and server
1 parent 056568e commit b5b1c9e

5 files changed

Lines changed: 431 additions & 33 deletions

File tree

client/main.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { profileView } from './profile';
2121
import { tournamentView } from './tournament';
2222
import { simulView } from './simul/simul';
2323
import { calendarView } from './calendar';
24-
import { pasteView } from './paste';
24+
import { pasteView, recordImportFfishError } from './paste';
2525
import { statsView } from './stats';
2626
import { volumeSettings, soundThemeSettings } from './sound';
2727
import { patch } from './document';
@@ -177,6 +177,7 @@ export function view(el: HTMLElement, model: PyChessModel): VNode {
177177
function start() {
178178
const placeholder = document.getElementById('placeholder');
179179
if (placeholder && el) {
180+
const dataView = el.getAttribute("data-view") ?? "";
180181

181182
// Check if we need to show username selection dialog
182183
if (model.oauthUsernameSelection && model.oauthUsernameSelection.oauth_id) {
@@ -189,17 +190,23 @@ function start() {
189190
}
190191
}
191192

192-
if (['round', 'analysis', 'puzzle', 'editor', 'tv', 'embed', 'paste'].includes(el.getAttribute("data-view") ?? "")) {
193+
if (['round', 'analysis', 'puzzle', 'editor', 'tv', 'embed', 'paste'].includes(dataView)) {
193194
console.time('load ffish');
194195
if (model["variant"] === "alice") {
195-
ffishAliceModule().then((loadedModule: any) => {
196+
const loadModule = dataView === 'paste'
197+
? ffishAliceModule({ printErr: recordImportFfishError })
198+
: ffishAliceModule();
199+
loadModule.then((loadedModule: any) => {
196200
console.timeEnd('load ffish_alice');
197201
loadedModule.loadVariantConfig(variantsIni);
198202
model.ffish = loadedModule;
199203
patch(placeholder, view(el, model));
200204
});
201205
} else {
202-
ffishModule().then((loadedModule: any) => {
206+
const loadModule = dataView === 'paste'
207+
? ffishModule({ printErr: recordImportFfishError })
208+
: ffishModule();
209+
loadModule.then((loadedModule: any) => {
203210
console.timeEnd('load ffish');
204211
loadedModule.loadVariantConfig(variantsIni);
205212
model.ffish = loadedModule;

client/paste.ts

Lines changed: 148 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,32 @@ import { importGameBugH } from "@/bug/paste.bug";
1212
const BRAINKING_SITE = '[Site "BrainKing.com (Prague, Czech Republic)"]';
1313
const EMBASSY_FEN = '[FEN "rnbqkmcbnr/pppppppppp/10/10/10/10/PPPPPPPPPP/RNBQKMCBNR w KQkq - 0 1"]';
1414
const BUGHOUSE_VARIANT = '[WhiteA';
15+
const IMPORT_FFISH_ERROR_BUFFER: string[] = [];
16+
const FEN_VALIDATION_ERRORS: Record<number, string> = {
17+
[-14]: 'Invalid counting rule field',
18+
[-13]: 'Invalid check count field',
19+
[-12]: 'Invalid promoted piece marker',
20+
[-11]: 'Invalid number of FEN fields',
21+
[-10]: 'Invalid character in board layout',
22+
[-9]: 'Touching kings are not allowed',
23+
[-8]: 'Invalid board geometry',
24+
[-7]: 'Invalid pocket information',
25+
[-6]: 'Invalid side to move field',
26+
[-5]: 'Invalid castling information',
27+
[-4]: 'Invalid en-passant square',
28+
[-3]: 'Invalid number of kings',
29+
[-2]: 'Invalid half-move counter',
30+
[-1]: 'Invalid move counter',
31+
[0]: 'Empty FEN',
32+
};
33+
34+
export function recordImportFfishError(text: string): void {
35+
const message = text.trim();
36+
if (/^Variant '.*' already exists\.$/.test(message)) return;
37+
console.warn(message);
38+
IMPORT_FFISH_ERROR_BUFFER.push(message);
39+
if (IMPORT_FFISH_ERROR_BUFFER.length > 200) IMPORT_FFISH_ERROR_BUFFER.shift();
40+
}
1541

1642

1743
export function pasteView(model: PyChessModel): VNode[] {
@@ -52,8 +78,11 @@ export function pasteView(model: PyChessModel): VNode[] {
5278
ffish.loadVariantConfig(variantsIni);
5379
const XHR = new XMLHttpRequest();
5480
const FD = new FormData();
81+
const ffishErrorStart = IMPORT_FFISH_ERROR_BUFFER.length;
5582

56-
let variant, initialFen, board;
83+
let variant: string;
84+
let initialFen: string;
85+
let board;
5786
let mainlineMoves: string[] = [];
5887

5988
try {
@@ -79,17 +108,15 @@ export function pasteView(model: PyChessModel): VNode[] {
79108

80109
for (let idx = 0; idx < moves.length; ++idx) {
81110
move = moves[idx];
82-
try {
83-
board.push(move);
84-
mainlineMoves.push(move);
85-
}
86-
catch (err) {
111+
const pushed = board.push(move);
112+
if (!pushed) {
87113
alert('Illegal move ' + move);
88114
status = 10;
89115
// LOSS for the moving player
90116
result = resultString(false, idx + 1, isHandicap);
91117
break;
92118
}
119+
mainlineMoves.push(move);
93120
}
94121

95122
FD.append('Variant', 'shogi');
@@ -108,11 +135,13 @@ export function pasteView(model: PyChessModel): VNode[] {
108135
} else {
109136

110137
const game = ffish.readGamePGN(pgn);
138+
const parserError = getLatestFfishError(ffishErrorStart);
139+
if (parserError) {
140+
throw new Error(parserError);
141+
}
111142

112-
variant = "chess";
113-
const v = game.headers("Variant");
114-
//console.log("Variant:", v);
115-
if (v) variant = v.toLowerCase();
143+
const variantInfo = parseVariantTag(game.headers("Variant"));
144+
variant = variantInfo.variant;
116145

117146
if (variant === 'alice') {
118147
// TODO
@@ -121,33 +150,42 @@ export function pasteView(model: PyChessModel): VNode[] {
121150
alert(error);
122151
return;
123152
}
153+
if (!(variant in VARIANTS)) {
154+
throw new Error(`Unsupported PGN Variant tag: ${variantInfo.raw}`);
155+
}
124156

125157
initialFen = VARIANTS[variant].startFen;
126158
const f = game.headers("FEN");
127-
if (f) initialFen = f;
159+
if (f) {
160+
const fenValidation = validateFenTag(ffish, f, variantInfo.variant, variantInfo.chess960);
161+
if (fenValidation !== null) {
162+
throw new Error(fenValidation);
163+
}
164+
initialFen = f;
165+
}
128166

129167
const t = game.headers("Termination");
130168
//console.log("Termination:", t);
131169
if (t) {
132-
status = getStatus(t.toLowerCase());
170+
const status = getStatus(t.toLowerCase());
133171
FD.append('Status', ""+status);
134172
}
135173

136-
// TODO: crazyhouse960 but without 960? (export to lichess hack)
137-
const is960 = variant.includes("960") || variant.includes('random');
138-
139-
board = new ffish.Board(variant, initialFen, is960);
174+
board = new ffish.Board(variant, initialFen, variantInfo.chess960);
140175

141-
mainlineMoves = game.mainlineMoves().split(" ");
176+
mainlineMoves = game.mainlineMoves().split(/\s+/).filter((move: string) => move.length > 0);
142177
for (let idx = 0; idx < mainlineMoves.length; ++idx) {
143-
board.push(mainlineMoves[idx]);
178+
const pushed = board.push(mainlineMoves[idx]);
179+
if (!pushed) {
180+
throw new Error(`Illegal move at ply ${idx + 1}: ${mainlineMoves[idx]}`);
181+
}
144182
}
145183

146184
const tags = (game.headerKeys() as string).split(' ');
147185
tags.forEach((tag) => {
148186
FD.append( tag, game.headers(tag) );
149187
});
150-
FD.append('moves', game.mainlineMoves());
188+
FD.append('moves', mainlineMoves.join(' '));
151189
FD.append('final_fen', board.fen());
152190
FD.append('username', model["username"]);
153191

@@ -156,22 +194,42 @@ export function pasteView(model: PyChessModel): VNode[] {
156194
}
157195
}
158196
catch(err) {
159-
e.setCustomValidity(err.message ? _('Invalid PGN') : '');
160-
alert(err);
197+
const message = buildImportErrorMessage(err, pgn, ffish);
198+
e.setCustomValidity(message);
199+
alert(message);
161200
return;
162201
}
163202

164203
XHR.onreadystatechange = function() {
165-
if (this.readyState === 4 && this.status === 200) {
166-
const response = JSON.parse(this.responseText);
204+
if (this.readyState !== 4) return;
205+
206+
let response: Record<string, string> = {};
207+
if (this.responseText) {
208+
try {
209+
response = JSON.parse(this.responseText);
210+
} catch (_err) {
211+
response = {};
212+
}
213+
}
214+
215+
if (this.status === 200) {
167216
if (response['gameId'] !== undefined) {
168217
window.location.assign(model["home"] + '/' + response['gameId']);
169-
} else if (response['error'] !== undefined) {
218+
return;
219+
}
220+
if (response['error'] !== undefined) {
170221
alert(response['error']);
222+
return;
171223
}
224+
alert(_('Import failed'));
225+
return;
172226
}
227+
228+
alert(response['error'] ?? `${_('Import failed')} (${this.status})`);
229+
};
230+
XHR.onerror = function() {
231+
alert(_('Import failed'));
173232
};
174-
console.log(FD);
175233
XHR.open("POST", "/import", true);
176234
XHR.send(FD);
177235
}
@@ -217,3 +275,68 @@ function getStatus(termination: string) {
217275
if (termination.includes('abandon')) return '7';
218276
return '11'; // unknown
219277
}
278+
279+
function parseVariantTag(rawVariant: string): { variant: string; chess960: boolean; raw: string } {
280+
const raw = rawVariant || 'chess';
281+
let variant = raw.toLowerCase();
282+
let chess960 = variant.includes("960") || variant.includes('random');
283+
284+
variant = variant.endsWith('960') ? variant.slice(0, -3) : variant;
285+
if (variant === "caparandom") {
286+
variant = "capablanca";
287+
chess960 = true;
288+
} else if (variant === "fischerandom") {
289+
variant = "chess";
290+
chess960 = true;
291+
}
292+
return { variant, chess960, raw };
293+
}
294+
295+
function validateFenTag(ffish: FairyStockfish, fen: string, variant: string, chess960: boolean): string | null {
296+
const validationCode = ffish.validateFen(fen, variant, chess960);
297+
if (validationCode === 1) return null;
298+
299+
const details = FEN_VALIDATION_ERRORS[validationCode] ?? 'Unknown FEN validation error';
300+
return `Invalid [FEN] tag (code ${validationCode}): ${details}.`;
301+
}
302+
303+
function getLatestFfishError(fromIndex: number): string | null {
304+
if (IMPORT_FFISH_ERROR_BUFFER.length <= fromIndex) return null;
305+
const latest = IMPORT_FFISH_ERROR_BUFFER[IMPORT_FFISH_ERROR_BUFFER.length - 1];
306+
return latest && latest.trim() ? latest.trim() : null;
307+
}
308+
309+
function extractPgnTags(pgn: string): Record<string, string> {
310+
const tags: Record<string, string> = {};
311+
const regex = /^\s*\[([A-Za-z0-9_]+)\s+"((?:[^"\\]|\\.)*)"\]\s*$/gm;
312+
let match: RegExpExecArray | null;
313+
while ((match = regex.exec(pgn)) !== null) {
314+
tags[match[1]] = match[2].replace(/\\"/g, '"');
315+
}
316+
return tags;
317+
}
318+
319+
function buildImportErrorMessage(err: unknown, pgn: string, ffish: FairyStockfish): string {
320+
const tags = extractPgnTags(pgn);
321+
const variantInfo = parseVariantTag(tags["Variant"] ?? "chess");
322+
323+
if (!(variantInfo.variant in VARIANTS)) {
324+
return `Unsupported PGN Variant tag: ${variantInfo.raw}.`;
325+
}
326+
327+
const fen = tags["FEN"];
328+
if (fen) {
329+
const fenValidation = validateFenTag(ffish, fen, variantInfo.variant, variantInfo.chess960);
330+
if (fenValidation !== null) return fenValidation;
331+
}
332+
333+
const errorMessage =
334+
err instanceof Error
335+
? err.message
336+
: (typeof err === "string" ? err : "");
337+
if (!errorMessage) return _('Invalid PGN');
338+
if (errorMessage.includes("memory access out of bounds")) {
339+
return "Failed to parse PGN. Check [Variant], [FEN], and move text formatting.";
340+
}
341+
return errorMessage;
342+
}

server/utils.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from __future__ import annotations
2-
from typing import TYPE_CHECKING
2+
from typing import TYPE_CHECKING, Callable
33

44
import asyncio
55
import json
@@ -340,9 +340,20 @@ async def import_game(request):
340340
log.debug("TimeControl tag parsing failed. %s", tc_tag)
341341
base, inc = 0, 0
342342

343-
move_stack = data.get("moves", "").split(" ")
344-
encode_method = get_server_variant(variant, chess960).move_encoding
345-
moves = [*map(encode_method, map(grand2zero, move_stack) if variant in GRANDS else move_stack)]
343+
try:
344+
server_variant = get_server_variant(variant, chess960)
345+
except KeyError:
346+
message = "Unsupported variant in imported PGN: %s" % variant
347+
log.warning("PGN import rejected: %s", message)
348+
return web.json_response({"error": message})
349+
350+
moves, import_error = _encode_import_moves(
351+
data.get("moves", ""),
352+
move_encoding=server_variant.move_encoding,
353+
grand_variant=server_variant.server_name in GRANDS,
354+
)
355+
if import_error is not None:
356+
return web.json_response({"error": import_error})
346357

347358
game_id = await new_id(None if app_state.db is None else app_state.db.game)
348359
existing = await app_state.db.game.find_one({"_id": {"$eq": game_id}})
@@ -410,6 +421,22 @@ async def import_game(request):
410421
return web.json_response({"gameId": game_id})
411422

412423

424+
def _encode_import_moves(
425+
raw_moves: str, *, move_encoding: Callable[[str], str], grand_variant: bool
426+
) -> tuple[list[str], str | None]:
427+
move_stack = raw_moves.split()
428+
moves: list[str] = []
429+
for ply, move in enumerate(move_stack, start=1):
430+
try:
431+
normalized_move = grand2zero(move) if grand_variant else move
432+
moves.append(move_encoding(normalized_move))
433+
except Exception as exc:
434+
message = "Invalid move '%s' at ply %s in imported PGN." % (move, ply)
435+
log.warning("PGN import rejected: %s (%s: %s)", message, type(exc).__name__, exc)
436+
return [], message
437+
return moves, None
438+
439+
413440
async def join_seek(
414441
app_state: PychessGlobalAppState,
415442
user: User,

0 commit comments

Comments
 (0)