فهرست منبع

Fixes #23446: Prevent duplicate submissions when uploading image attachments (#23525)

Martin Hauser 1 روز پیش
والد
کامیت
03ffaf939a

+ 1 - 1
netbox/core/tables/template_code.py

@@ -42,7 +42,7 @@ DATA_SOURCE_SYNC_BUTTON = """
 {% load i18n %}
 {% if perms.core.sync_datasource %}
     {% if record.ready_for_sync %}
-        <button class="btn btn-primary btn-sm" type="submit" formaction="{% url 'core:datasource_sync' pk=record.pk %}?return_url={{ request.get_full_path|urlencode }}" formmethod="post">
+        <button class="btn btn-primary btn-sm" type="submit" formaction="{% url 'core:datasource_sync' pk=record.pk %}?return_url={{ request.get_full_path|urlencode }}" formmethod="post" data-disable-on-submit>
             <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync" %}
         </button>
     {% else %}

+ 25 - 0
netbox/core/tests/test_views.py

@@ -63,6 +63,31 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    def test_sync_form_submission_guard(self):
+        """The Sync form of a ready data source opts into the duplicate submission guard."""
+        self.add_permissions('core.view_datasource', 'core.sync_datasource')
+        datasource = DataSource.objects.first()
+        url = reverse('core:datasource_sync', kwargs={'pk': datasource.pk})
+
+        response = self.client.get(datasource.get_absolute_url())
+
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, f'<form action="{url}" method="post" data-disable-on-submit>')
+
+    def test_sync_buttons_submission_guard(self):
+        """Each ready data source's Sync button opts into the duplicate submission guard."""
+        self.add_permissions('core.view_datasource', 'core.sync_datasource')
+        list_url = reverse('core:datasource_list')
+
+        response = self.client.get(list_url)
+
+        self.assertHttpStatus(response, 200)
+        for name in ('Data Source 1', 'Data Source 2', 'Data Source 3'):
+            url = reverse('core:datasource_sync', kwargs={'pk': DataSource.objects.get(name=name).pk})
+            self.assertContains(
+                response, f'formaction="{url}?return_url={list_url}" formmethod="post" data-disable-on-submit>'
+            )
+
 
 class DataFileTestCase(
     ViewTestCases.GetObjectViewTestCase,

+ 18 - 0
netbox/extras/tests/test_views.py

@@ -544,6 +544,24 @@ class ImageAttachmentTestCase(
             ]
         )
 
+    @tag('regression')
+    def test_add_form_submission_guard(self):
+        """The add form opts into the duplicate submission guard."""
+        self.add_permissions('extras.add_imageattachment')
+        site = Site.objects.get(slug='site-1')
+
+        response = self.client.get(reverse('extras:imageattachment_add'), {
+            'object_type': ContentType.objects.get_for_model(Site).pk,
+            'object_id': site.pk,
+        })
+
+        self.assertHttpStatus(response, 200)
+        self.assertIn(
+            '<form action="" method="post" enctype="multipart/form-data" '
+            'class="object-edit object-edit--with-sticky-actions mt-5" data-disable-on-submit>',
+            response.content.decode(),
+        )
+
 
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ExportTemplate

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 2 - 0
netbox/project-static/src/forms/index.ts

@@ -3,6 +3,7 @@ import { initFormElements } from './elements';
 import { initFilterModifiers } from './filterModifiers';
 import { initPortMappings } from './portMappings';
 import { initSpeedSelector } from './speedSelector';
+import { initSubmissionGuard } from './submissionGuard';
 
 export function initForms(): void {
   for (const func of [
@@ -11,6 +12,7 @@ export function initForms(): void {
     initFilterModifiers,
     initClearField,
     initPortMappings,
+    initSubmissionGuard,
   ]) {
     func();
   }

+ 68 - 0
netbox/project-static/src/forms/submissionGuard.ts

@@ -0,0 +1,68 @@
+const GUARD_ATTRIBUTE = 'data-disable-on-submit';
+
+// Forms with a guarded submission in flight, mapped to the buttons the guard marked.
+const pendingForms = new Map<HTMLFormElement, HTMLButtonElement[]>();
+
+/**
+ * Dim the form's enabled submit buttons for handleSubmit(). Class and ARIA only, since `disabled`
+ * drops the submitter and survives a Firefox reload.
+ */
+function markButtons(form: HTMLFormElement): HTMLButtonElement[] {
+  const marked: HTMLButtonElement[] = [];
+  for (const button of form.querySelectorAll<HTMLButtonElement>('button[type="submit"]')) {
+    if (button.disabled || button.classList.contains('disabled')) continue;
+    button.classList.add('disabled');
+    button.setAttribute('aria-disabled', 'true');
+    marked.push(button);
+  }
+  return marked;
+}
+
+/** Undo markButtons() for a form and drop its lock. Used on a late cancel and a cache restore. */
+function release(form: HTMLFormElement): void {
+  for (const button of pendingForms.get(form) ?? []) {
+    button.classList.remove('disabled');
+    button.removeAttribute('aria-disabled');
+  }
+  pendingForms.delete(form);
+}
+
+/** Delegated submit listener: lock an opted-in form and cancel its repeat submits while locked. */
+function handleSubmit(event: SubmitEvent): void {
+  const form = event.target;
+  if (!(form instanceof HTMLFormElement)) return;
+
+  // The lock covers every submitter of the form, not only the opted-in one.
+  if (pendingForms.has(form)) {
+    event.preventDefault();
+    return;
+  }
+
+  // Canceled by another handler, such as htmx, so no navigation follows.
+  if (event.defaultPrevented) return;
+
+  if (form.hasAttribute(GUARD_ATTRIBUTE) || event.submitter?.hasAttribute(GUARD_ATTRIBUTE)) {
+    pendingForms.set(form, markButtons(form));
+    // A listener that runs after this one can still cancel the submission.
+    setTimeout(() => {
+      if (event.defaultPrevented) release(form);
+    });
+  }
+}
+
+/** Release every lock when the page comes back from the back/forward cache. */
+function handlePageShow(event: PageTransitionEvent): void {
+  // A back/forward cache restore keeps both the marked buttons and this module's state.
+  if (!event.persisted) return;
+
+  for (const form of pendingForms.keys()) {
+    release(form);
+  }
+}
+
+/** Block repeat submits of a form marked data-disable-on-submit on itself or its submitter. */
+export function initSubmissionGuard(): void {
+  // htmx re-inits pass the same listeners again, which the DOM ignores.
+  document.addEventListener('submit', handleSubmit);
+  window.addEventListener('pageshow', handlePageShow);
+}

+ 1 - 1
netbox/templates/core/datasource.html

@@ -4,7 +4,7 @@
 {% block extra_controls %}
   {% if perms.core.sync_datasource %}
     {% if object.ready_for_sync %}
-      <form action="{% url 'core:datasource_sync' pk=object.pk %}" method="post">
+      <form action="{% url 'core:datasource_sync' pk=object.pk %}" method="post" data-disable-on-submit>
         {% csrf_token %}
         <button type="submit" class="btn btn-primary">
           <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync" %}

+ 1 - 1
netbox/templates/generic/object_edit.html

@@ -58,7 +58,7 @@ Context:
       {% include 'inc/missing_prerequisites.html' %}
     {% endif %}
 
-    <form action="" method="post" enctype="multipart/form-data" class="object-edit object-edit--with-sticky-actions mt-5">
+    <form action="" method="post" enctype="multipart/form-data" class="object-edit object-edit--with-sticky-actions mt-5" data-disable-on-submit>
       {% csrf_token %}
 
       {% block pre_form_fields %}{% endblock pre_form_fields %}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است