diff --git a/bc/assets/templates/includes/header.html b/bc/assets/templates/includes/header.html index f14c6173..399341b6 100644 --- a/bc/assets/templates/includes/header.html +++ b/bc/assets/templates/includes/header.html @@ -1,4 +1,4 @@ -{% load static %} +{% load static web_extras %}
@@ -204,7 +204,13 @@ {% else %}
{% url 'sign-in' as sign_in %} - {% include './action-button.html' with link=sign_in text="Sign In" size='sm' %} + {% if request.path != "/sign-out/" %} + {% with sign_in|addstr:"?next="|addstr:request.path as sign_in_w_redirect %} + {% include './action-button.html' with link=sign_in_w_redirect text="Sign In" size='sm' %} + {% endwith %} + {% else %} + {% include './action-button.html' with link=sign_in text="Sign In" size='sm' %} + {% endif %}
{% endif %}
diff --git a/bc/core/utils/urls.py b/bc/core/utils/urls.py index ca0057ac..287ed342 100644 --- a/bc/core/utils/urls.py +++ b/bc/core/utils/urls.py @@ -1,22 +1,66 @@ +from ada_url import URL from django.conf import settings from django.http import HttpRequest from django.urls import reverse from django.utils.http import url_has_allowed_host_and_scheme +BASE_URL = ( + "https://www.courtlistener.com" + if not settings.DEVELOPMENT + else "http://localhost:8000" +) -def get_redirect_or_login_url(request: HttpRequest, field_name: str) -> str: - """Get the redirect if it's safe, or send the user to the login page + +def parse_url_with_ada(url: str) -> str: + """Parses a URL using the `URL` class from the `Ada`. + + Handles relative paths by adding the `BASE_URL` to the class constructor. + If the URL is already absolute, this step has no effect. Extracts the + parsed URL from the `href` attribute and attempts to remove the `BASE_URL` + if it was added previously. + + Returns an empty string If the input URL is invalid or cannot be parsed. + + :param url: The URL to parse. + :return: The parsed URL or an empty string if the input URL is invalid or + cannot be parsed. + """ + if not url: + return "" + + try: + ada_url = URL(url, base=BASE_URL) + return ada_url.href.replace(BASE_URL, "") + except ValueError: + return "" + + +def get_redirect_or_login_url( + request: HttpRequest, redirect_field_name: str +) -> str: + """ + Retrieves a safe redirect URL from the request or returns the login URL. + + This function checks for a redirect URL in both the POST and GET data of + the provided request object. It then parses the retrieved URL using the + `parse_url_with_ada` helper and performs safety checks using the + `is_safe_url` function. :param request: The HTTP request - :param field_name: The field where the redirect is located + :param redirect_field_name: The name of the field containing the redirect + URL. :return: Either the value requested or the default LOGIN_REDIRECT_URL, if a sanity or security check failed. """ - url = request.GET.get(field_name, "") - is_safe = is_safe_url(url, request) - if not is_safe: + redirect_url = request.POST.get( + redirect_field_name, + request.GET.get(redirect_field_name, ""), + ) + cleaned_url = parse_url_with_ada(redirect_url) + safe = is_safe_url(cleaned_url, request) + if not safe: return settings.LOGIN_REDIRECT_URL - return url + return cleaned_url def is_safe_url(url: str, request: HttpRequest) -> bool: @@ -45,11 +89,10 @@ def is_safe_url(url: str, request: HttpRequest) -> bool: """ sign_in_url = reverse("sign-in") in url register_in_url = reverse("register") in url + no_url = not url not_safe_url = not url_has_allowed_host_and_scheme( url, allowed_hosts={request.get_host()}, require_https=request.is_secure(), ) - if any([sign_in_url, register_in_url, not_safe_url]): - return False - return True + return not any([sign_in_url, register_in_url, no_url, not_safe_url]) diff --git a/bc/subscription/utils/courtlistener.py b/bc/subscription/utils/courtlistener.py index b0c407ec..98c2c927 100644 --- a/bc/subscription/utils/courtlistener.py +++ b/bc/subscription/utils/courtlistener.py @@ -26,14 +26,11 @@ r"(?P(https:\/{2}storage\.courtlistener\.com\/recap\/gov.uscourts.(?P[a-z]+).(?P\d+)))(?:\/.*)" ) -CL_API = { - "docket": "https://www.courtlistener.com/api/rest/v3/dockets/", - "docket-alerts": "https://www.courtlistener.com/api/rest/v3/docket-alerts/", - "docket-entries": "https://www.courtlistener.com/api/rest/v3/docket-entries/", - "recap-documents": "https://www.courtlistener.com/api/rest/v3/recap-documents/", - "recap-fetch": "https://www.courtlistener.com/api/rest/v3/recap-fetch/", - "media-storage": "https://storage.courtlistener.com/", -} +CL_API_URL = ( + lambda suffix: f"https://www.courtlistener.com/api/rest/v4/{suffix}/" +) + +CL_MEDIA_STORAGE = "https://storage.courtlistener.com/" pacer_to_cl_ids = { # Maps PACER ids to their CL equivalents @@ -131,10 +128,10 @@ class DocketDict(TypedDict): def lookup_docket_by_cl_id(cl_id: int) -> DocketDict: """ - Performs a GET query on /api/rest/v3/dockets/ + Performs a GET query on /api/rest/v4/dockets/ to get a Docket using the CourtListener ID """ - url = f"{CL_API['docket']}{cl_id}/" + url = f"{CL_API_URL('dockets')}{cl_id}/" response = requests.get(url, headers=auth_header(), timeout=5) response.raise_for_status() return response.json() @@ -150,11 +147,11 @@ class DocumentDict(TypedDict): def lookup_document_by_doc_id(doc_id: int | None) -> DocumentDict: """ - Performs a GET query on /api/rest/v3/recap-documents/ + Performs a GET query on /api/rest/v4/recap-documents/ using the document_id to get a recap document """ response = requests.get( - f"{CL_API['recap-documents']}{doc_id}/", + f"{CL_API_URL('recap-documents')}{doc_id}/", params={ "fields": "id,absolute_url,filepath_local,page_count,pacer_doc_id" }, @@ -168,7 +165,7 @@ def lookup_document_by_doc_id(doc_id: int | None) -> DocumentDict: def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None: """ - Performs a GET query on /api/rest/v3/recap/ + Performs a GET query on /api/rest/v4/recap/ using the docket_id to get the first entry of the case. Args: @@ -189,7 +186,7 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None: } response = requests.get( - f"{CL_API['recap-documents']}", + f"{CL_API_URL('recap-documents')}", params=params, headers=auth_header(), timeout=5, @@ -197,7 +194,7 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None: response.raise_for_status() data = response.json() - if not data["count"]: + if not data["results"]: return None document = data["results"][0] @@ -211,7 +208,7 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None: def download_pdf_from_cl(filepath: str) -> bytes: - document_url = f"{CL_API['media-storage']}{filepath}" + document_url = f"{CL_MEDIA_STORAGE}{filepath}" document_request = requests.get(document_url, timeout=3) document_request.raise_for_status() return document_request.content @@ -219,12 +216,12 @@ def download_pdf_from_cl(filepath: str) -> bytes: def purchase_pdf_by_doc_id(doc_id: int | None, docket_id: int | None) -> int: """ - Performs a POST query on /api/rest/v3/recap-fetch/ + Performs a POST query on /api/rest/v4/recap-fetch/ using the document_id from CL and the PACER's login credentials. """ response = requests.post( - f"{CL_API['recap-fetch']}", + f"{CL_API_URL('recap-fetch')}", json={ "request_type": 2, "pacer_username": settings.PACER_USERNAME, @@ -242,19 +239,19 @@ def purchase_pdf_by_doc_id(doc_id: int | None, docket_id: int | None) -> int: def lookup_docket_by_case_number(court: str, docket_number: str): """ - Performs a GET query on /api/rest/v3/dockets/ + Performs a GET query on /api/rest/v4/dockets/ using the court_id and docket_number to get a Docket. """ response = requests.get( - CL_API["docket"], + CL_API_URL("dockets"), params={"court_id": court, "docket_number": docket_number}, headers=auth_header(), timeout=5, ) data = response.json() - num_results = data["count"] + num_results = len(data["results"]) if num_results == 1: return data["results"][0] elif num_results == 0: @@ -296,11 +293,11 @@ def lookup_docket_by_case_number(court: str, docket_number: str): def subscribe_to_docket_alert(cl_id: int) -> bool: """ - Performs a POST query on /api/rest/v3/docket-alerts/ + Performs a POST query on /api/rest/v4/docket-alerts/ to subscribe to docket alerts for a given CourtListener docket ID. """ response = requests.post( - CL_API["docket-alerts"], + CL_API_URL("docket-alerts"), headers=auth_header(), data={ "docket": cl_id, diff --git a/bc/users/tests/test_views.py b/bc/users/tests/test_views.py new file mode 100644 index 00000000..868c72a8 --- /dev/null +++ b/bc/users/tests/test_views.py @@ -0,0 +1,114 @@ +from http import HTTPStatus + +from django.test import LiveServerTestCase +from django.urls import reverse + + +class UserTest(LiveServerTestCase): + async def test_simple_auth_urls_GET(self) -> None: + """Can we at least GET all the basic auth URLs?""" + reverse_names = [ + "sign-in", + "password_reset", + "password_reset_done", + "password_reset_complete", + "email_confirmation_request", + "email_confirmation_request_success", + ] + for reverse_name in reverse_names: + path = reverse(reverse_name) + r = await self.async_client.get(path) + self.assertEqual( + r.status_code, + HTTPStatus.OK, + msg="Got wrong status code for page at: {path}. " + "Status Code: {code}".format(path=path, code=r.status_code), + ) + + async def test_login_redirects(self) -> None: + """Do we allow good redirects in login while banning bad ones?""" + next_params = [ + # A safe redirect + (reverse("little_cases"), False), + # Redirection to the register page + (reverse("register"), True), + # No open redirects (to a domain outside CL) + ("https://evil.com&email=e%40e.net", True), + # No javascript (!) + ("javascript:confirm(document.domain)", True), + # No spaces + ("/test test", True), + # CRLF injection attack + ( + "/%0d/evil.com/&email=Your+Account+still+in+maintenance,please+click+Return+below", + True, + ), + # XSS vulnerabilities + ( + "register/success/?next=java%0d%0ascript%0d%0a:alert(document.cookie)&email=Reflected+XSS+here", + True, + ), + ] + for next_param, is_not_safe in next_params: + bad_url = "{host}{path}?next={next}".format( + host=self.live_server_url, + path=reverse("sign-in"), + next=next_param, + ) + response = await self.async_client.get(bad_url) + with self.subTest("Checking redirect in login", url=bad_url): + if is_not_safe: + self.assertNotIn( + f'value="{next_param}"', + response.content.decode(), + msg="'%s' found in HTML of response. This suggests it was " + "not cleaned by the sanitize_redirection function." + % next_param, + ) + else: + self.assertIn( + f'value="{next_param}"', + response.content.decode(), + msg="'%s' not found in HTML of response. This suggests it " + "was sanitized when it should not have been." + % next_param, + ) + + async def test_prevent_text_injection_in_success_registration(self): + """Can we handle text injection attacks?""" + evil_text = "visit https://evil.com/malware.exe to win $100 giftcard" + url_params = [ + # A safe redirect and email + (reverse("little_cases"), "test@free.law", False), + # Text injection attack + (reverse("little_cases"), evil_text, True), + # open redirect and text injection attack + ("https://evil.com&email=e%40e.net", evil_text, True), + ] + + for next_param, email, is_evil in url_params: + url = "{host}{path}?next={next}&email={email}".format( + host=self.live_server_url, + path=reverse("register_success"), + next=next_param, + email=email, + ) + response = await self.async_client.get(url) + with self.subTest("Checking url", url=url): + if is_evil: + self.assertNotIn( + email, + response.content.decode(), + msg="'%s' found in HTML of response. This indicates a " + "potential security vulnerability. The view likely " + "failed to properly validate it." % email, + ) + else: + self.assertIn( + email, + response.content.decode(), + msg="'%s' not found in HTML of response. This suggests a " + "a potential issue with the validation logic. The email " + "address may have been incorrectly identified as invalid" + % email, + ) diff --git a/bc/users/urls.py b/bc/users/urls.py index b0794702..111909ef 100644 --- a/bc/users/urls.py +++ b/bc/users/urls.py @@ -7,6 +7,7 @@ from .forms import ConfirmedEmailAuthenticationForm from .views import ( RateLimitedPasswordResetView, + SafeRedirectLoginView, confirm_email, delete_account, delete_profile_done, @@ -24,7 +25,7 @@ path( "sign-in/", ratelimiter_unsafe_10_per_m( - auth_views.LoginView.as_view( + SafeRedirectLoginView.as_view( template_name="register/login.html", authentication_form=ConfirmedEmailAuthenticationForm, ) diff --git a/bc/users/views.py b/bc/users/views.py index 68045ade..80cbc244 100644 --- a/bc/users/views.py +++ b/bc/users/views.py @@ -4,17 +4,21 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import logout, update_session_auth_hash +from django.contrib.auth import views as auth_views from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.views import PasswordResetView +from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.mail import send_mail from django.core.signing import BadSignature, SignatureExpired +from django.core.validators import validate_email from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, render from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.http import urlencode from django.utils.timezone import now +from django.views.decorators.cache import never_cache from django.views.decorators.debug import ( sensitive_post_parameters, sensitive_variables, @@ -44,6 +48,7 @@ @sensitive_variables("cd", "signed_pk", "email") @ratelimiter_unsafe_10_per_m @ratelimiter_unsafe_2000_per_h +@never_cache def register(request: HttpRequest) -> HttpResponse: """allow only an anonymous user to register""" if not request.user.is_anonymous: @@ -108,13 +113,23 @@ def register(request: HttpRequest) -> HttpResponse: ) +@never_cache def register_success(request: HttpRequest) -> HttpResponse: """ Let the user know they have been registered and allow them to continue where they left off. + + Changes here should be reflected in the CourtListener's + register_success function. """ redirect_to = get_redirect_or_login_url(request, "next") email = request.GET.get("email", "") + if email: + try: + validate_email(email) + except ValidationError: + raise SuspiciousOperation("Invalid Email address") + default_from = parseaddr(settings.DEFAULT_FROM_EMAIL)[1] return render( request, @@ -129,6 +144,7 @@ def register_success(request: HttpRequest) -> HttpResponse: @sensitive_variables("signed_pk") +@never_cache def confirm_email(request, signed_pk): """Confirms email addresses for a user and sends an email to the admins. @@ -239,6 +255,7 @@ class RateLimitedPasswordResetView(PasswordResetView): "email", ) @login_required +@never_cache def profile_settings(request: AuthenticatedHttpRequest) -> HttpResponse: old_email = request.user.email user = request.user @@ -287,6 +304,7 @@ def profile_settings(request: AuthenticatedHttpRequest) -> HttpResponse: @sensitive_post_parameters("old_password", "new_password1", "new_password2") @login_required +@never_cache def password_change(request: AuthenticatedHttpRequest) -> HttpResponse: if request.method == "POST": form = PasswordChangeForm(user=request.user, data=request.POST) @@ -308,6 +326,7 @@ def password_change(request: AuthenticatedHttpRequest) -> HttpResponse: @login_required @ratelimiter_unsafe_10_per_m @ratelimiter_unsafe_2000_per_h +@never_cache def take_out(request: AuthenticatedHttpRequest) -> HttpResponse: if request.method == "POST": email: EmailType = emails["take_out_requested"] @@ -337,6 +356,7 @@ def take_out_done(request: HttpRequest) -> HttpResponse: @login_required @ratelimiter_unsafe_10_per_m @ratelimiter_unsafe_2000_per_h +@never_cache def delete_account(request: AuthenticatedHttpRequest) -> HttpResponse: if request.method == "POST": delete_form = AccountDeleteForm(request, request.POST) @@ -365,3 +385,24 @@ def delete_account(request: AuthenticatedHttpRequest) -> HttpResponse: def delete_profile_done(request: HttpRequest) -> HttpResponse: return render(request, "profile/deleted.html") + + +class SafeRedirectLoginView(auth_views.LoginView): + """ + Custom LoginView that validates and sanitizes the redirect URL after a + successful login. + This view inherits from Django's built-in LoginView but adds an extra layer + of security by ensuring the redirect URL submitted by the login form is safe + It prevents potential open redirect vulnerabilities. + """ + + def get_redirect_url(self): + """ + Return the user-originating redirect URL if it's safe. otherwise falls + back to the default. + This method ensures users cannot be redirected to malicious URLs after + logging in, even if they attempt to provide one. + """ + return get_redirect_or_login_url( + self.request, self.redirect_field_name + ) diff --git a/poetry.lock b/poetry.lock index 66f8364b..40ef58d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,73 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "ada-url" +version = "1.15.3" +description = "URL parser and manipulator based on the WHAT WG URL standard" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ada_url-1.15.3-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:486ed6775faaf915efb82e4dea9224d388ca743aa572996240ffda20e19dd769"}, + {file = "ada_url-1.15.3-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:cf0facdc4e66cadafdfb7ccb914e03aae2571dd8f70a28531a60019d8888641b"}, + {file = "ada_url-1.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2af6a7453bfd11d7ff3fb9710447887888206e5c3a81ceb7f0f23390d48876e"}, + {file = "ada_url-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e0a17dc90d2c42293a44a69308468d2b892564736cc01ef47326824cc9a708"}, + {file = "ada_url-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3835ebabff21bcb870bba21908e34a642bc8a4ae8a40e4a83b7343f04316b0fb"}, + {file = "ada_url-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1cf1e261b94dafa9f9d7eb946a1d76a4d5e760422988236ef9d583b0580e67d"}, + {file = "ada_url-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79144b5a24dfff82cec6b72995668b3d461f21a1e6ef1a57657f5470dfd2c23a"}, + {file = "ada_url-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:2a38f62abb9dfe7c1bf8c13c3c9d1da94412242199b767f4d7b9b1705739cad4"}, + {file = "ada_url-1.15.3-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:31d927fcb72ad77e7cb077e706259c20449460f6cc3fc5705dc4a13090ba49e7"}, + {file = "ada_url-1.15.3-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:2137025c87c419e5360394c8150a8d1a5d3e8f0dc9e63a3a0c42bba0aa1b872f"}, + {file = "ada_url-1.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89655b4174c4f730f9f43c5df871f1a74904845caec540d0419ac8520cba8073"}, + {file = "ada_url-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017cf3a213ff274b3ab74a44834b052632f18a2e137e2d550c9b77ff0877fae6"}, + {file = "ada_url-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84d6dda6442c43cda776031874c28c4b83658688665ed1b13deee9a433271bb7"}, + {file = "ada_url-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb25a889c47938dbdbd28377e61a0c785292e1bb8808e82bb1591b7c9a327451"}, + {file = "ada_url-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a48193a753e24d130e6a731aae95f3fca32535bfd3db7c295f7040f60d777e2"}, + {file = "ada_url-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:e97d15973e1a5d952a3177669e320756b3b8eb6bd4d9eff48f5077710e575156"}, + {file = "ada_url-1.15.3-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:82797ae677e3ed5399c209932d05db32615118d550cb77c184c6a98f80a4a42d"}, + {file = "ada_url-1.15.3-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:f70bc89bfbfbcc51cc1ee8cb9cab12663945acac28a724b1eb64eb2830f9f0f2"}, + {file = "ada_url-1.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:abd9ba0783eef17a94d119a64d8efb1ee73b0bd328f521caa9919924c61da409"}, + {file = "ada_url-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:580e64e0e29cbcd55f95896037472028765f28cf7d1f43a27d5f670d98a3e299"}, + {file = "ada_url-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c253ac0c82680e2aaef793acba35d10170474dbd02bd3db8a46375801e4a8ab2"}, + {file = "ada_url-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f87a642809852cf686caaf9c2ae0ee1e2d2e89449683a92a433a8e81ab3e13a"}, + {file = "ada_url-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddc159a70eacb54afb1ed3590ae79c29e19c0b4031c5bdc915d47b6c40222c45"}, + {file = "ada_url-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d2efe08608f2f1be01ac33922edea808083dad01b4512a661895028a25fb0ed"}, + {file = "ada_url-1.15.3-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:dc6c9108111a980f9de2878bb380efacab286cc77e1d832ddc4969554bc8231a"}, + {file = "ada_url-1.15.3-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:cc3fda231f10618b6fbc54b4f2c1485e68ec54726bddcf51c826917ca637fb34"}, + {file = "ada_url-1.15.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:41023e4d62d6df874b333491290b094efa7ecdbe352004ff2ecad45a8b12c743"}, + {file = "ada_url-1.15.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5f9808e1f051e33f1a1adc27cf708de630cb0495178249cb3b132dfe535c569"}, + {file = "ada_url-1.15.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68ec1ed7e3572807b489ee6a6f3da27ad9b6c292f1a32b4ef1cc8ec7775d623"}, + {file = "ada_url-1.15.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:39a51d4a881adfea6430a74e0042eb21582cfd4974883ab51980ff54c64447f7"}, + {file = "ada_url-1.15.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:84f4ea55c937ed9d7f37986a757fba87cef80f805cd54fdc4f616dd45bd38e33"}, + {file = "ada_url-1.15.3-cp38-cp38-win_amd64.whl", hash = "sha256:bebfc6d909aeb7fa8f1098924219a414c3d4d339a45b468b5b6b3b3f05f81ad8"}, + {file = "ada_url-1.15.3-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:8030483ee0f08852eeb9c7870841a0e1d0d773071fd05ff1b21d45efabba8954"}, + {file = "ada_url-1.15.3-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:923f1f13040134a7147c5530eca6c832b6958704bf7dd0c593f7b6786bcdc535"}, + {file = "ada_url-1.15.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6731a9c7ec646b70e7c4c6b9f68a3b7d3f0f0e0e4eed600bae3f5bb976df0b4"}, + {file = "ada_url-1.15.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75166e872b1e5618d9b197baf0ebb26f3b2432cdbd186c1765126cdc92946ac4"}, + {file = "ada_url-1.15.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b5c45c9e0f04caa5b98decb64c9da9d32e26f8f3a262616ee3b78748f103fdb"}, + {file = "ada_url-1.15.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe6192744b54d729034994b7e2643519c09a8b4a00f7b0e368757fb9b5aa5145"}, + {file = "ada_url-1.15.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:279f64ef3821cf870667063973b0f35ed6bcf9752c1e4fabeaf5d81bb6eb15fb"}, + {file = "ada_url-1.15.3-cp39-cp39-win_amd64.whl", hash = "sha256:0466b7ec95a43a6997184a67a0338a7177d01dade3d39426b2a810a85143f0dc"}, + {file = "ada_url-1.15.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b9a9ebcd27c2cb59b049fd06664c98f7505b3f856c3ac7df087bc45e518f8cd6"}, + {file = "ada_url-1.15.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9b14d002085e6ede41571373b23bca367f89c6e97542be73053b0d8ddbf77c80"}, + {file = "ada_url-1.15.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8d52db4880d274ac0936f3742685140389a2a28be932a97d9a2183efb6ecece"}, + {file = "ada_url-1.15.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2112a62bba370db2281323afb7d757bf6ae0d0aada736b59b142233825f11998"}, + {file = "ada_url-1.15.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:81d5a0923c9703cddd2dde90f03d132d29282d5e48a0ac796fd4c2be39ca8780"}, + {file = "ada_url-1.15.3-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0260dd4f1df3b33193699939b925547dc551e81348a53a65b911a50a9d5f1473"}, + {file = "ada_url-1.15.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:63e5a8527573d2cf9c0c788cd104c6d784ee1af5ff9cbb9de67e1b4197186dfb"}, + {file = "ada_url-1.15.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3906efeff1f5d34390aefcf33812fdbea4e62eeae2854358bd0b83797ae944c5"}, + {file = "ada_url-1.15.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:823547085c3a84b00913a009c8688bfbce5e347284558777d8d2d1fe88708388"}, + {file = "ada_url-1.15.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:be5a2210110c9a7c09c063c6c46f6501a08eba1b5d1b2ed117c7fbc48e21c8aa"}, + {file = "ada_url-1.15.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:966401b06212f4328ba9df21a96e483e3e206659762e7bdbfe482fe6342b56cd"}, + {file = "ada_url-1.15.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:893922defdfdcbea2363ee9fea326e095ad2f95dee117841bfdf57cac4d09d84"}, + {file = "ada_url-1.15.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007bcc66296e1c3076965be77147bb9e088276665d276e4d4358d6c06771f73f"}, + {file = "ada_url-1.15.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c6bb39c48b1c1632134ed73f4eaa610aafd5d3a3a77999d07bcc338c3712c95"}, + {file = "ada_url-1.15.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:eebc0242333320602f078d7c4d4e72191180e2db2b05797c650537083beb0560"}, + {file = "ada_url-1.15.3.tar.gz", hash = "sha256:c1d3bd341082d887c9503bffc10a3b5b33bc514a89c425b656078065a2c8f599"}, +] + +[package.dependencies] +cffi = "*" + [[package]] name = "ansicolors" version = "1.1.8" @@ -2191,4 +2259,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d3413b28dda1b65b40e1e8df2399e23e8b4e828e652f2c92038f41b1a3818bb0" +content-hash = "113cc70020e566bb0cdd24a14c3e08d28699fd13704bcbe9b91e64f4af9ef915" diff --git a/pyproject.toml b/pyproject.toml index 16c01cd5..8d4ef33f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ version = "0.0.1" "Organisation Homepage" = "https://free.law/" [tool.poetry.dependencies] +ada-url = "^1.15.3" python = "^3.11" art = "^6.2" requests = "^2.32.0"