Avoid the onclick attribute in forms

Issue #132 resolved
M G created an issue

In postman/view.html and in postman/base_folder.html, the action attribute is empty and set when a button is clicked with the onclick attribute. This method has two drawbacks:

  • the resulting HTML is invalid (the action attribute cannot be left empty)
  • nothing happens with a strong content security policy and messages cannot be deleted, archived or marked as read or unread.

A valid method is to set a name and a value attributes to the buttons, like this:

`

{% block pm_delete_button %}<button name="action" value="delete" type="submit" class="btn btn-sm btn-danger pm_btn pm_btn-delete" >{% trans "Delete" %}</button>{% endblock %}
{% block pm_archive_button %}<button name="action" value="archive" type="submit" class="btn btn-sm btn-primary pm_btn pm_btn-archive" >{% trans "Archive" %}</button>{% endblock %}
{% block pm_undelete_button %}<button name="action" value="undelete" type="submit" class="btn btn-sm btn-primary pm_btn pm_btn-undelete" >{% trans "Undelete" %}</button>{% endblock %}
{% block pm_read_button %}<button name="action" value="read" type="submit" class="btn btn-sm btn-primary pm_btn pm_btn-read" >{% trans "Mark as read" %}</button>{% endblock %}
{% block pm_unread_button %}<button name="action" value="unread" type="submit" class="btn btn-sm btn-primary pm_btn pm_btn-unread" >{% trans "Mark as unread" %}</button>{% endblock %}`

and to use the same view for all actions:

class MessageUpdateView(UpdateDualMixin, View):
    """View for updating messages or conversations.

    This single view replaces the following views: `ArchiveView`, `DeleteView`,
    `UndeleteView`, 'MarkReadView', 'MarkUnreadView'.
    This is required to avoid the onclick attribute in the template (forbidden by CSP).
    """

    @property
    def selected_action(self):
        """Get the action to perform on the selected messages or conversations."""
        action = self.request.POST.get("action")
        if action not in {"delete", "archive", "undelete", "read", "unread"}:
            raise PermissionDenied
        return action

    def _action(self, user: AbstractBaseUser, filter_: Q):
        """Perform the action on the selected messages or conversations."""
        if self.selected_action in {"read", "unread"}:
            Message.objects.as_recipient(user, filter_).filter(
                **{f"{self.field_bit}__isnull": bool(self.field_value)}
            ).update(**{self.field_bit: self.field_value})
        else:
            super()._action(user, filter_)
        # an empty set cannot be estimated as an error, it may be just a badly chosen selection

    @property
    def field_bit(self):
        """Get the field to update."""
        return {
            "delete": "deleted_at",
            "archive": "archived",
            "undelete": "deleted_at",
            "read": "read_at",
            "unread": "read_at",
        }[self.selected_action]

    @property
    def success_msg(self):
        """Get the success message to display."""
        return {
            "delete": _("Messages or conversations successfully deleted."),
            "archive": _("Messages or conversations successfully archived."),
            "undelete": _("Messages or conversations successfully recovered."),
            "read": _("Messages or conversations successfully marked as read."),
            "unread": _("Messages or conversations successfully marked as unread."),
        }[self.selected_action]

    @property
    def field_value(self):
        """Get the value to set the field to."""
        n = now()
        return {"delete": n, "archive": True, "undelete": None, "read": n, "unread": None}[self.selected_action]

Comments (5)

  1. Patrick Samson repo owner
    • changed status to open

    OK for the name/value attributes.

    For backwards compatibility, the existing views will be kept and marked as deprecated.

    The foreseen code is:

    class ActionView(UpdateMessageMixin, View):
        actions = {  # action -> query_index, field_bit, field_value, success_msg
            'archive': (1, 'archived', True, gettext_lazy("Messages or conversations successfully archived.")),
            'delete': (1, 'deleted_at', now(), gettext_lazy("Messages or conversations successfully deleted.")),
            'undelete': (1, 'deleted_at', None, gettext_lazy("Messages or conversations successfully recovered.")),
            'read': (2, 'read_at', now(), gettext_lazy("Messages or conversations successfully marked as read.")),
            'unread': (2, 'read_at', None, gettext_lazy("Messages or conversations successfully marked as unread."))
        }
    
        def _action(self, user: 'AbstractBaseUser', filter: Q):
            definition = self.actions.get(self.request.POST.get('action', ''))
            if not definition:
                raise BadRequest
            (query_index, field_bit, field_value, self.success_msg) = definition
            if query_index == 1:
                # ... as in UpdateDualMixin
            else:
                # ... as in UpdateRecipientMixin
    
  2. M G reporter

    I did not know the existence of the formaction attribute. It seems to be a simple way to remove the JavaScript code.

  3. Patrick Samson repo owner

    So, now, changes should be only in the templates:

    diff --git a/postman/templates/postman/base_folder.html b/postman/templates/postman/base_folder.html
    @@ -19,13 +19,13 @@ in the INSTALLED_APPS setting.
     <span class="pm_by-mode">{% if by_message %}<a href="{{ by_conversation_url }}">{% endif %}{% trans "by conversation" %}{% if by_message %}</a>{% endif %}</span>
     <span class="pm_by-mode">{% if by_conversation %}<a href="{{ by_message_url }}">{% endif %}{% trans "by message" %}{% if by_conversation %}</a>{% endif %}</span>
     </div>{% endblock pm_by_modes %}
    -<form action="{% block pm_form_action %}{% endblock %}" method="post">{% csrf_token %}
    +<form method="post">{% csrf_token %}
     {% block pm_form_buttons %}<span id="pm_buttons">
    -{% block pm_delete_button %}<button type="submit" class="pm_btn pm_btn-delete" onclick="this.form.action='{% url 'postman:delete' %}'">{% trans "Delete" %}</button>{% endblock %}
    -{% block pm_archive_button %}<button type="submit" class="pm_btn pm_btn-archive" onclick="this.form.action='{% url 'postman:archive' %}'">{% trans "Archive" %}</button>{% endblock %}
    -{% block pm_undelete_button %}<button type="submit" class="pm_btn pm_btn-undelete" onclick="this.form.action='{% url 'postman:undelete' %}'">{% trans "Undelete" %}</button>{% endblock %}
    -{% block pm_read_button %}<button type="submit" class="pm_btn pm_btn-read" onclick="this.form.action='{% url 'postman:mark-read' %}'">{% trans "Mark as read" %}</button>{% endblock %}
    -{% block pm_unread_button %}<button type="submit" class="pm_btn pm_btn-unread" onclick="this.form.action='{% url 'postman:mark-unread' %}'">{% trans "Mark as unread" %}</button>{% endblock %}
    +{% block pm_delete_button %}<button type="submit" class="pm_btn pm_btn-delete" formaction="{% url 'postman:delete' %}">{% trans "Delete" %}</button>{% endblock %}
    +{% block pm_archive_button %}<button type="submit" class="pm_btn pm_btn-archive" formaction="{% url 'postman:archive' %}">{% trans "Archive" %}</button>{% endblock %}
    +{% block pm_undelete_button %}<button type="submit" class="pm_btn pm_btn-undelete" formaction="{% url 'postman:undelete' %}">{% trans "Undelete" %}</button>{% endblock %}
    +{% block pm_read_button %}<button type="submit" class="pm_btn pm_btn-read" formaction="{% url 'postman:mark-read' %}">{% trans "Mark as read" %}</button>{% endblock %}
    +{% block pm_unread_button %}<button type="submit" class="pm_btn pm_btn-unread" formaction="{% url 'postman:mark-unread' %}">{% trans "Mark as unread" %}</button>{% endblock %}
     </span>{% endblock %}
     <table id="pm_messages">
      <thead>
    
    diff --git a/postman/templates/postman/base_write.html b/postman/templates/postman/base_write.html
    @@ -10,7 +10,7 @@
     {% block content %}
     <div id="postman">
     <h1>{% block pm_write_title %}{% endblock %}</h1>
    -<form action="{% if next_url %}?next={{ next_url|urlencode }}{% endif %}" method="post">{% csrf_token %}
    +<form{% if next_url %} action="?next={{ next_url|urlencode }}"{% endif %} method="post">{% csrf_token %}
     <table>
     {% block pm_write_recipient %}{% endblock %}
     {{ form.as_table }}
    
    diff --git a/postman/templates/postman/view.html b/postman/templates/postman/view.html
    @@ -15,14 +15,14 @@
      <div class="pm_body">{{ message.body|linebreaksbr }}</div>
     </div>
     {% if forloop.last %}
    -<form action="" method="post">{% csrf_token %}
    +<form method="post">{% csrf_token %}
     <input type="hidden" {% if pm_messages|length > 1 and message.thread_id %}name="tpks" value="{{ message.thread_id }}"{% else %}name="pks" value="{{ message.pk }}"{% endif %}>
     <a href="{{ next_url }}" class="pm_action pm_action-back">{% trans "Back" %}</a>
     <span id="pm_buttons">
     <button type="submit" class="pm_btn pm_btn-delete"
    - onclick="this.form.action='{% url 'postman:delete' %}?next={{ next_url|urlencode }}'">{% trans "Delete" %}</button>
    +formaction="{% url 'postman:delete' %}?next={{ next_url|urlencode }}">{% trans "Delete" %}</button>
     {% if not archived %}<button type="submit" class="pm_btn pm_btn-archive"
    - onclick="this.form.action='{% url 'postman:archive' %}?next={{ next_url|urlencode }}'">{% trans "Archive" %}</button>{% endif %}
    +formaction="{% url 'postman:archive' %}?next={{ next_url|urlencode }}">{% trans "Archive" %}</button>{% endif %}
     </span>
     {% if reply_to_pk %}<a href="{% url 'postman:reply' reply_to_pk %}?next={{ next_url|urlencode }}"
      class="pm_action pm_action-reply">{% trans "Reply" %}</a>{% endif %}
    

  4. Log in to comment