This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 1e376c7 Remove detected instances of markup in strings
1e376c7 is described below
commit 1e376c7e21694b4d15bd220fc4e0874ffb73ad5d
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Oct 27 19:07:15 2025 +0000
Remove detected instances of markup in strings
---
atr/admin/__init__.py | 55 +++++++++++++++++--------------------
atr/analysis.py | 13 ---------
atr/get/__init__.py | 4 +--
atr/get/example_test.py | 38 --------------------------
atr/htm.py | 70 ++++++++++++++++++++++++++++++++++++++++--------
atr/mail.py | 51 +++--------------------------------
atr/post/__init__.py | 4 +--
atr/post/example_test.py | 39 ---------------------------
atr/routes/download.py | 17 +++++++-----
atr/routes/finish.py | 3 +--
atr/routes/published.py | 31 +++++++++++----------
11 files changed, 118 insertions(+), 207 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 8edf6f2..676d572 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -64,6 +64,10 @@ class BrowseAsUserForm(forms.Typed):
submit = forms.submit("Browse as this user")
+class CheckKeysForm(forms.Typed):
+ submit = forms.submit("Check public signing key details")
+
+
class DeleteCommitteeKeysForm(forms.Typed):
committee_name = forms.select("Committee")
confirm_delete = forms.string(
@@ -86,12 +90,20 @@ class DeleteReleaseForm(forms.Typed):
submit = forms.submit("Delete selected releases permanently")
+class DeleteTestKeysForm(forms.Typed):
+ submit = forms.submit("Delete all OpenPGP keys for test user")
+
+
class LdapLookupForm(forms.Typed):
uid = forms.optional("ASF UID (optional)", placeholder="Enter ASF UID,
e.g. johnsmith, or * for all")
email = forms.optional("Email address (optional)", placeholder="Enter
email address, e.g. [email protected]")
submit = forms.submit("Lookup")
+class RegenerateKeysForm(forms.Typed):
+ submit = forms.submit("Regenerate all KEYS files")
+
+
@admin.get("/all-releases")
async def all_releases(session: web.Committer) -> str:
"""Display a list of all releases across all phases."""
@@ -298,19 +310,14 @@ async def _delete_test_openpgp_keys(session:
web.Committer) -> quart.Response |
test_uid = "test"
if quart.request.method != "POST":
- empty_form = await forms.Empty.create_form()
- return quart.Response(
- f"""
-<form method="post">
- <button type="submit">Delete all OpenPGP keys for {test_uid} user</button>
- {empty_form.hidden_tag()}
-</form>
-""",
- mimetype="text/html",
- )
+ delete_form = await DeleteTestKeysForm.create_form()
+ rendered_form = forms.render_simple(delete_form, action="")
+ return quart.Response(str(rendered_form), mimetype="text/html")
# This is a POST request
- await util.validate_empty_form()
+ delete_form = await DeleteTestKeysForm.create_form()
+ if not await delete_form.validate_on_submit():
+ raise base.ASFQuartException("Invalid form submission. Please check
your input and try again.", errorcode=400)
async with storage.write() as write:
wafc = write.as_foundation_committer()
@@ -442,16 +449,9 @@ async def keys_check_post(session: web.Committer) ->
quart.Response:
async def _keys_check(session: web.Committer) -> quart.Response:
"""Check public signing key details."""
if quart.request.method != "POST":
- empty_form = await forms.Empty.create_form()
- return quart.Response(
- f"""
-<form method="post">
- <button type="submit">Check public signing key details</button>
- {empty_form.hidden_tag()}
-</form>
-""",
- mimetype="text/html",
- )
+ check_form = await CheckKeysForm.create_form()
+ rendered_form = forms.render_simple(check_form, action="")
+ return quart.Response(str(rendered_form), mimetype="text/html")
try:
result = await _check_keys()
@@ -474,16 +474,9 @@ async def keys_regenerate_all_post(session: web.Committer)
-> quart.Response:
async def _keys_regenerate_all(session: web.Committer) -> quart.Response:
"""Regenerate the KEYS file for all committees."""
if quart.request.method != "POST":
- empty_form = await forms.Empty.create_form()
- return quart.Response(
- f"""
-<form method="post">
- <button type="submit">Regenerate all KEYS files</button>
- {empty_form.hidden_tag()}
-</form>
-""",
- mimetype="text/html",
- )
+ regenerate_form = await RegenerateKeysForm.create_form()
+ rendered_form = forms.render_simple(regenerate_form, action="")
+ return quart.Response(str(rendered_form), mimetype="text/html")
async with db.session() as data:
committee_names = [c.name for c in await data.committee().all()]
diff --git a/atr/analysis.py b/atr/analysis.py
index d0ef58a..7a86426 100755
--- a/atr/analysis.py
+++ b/atr/analysis.py
@@ -179,19 +179,6 @@ def architecture_pattern() -> str:
return "(" + "|".join(architectures) + ")(?=[_.-])"
-def candidate_highlight(path: pathlib.Path) -> str:
- parts = []
- for part in path.parts:
- if ("<" in part) or (">" in part) or ("&" in part):
- # TODO: Should perhaps check for ' and " too for attribute value
safety
- raise ValueError(f"Invalid path segment: {part}")
- if _CANDIDATE_WHOLE.match(part):
- parts.append(f"<strong>{part}</strong>")
- continue
- parts.append(_CANDIDATE_PARTIAL.sub(r"<strong>\g<0></strong>", part))
- return str(pathlib.Path(*parts))
-
-
def candidate_match(segment: str) -> re.Match[str] | None:
return _CANDIDATE_WHOLE.match(segment) or
_CANDIDATE_PARTIAL.search(segment)
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 729f0db..352ab48 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -24,8 +24,6 @@ import atr.get.compose as compose
import atr.get.distribution as distribution
import atr.get.vote as vote
-from .example_test import respond as example_test
-
ROUTES_MODULE: Final[Literal[True]] = True
-__all__ = ["announce", "candidate", "committees", "compose", "distribution",
"example_test", "vote"]
+__all__ = ["announce", "candidate", "committees", "compose", "distribution",
"vote"]
diff --git a/atr/get/example_test.py b/atr/get/example_test.py
deleted file mode 100644
index 2a29a13..0000000
--- a/atr/get/example_test.py
+++ /dev/null
@@ -1,38 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-import atr.blueprints.get as get
-import atr.forms as forms
-import atr.post as post
-import atr.util as util
-import atr.web as web
-
-
[email protected]("/example/test")
-async def respond(session: web.Committer) -> str:
- empty_form = await forms.Empty.create_form()
- return f"""\
-<h1>Test route (GET)</h1>
-<p>Hello, {session.asf_uid}!</p>
-<p>This is a test GET route for committers only.</p>
-
-<h2>Test POST submission</h2>
-<form method="post" action="{util.as_url(post.example_test)}">
- {empty_form.hidden_tag()}
- <button type="submit" class="btn btn-primary">Submit to POST route</button>
-</form>
-"""
diff --git a/atr/htm.py b/atr/htm.py
index ac3587d..9d2e1b9 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -17,6 +17,7 @@
from __future__ import annotations
+import contextlib
from typing import TYPE_CHECKING, Any
import htpy
@@ -24,12 +25,14 @@ import htpy
from . import log
if TYPE_CHECKING:
- from collections.abc import Callable
-
+ from collections.abc import Callable, Generator
type Element = htpy.Element
+type VoidElement = htpy.VoidElement
a = htpy.a
+body = htpy.body
+br = htpy.br
button = htpy.button
code = htpy.code
details = htpy.details
@@ -39,6 +42,7 @@ form = htpy.form
h1 = htpy.h1
h2 = htpy.h2
h3 = htpy.h3
+html = htpy.html
li = htpy.li
p = htpy.p
pre = htpy.pre
@@ -46,11 +50,13 @@ script = htpy.script
span = htpy.span
strong = htpy.strong
summary = htpy.summary
+style = htpy.style
table = htpy.table
tbody = htpy.tbody
td = htpy.td
th = htpy.th
thead = htpy.thead
+title = htpy.title
tr = htpy.tr
ul = htpy.ul
@@ -99,6 +105,19 @@ class Block:
def __repr__(self) -> str:
return f"{self.element!r}[*{self.elements!r}]"
+ def __check_parent(self, child_tag: str, allowed_parent_tags: set[str]) ->
None:
+ # TODO: We should make this a static check
+ tag_name = self.__tag_name()
+ if self.element is not None:
+ if tag_name not in allowed_parent_tags:
+ permitted = ", ".join(allowed_parent_tags) + f", not
{tag_name}"
+ raise ValueError(f"{child_tag} can only be used as a child of
{permitted}")
+
+ def __tag_name(self) -> str:
+ if self.element is None:
+ return "div"
+ return self.element._name
+
def append(self, eob: Block | Element) -> None:
match eob:
case Block():
@@ -107,11 +126,19 @@ class Block:
case htpy.Element():
self.elements.append(eob)
- def collect(self, separator: str | None = None, depth: int = 1) -> Element:
+ @contextlib.contextmanager
+ def block(
+ self, element: Element | None = None, separator: Element | VoidElement
| str | None = None
+ ) -> Generator[Block, Any, Any]:
+ block = Block(element)
+ yield block
+ self.append(block.collect(separator=separator))
+
+ def collect(self, separator: Element | VoidElement | str | None = None,
depth: int = 1) -> Element:
src = log.caller_name(depth=depth)
if separator is not None:
- separated: list[Element | str] = [separator] * (2 *
len(self.elements) - 1)
+ separated: list[Element | VoidElement | str] = [separator] * (2 *
len(self.elements) - 1)
separated[::2] = self.elements
elements = separated
else:
@@ -124,21 +151,24 @@ class Block:
self.element._name,
self.element._attrs,
self.element._children,
- )
- # TODO: Check that there are no injection attacks possible here
- if ' data-src="' not in new_element._attrs:
- if new_element._attrs:
- new_element._attrs = new_element._attrs + f' data-src="{src}"'
- else:
- new_element._attrs = f' data-src="{src}"'
+ )(data_src=src)
+ # if self.element._name == "html":
+ # return "<!doctype html>" + new_element[*elements]
return new_element[*elements]
@property
def a(self) -> BlockElementCallable:
+ self.__check_parent("a", {"div", "p", "pre"})
return BlockElementCallable(self, a)
+ @property
+ def body(self) -> BlockElementCallable:
+ self.__check_parent("body", {"html"})
+ return BlockElementCallable(self, body)
+
@property
def code(self) -> BlockElementCallable:
+ self.__check_parent("code", {"div", "p"})
return BlockElementCallable(self, code)
@property
@@ -151,26 +181,32 @@ class Block:
@property
def h1(self) -> BlockElementCallable:
+ self.__check_parent("h1", {"body", "div"})
return BlockElementCallable(self, h1)
@property
def h2(self) -> BlockElementCallable:
+ self.__check_parent("h2", {"body", "div"})
return BlockElementCallable(self, h2)
@property
def h3(self) -> BlockElementCallable:
+ self.__check_parent("h3", {"body", "div"})
return BlockElementCallable(self, h3)
@property
def li(self) -> BlockElementCallable:
+ self.__check_parent("li", {"ul", "ol"})
return BlockElementCallable(self, li)
@property
def p(self) -> BlockElementCallable:
+ self.__check_parent("p", {"body", "div"})
return BlockElementCallable(self, p)
@property
def pre(self) -> BlockElementCallable:
+ self.__check_parent("pre", {"body", "div"})
return BlockElementCallable(self, pre)
@property
@@ -181,19 +217,31 @@ class Block:
def strong(self) -> BlockElementCallable:
return BlockElementCallable(self, strong)
+ @property
+ def style(self) -> BlockElementCallable:
+ self.__check_parent("style", {"html", "head"})
+ return BlockElementCallable(self, style)
+
@property
def summary(self) -> BlockElementCallable:
return BlockElementCallable(self, summary)
@property
def table(self) -> BlockElementCallable:
+ self.__check_parent("table", {"body", "div"})
return BlockElementCallable(self, table)
def text(self, text: str) -> None:
self.elements.append(text)
+ @property
+ def title(self) -> BlockElementCallable:
+ self.__check_parent("title", {"head", "html"})
+ return BlockElementCallable(self, title)
+
@property
def ul(self) -> BlockElementCallable:
+ self.__check_parent("ul", {"body", "div"})
return BlockElementCallable(self, ul)
diff --git a/atr/mail.py b/atr/mail.py
index fd61d62..17fafe4 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -31,11 +31,7 @@ import atr.log as log
# We could e.g. use uppercase instead of global_
# It's not always worth identifying globals as globals
# But in many cases we should do so
-# TODO: Get at least global_dkim_domain from configuration
-# And probably global_dkim_selector too
-# global_dkim_selector: str = "mail"
-global_dkim_domain: str = "apache.org"
-# global_secret_key: str | None = None
+global_domain: str = "apache.org"
_MAIL_RELAY: Final[str] = "mail-relay.apache.org"
_SMTP_PORT: Final[int] = 587
@@ -55,14 +51,14 @@ async def send(message: Message) -> tuple[str, list[str]]:
"""Send an email notification about an artifact or a vote."""
log.info(f"Sending email for event: {message}")
from_addr = message.email_sender
- if not from_addr.endswith(f"@{global_dkim_domain}"):
- raise ValueError(f"from_addr must end with @{global_dkim_domain}, got
{from_addr}")
+ if not from_addr.endswith(f"@{global_domain}"):
+ raise ValueError(f"from_addr must end with @{global_domain}, got
{from_addr}")
to_addr = message.email_recipient
_validate_recipient(to_addr)
# UUID4 is entirely random, with no timestamp nor namespace
# It does have 6 version and variant bits, so only 122 bits are random
- mid = f"{uuid.uuid4()}@{global_dkim_domain}"
+ mid = f"{uuid.uuid4()}@{global_domain}"
headers = [
f"From: {from_addr}",
f"To: {to_addr}",
@@ -100,49 +96,10 @@ async def send(message: Message) -> tuple[str, list[str]]:
return mid, errors
-# def set_secret_key(key: str) -> None:
-# """Set the secret key for DKIM signing."""
-# global global_secret_key
-# global_secret_key = key
-
-
-# async def set_secret_key_default() -> None:
-# # TODO: Document this, or improve it
-# project_root =
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
-# dkim_path = os.path.join(project_root, "state", "dkim.private")
-
-# async with aiofiles.open(dkim_path) as f:
-# dkim_key = await f.read()
-# set_secret_key(dkim_key.strip())
-# log.info("DKIM key loaded and set successfully")
-
-
async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) ->
list[str]:
"""Send an email to multiple recipients."""
message_bytes = bytes(msg_text, "utf-8")
- # if global_secret_key is None:
- # # This is a severe configuration error
- # # It does not count as a send error to only warn about
- # raise ValueError("global_secret_key is not set")
-
- # # DKIM sign the message
- # private_key = bytes(global_secret_key, "utf-8")
-
- # # Create a DKIM signature
- # sig = dkim.sign(
- # message=message_bytes,
- # selector=bytes(global_dkim_selector, "utf-8"),
- # domain=bytes(global_dkim_domain, "utf-8"),
- # privkey=private_key,
- # include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"],
- # )
-
- # # Prepend the DKIM signature to the message
- # dkim_msg = sig + message_bytes
-
- # log.info(f"email_send_many: {dkim_msg}")
-
errors = []
for addr in to_addrs:
try:
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index 768113b..ca6989d 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -22,8 +22,6 @@ import atr.post.candidate as candidate
import atr.post.distribution as distribution
import atr.post.vote as vote
-from .example_test import respond as example_test
-
ROUTES_MODULE: Final[Literal[True]] = True
-__all__ = ["announce", "candidate", "distribution", "example_test", "vote"]
+__all__ = ["announce", "candidate", "distribution", "vote"]
diff --git a/atr/post/example_test.py b/atr/post/example_test.py
deleted file mode 100644
index fce4b45..0000000
--- a/atr/post/example_test.py
+++ /dev/null
@@ -1,39 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-import quart
-
-import atr.blueprints.post as post
-import atr.get as get
-import atr.util as util
-import atr.web as web
-
-
[email protected]("/example/test")
-async def respond(session: web.Committer) -> quart.Response:
- await util.validate_empty_form()
- await quart.flash("POST request successful!", "success")
-
- return quart.Response(
- f"""\
-<h1>Test route (POST)</h1>
-<p>Hello, {session.asf_uid}!</p>
-<p>This POST route was successfully called!</p>
-<p><a href="{util.as_url(get.example_test)}">Go back to the GET route</a></p>
-""",
- mimetype="text/html",
- )
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 974fc40..8a8614d 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -24,11 +24,13 @@ import aiofiles
import aiofiles.os
import asfquart.base as base
import quart
+import werkzeug.http
import werkzeug.wrappers.response as response
import zipstream
import atr.config as config
import atr.db as db
+import atr.htm as htm
import atr.models.sql as sql
import atr.route as route
import atr.routes.mapping as mapping
@@ -141,9 +143,10 @@ async def zip_selected(
yield chunk
headers = {
- "Content-Disposition": f'attachment; filename="{release.name}.zip"',
+ "Content-Disposition": f"attachment;
filename={werkzeug.http.quote_header_value(release.name + '.zip')}",
"Content-Type": "application/zip",
}
+ # TODO: Write a type safe wrapper for quart.Response that ensures headers
are encoded correctly
return quart.Response(stream_zip(files_to_zip), headers=headers,
mimetype="application/zip")
@@ -210,7 +213,9 @@ async def _list(
if is_file or is_dir:
files.append(file_in_dir)
files.sort()
- html = []
+ html = htm.Block(htm.html)
+ html.style["body { margin: 1rem; font: 1.25rem/1.5 serif; }"]
+ div = htm.Block()
# Add link to parent directory if not at root
if file_path != ".":
@@ -221,7 +226,7 @@ async def _list(
version_name=version_name,
file_path=parent_path_str,
)
- html.append(f'<a href="{parent_link_url}">../</a>')
+ div.a(href=parent_link_url)["../"]
# List files and directories
for item_in_dir in files:
@@ -233,7 +238,7 @@ async def _list(
file_path=relative_path_str,
)
display_name = f"{item_in_dir}/" if await
aiofiles.os.path.isdir(full_path / item_in_dir) else str(item_in_dir)
- html.append(f'<a href="{link_url}">{display_name}</a>')
- head = "<style>body { margin: 1rem; font: 1.25rem/1.5 serif; }</style>"
- response_body = head + "<br>\n".join(html)
+ div.a(href=link_url)[display_name]
+ html.body[div.collect(separator=htm.br)]
+ response_body = html.collect()
return quart.Response(response_body, mimetype="text/html")
diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 2388044..3f60489 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -218,8 +218,7 @@ async def _analyse_rc_tags(latest_revision_dir:
pathlib.Path) -> RCTagAnalysisRe
if len(r.affected_paths_preview) >= 5:
# Can't break here, because we need to update the counts
continue
- highlighted_preview = analysis.candidate_highlight(p_rel)
- r.affected_paths_preview.append((highlighted_preview,
stripped_path_str))
+ r.affected_paths_preview.append((original_path_str, stripped_path_str))
return r
diff --git a/atr/routes/published.py b/atr/routes/published.py
index 175ddd7..daed201 100644
--- a/atr/routes/published.py
+++ b/atr/routes/published.py
@@ -22,6 +22,7 @@ from datetime import datetime
import aiofiles.os
import quart
+import atr.htm as htm
import atr.route as route
import atr.util as util
@@ -41,21 +42,25 @@ async def root(session: route.CommitterSession) ->
quart.Response:
async def _directory_listing(full_path: pathlib.Path, current_path: str) ->
quart.Response:
- html_parts = [
- "<!doctype html>",
- f"<title>Index of /{current_path}</title>",
- "<style>body { margin: 1rem; }</style>",
- f"<h1>Index of /{current_path}</h1>",
- "<pre>",
- ]
+ html = htm.Block(htm.html)
+ html.title[f"Index of /{current_path}"]
+ html.style["body { margin: 1rem; }"]
+ with html.block(htm.body) as body:
+ htm.h1[f"Index of /{current_path}"]
+ with body.block(htm.pre) as pre:
+ await _directory_listing_pre(full_path, current_path, pre)
+ return quart.Response(html.collect(), mimetype="text/html")
+
+async def _directory_listing_pre(full_path: pathlib.Path, current_path: str,
pre: htm.Block) -> None:
if current_path:
parent_path = pathlib.Path(current_path).parent
parent_url_path = str(parent_path) if str(parent_path) != "." else ""
if parent_url_path:
- html_parts.append(f'<a href="{util.as_url(path,
path=parent_url_path)}">../</a>')
+ pre.a(href=util.as_url(path, path=parent_url_path))["../"]
else:
- html_parts.append(f'<a href="{util.as_url(root)}">../</a>')
+ pre.a(href=util.as_url(root))["../"]
+ pre.text("\n\n")
entries = []
dir_contents = await aiofiles.os.listdir(full_path)
@@ -80,11 +85,9 @@ async def _directory_listing(full_path: pathlib.Path,
current_path: str) -> quar
mtime =
datetime.fromtimestamp(stat_info.st_mtime).strftime("%Y-%m-%d %H:%M")
entry_path = str(pathlib.Path(current_path) / entry["name"])
display_name = f"{entry['name']}/" if is_dir else entry["name"]
- link = f'<a href="{util.as_url(path,
path=entry_path)}">{display_name}</a>'
- html_parts.append(f"{mode} {nlink} {size} {mtime} {link}")
-
- html_parts.append("</pre>")
- return quart.Response("\n".join(html_parts), mimetype="text/html")
+ pre.text(f"{mode} {nlink} {size} {mtime} ")
+ pre.a(href=util.as_url(path, path=entry_path))[display_name]
+ pre.text("\n")
async def _file_content(full_path: pathlib.Path) -> quart.Response:
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]