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-release.git


The following commit(s) were added to refs/heads/main by this push:
     new a44adfc  Add a URL input tab to the KEYS upload form
a44adfc is described below

commit a44adfcdb9c8d17cad48a7d0d5bf4adcebd39bec
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jun 17 15:34:27 2025 +0100

    Add a URL input tab to the KEYS upload form
---
 atr/routes/keys.py             | 97 +++++++++++++++++++++++++++++++++---------
 atr/templates/keys-upload.html | 47 ++++++++++++++++++--
 2 files changed, 122 insertions(+), 22 deletions(-)

diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index ab86ce0..127497c 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -26,11 +26,12 @@ import logging
 import logging.handlers
 import pathlib
 import textwrap
-from collections.abc import Sequence
+from collections.abc import Awaitable, Callable, Sequence
 
 import aiofiles.os
 import asfquart as asfquart
 import asfquart.base as base
+import httpx
 import quart
 import sqlmodel
 import werkzeug.datastructures as datastructures
@@ -68,6 +69,56 @@ class UpdateCommitteeKeysForm(util.QuartFormTyped):
     submit = wtforms.SubmitField("Update KEYS file")
 
 
+class UploadKeyFormBase(util.QuartFormTyped):
+    key = wtforms.FileField(
+        "KEYS file",
+        validators=[wtforms.validators.Optional()],
+        description=(
+            "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-----".'
+        ),
+    )
+    keys_url = wtforms.URLField(
+        "KEYS file URL",
+        validators=[wtforms.validators.Optional(), wtforms.validators.URL()],
+        render_kw={"placeholder": "Enter URL to KEYS file"},
+        description="Enter a URL to a KEYS file. This will be fetched by the 
server.",
+    )
+    submit = wtforms.SubmitField("Upload KEYS file")
+    selected_committees = wtforms.SelectMultipleField(
+        "Associate keys with committees",
+        choices=[(c.name, c.display_name) for c in [] if (not 
util.committee_is_standing(c.name))],
+        coerce=str,
+        option_widget=wtforms.widgets.CheckboxInput(),
+        widget=wtforms.widgets.ListWidget(prefix_label=False),
+        validators=[wtforms.validators.InputRequired("You must select at least 
one committee")],
+        description=(
+            "Select the committees with which to associate these keys. You 
must be a member of the selected committees."
+        ),
+    )
+
+    async def validate(self, extra_validators: dict | None = None) -> bool:
+        """Ensure that either a file is uploaded or a URL is provided, but not 
both."""
+        if not await super().validate(extra_validators):
+            return False
+        if not self.key.data and not self.keys_url.data:
+            msg = "Either a file or a URL is required."
+            if self.key.errors and isinstance(self.key.errors, list):
+                self.key.errors.append(msg)
+            else:
+                self.key.errors = [msg]
+            return False
+        if self.key.data and self.keys_url.data:
+            msg = "Provide either a file or a URL, not both."
+            if self.key.errors and isinstance(self.key.errors, list):
+                self.key.errors.append(msg)
+            else:
+                self.key.errors = [msg]
+            return False
+        return True
+
+
 @routes.committer("/keys/add", methods=["GET", "POST"])
 async def add(session: routes.CommitterSession) -> str:
     """Add a new public signing key to the user's account."""
@@ -426,7 +477,7 @@ async def update_committee_keys(session: 
routes.CommitterSession, committee_name
     return await session.redirect(keys)
 
 
[email protected]("/keys/upload", methods=["GET", "POST"])
[email protected]("/keys/upload", methods=["GET", "POST"], 
measure_performance=False)
 async def upload(session: routes.CommitterSession) -> str:
     """Upload a KEYS file containing multiple GPG keys."""
     # Get committees for all projects the user is a member of
@@ -434,17 +485,7 @@ async def upload(session: routes.CommitterSession) -> str:
         project_list = session.committees + session.projects
         user_committees = await data.committee(name_in=project_list).all()
 
-    class UploadKeyForm(util.QuartFormTyped):
-        key = wtforms.FileField(
-            "KEYS file",
-            validators=[wtforms.validators.InputRequired("A KEYS file is 
required")],
-            description=(
-                "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-----".'
-            ),
-        )
-        submit = wtforms.SubmitField("Upload KEYS file")
+    class UploadKeyForm(UploadKeyFormBase):
         selected_committees = wtforms.SelectMultipleField(
             "Associate keys with committees",
             choices=[(c.name, c.display_name) for c in user_committees if (not 
util.committee_is_standing(c.name))],
@@ -487,9 +528,18 @@ async def upload(session: routes.CommitterSession) -> str:
         )
 
     if await form.validate_on_submit():
-        key_file = form.key.data
-        if not isinstance(key_file, datastructures.FileStorage):
-            return await render(error="Invalid file upload")
+        keys_text = ""
+        if form.key.data:
+            key_file = form.key.data
+            if not isinstance(key_file, datastructures.FileStorage):
+                return await render(error="Invalid file upload")
+            keys_content = await asyncio.to_thread(key_file.read)
+            keys_text = keys_content.decode("utf-8", errors="replace")
+        elif form.keys_url.data:
+            keys_text = await _get_keys_text(form.keys_url.data, render)
+
+        if not keys_text:
+            return await render(error="No KEYS data found.")
 
         # Get selected committee list from the form
         selected_committees = form.selected_committees.data
@@ -497,9 +547,6 @@ async def upload(session: routes.CommitterSession) -> str:
             return await render(error="You must select at least one committee")
         # This is a KEYS file of multiple GPG keys
         # We need to parse it and add each key to the user's account
-        keys_content = await asyncio.to_thread(key_file.read)
-        keys_text = keys_content.decode("utf-8", errors="replace")
-
         try:
             upload_results, success_count, error_count, submitted_committees = 
await interaction.upload_keys(
                 project_list, keys_text, selected_committees
@@ -522,6 +569,18 @@ async def upload(session: routes.CommitterSession) -> str:
     return await render()
 
 
+async def _get_keys_text(keys_url: str, render: Callable[[str], 
Awaitable[str]]) -> str:
+    try:
+        async with httpx.AsyncClient() as client:
+            response = await client.get(keys_url, follow_redirects=True)
+            response.raise_for_status()
+            return response.text
+    except httpx.HTTPStatusError as e:
+        raise base.ASFQuartException(f"Error fetching URL: 
{e.response.status_code} {e.response.reason_phrase}")
+    except httpx.RequestError as e:
+        raise base.ASFQuartException(f"Error fetching URL: {e}")
+
+
 async def _write_keys_file(
     project: models.Project,
     base_finished_dir: pathlib.Path,
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
index 34743e0..7d36a86 100644
--- a/atr/templates/keys-upload.html
+++ b/atr/templates/keys-upload.html
@@ -153,9 +153,50 @@
       <div class="row mb-3 pb-3 border-bottom">
         {{ forms.label(form.key, col="md2") }}
         <div class="col-md-9">
-          {{ forms.widget(form.key, id=form.key.id) }}
-          {{ forms.errors(form.key, classes="invalid-feedback d-block") }}
-          {{ forms.description(form.key, classes="form-text text-muted mt-2") 
}}
+          <ul class="nav nav-tabs" id="keysUploadTab" role="tablist">
+            <li class="nav-item" role="presentation">
+              <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</button>
+            </li>
+            <li class="nav-item" role="presentation">
+              <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</button>
+            </li>
+          </ul>
+          <div class="tab-content" id="keysUploadTabContent">
+            <div class="tab-pane fade show active"
+                 id="file-upload-pane"
+                 role="tabpanel"
+                 aria-labelledby="file-upload-tab">
+              <div class="pt-3">
+                {{ forms.widget(form.key, id=form.key.id) }}
+                {{ forms.errors(form.key, classes="invalid-feedback d-block") 
}}
+                {{ forms.description(form.key, classes="form-text text-muted 
mt-2") }}
+              </div>
+            </div>
+            <div class="tab-pane fade"
+                 id="url-upload-pane"
+                 role="tabpanel"
+                 aria-labelledby="url-upload-tab">
+              <div class="pt-3">
+                {{ forms.widget(form.keys_url, classes="form-control") }}
+                {{ forms.errors(form.keys_url, classes="invalid-feedback 
d-block") }}
+                {{ forms.description(form.keys_url, classes="form-text 
text-muted mt-2") }}
+              </div>
+            </div>
+          </div>
         </div>
       </div>
 


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

Reply via email to