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 89c5155  Use separate forms for the two ways to upload KEYS files
89c5155 is described below

commit 89c5155f1d70abc47b282bf230504b7f43c7d497
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Dec 16 19:53:08 2025 +0000

    Use separate forms for the two ways to upload KEYS files
---
 atr/post/keys.py   | 117 +++++++++++++++++++++---------
 atr/shared/keys.py | 204 ++++++++++++++++++++++-------------------------------
 2 files changed, 165 insertions(+), 156 deletions(-)

diff --git a/atr/post/keys.py b/atr/post/keys.py
index 9a1728f..2ba25a4 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -17,6 +17,7 @@
 
 
 import asyncio
+from typing import Final
 
 import aiohttp
 import asfquart.base as base
@@ -35,6 +36,8 @@ import atr.storage.types as types
 import atr.util as util
 import atr.web as web
 
+_KEYS_BASE_URL: Final[str] = "https://downloads.apache.org";
+
 
 @post.committer("/keys/add")
 @post.form(shared.keys.AddOpenPGPKeyForm)
@@ -179,41 +182,18 @@ async def ssh_add(session: web.Committer, 
add_ssh_key_form: shared.keys.AddSSHKe
 @post.committer("/keys/upload")
 @post.form(shared.keys.UploadKeysForm)
 async def upload(session: web.Committer, upload_form: 
shared.keys.UploadKeysForm) -> str:
-    """Upload a KEYS file containing multiple OpenPGP keys."""
-    keys_text = ""
-
-    try:
-        if upload_form.key:
-            keys_content = await asyncio.to_thread(upload_form.key.read)
-            keys_text = keys_content.decode("utf-8", errors="replace")
-        elif upload_form.keys_url:
-            keys_text = await _fetch_keys_from_url(str(upload_form.keys_url))
-
-        if not keys_text:
-            await quart.flash("No KEYS data found", "error")
-            return await shared.keys.render_upload_page(error=True)
-
-        selected_committee = upload_form.selected_committee
-
-        async with storage.write() as write:
-            wacp = write.as_committee_participant(selected_committee)
-            outcomes = await wacp.keys.ensure_associated(keys_text)
-
-        success_count = outcomes.result_count
-        error_count = outcomes.error_count
-        total_count = success_count + error_count
-
-        message = f"Processed {total_count} keys: {success_count} successful"
-        if error_count > 0:
-            message += f", {error_count} failed"
+    """Upload or fetch a KEYS file containing multiple OpenPGP keys."""
+    match upload_form:
+        case shared.keys.UploadFileForm() as upload_file_form:
+            return await _upload_file_keys(upload_file_form)
+        case shared.keys.UploadRemoteForm() as upload_remote_form:
+            return await _upload_remote_keys(upload_remote_form)
 
-        await quart.flash(message, "success" if (success_count > 0) else 
"error")
 
-        return await shared.keys.render_upload_page(results=outcomes, 
submitted_committees=[selected_committee])
-    except Exception as e:
-        log.exception("Error uploading KEYS file:")
-        await quart.flash(f"Error processing KEYS file: {e!s}", "error")
-        return await shared.keys.render_upload_page(error=True)
+def _construct_keys_url(committee_name: str, *, is_podling: bool) -> str:
+    if is_podling:
+        return f"{_KEYS_BASE_URL}/incubator/{committee_name}/KEYS"
+    return f"{_KEYS_BASE_URL}/{committee_name}/KEYS"
 
 
 async def _delete_openpgp_key(
@@ -247,10 +227,36 @@ async def _delete_ssh_key(session: web.Committer, 
delete_form: shared.keys.Delet
     return await session.redirect(get.keys.keys, success="SSH key deleted 
successfully")
 
 
+async def _upload_remote_keys(upload_remote_form: 
shared.keys.UploadRemoteForm) -> str:
+    """Fetch KEYS file from ASF downloads."""
+    try:
+        selected_committee = upload_remote_form.committee
+        async with db.session() as data:
+            committee = await data.committee(name=selected_committee).get()
+            if not committee:
+                await quart.flash(f"Committee '{selected_committee}' not 
found", "error")
+                return await shared.keys.render_upload_page(error=True)
+            is_podling = committee.is_podling
+
+        keys_url = _construct_keys_url(selected_committee, 
is_podling=is_podling)
+        keys_text = await _fetch_keys_from_url(keys_url)
+
+        if not keys_text:
+            await quart.flash("No KEYS data found at ASF downloads", "error")
+            return await shared.keys.render_upload_page(error=True)
+
+        return await _process_keys(keys_text, selected_committee)
+    except Exception as e:
+        log.exception("Error fetching KEYS file from ASF:")
+        await quart.flash(f"Error fetching KEYS file: {e!s}", "error")
+        return await shared.keys.render_upload_page(error=True)
+
+
 async def _fetch_keys_from_url(keys_url: str) -> str:
-    """Fetch KEYS file content from a URL."""
+    """Fetch KEYS file from ASF downloads."""
     try:
-        async with aiohttp.ClientSession() as session:
+        timeout = aiohttp.ClientTimeout(total=30)
+        async with aiohttp.ClientSession(timeout=timeout) as session:
             async with session.get(keys_url, allow_redirects=True) as response:
                 response.raise_for_status()
                 return await response.text()
@@ -260,6 +266,25 @@ async def _fetch_keys_from_url(keys_url: str) -> str:
         raise base.ASFQuartException(f"Network error while fetching keys: 
{e}", errorcode=503)
 
 
+async def _process_keys(keys_text: str, selected_committee: str) -> str:
+    """Process keys text and associate with committee."""
+    async with storage.write() as write:
+        wacp = write.as_committee_participant(selected_committee)
+        outcomes = await wacp.keys.ensure_associated(keys_text)
+
+    success_count = outcomes.result_count
+    error_count = outcomes.error_count
+    total_count = success_count + error_count
+
+    message = f"Processed {total_count} keys: {success_count} successful"
+    if error_count > 0:
+        message += f", {error_count} failed"
+
+    await quart.flash(message, "success" if (success_count > 0) else "error")
+
+    return await shared.keys.render_upload_page(results=outcomes, 
submitted_committees=[selected_committee])
+
+
 async def _update_committee_keys(
     session: web.Committer, update_form: shared.keys.UpdateCommitteeKeysForm
 ) -> web.WerkzeugResponse:
@@ -277,3 +302,25 @@ async def _update_committee_keys(
                 await quart.flash(f"Error regenerating the KEYS file for the 
{committee_name} committee.", "error")
 
     return await session.redirect(get.keys.keys)
+
+
+async def _upload_file_keys(upload_file_form: shared.keys.UploadFileForm) -> 
str:
+    """Handle file upload."""
+    try:
+        if upload_file_form.key is None:
+            await quart.flash("No KEYS file uploaded", "error")
+            return await shared.keys.render_upload_page(error=True)
+
+        keys_content = await asyncio.to_thread(upload_file_form.key.read)
+        keys_text = keys_content.decode("utf-8", errors="replace")
+
+        if not keys_text:
+            await quart.flash("No KEYS data found", "error")
+            return await shared.keys.render_upload_page(error=True)
+
+        selected_committee = upload_file_form.selected_committee
+        return await _process_keys(keys_text, selected_committee)
+    except Exception as e:
+        log.exception("Error uploading KEYS file:")
+        await quart.flash(f"Error processing KEYS file: {e!s}", "error")
+        return await shared.keys.render_upload_page(error=True)
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 65889a2..4b66d67 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -19,7 +19,6 @@
 
 from typing import Annotated, Literal
 
-import htpy
 import markupsafe
 import pydantic
 
@@ -33,7 +32,9 @@ import atr.util as util
 
 type DELETE_OPENPGP_KEY = Literal["delete_openpgp_key"]
 type DELETE_SSH_KEY = Literal["delete_ssh_key"]
+type UPLOAD_REMOTE_KEYS = Literal["upload_remote_keys"]
 type UPDATE_COMMITTEE_KEYS = Literal["update_committee_keys"]
+type UPLOAD_FILE_KEYS = Literal["upload_file_keys"]
 
 
 class AddOpenPGPKeyForm(form.Form):
@@ -91,18 +92,13 @@ class UpdateKeyCommitteesForm(form.Form):
     )
 
 
-class UploadKeysForm(form.Form):
+class UploadFileForm(form.Form):
+    variant: UPLOAD_FILE_KEYS = form.value(UPLOAD_FILE_KEYS)
     key: form.File = form.label(
         "KEYS file",
         "Upload a KEYS file containing multiple PGP public keys."
         " The file should contain keys in ASCII-armored format, starting with"
         ' "-----BEGIN PGP PUBLIC KEY BLOCK-----".',
-        widget=form.Widget.CUSTOM,
-    )
-    keys_url: form.OptionalURL = form.label(
-        "KEYS file URL",
-        "Enter a URL to a KEYS file. This will be fetched by the server.",
-        widget=form.Widget.CUSTOM,
     )
     selected_committee: str = form.label(
         "Associate keys with committee",
@@ -111,14 +107,88 @@ class UploadKeysForm(form.Form):
     )
 
     @pydantic.model_validator(mode="after")
-    def validate_key_source(self) -> "UploadKeysForm":
-        if (not self.key) and (not self.keys_url):
-            raise ValueError("Either a file or a URL is required")
-        if self.key and self.keys_url:
-            raise ValueError("Provide either a file or a URL, not both")
+    def validate_key_required(self) -> "UploadFileForm":
+        if not self.key:
+            raise ValueError("A KEYS file is required")
         return self
 
 
+class UploadRemoteForm(form.Form):
+    variant: UPLOAD_REMOTE_KEYS = form.value(UPLOAD_REMOTE_KEYS)
+    committee: str = form.label(
+        "Committee",
+        "Select the committee whose KEYS file to fetch from ASF downloads.",
+        widget=form.Widget.RADIO,
+    )
+
+
+type UploadKeysForm = Annotated[
+    UploadFileForm | UploadRemoteForm,
+    form.DISCRIMINATOR,
+]
+
+
+async def render_upload_page(
+    results: storage.outcome.List | None = None,
+    submitted_committees: list[str] | None = None,
+    error: bool = False,
+) -> str:
+    """Render the upload page with optional results."""
+    import atr.get as get
+    import atr.post as post
+
+    async with storage.write() as write:
+        participant_of_committees = await write.participant_of_committees()
+
+    eligible_committees = [
+        c for c in participant_of_committees if (not 
util.committee_is_standing(c.name)) or (c.name == "tooling")
+    ]
+
+    committee_choices = [(c.name, c.display_name) for c in eligible_committees]
+    committee_map = {c.name: c.display_name for c in eligible_committees}
+
+    page = htm.Block()
+    page.p[htm.a(".atr-back-link", href=util.as_url(get.keys.keys))["← Back to 
Manage keys"]]
+    page.h1["Import KEYS"]
+    page.p["Import OpenPGP public signing keys from a KEYS file."]
+
+    if results and submitted_committees:
+        page.append(_get_results_table_css())
+        _render_results_table(page, results, submitted_committees, 
committee_map)
+
+    page.h2["Upload a file"]
+    page.p["Upload a KEYS file from your computer."]
+
+    form.render_block(
+        page,
+        model_cls=shared.keys.UploadFileForm,
+        action=util.as_url(post.keys.upload),
+        submit_label="Upload KEYS file",
+        defaults={"selected_committee": committee_choices},
+        border=True,
+        wider_widgets=True,
+    )
+
+    page.h2(".mt-5")["Fetch existing KEYS file"]
+    page.p["Fetch the KEYS file from the ASF downloads server for the selected 
committee."]
+
+    form.render_block(
+        page,
+        model_cls=shared.keys.UploadRemoteForm,
+        action=util.as_url(post.keys.upload),
+        submit_label="Fetch KEYS file",
+        defaults={"committee": committee_choices},
+        border=True,
+        wider_widgets=True,
+    )
+
+    return await template.blank(
+        "Import KEYS",
+        content=page.collect(),
+        description="Import OpenPGP public signing keys from a KEYS file.",
+    )
+
+
 def _get_results_table_css() -> htm.Element:
     return htm.style[
         markupsafe.Markup(
@@ -246,111 +316,3 @@ def _render_results_table(
         for outcome in processing_errors:
             err = outcome.error_or_none()
             page.div(".alert.alert-danger.p-2.mb-3")[str(err)]
-
-
-async def render_upload_page(
-    results: storage.outcome.List | None = None,
-    submitted_committees: list[str] | None = None,
-    error: bool = False,
-) -> str:
-    """Render the upload page with optional results."""
-    import atr.get as get
-    import atr.post as post
-
-    async with storage.write() as write:
-        participant_of_committees = await write.participant_of_committees()
-
-    eligible_committees = [
-        c for c in participant_of_committees if (not 
util.committee_is_standing(c.name)) or (c.name == "tooling")
-    ]
-
-    committee_choices = [(c.name, c.display_name) for c in eligible_committees]
-    committee_map = {c.name: c.display_name for c in eligible_committees}
-
-    page = htm.Block()
-    page.p[htm.a(".atr-back-link", href=util.as_url(get.keys.keys))["← Back to 
Manage keys"]]
-    page.h1["Upload a KEYS file"]
-    page.p["Upload a KEYS file containing multiple OpenPGP public signing 
keys."]
-
-    if results and submitted_committees:
-        page.append(_get_results_table_css())
-        _render_results_table(page, results, submitted_committees, 
committee_map)
-
-    custom_tabs_widget = _render_upload_tabs()
-
-    form.render_block(
-        page,
-        model_cls=shared.keys.UploadKeysForm,
-        action=util.as_url(post.keys.upload),
-        submit_label="Upload KEYS file",
-        cancel_url=util.as_url(get.keys.keys),
-        defaults={"selected_committee": committee_choices},
-        custom={"key": custom_tabs_widget},
-        skip=["keys_url"],
-        border=True,
-        wider_widgets=True,
-    )
-
-    return await template.blank(
-        "Upload a KEYS file",
-        content=page.collect(),
-        description="Upload a KEYS file containing multiple OpenPGP public 
signing keys.",
-    )
-
-
-def _render_upload_tabs() -> htm.Element:
-    """Render the tabbed interface for file upload or URL input."""
-    tabs_ul = htm.ul(".nav.nav-tabs", id="keysUploadTab", role="tablist")[
-        htm.li(".nav-item", role="presentation")[
-            htpy.button(
-                class_="nav-link active",
-                id="file-upload-tab",
-                data_bs_toggle="tab",
-                data_bs_target="#file-upload-pane",
-                type="button",
-                role="tab",
-                aria_controls="file-upload-pane",
-                aria_selected="true",
-            )["Upload from file"]
-        ],
-        htm.li(".nav-item", role="presentation")[
-            htpy.button(
-                class_="nav-link",
-                id="url-upload-tab",
-                data_bs_toggle="tab",
-                data_bs_target="#url-upload-pane",
-                type="button",
-                role="tab",
-                aria_controls="url-upload-pane",
-                aria_selected="false",
-            )["Upload from URL"]
-        ],
-    ]
-
-    file_pane = htm.div(".tab-pane.fade.show.active", id="file-upload-pane", 
role="tabpanel")[
-        htm.div(".pt-3")[
-            htpy.input(class_="form-control", id="key", name="key", 
type="file"),
-            htm.div(".form-text.text-muted.mt-2")[
-                "Upload a KEYS file containing multiple PGP public keys. The 
file should contain keys in "
-                'ASCII-armored format, starting with "-----BEGIN PGP PUBLIC 
KEY BLOCK-----".'
-            ],
-        ]
-    ]
-
-    url_pane = htm.div(".tab-pane.fade", id="url-upload-pane", 
role="tabpanel")[
-        htm.div(".pt-3")[
-            htpy.input(
-                class_="form-control",
-                id="keys_url",
-                name="keys_url",
-                placeholder="Enter URL to KEYS file",
-                type="url",
-                value="",
-            ),
-            htm.div(".form-text.text-muted.mt-2")["Enter a URL to a KEYS file. 
This will be fetched by the server."],
-        ]
-    ]
-
-    tab_content = htm.div(".tab-content", 
id="keysUploadTabContent")[file_pane, url_pane]
-
-    return htm.div[tabs_ul, tab_content]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to