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 7722c95  Add a form to create release preview directories
7722c95 is described below

commit 7722c9541cb3711dfebd7856c7cb8f82d77c1511
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu May 22 20:03:56 2025 +0100

    Add a form to create release preview directories
---
 atr/routes/finish.py               | 176 +++++++++++++++++++++++++++++--------
 atr/templates/finish-selected.html |  24 +++++
 2 files changed, 165 insertions(+), 35 deletions(-)

diff --git a/atr/routes/finish.py b/atr/routes/finish.py
index 04ded95..60c7469 100644
--- a/atr/routes/finish.py
+++ b/atr/routes/finish.py
@@ -37,6 +37,23 @@ SPECIAL_SUFFIXES: Final[frozenset[str]] = frozenset({".asc", 
".sha256", ".sha512
 _LOGGER: Final = logging.getLogger(__name__)
 
 
+class CreateDirectoryForm(util.QuartFormTyped):
+    """Form for creating a new directory within a preview revision."""
+
+    new_directory_name = wtforms.StringField(
+        "New directory name",
+        validators=[
+            wtforms.validators.InputRequired(),
+            wtforms.validators.Regexp(
+                
r"^(?!.*\.\.)(?!^\.$)(?!^/)(?!.*/$)[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)*$",
+                message="Invalid characters or structure. Use a-z, 0-9, _, -."
+                " No leading or trailing slashes, and no dots. No '..' 
segments.",
+            ),
+        ],
+    )
+    submit = wtforms.SubmitField("Create directory")
+
+
 class MoveFileForm(util.QuartFormTyped):
     """Form for moving a file within a preview revision."""
 
@@ -75,21 +92,40 @@ async def selected(
         await quart.flash("Preview revision directory not found.", "error")
         return await session.redirect(root.index)
 
-    form = await MoveFileForm.create_form(data=await quart.request.form if 
(quart.request.method == "POST") else None)
+    formdata = None
+    if quart.request.method == "POST":
+        formdata = await quart.request.form
+
+    move_form = await MoveFileForm.create_form(
+        data=formdata if (formdata and formdata.get("form_action") != 
"create_dir") else None
+    )
+    create_dir_form = await CreateDirectoryForm.create_form(
+        data=formdata if (formdata and formdata.get("form_action") == 
"create_dir") else None
+    )
 
     # Populate choices dynamically for both GET and POST
-    form.source_file.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
-    form.target_directory.choices = sorted([(str(d), str(d)) for d in 
target_dirs])
+    move_form.source_file.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
+    move_form.target_directory.choices = sorted([(str(d), str(d)) for d in 
target_dirs])
     can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
 
-    if (quart.request.method == "POST") and can_move:
-        match await _move_file(form, session, project_name, version_name):
-            case None:
-                pass
-            case tuple() as resp_tuple:
-                return resp_tuple
-            case resp_obj if isinstance(resp_obj, response.Response):
-                return resp_obj
+    if formdata:
+        form_action = formdata.get("form_action")
+        if form_action == "create_dir":
+            if await create_dir_form.validate_on_submit():
+                new_dir_name = create_dir_form.new_directory_name.data
+                if new_dir_name:
+                    return await _create_directory_in_revision(
+                        pathlib.Path(new_dir_name),
+                        session,
+                        project_name,
+                        version_name,
+                        
quart.request.accept_mimetypes.best_match(["application/json", "text/html"])
+                        == "application/json",
+                    )
+        elif (form_action != "create_dir") and can_move:
+            move_result = await _move_file(move_form, session, project_name, 
version_name)
+            if move_result is not None:
+                return move_result
 
     # resp = await quart.current_app.make_response(template_rendered)
     # resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -102,13 +138,86 @@ async def selected(
         server_domain=session.app_host,
         release=release,
         source_files=sorted(source_files_rel),
-        form=form,
+        form=move_form,
+        create_dir_form=create_dir_form,
         can_move=can_move,
         user_ssh_keys=user_ssh_keys,
         target_dirs=sorted(list(target_dirs)),
     )
 
 
+async def _respond(
+    session: routes.CommitterSession,
+    project_name: str,
+    version_name: str,
+    wants_json: bool,
+    ok: bool,
+    msg: str,
+    http_status: int = 200,
+) -> tuple[quart_response.Response, int] | response.Response:
+    """Helper to respond with JSON or flash message and redirect."""
+    if wants_json:
+        return quart.jsonify(ok=ok, message=msg), http_status
+    await quart.flash(msg, "success" if ok else "error")
+    return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+
+
+async def _create_directory_in_revision(
+    new_dir_rel: pathlib.Path,
+    session: routes.CommitterSession,
+    project_name: str,
+    version_name: str,
+    wants_json: bool,
+) -> tuple[quart_response.Response, int] | response.Response:
+    try:
+        description = "Directory creation through web interface"
+        async with revision.create_and_manage(
+            project_name, version_name, session.uid, description=description
+        ) as creating:
+            target_path = creating.interim_path / new_dir_rel
+            # Path traversal check
+            try:
+                resolved_target_path = target_path.resolve()
+                
resolved_target_path.relative_to(creating.interim_path.resolve())
+                await aiofiles.os.makedirs(target_path, exist_ok=False)
+            except ValueError:
+                creating.failed = True
+                # Path traversal attempt detected
+                return await _respond(
+                    session, project_name, version_name, wants_json, False, 
"Invalid directory path.", 400
+                )
+            except FileExistsError:
+                creating.failed = True
+                return await _respond(
+                    session,
+                    project_name,
+                    version_name,
+                    wants_json,
+                    False,
+                    f"Directory or file '{new_dir_rel}' already exists.",
+                    400,
+                )
+        return await _respond(
+            session,
+            project_name,
+            version_name,
+            wants_json,
+            True,
+            f"Created directory '{new_dir_rel}'.",
+            201 if wants_json else 200,
+        )
+    except OSError as e:
+        _LOGGER.exception("Error creating directory in new revision")
+        return await _respond(
+            session, project_name, version_name, wants_json, False, f"Error 
creating directory: {e}", 500
+        )
+    except Exception as e:
+        _LOGGER.exception("Unexpected error during directory creation")
+        return await _respond(
+            session, project_name, version_name, wants_json, False, f"An 
unexpected error occurred: {e!s}", 500
+        )
+
+
 async def _move_file(
     form: MoveFileForm, session: routes.CommitterSession, project_name: str, 
version_name: str
 ) -> tuple[quart_response.Response, int] | response.Response | None:
@@ -128,7 +237,7 @@ async def _move_file(
                 label_text = label_object.text if label_object else 
field_name.replace("_", " ").title()
                 error_messages.append(f"{label_text}: {', 
'.join(field_errors)}")
             error_string = "; ".join(error_messages)
-            return quart.jsonify(error=error_string), 400
+            return await _respond(session, project_name, version_name, True, 
False, error_string, 400)
         else:
             for field_name, field_errors in form.errors.items():
                 field_object = getattr(form, field_name, None)
@@ -136,7 +245,7 @@ async def _move_file(
                 label_text = label_object.text if label_object else 
field_name.replace("_", " ").title()
                 for error_message_text in field_errors:
                     await quart.flash(f"{label_text}: {error_message_text}", 
"warning")
-    return None
+            return None
 
 
 async def _move_file_to_revision(
@@ -146,7 +255,7 @@ async def _move_file_to_revision(
     project_name: str,
     version_name: str,
     wants_json: bool,
-) -> tuple[quart_response.Response, int] | response.Response | None:
+) -> tuple[quart_response.Response, int] | response.Response:
     try:
         description = "File move through web interface"
         async with revision.create_and_manage(
@@ -162,37 +271,34 @@ async def _move_file_to_revision(
                 # (But also do not raise an exception)
                 creating.failed = True
                 msg = f"Files already exist in '{target_dir_rel}': {', 
'.join(collisions)}"
-                if wants_json:
-                    return quart.jsonify(error=msg), 400
-                await quart.flash(msg, "error")
-                return await session.redirect(selected, 
project_name=project_name, version_name=version_name)
+                return await _respond(session, project_name, version_name, 
wants_json, False, msg, 400)
 
             for f in bundle:
                 await aiofiles.os.rename(creating.interim_path / f, 
creating.interim_path / target_dir_rel / f.name)
 
-        await quart.flash(f"Moved {', '.join(f.name for f in bundle)}", 
"success")
-        return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+        return await _respond(
+            session, project_name, version_name, wants_json, True, f"Moved {', 
'.join(f.name for f in bundle)}", 200
+        )
 
     except FileNotFoundError:
         _LOGGER.exception("File not found during move operation in new 
revision")
-        msg = "Error: Source file not found during move operation."
-        if wants_json:
-            return quart.jsonify(error=msg), 400
-        await quart.flash(msg, "error")
+        return await _respond(
+            session,
+            project_name,
+            version_name,
+            wants_json,
+            False,
+            "Error: Source file not found during move operation.",
+            400,
+        )
     except OSError as e:
         _LOGGER.exception("Error moving file in new revision")
-        msg = f"Error moving file: {e}"
-        if wants_json:
-            return quart.jsonify(error=msg), 500
-        await quart.flash(msg, "error")
+        return await _respond(session, project_name, version_name, wants_json, 
False, f"Error moving file: {e}", 500)
     except Exception as e:
         _LOGGER.exception("Unexpected error during file move")
-        msg = f"An unexpected error occurred: {e!s}"
-        if wants_json:
-            return quart.jsonify(error=msg), 500
-        await quart.flash(msg, "error")
-
-    return await session.redirect(selected, project_name=project_name, 
version_name=version_name)
+        return await _respond(
+            session, project_name, version_name, wants_json, False, f"An 
unexpected error occurred: {e!s}", 500
+        )
 
 
 def _related_files(path: pathlib.Path) -> list[pathlib.Path]:
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index 616c831..e1c4e58 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -176,6 +176,30 @@
       File moving is disabled as all files are currently in the same directory 
or the revision is empty.
     </div>
   {% endif %}
+
+  <h2>Manage directories</h2>
+  <h3>Create a new directory</h3>
+  <form method="post">
+    {{ create_dir_form.hidden_tag() }}
+    <div class="d-flex">
+      {{ create_dir_form.new_directory_name(class="form-control", 
placeholder="New directory name") }}
+      <button type="submit"
+              class="btn btn-primary ms-2 text-nowrap"
+              name="form_action"
+              value="create_dir">Create directory</button>
+    </div>
+    {{ forms.errors(create_dir_form.new_directory_name, classes="text-danger 
small mt-1") }}
+  </form>
+
+  <!--
+  TODO: Add a form to delete a directory.
+  <h3>Delete a directory</h3>
+  <p>Delete a directory to remove it from the release.</p>
+  <form>
+    <input type="text" class="form-control" placeholder="Directory name" />
+    <button type="submit" class="btn btn-danger">Delete directory</button>
+  </form>
+-->
 {% endblock content %}
 
 {% block javascripts %}


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

Reply via email to