Skip to content

Commit bfb44c2

Browse files
committed
Extract title from html error messages
1 parent cadb637 commit bfb44c2

2 files changed

Lines changed: 64 additions & 3 deletions

File tree

lib/core/error_dialogs.dart

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,12 @@ class FormHttpErrorsWidget extends StatelessWidget {
430430
Widget build(BuildContext context) {
431431
final theme = Theme.of(context);
432432

433+
// A JSON endpoint answering with HTML often means a proxy or bot wall such
434+
// as Anubis intercepted the request. Such pages render near-blank through
435+
// flutter_html since their visible content is JS-driven. In these cases we
436+
// try pull out the <title>, which usually names the culprit
437+
final htmlTitle = exception.type == ErrorType.html ? htmlErrorTitle(exception.htmlError) : null;
438+
433439
return Container(
434440
constraints: const BoxConstraints(maxHeight: 250),
435441
decoration: BoxDecoration(
@@ -441,9 +447,18 @@ class FormHttpErrorsWidget extends StatelessWidget {
441447
child: Column(
442448
children: [
443449
Icon(Icons.error_outline, color: theme.colorScheme.error),
444-
if (exception.type == ErrorType.html)
445-
ServerHtmlError(data: exception.htmlError)
446-
else
450+
if (exception.type == ErrorType.html) ...[
451+
if (htmlTitle != null)
452+
Text(
453+
htmlTitle,
454+
textAlign: TextAlign.center,
455+
style: TextStyle(
456+
fontWeight: FontWeight.bold,
457+
color: theme.colorScheme.error,
458+
),
459+
),
460+
ServerHtmlError(data: exception.htmlError),
461+
] else
447462
...formatApiErrors(
448463
extractErrors(exception.errors),
449464
color: theme.colorScheme.error,
@@ -454,3 +469,25 @@ class FormHttpErrorsWidget extends StatelessWidget {
454469
);
455470
}
456471
}
472+
473+
/// Extracts the `<title>` of an HTML error body (e.g. a proxy or bot-wall page
474+
/// such as Cloudflare or Anubis), with the few HTML entities that show up in
475+
/// such titles unescaped. Returns null when there is no usable title.
476+
String? htmlErrorTitle(String html) {
477+
final match = RegExp(
478+
r'<title[^>]*>(.*?)</title>',
479+
caseSensitive: false,
480+
dotAll: true,
481+
).firstMatch(html);
482+
final raw = match?.group(1)?.trim();
483+
if (raw == null || raw.isEmpty) {
484+
return null;
485+
}
486+
return raw
487+
.replaceAll('&#39;', "'")
488+
.replaceAll('&#x27;', "'")
489+
.replaceAll('&quot;', '"')
490+
.replaceAll('&lt;', '<')
491+
.replaceAll('&gt;', '>')
492+
.replaceAll('&amp;', '&');
493+
}

test/core/error_dialogs_test.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,28 @@ void main() {
4343
expect(texts.every((t) => t.style?.color == Colors.red), isTrue);
4444
});
4545
});
46+
47+
group('htmlErrorTitle', () {
48+
test('extracts and unescapes the title of a bot-wall page', () {
49+
const html =
50+
'<!doctype html><html lang="en"><head>'
51+
'<title>Making sure you&#39;re not a bot!</title>'
52+
'</head><body></body></html>';
53+
54+
expect(htmlErrorTitle(html), "Making sure you're not a bot!");
55+
});
56+
57+
test('matches case-insensitively and ignores attributes on the title tag', () {
58+
expect(
59+
htmlErrorTitle('<HEAD><TITLE data-x="1">Just a moment...</TITLE></HEAD>'),
60+
'Just a moment...',
61+
);
62+
});
63+
64+
test('returns null when there is no usable title', () {
65+
expect(htmlErrorTitle('<html><body>no title here</body></html>'), isNull);
66+
expect(htmlErrorTitle('<html><head><title> </title></head></html>'), isNull);
67+
expect(htmlErrorTitle(''), isNull);
68+
});
69+
});
4670
}

0 commit comments

Comments
 (0)