Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion hypha/core/middleware/htmx.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
from urllib.parse import parse_qs, urlencode, urlparse

from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
Expand All @@ -7,7 +8,16 @@

class HtmxMessageMiddleware(MiddlewareMixin):
"""
Middleware that moves messages into the HX-Trigger header when request is made with HTMX
Middleware that transfers Django messages into the HX-Trigger header for HTMX requests.

This middleware captures Django messages and adds them to the HX-Trigger header
when the request is made with HTMX. It preserves any existing HX-Trigger values
and adds the messages in a standardized format that can be processed by frontend
JavaScript.

The messages are formatted as an array of objects, each containing:
- message: The text content of the message
- tags: CSS class names or other categorization for the message
"""

def process_response(
Expand Down Expand Up @@ -52,3 +62,55 @@ def process_response(
response.headers["HX-Trigger"] = json.dumps(hx_trigger)

return response


class HtmxAuthRedirectMiddleware:
"""
Middleware to handle HTMX authentication redirects properly.

When an HTMX request results in a 302 redirect (typically for authentication),
this middleware:
1. Changes the response status code to 204 (No Content)
2. Adds an HX-Redirect header with the redirect URL
3. Preserves the original request path in the 'next' query parameter

This ensures that after authentication, the user is returned to the page
they were attempting to access, maintaining a seamless UX with HTMX.

Credits: https://www.caktusgroup.com/blog/2022/11/11/how-handle-django-login-redirects-htmx/
"""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
response = self.get_response(request)
# HTMX request returning 302 likely is login required.
# Take the redirect location and send it as the HX-Redirect header value,
# with 'next' query param set to where the request originated. Also change
# response status code to 204 (no content) so that htmx will obey the
# HX-Redirect header value.
if request.headers.get("HX-Request") == "true" and response.status_code == 302:
# Determine the next path from referer or current request path
ref_header = request.headers.get("Referer", "")
if ref_header:
referer = urlparse(ref_header)
next_path = referer.path
else:
next_path = request.path

# Parse the redirect URL
redirect_url = urlparse(response["location"])

# Set response status code to 204 for HTMX to process the redirect
response.status_code = 204

# Update the "?next" query parameter
query_params = parse_qs(redirect_url.query)
query_params["next"] = [next_path]
new_query = urlencode(query_params, doseq=True)

# Set the new HX-Redirect header
response.headers["HX-Redirect"] = f"{redirect_url.path}?{new_query}"

return response
1 change: 1 addition & 0 deletions hypha/core/middleware/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

155 changes: 155 additions & 0 deletions hypha/core/middleware/tests/test_htmx_auth_redirect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import pytest
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.test import RequestFactory
from django.urls import path
from django.views.decorators.http import require_GET

from hypha.apply.users.tests.factories import UserFactory
from hypha.core.middleware.htmx import HtmxAuthRedirectMiddleware


# Create test views
@require_GET
def public_view(request):
return HttpResponse("Public view")


@login_required
@require_GET
def private_view(request):
return HttpResponse("Private view")


# Create test URL patterns
urlpatterns = [
path("public/", public_view, name="public"),
path("private/", private_view, name="private"),
]


# Mock response handler for middleware
def get_auth_redirect_response(request):
# For authenticated paths, return a redirect response
if request.path == "/private/":
response = HttpResponse()
response.status_code = 302
response["Location"] = "/accounts/login/?next=/some/path/"
return response
# For non-auth paths, return a regular response
elif request.path == "/public/":
response = HttpResponse("Public content")
return response
# For other redirects
elif request.path == "/redirect/":
response = HttpResponse()
response.status_code = 302
response["Location"] = "/other-page/"
return response
return HttpResponse()


@pytest.fixture
def request_factory():
return RequestFactory()


@pytest.fixture
def user():
return UserFactory()


@pytest.fixture
def middleware():
return HtmxAuthRedirectMiddleware(get_response=get_auth_redirect_response)


@pytest.fixture
def settings_with_login_url(settings):
settings.LOGIN_URL = "/accounts/login/"
return settings


def test_htmx_auth_redirect(request_factory, middleware, settings_with_login_url):
"""Test that HTMX requests to authenticated views are redirected."""
# Create a request with HTMX headers
request = request_factory.get("/private/")
request.headers = {"HX-Request": "true"}
request.path = "/private/"

# Process the request through middleware
response = middleware(request)

# Check that the response is modified with HX-Redirect
assert response.status_code == 204
assert response.headers["HX-Redirect"] == "/accounts/login/?next=%2Fprivate%2F"


def test_htmx_auth_redirect_with_referer(
request_factory, middleware, settings_with_login_url
):
"""Test that HTMX requests with Referer header are redirected correctly."""
# Create a request with HTMX headers and Referer
request = request_factory.get("/private/")
request.headers = {
"HX-Request": "true",
"Referer": "https://example.com/some/page/",
}
request.path = "/private/"

# Process the request through middleware
response = middleware(request)

# Check that the response uses the Referer's path in the next parameter
assert response.status_code == 204
assert response.headers["HX-Redirect"] == "/accounts/login/?next=%2Fsome%2Fpage%2F"


def test_non_htmx_request_not_redirected(
request_factory, middleware, settings_with_login_url
):
"""Test that non-HTMX requests are not affected."""
# Create a regular request without HTMX headers
request = request_factory.get("/private/")
request.headers = {}
request.path = "/private/"

# Process the request through middleware
response = middleware(request)

# Assert that the middleware didn't change the response status
assert response.status_code == 302
assert "HX-Redirect" not in response.headers
assert response["Location"] == "/accounts/login/?next=/some/path/"


def test_htmx_non_auth_redirect_not_affected(
request_factory, middleware, settings_with_login_url
):
"""Test that HTMX requests with non-auth redirects are not affected."""
# Create a request with HTMX headers to a path that redirects elsewhere
request = request_factory.get("/redirect/")
request.headers = {"HX-Request": "true"}
request.path = "/redirect/"

# Process the request through middleware
response = middleware(request)

# Assert that the middleware handles the redirect with HX-Redirect
assert response.status_code == 204
assert response.headers["HX-Redirect"] == "/other-page/?next=%2Fredirect%2F"


def test_htmx_normal_request(request_factory, middleware, settings_with_login_url):
"""Test that HTMX requests to public views work normally."""
# Create a request with HTMX headers to a public path
request = request_factory.get("/public/")
request.headers = {"HX-Request": "true"}
request.path = "/public/"

# Process the request through middleware
response = middleware(request)

# Assert that the middleware didn't affect the normal response
assert response.status_code == 200
assert "HX-Redirect" not in response.headers
1 change: 1 addition & 0 deletions hypha/settings/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"hypha.apply.middleware.HandleProtectionErrorMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"hypha.core.middleware.htmx.HtmxMessageMiddleware",
"hypha.core.middleware.htmx.HtmxAuthRedirectMiddleware",
"hypha.apply.projects.middleware.ProjectsEnabledMiddleware",
]

Expand Down
Loading