redbot 2.7.0__tar.gz → 2.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {redbot-2.7.0/redbot.egg-info → redbot-2.7.1}/PKG-INFO +3 -3
- {redbot-2.7.0 → redbot-2.7.1}/pyproject.toml +2 -2
- {redbot-2.7.0 → redbot-2.7.1}/redbot/__init__.py +1 -1
- {redbot-2.7.0 → redbot-2.7.1}/redbot/daemon.py +1 -17
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/html.py +2 -1
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/response_finish.html +4 -2
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/response_multi_finish.html +19 -7
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/response_start.html +1 -1
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/text.py +21 -16
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/__init__.py +41 -8
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/fetch.py +19 -4
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/link_parse.py +68 -12
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webbotauth.py +25 -10
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/captcha.py +68 -23
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/save.py +80 -36
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/saved_tests.py +13 -7
- {redbot-2.7.0 → redbot-2.7.1/redbot.egg-info}/PKG-INFO +3 -3
- {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/requires.txt +2 -2
- {redbot-2.7.0 → redbot-2.7.1}/LICENSE.md +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/MANIFEST.in +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/README.md +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/check-circle.svg +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/exclamation-circle.svg +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/info-circle.svg +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/question-circle.svg +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/icons/times-circle.svg +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/apple-touch-icon-144x144.png +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/apple-touch-icon-152x152.png +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/favicon-16x16.png +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/favicon-32x32.png +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/favicon.ico +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/mstile-144x144.png +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/logo/redbot-sq.png +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/prettify.js +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/prettify.js.map +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/red_style.css +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/red_style.css.map +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/script.js +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/script.js.map +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/assets/style.css +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/cli.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/har.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/html_base.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/null_links.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/formatter/templates/footer.html +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/gc.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/i18n.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/note.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/base.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/conneg.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/etag_validate.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/lm_validate.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/resource/active_check/range.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/es/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/es/LC_MESSAGES/messages.po +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/extra_strings.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/fr/LC_MESSAGES/messages.po +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/ja/LC_MESSAGES/messages.po +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/messages.pot +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/translations/zh/LC_MESSAGES/messages.po +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/type.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/utils.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/__init__.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/base.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/client_error.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/error.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/run_test.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/handlers/show.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/headers.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/links.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot/webui/ratelimit.py +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/SOURCES.txt +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/dependency_links.txt +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/entry_points.txt +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/redbot.egg-info/top_level.txt +0 -0
- {redbot-2.7.0 → redbot-2.7.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redbot
|
|
3
|
-
Version: 2.7.
|
|
3
|
+
Version: 2.7.1
|
|
4
4
|
Summary: REDbot is lint for HTTP.
|
|
5
5
|
Author-email: Mark Nottingham <mnot@mnot.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -18,12 +18,12 @@ Description-Content-Type: text/markdown
|
|
|
18
18
|
License-File: LICENSE.md
|
|
19
19
|
Requires-Dist: cryptography>=42.0.0
|
|
20
20
|
Requires-Dist: http-sf>=1.4.0
|
|
21
|
-
Requires-Dist: httplint>=2026.10.
|
|
21
|
+
Requires-Dist: httplint>=2026.10.3
|
|
22
22
|
Requires-Dist: Jinja2>=3.1.2
|
|
23
23
|
Requires-Dist: markdown>=3.4.4
|
|
24
24
|
Requires-Dist: MarkupSafe>=2.1.3
|
|
25
25
|
Requires-Dist: netaddr>=1.2.1
|
|
26
|
-
Requires-Dist: thor>=0.16.
|
|
26
|
+
Requires-Dist: thor>=0.16.1
|
|
27
27
|
Provides-Extra: systemd
|
|
28
28
|
Requires-Dist: cysystemd; extra == "systemd"
|
|
29
29
|
Provides-Extra: dev
|
|
@@ -22,12 +22,12 @@ license-files = ["LICENSE.md"]
|
|
|
22
22
|
dependencies = [
|
|
23
23
|
"cryptography >= 42.0.0",
|
|
24
24
|
"http-sf >= 1.4.0",
|
|
25
|
-
"httplint >= 2026.10.
|
|
25
|
+
"httplint >= 2026.10.3",
|
|
26
26
|
"Jinja2 >= 3.1.2",
|
|
27
27
|
"markdown >= 3.4.4",
|
|
28
28
|
"MarkupSafe >= 2.1.3",
|
|
29
29
|
"netaddr >= 1.2.1",
|
|
30
|
-
"thor >= 0.16.
|
|
30
|
+
"thor >= 0.16.1",
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
[project.urls]
|
|
@@ -453,28 +453,12 @@ in standalone server mode. Details follow.
|
|
|
453
453
|
(b"Content-Type", DIRECTORY_CONTENT_TYPE.encode("ascii")),
|
|
454
454
|
(b"Cache-Control", b"max-age=%d" % DIRECTORY_MAX_AGE),
|
|
455
455
|
]
|
|
456
|
-
|
|
457
|
-
if authority:
|
|
458
|
-
headers += signer.sign_directory(authority)
|
|
456
|
+
headers += signer.sign_directory()
|
|
459
457
|
self.exchange.response_start(b"200", b"OK", headers)
|
|
460
458
|
self.exchange.response_body(body)
|
|
461
459
|
self.exchange.response_done([])
|
|
462
460
|
return None
|
|
463
461
|
|
|
464
|
-
def request_authority(self) -> str:
|
|
465
|
-
"The authority (Host) of the incoming request, for signing."
|
|
466
|
-
for name, value in self.req_hdrs:
|
|
467
|
-
if name.lower() == b"host":
|
|
468
|
-
host = value.decode("ascii", "replace").strip().lower()
|
|
469
|
-
# Drop a default port so the signed @authority matches what a
|
|
470
|
-
# verifier normalizes to (RFC 9421 derives @authority this way).
|
|
471
|
-
for suffix in (":443", ":80"):
|
|
472
|
-
if host.endswith(suffix):
|
|
473
|
-
host = host[: -len(suffix)]
|
|
474
|
-
break
|
|
475
|
-
return host
|
|
476
|
-
return ""
|
|
477
|
-
|
|
478
462
|
def not_found(self, path: bytes) -> None:
|
|
479
463
|
headers = []
|
|
480
464
|
headers.append((b"Content-Type", b"text/plain"))
|
|
@@ -89,7 +89,7 @@ class SingleEntryHtmlFormatter(BaseHtmlFormatter):
|
|
|
89
89
|
def finish_output(self) -> None:
|
|
90
90
|
self.final_status()
|
|
91
91
|
media_type = self.resource.response.headers.parsed.get("content-type", [""])[0]
|
|
92
|
-
if self.resource.
|
|
92
|
+
if self.resource.has_response or self.resource.nonfinal_responses:
|
|
93
93
|
validator_link = self.validators.get(media_type, None)
|
|
94
94
|
if validator_link and self.resource.response.complete:
|
|
95
95
|
validator_link = validator_link % e_query_arg(self.resource.request.uri or "")
|
|
@@ -355,6 +355,7 @@ class TableHtmlFormatter(BaseHtmlFormatter):
|
|
|
355
355
|
**{
|
|
356
356
|
"droid_lists": self.make_droid_lists(self.resource),
|
|
357
357
|
"problems": self.problems,
|
|
358
|
+
"show_har": self.resource.response.complete,
|
|
358
359
|
"levels": levels,
|
|
359
360
|
"is_saved": is_saved,
|
|
360
361
|
"save_mtime": save_mtime,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
{% if resource.fetch_error %}
|
|
19
19
|
<p class='error'>
|
|
20
|
-
{{ resource.fetch_error.desc }}
|
|
20
|
+
{{ resource.fetch_error.desc }}{% if resource.fetch_error.detail %} ({{ resource.fetch_error.detail }}){% endif %}
|
|
21
21
|
</p>
|
|
22
22
|
{% endif %}
|
|
23
23
|
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
<span class='option' title='{{ _("How large the response headers are, including the status line") }}'>{{
|
|
39
39
|
_("response headers") }}: {{ resource.response_header_length|f_num }} {{ _("bytes") }}</span>
|
|
40
40
|
|
|
41
|
+
{% if resource.response.complete %}
|
|
41
42
|
<span class='option' title='{{ _("How large the response content is") }}'>{{ _("content") }}: {{
|
|
42
43
|
resource.response.content_length|f_num }} {{ _("bytes") }}</span>
|
|
43
44
|
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
_("transfer overhead") }}: {{ (resource.response.transfer_length -
|
|
47
48
|
resource.response.content_length)|f_num }} {{ _("bytes") }}</span>
|
|
48
49
|
{%- endif %}
|
|
50
|
+
{% endif %}
|
|
49
51
|
|
|
50
52
|
<br />
|
|
51
53
|
|
|
@@ -53,7 +55,7 @@
|
|
|
53
55
|
<a href='#' id='body_view' accesskey='b'>{{ _("view content") }}</a>
|
|
54
56
|
</div>
|
|
55
57
|
|
|
56
|
-
{% if is_resource %}
|
|
58
|
+
{% if is_resource and resource.response.complete %}
|
|
57
59
|
<div class='option'>
|
|
58
60
|
{{ har_link }}
|
|
59
61
|
</div>
|
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
{% endif %}
|
|
28
28
|
{%- endmacro %}
|
|
29
29
|
|
|
30
|
+
{% macro notes_cell(resource) %}
|
|
31
|
+
<td>
|
|
32
|
+
{% for problem in resource.response.notes if problem.level in [levels.WARN, levels.BAD] %}
|
|
33
|
+
<span class='prob_num'>{{ problem|index_problem }}<span class='hidden'><span class='tip'>{{ problem.detail
|
|
34
|
+
}}</span></span></span>
|
|
35
|
+
{% endfor %}
|
|
36
|
+
</td>
|
|
37
|
+
{%- endmacro %}
|
|
38
|
+
|
|
30
39
|
<table id='summary'>
|
|
31
40
|
{% for heading, droids in droid_lists %}
|
|
32
41
|
{% if heading != "" %}
|
|
@@ -70,20 +79,21 @@
|
|
|
70
79
|
<td>{{ yes_no(resource.gzip_support) }}</td>
|
|
71
80
|
{% endif %}
|
|
72
81
|
<td>{{ yes_no(resource.partial_support) }}</td>
|
|
73
|
-
|
|
74
|
-
{% for problem in resource.response.notes if problem.level in [levels.WARN, levels.BAD] %}
|
|
75
|
-
<span class='prob_num'>{{ problem|index_problem }}<span class='hidden'><span class='tip'>{{ problem.detail
|
|
76
|
-
}}</span></span></span>
|
|
77
|
-
{% endfor %}
|
|
78
|
-
</td>
|
|
82
|
+
{{ notes_cell(resource) }}
|
|
79
83
|
{% else %}
|
|
80
|
-
|
|
84
|
+
{% if resource.has_response %}
|
|
85
|
+
<td>{{ resource.response.status_code_str }}</td>
|
|
86
|
+
{% endif %}
|
|
87
|
+
<td colspan="{{ 9 if resource.has_response else 11 }}">
|
|
81
88
|
{% if resource.fetch_error == None %}
|
|
82
89
|
{{ _("response incomplete") }}
|
|
83
90
|
{% else %}
|
|
84
91
|
{{ resource.fetch_error.desc or _("unknown problem") }}
|
|
85
92
|
{% endif %}
|
|
86
93
|
</td>
|
|
94
|
+
{% if resource.has_response %}
|
|
95
|
+
{{ notes_cell(resource) }}
|
|
96
|
+
{% endif %}
|
|
87
97
|
{% endif %}
|
|
88
98
|
</tr>
|
|
89
99
|
{% endfor %}
|
|
@@ -91,9 +101,11 @@
|
|
|
91
101
|
</table>
|
|
92
102
|
|
|
93
103
|
<div class="options">
|
|
104
|
+
{% if show_har %}
|
|
94
105
|
<div class='option'>
|
|
95
106
|
{{ har_link }}
|
|
96
107
|
</div>
|
|
108
|
+
{% endif %}
|
|
97
109
|
{% if not is_saved %}
|
|
98
110
|
{% if allow_save %}
|
|
99
111
|
<div class='option' title='{{ _("Save these results for future reference") }}'>
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
|
|
64
64
|
<body class="{{ extra_body_class }}">
|
|
65
65
|
|
|
66
|
-
{{ links.save_form(test_id, descend) | safe }}
|
|
66
|
+
{% if test_id %}{{ links.save_form(test_id, descend) | safe }}{% endif %}
|
|
67
67
|
|
|
68
68
|
<div id="request">
|
|
69
69
|
<h1><a href="{{ links.home_link() }}"><span class="hilight"><abbr title="Resource Expert Droid">RED</abbr></span>bot</a>{{ extra_title
|
|
@@ -86,30 +86,30 @@ class BaseTextFormatter(Formatter):
|
|
|
86
86
|
|
|
87
87
|
def finish_output(self) -> None:
|
|
88
88
|
"Fill in the template with RED's results."
|
|
89
|
-
|
|
89
|
+
response = self.resource.response
|
|
90
|
+
if self.resource.has_response or self.resource.nonfinal_responses:
|
|
90
91
|
if self.resource.nonfinal_responses:
|
|
91
92
|
self.output(
|
|
92
93
|
NL.join([self.format_headers(r) for r in self.resource.nonfinal_responses])
|
|
93
94
|
+ NL
|
|
94
95
|
+ NL
|
|
95
96
|
)
|
|
96
|
-
if self.resource.
|
|
97
|
-
self.output(self.format_headers(
|
|
98
|
-
|
|
99
|
-
self.
|
|
100
|
-
self.error_template % self._format_fetch_error(self.resource.fetch_error)
|
|
101
|
-
)
|
|
97
|
+
if self.resource.has_response:
|
|
98
|
+
self.output(self.format_headers(response) + NL + NL)
|
|
99
|
+
if not response.complete:
|
|
100
|
+
self.output_fetch_error(self.resource)
|
|
102
101
|
|
|
103
102
|
self.output(self.format_recommendations(self.resource) + NL)
|
|
104
103
|
else:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
self.output_fetch_error(self.resource)
|
|
105
|
+
|
|
106
|
+
def output_fetch_error(self, resource: HttpResource) -> None:
|
|
107
|
+
"Output the error for a resource whose fetch failed, if there is one."
|
|
108
|
+
if resource.fetch_error is None:
|
|
109
|
+
return
|
|
110
|
+
if not isinstance(resource.fetch_error, httperr.HttpError):
|
|
111
|
+
raise AssertionError("Unknown incomplete response error.")
|
|
112
|
+
self.output(self.error_template % self._format_fetch_error(resource.fetch_error))
|
|
113
113
|
|
|
114
114
|
@staticmethod
|
|
115
115
|
def _format_fetch_error(error: httperr.HttpError) -> str:
|
|
@@ -227,7 +227,12 @@ class TextListFormatter(BaseTextFormatter):
|
|
|
227
227
|
subresources.sort(key=operator.attrgetter("request.uri"))
|
|
228
228
|
for subresource in subresources:
|
|
229
229
|
self.output(self.format_uri(subresource) + NL + NL)
|
|
230
|
-
|
|
230
|
+
if not subresource.has_response: # no response to show
|
|
231
|
+
self.output_fetch_error(subresource)
|
|
232
|
+
else:
|
|
233
|
+
self.output(self.format_headers(subresource.response) + NL + NL)
|
|
234
|
+
if not subresource.response.complete:
|
|
235
|
+
self.output_fetch_error(subresource)
|
|
231
236
|
self.output(self.format_recommendations(subresource) + NL + NL)
|
|
232
237
|
|
|
233
238
|
def format_uri(self, resource: HttpResource) -> str:
|
|
@@ -15,7 +15,7 @@ from typing import Dict, List, Optional, Set, Tuple, Union
|
|
|
15
15
|
from urllib.parse import urljoin
|
|
16
16
|
|
|
17
17
|
import thor
|
|
18
|
-
from thor.http.error import UrlError
|
|
18
|
+
from thor.http.error import HttpError, UrlError
|
|
19
19
|
from thor.http.uri import parse_uri
|
|
20
20
|
|
|
21
21
|
from redbot.resource import link_parse
|
|
@@ -59,7 +59,7 @@ class HttpResource(RedFetcher):
|
|
|
59
59
|
if descend is true, the response will be parsed for links and HttpResources started for each
|
|
60
60
|
link, enumerated in .linked. Linked resources don't run active checks.
|
|
61
61
|
|
|
62
|
-
Emits "check_done" when everything has finished.
|
|
62
|
+
Emits "check_done" once, when everything has finished.
|
|
63
63
|
"""
|
|
64
64
|
|
|
65
65
|
check_name = "default"
|
|
@@ -86,7 +86,9 @@ class HttpResource(RedFetcher):
|
|
|
86
86
|
self.links: Dict[str, Set[str]] = {}
|
|
87
87
|
self.link_count: int = 0
|
|
88
88
|
self.linked: List[Tuple[HttpResource, str]] = [] # linked HttpResources
|
|
89
|
-
self._link_parser = link_parse.HTMLLinkParser(
|
|
89
|
+
self._link_parser = link_parse.HTMLLinkParser(
|
|
90
|
+
self.response, [self.process_link], err=self._link_error
|
|
91
|
+
)
|
|
90
92
|
if self.descend or config.getboolean("content_links", False):
|
|
91
93
|
self.response.decoded.processors.append(self._link_parser.feed_bytes)
|
|
92
94
|
|
|
@@ -95,8 +97,11 @@ class HttpResource(RedFetcher):
|
|
|
95
97
|
Response is available; perform subordinate requests (e.g., conneg check).
|
|
96
98
|
"""
|
|
97
99
|
if self.response.complete and self.active_checks_enabled:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
# Register every check before starting any: one that finishes synchronously mustn't
|
|
101
|
+
# empty the task map while others are still to be started.
|
|
102
|
+
active = list(self.subreqs.values())
|
|
103
|
+
self.add_check(*active)
|
|
104
|
+
for active_check in active:
|
|
100
105
|
active_check.check()
|
|
101
106
|
else:
|
|
102
107
|
self.finish_check()
|
|
@@ -140,7 +145,8 @@ class HttpResource(RedFetcher):
|
|
|
140
145
|
raise KeyError(f"* Can't find {resource} in task map: {self._task_map}") from None
|
|
141
146
|
tasks_left = len(self._task_map)
|
|
142
147
|
# self.emit("debug", "%s checks remaining: %i" % (repr(self), tasks_left))
|
|
143
|
-
|
|
148
|
+
# Links that fail immediately mustn't complete us while we're still reading the response.
|
|
149
|
+
if tasks_left == 0 and self.fetch_done and not self.check_done:
|
|
144
150
|
self.check_done = True
|
|
145
151
|
self.emit("check_done")
|
|
146
152
|
|
|
@@ -167,7 +173,12 @@ class HttpResource(RedFetcher):
|
|
|
167
173
|
if self.descend and tag not in ["a"] and link not in self.links[tag]:
|
|
168
174
|
linked = HttpResource(self.config, active_checks_enabled=False)
|
|
169
175
|
credentials, other_headers = split_credentials(self.request.headers.text)
|
|
170
|
-
|
|
176
|
+
uri: Optional[str]
|
|
177
|
+
try:
|
|
178
|
+
uri = urljoin(base, link)
|
|
179
|
+
except ValueError:
|
|
180
|
+
uri = None
|
|
181
|
+
linked.set_request(uri if uri is not None else link, headers=other_headers)
|
|
171
182
|
# Compare the normalised URIs, not the link text: normalisation can change the host.
|
|
172
183
|
# Credentials stay off unless both origins are known and equal.
|
|
173
184
|
linked_origin = origin_of(linked.request.uri)
|
|
@@ -177,11 +188,33 @@ class HttpResource(RedFetcher):
|
|
|
177
188
|
)
|
|
178
189
|
self.linked.append((linked, tag))
|
|
179
190
|
self.add_check(linked)
|
|
180
|
-
|
|
191
|
+
if uri is None: # can't be resolved; list it as failed and carry on
|
|
192
|
+
self._fail_link(linked, UrlError("link could not be resolved"))
|
|
193
|
+
else:
|
|
194
|
+
try:
|
|
195
|
+
linked.check()
|
|
196
|
+
except Exception as why: # pylint: disable=broad-except
|
|
197
|
+
self._fail_link(linked, HttpError(str(why)))
|
|
181
198
|
self.links[tag].add(link)
|
|
182
199
|
if not self.response.base_uri:
|
|
183
200
|
self.response.base_uri = base
|
|
184
201
|
|
|
202
|
+
def _fail_link(self, linked: "HttpResource", error: HttpError) -> None:
|
|
203
|
+
"Record why a linked resource couldn't be checked, and complete it."
|
|
204
|
+
linked.fetch_error = linked.fetch_error or error
|
|
205
|
+
linked.stop()
|
|
206
|
+
if linked in self._task_map: # stop() didn't complete it
|
|
207
|
+
self.finish_check(linked)
|
|
208
|
+
try: # after cleanup, so a failing listener can't undo it
|
|
209
|
+
uri = (linked.request.uri or "")[:200]
|
|
210
|
+
self.emit("debug", f"checking link {uri} failed: {error!r}")
|
|
211
|
+
except Exception: # pylint: disable=broad-except
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
def _link_error(self, message: str) -> None:
|
|
215
|
+
"Report a problem parsing or handling links."
|
|
216
|
+
self.emit("debug", f"link parsing: {message}")
|
|
217
|
+
|
|
185
218
|
def stop(self) -> None:
|
|
186
219
|
"Stop the resource and any sub-resources."
|
|
187
220
|
for task in list(self._task_map):
|
|
@@ -116,6 +116,11 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
116
116
|
out.append("fetch_done")
|
|
117
117
|
return f"<{', '.join(out)} at {id(self):#x}>"
|
|
118
118
|
|
|
119
|
+
@property
|
|
120
|
+
def has_response(self) -> bool:
|
|
121
|
+
"Whether a status line arrived, so there are headers to show even if the body failed."
|
|
122
|
+
return self.response.status_code is not None
|
|
123
|
+
|
|
119
124
|
def preflight(self) -> bool:
|
|
120
125
|
"""
|
|
121
126
|
Check to see if we should bother running. Return True
|
|
@@ -189,6 +194,13 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
189
194
|
"Start an exchange for the current request, optionally adding extra headers."
|
|
190
195
|
assert self.request.method, "method not set in _send_request"
|
|
191
196
|
assert self.request.uri, "uri not set in _send_request"
|
|
197
|
+
try:
|
|
198
|
+
wire_uri = self.request.uri.encode("ascii")
|
|
199
|
+
except UnicodeEncodeError:
|
|
200
|
+
# e.g., httplint leaves a non-ASCII password in the URI unencoded
|
|
201
|
+
self.fetch_error = httperr.UrlError("URI contains non-ASCII characters")
|
|
202
|
+
self._fetch_done()
|
|
203
|
+
return
|
|
192
204
|
self.exchange = self.client.exchange()
|
|
193
205
|
self.exchange.on("response_nonfinal", self._response_nonfinal)
|
|
194
206
|
self.exchange.once("response_start", self._response_start)
|
|
@@ -210,7 +222,7 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
210
222
|
self.request.start_time = time.time()
|
|
211
223
|
self.exchange.request_start(
|
|
212
224
|
self.request.method.encode("ascii"),
|
|
213
|
-
|
|
225
|
+
wire_uri,
|
|
214
226
|
req_hdrs,
|
|
215
227
|
)
|
|
216
228
|
if not self.fetch_done: # the request could have immediately failed.
|
|
@@ -292,6 +304,7 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
292
304
|
assert self.exchange.res_version, "exchange.res_version not set in _response_start"
|
|
293
305
|
self.response.process_response_topline(self.exchange.res_version, status, phrase)
|
|
294
306
|
self.response.process_headers(res_headers)
|
|
307
|
+
self.response_header_length = self.exchange.input_header_length
|
|
295
308
|
self.emit("response_headers_available")
|
|
296
309
|
|
|
297
310
|
def _response_body(self, chunk: bytes) -> None:
|
|
@@ -341,10 +354,12 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
341
354
|
self.response.notes.add("body", EXTRA_DATA, sample=err_sample)
|
|
342
355
|
elif isinstance(error, httperr.ChunkError):
|
|
343
356
|
self.response.notes.add("field-transfer-encoding", BAD_CHUNK, chunk_sample=err_sample)
|
|
357
|
+
self.fetch_error = error # thor stops the response here, so it never completes
|
|
344
358
|
elif isinstance(error, httperr.HeaderSpaceError) and error.detail:
|
|
345
|
-
subject = f"field-{
|
|
346
|
-
self.response.notes.add(subject, HEADER_NAME_SPACE, header_name=
|
|
347
|
-
|
|
359
|
+
subject = f"field-{err_sample.lower().strip()}"
|
|
360
|
+
self.response.notes.add(subject, HEADER_NAME_SPACE, header_name=err_sample)
|
|
361
|
+
# thor sends nothing after an error, so the response never completes.
|
|
362
|
+
self.fetch_error = error
|
|
348
363
|
else:
|
|
349
364
|
self.fetch_error = error
|
|
350
365
|
self._fetch_done()
|
|
@@ -7,15 +7,30 @@ Parse links from a stream of HTML data.
|
|
|
7
7
|
import codecs
|
|
8
8
|
from html.parser import HTMLParser
|
|
9
9
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
10
|
+
from urllib.parse import urljoin, urlsplit
|
|
10
11
|
|
|
11
|
-
from httplint.
|
|
12
|
+
from httplint.charset import is_text_encoding, whatwg_encoding
|
|
13
|
+
from httplint.field.utils import split_params, unquote_string
|
|
12
14
|
from httplint.message import HttpMessageLinter
|
|
13
|
-
from httplint.syntax import rfc9110
|
|
14
15
|
|
|
15
16
|
from redbot.utils import decode_text
|
|
16
17
|
|
|
17
18
|
DEFAULT_ENCODING = "utf-8"
|
|
18
19
|
MAX_FEED_BYTES = 2 * 1024 * 1024
|
|
20
|
+
# A <base> inside these isn't the document's base (noscript: as when scripting is enabled).
|
|
21
|
+
INERT_TAGS = ("template", "svg", "math", "noscript")
|
|
22
|
+
|
|
23
|
+
# Python codecs that decode bytes to text but that a <meta> must not select: UTF-7 and
|
|
24
|
+
# UTF-16/32 (HTML treats a UTF-16 <meta> as UTF-8), and ones that aren't character encodings.
|
|
25
|
+
NOT_META_CHARSETS = (
|
|
26
|
+
"utf_7",
|
|
27
|
+
"utf_16",
|
|
28
|
+
"utf_32",
|
|
29
|
+
"unicode_escape",
|
|
30
|
+
"raw_unicode_escape",
|
|
31
|
+
"idna",
|
|
32
|
+
"punycode",
|
|
33
|
+
)
|
|
19
34
|
|
|
20
35
|
|
|
21
36
|
class HTMLLinkParser(HTMLParser):
|
|
@@ -52,7 +67,7 @@ class HTMLLinkParser(HTMLParser):
|
|
|
52
67
|
self,
|
|
53
68
|
message: HttpMessageLinter,
|
|
54
69
|
link_procs: List[Callable[[str, str, str, str], None]],
|
|
55
|
-
err: Optional[Callable[[str],
|
|
70
|
+
err: Optional[Callable[[str], None]] = None,
|
|
56
71
|
) -> None:
|
|
57
72
|
self.message = message
|
|
58
73
|
self.link_procs = link_procs
|
|
@@ -61,6 +76,9 @@ class HTMLLinkParser(HTMLParser):
|
|
|
61
76
|
self.last_err_pos: int = 0
|
|
62
77
|
self.ok = True
|
|
63
78
|
self.bytes_fed = 0
|
|
79
|
+
self.base_seen = False
|
|
80
|
+
self.inert_depth = {tag: 0 for tag in INERT_TAGS}
|
|
81
|
+
self.decode_as: Optional[str] = None
|
|
64
82
|
HTMLParser.__init__(self)
|
|
65
83
|
|
|
66
84
|
def __getstate__(self) -> Dict[str, Any]:
|
|
@@ -73,7 +91,7 @@ class HTMLLinkParser(HTMLParser):
|
|
|
73
91
|
if self.bytes_fed > MAX_FEED_BYTES:
|
|
74
92
|
self.ok = False
|
|
75
93
|
return
|
|
76
|
-
encoding = self.message.character_encoding or DEFAULT_ENCODING
|
|
94
|
+
encoding = self.decode_as or self.message.character_encoding or DEFAULT_ENCODING
|
|
77
95
|
decoded = decode_text(bchunk, encoding, "ignore")
|
|
78
96
|
self.feed(decoded)
|
|
79
97
|
|
|
@@ -106,7 +124,13 @@ class HTMLLinkParser(HTMLParser):
|
|
|
106
124
|
for proc in self.link_procs:
|
|
107
125
|
proc(self.message.base_uri, target, tag, title)
|
|
108
126
|
elif tag == "base":
|
|
109
|
-
|
|
127
|
+
attr_d = dict(attrs)
|
|
128
|
+
inert = any(self.inert_depth.values())
|
|
129
|
+
if "href" in attr_d and not self.base_seen and not inert: # first <base href> counts
|
|
130
|
+
self.base_seen = True
|
|
131
|
+
self.message.base_uri = resolve_base(self.message.base_uri, attr_d["href"] or "")
|
|
132
|
+
elif tag in self.inert_depth:
|
|
133
|
+
self.inert_depth[tag] += 1
|
|
110
134
|
elif tag == "meta":
|
|
111
135
|
attr_d = dict(attrs)
|
|
112
136
|
if (attr_d.get("http-equiv", "") or "").lower() != "content-type":
|
|
@@ -119,18 +143,36 @@ class HTMLLinkParser(HTMLParser):
|
|
|
119
143
|
media_type, params = ct, ""
|
|
120
144
|
media_type = media_type.lower()
|
|
121
145
|
param_dict: Dict[str, Optional[str]] = {}
|
|
122
|
-
for param in
|
|
146
|
+
for param in split_params(params):
|
|
123
147
|
try:
|
|
124
148
|
attr, val = param.split("=", 1)
|
|
125
149
|
param_dict[attr.lower()] = unquote_string(val)
|
|
126
150
|
except ValueError:
|
|
127
151
|
param_dict[param.lower()] = None
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
152
|
+
self._set_meta_charset(param_dict.get("charset"))
|
|
153
|
+
|
|
154
|
+
def _set_meta_charset(self, label: Optional[str]) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Apply a <meta> charset. A label that the Encoding Standard and Python both know is
|
|
157
|
+
recorded on the message, as for the header. A Python-only text encoding (`latin-1`,
|
|
158
|
+
`mac-roman`) is only used to decode later chunks, so the message never claims it.
|
|
159
|
+
Anything else is ignored.
|
|
160
|
+
"""
|
|
161
|
+
if not label or not is_text_encoding(label):
|
|
162
|
+
return
|
|
163
|
+
encoding = whatwg_encoding(label)
|
|
164
|
+
if encoding == "replacement":
|
|
165
|
+
return
|
|
166
|
+
if encoding:
|
|
167
|
+
self.message.whatwg_encoding = encoding
|
|
168
|
+
self.message.character_encoding = label
|
|
169
|
+
self.decode_as = None
|
|
170
|
+
elif not codecs.lookup(label).name.replace("-", "_").startswith(NOT_META_CHARSETS):
|
|
171
|
+
self.decode_as = label
|
|
172
|
+
|
|
173
|
+
def handle_endtag(self, tag: str) -> None:
|
|
174
|
+
if self.inert_depth.get(tag):
|
|
175
|
+
self.inert_depth[tag] -= 1
|
|
134
176
|
|
|
135
177
|
def error(self, message: str) -> None:
|
|
136
178
|
self.errors += 1
|
|
@@ -145,5 +187,19 @@ class HTMLLinkParser(HTMLParser):
|
|
|
145
187
|
self.err(message)
|
|
146
188
|
|
|
147
189
|
|
|
190
|
+
def resolve_base(page_uri: str, href: str) -> str:
|
|
191
|
+
"""
|
|
192
|
+
Resolve a <base href> against the page's URI. As in the HTML spec, an unparseable href or a
|
|
193
|
+
data: or javascript: URI leaves the page URI as the base (Chromium uses about:blank instead).
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
base = urljoin(page_uri, href.strip(" \t\n\f\r"))
|
|
197
|
+
if urlsplit(base).scheme.lower() in ("data", "javascript"):
|
|
198
|
+
return page_uri
|
|
199
|
+
except ValueError:
|
|
200
|
+
return page_uri
|
|
201
|
+
return base
|
|
202
|
+
|
|
203
|
+
|
|
148
204
|
class BadErrorIReallyMeanIt(Exception):
|
|
149
205
|
"""See http://bugs.python.org/issue8885 for why this is necessary."""
|
|
@@ -87,6 +87,14 @@ class WebBotAuthSigner:
|
|
|
87
87
|
raise WebBotAuthError("Web Bot Auth key must be an Ed25519 private key.")
|
|
88
88
|
self._key = key
|
|
89
89
|
self.directory = directory.strip().strip('"')
|
|
90
|
+
# Verifiers derive @authority from the directory URL they fetched. A
|
|
91
|
+
# scheme-less directory is taken as https.
|
|
92
|
+
try:
|
|
93
|
+
self.directory_authority = self.authority(
|
|
94
|
+
self.directory if "://" in self.directory else f"https://{self.directory}"
|
|
95
|
+
)
|
|
96
|
+
except ValueError as why:
|
|
97
|
+
raise WebBotAuthError(f"Invalid Web Bot Auth directory '{directory}'.") from why
|
|
90
98
|
self.validity = validity
|
|
91
99
|
self.key_created = key_created
|
|
92
100
|
|
|
@@ -121,15 +129,16 @@ class WebBotAuthSigner:
|
|
|
121
129
|
|
|
122
130
|
def _build_signature(
|
|
123
131
|
self,
|
|
124
|
-
components: List[Tuple[
|
|
132
|
+
components: List[Tuple[http_sf.types.ItemType, str]],
|
|
125
133
|
tag: str,
|
|
126
134
|
validity: int,
|
|
127
135
|
) -> Tuple[str, str]:
|
|
128
136
|
"""
|
|
129
137
|
Construct the RFC 9421 signature base over ``components`` (each an
|
|
130
|
-
ordered (component-
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
ordered (component-identifier, component-value) pair; an identifier is a
|
|
139
|
+
structured-field item, so it can carry parameters such as ``req``), sign
|
|
140
|
+
it, and return the Signature-Input and Signature dictionary-member values
|
|
141
|
+
for SIG_LABEL. ``validity`` is the signature lifetime in seconds.
|
|
133
142
|
|
|
134
143
|
Structured-field values are serialized with ``http_sf`` so the
|
|
135
144
|
@signature-params line in the base and the Signature-Input header are
|
|
@@ -144,10 +153,10 @@ class WebBotAuthSigner:
|
|
|
144
153
|
"nonce": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
|
|
145
154
|
"tag": tag,
|
|
146
155
|
}
|
|
147
|
-
covered
|
|
156
|
+
covered = [identifier for identifier, _ in components]
|
|
148
157
|
inner_list: http_sf.types.InnerListType = (covered, params)
|
|
149
158
|
|
|
150
|
-
base_lines = [f"{http_sf.ser(
|
|
159
|
+
base_lines = [f"{http_sf.ser(identifier)}: {value}" for identifier, value in components]
|
|
151
160
|
base_lines.append(f'{http_sf.ser("@signature-params")}: {http_sf.ser([inner_list])}')
|
|
152
161
|
signature = self._key.sign("\n".join(base_lines).encode("utf-8"))
|
|
153
162
|
|
|
@@ -157,7 +166,10 @@ class WebBotAuthSigner:
|
|
|
157
166
|
"Return the Web Bot Auth headers to add to a request for ``uri``."
|
|
158
167
|
agent_field = http_sf.ser(self.directory) # Signature-Agent value (an sf-string)
|
|
159
168
|
sig_input, sig = self._build_signature(
|
|
160
|
-
[
|
|
169
|
+
[
|
|
170
|
+
(("@authority", {}), self.authority(uri)),
|
|
171
|
+
(("signature-agent", {}), agent_field),
|
|
172
|
+
],
|
|
161
173
|
REQUEST_TAG,
|
|
162
174
|
self.validity,
|
|
163
175
|
)
|
|
@@ -167,10 +179,13 @@ class WebBotAuthSigner:
|
|
|
167
179
|
(b"Signature", sig.encode("ascii")),
|
|
168
180
|
]
|
|
169
181
|
|
|
170
|
-
def sign_directory(self
|
|
171
|
-
"Return the signature headers for
|
|
182
|
+
def sign_directory(self) -> List[Tuple[bytes, bytes]]:
|
|
183
|
+
"Return the signature headers for the directory response."
|
|
184
|
+
# Directory draft section 5.2: @authority must carry req (RFC 9421 section 2.4).
|
|
172
185
|
sig_input, sig = self._build_signature(
|
|
173
|
-
[("@authority",
|
|
186
|
+
[(("@authority", {"req": True}), self.directory_authority)],
|
|
187
|
+
DIRECTORY_TAG,
|
|
188
|
+
DIRECTORY_VALIDITY,
|
|
174
189
|
)
|
|
175
190
|
return [
|
|
176
191
|
(b"Signature-Input", sig_input.encode("ascii")),
|