diff --git a/aurweb/templates.py b/aurweb/templates.py index 9439f3a3..1e09bf61 100644 --- a/aurweb/templates.py +++ b/aurweb/templates.py @@ -26,6 +26,7 @@ env.filters["tr"] = l10n.tr # Utility filters. env.filters["dt"] = util.timestamp_to_datetime env.filters["as_timezone"] = util.as_timezone +env.filters["dedupe_qs"] = util.dedupe_qs # Add captcha filters. env.filters["captcha_salt"] = captcha.captcha_salt_filter diff --git a/aurweb/util.py b/aurweb/util.py index 1615e00a..0aec6f45 100644 --- a/aurweb/util.py +++ b/aurweb/util.py @@ -3,8 +3,9 @@ import random import re import string +from collections import OrderedDict from datetime import datetime -from urllib.parse import urlparse +from urllib.parse import quote_plus, urlparse from zoneinfo import ZoneInfo from email_validator import EmailNotValidError, EmailUndeliverableError, validate_email @@ -106,6 +107,29 @@ def as_timezone(dt: datetime, timezone: str): return dt.astimezone(tz=ZoneInfo(timezone)) +def dedupe_qs(query_string: str, *additions): + """ Dedupe keys found in a query string by rewriting it without + duplicates found while iterating from the end to the beginning, + using an ordered memo to track keys found and persist locations. + + That is, query string 'a=1&b=1&a=2' will be deduped and converted + to 'b=1&a=2'. + + :param query_string: An HTTP URL query string. + :param *additions: Optional additional fields to add to the query string. + :return: Deduped query string, including *additions at the tail. + """ + for addition in list(additions): + query_string += f"&{addition}" + + qs = OrderedDict() + for item in reversed(query_string.split('&')): + key, value = item.split('=') + if key not in qs: + qs[key] = value + return '&'.join([f"{k}={quote_plus(v)}" for k, v in reversed(qs.items())]) + + def jsonify(obj): """ Perform a conversion on obj if it's needed. """ if isinstance(obj, datetime): diff --git a/test/test_util.py b/test/test_util.py index d58a8ae2..074de494 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from datetime import datetime from zoneinfo import ZoneInfo @@ -14,3 +15,18 @@ def test_as_timezone(): ts = datetime.utcnow().timestamp() dt = util.timestamp_to_datetime(ts) assert util.as_timezone(dt, "UTC") == dt.astimezone(tz=ZoneInfo("UTC")) + + +def test_dedupe_qs(): + items = OrderedDict() + items["key1"] = "test" + items["key2"] = "blah" + items["key3"] = 1 + + # Construct and test our query string. + query_string = '&'.join([f"{k}={v}" for k, v in items.items()]) + assert query_string == "key1=test&key2=blah&key3=1" + + # Add key1=changed and key2=changed to the query and dedupe it. + deduped = util.dedupe_qs(query_string, "key1=changed", "key3=changed") + assert deduped == "key2=blah&key1=changed&key3=changed"