diff --git a/http-api/app/routes/enrollments/slots.py b/http-api/app/routes/enrollments/slots.py index 8b6efb5..a42bb9c 100644 --- a/http-api/app/routes/enrollments/slots.py +++ b/http-api/app/routes/enrollments/slots.py @@ -34,5 +34,6 @@ def get_vacancies(): # Post-migration: uncomment the following line # ComposeKey(str(tenant.id), prefix='slots#orgs'), ComposeKey(str(tenant.id), prefix='vacancies'), - ) + ), + limit=150, ) diff --git a/id.saladeaula.digital/app/integrations/__init__.py b/id.saladeaula.digital/app/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/id.saladeaula.digital/app/apigateway_oauth2.py b/id.saladeaula.digital/app/integrations/apigateway_oauth2.py similarity index 100% rename from id.saladeaula.digital/app/apigateway_oauth2.py rename to id.saladeaula.digital/app/integrations/apigateway_oauth2.py diff --git a/id.saladeaula.digital/app/jose_.py b/id.saladeaula.digital/app/jose_.py index ead1b78..4ab0d77 100644 --- a/id.saladeaula.digital/app/jose_.py +++ b/id.saladeaula.digital/app/jose_.py @@ -39,7 +39,16 @@ def generate_refresh_token(user_id: str) -> str: def verify_jwt(token: str) -> dict: try: - payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + payload = jwt.decode( + token, + JWT_SECRET, + algorithms=[JWT_ALGORITHM], + issuer=ISSUER, + options={ + 'require': ['exp', 'sub', 'iss'], + 'leeway': 60, + }, + ) return payload except ExpiredSignatureError: raise ForbiddenError('Token expired') diff --git a/id.saladeaula.digital/app/oauth2.py b/id.saladeaula.digital/app/oauth2.py index abd612a..609ad54 100644 --- a/id.saladeaula.digital/app/oauth2.py +++ b/id.saladeaula.digital/app/oauth2.py @@ -7,13 +7,13 @@ from aws_lambda_powertools.event_handler.exceptions import NotFoundError from layercake.dateutils import now, ttl from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair -from apigateway_oauth2 import ( +from boto3clients import dynamodb_client +from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE +from integrations.apigateway_oauth2 import ( AuthorizationServer, OAuth2Client, OAuth2Token, ) -from boto3clients import dynamodb_client -from config import DYNAMODB_SORT_KEY, OAUTH2_TABLE oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) @@ -33,12 +33,11 @@ def create_save_token_func(persistence_layer: DynamoDBPersistenceLayer): return save_token -class ClientNotFoundError(NotFoundError): - def __init__(self, *_): - super().__init__('Client not found') - - def create_query_client_func(persistence_layer: DynamoDBPersistenceLayer): + class ClientNotFoundError(NotFoundError): + def __init__(self, *_): + super().__init__('Client not found') + def query_client(client_id) -> OAuth2Client: client = persistence_layer.collection.get_item( KeyPair('OAUTH2_CLIENT', f'CLIENT_ID#{client_id}'), @@ -89,19 +88,16 @@ def save_authorization_code(code, request): ) -def exists_nonce(nonce, request): - nonce_ = oauth2_layer.get_item( - KeyPair( - f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}', - f'NONCE#{nonce}', - ) - ) - return bool(nonce_) - - class OpenIDCode(OpenIDCode_): def exists_nonce(self, nonce, request): - return exists_nonce(nonce, request) + nonce_ = oauth2_layer.get_item( + KeyPair( + f'OAUTH2_CODE#CLIENT_ID#{request.payload.client_id}', # type:ignore + f'NONCE#{nonce}', + ) + ) + + return bool(nonce_) def get_jwt_config(self, grant): return DUMMY_JWT_CONFIG diff --git a/id.saladeaula.digital/app/routes/authorize.py b/id.saladeaula.digital/app/routes/authorize.py index 02aa450..778d2e5 100644 --- a/id.saladeaula.digital/app/routes/authorize.py +++ b/id.saladeaula.digital/app/routes/authorize.py @@ -1,33 +1,84 @@ -from uuid import uuid4 +from http import HTTPStatus +from http.cookies import SimpleCookie +from urllib.parse import ParseResult, quote, urlencode, urlunparse from authlib.oauth2 import OAuth2Error from authlib.oauth2.rfc6749 import errors +from aws_lambda_powertools import Logger +from aws_lambda_powertools.event_handler import Response from aws_lambda_powertools.event_handler.api_gateway import Router +from jose_ import verify_jwt from oauth2 import authorization router = Router() +logger = Logger(__name__) @router.get('/authorize') def authorize(): - user = { - 'id': str(uuid4()), - 'sub': 'sergio@somosbeta.com.br', - } + current_event = router.current_event + cookies = _parse_cookies(current_event.get('cookies', [])) # type: ignore + id_token = cookies.get('id_token') + continue_url = quote( + urlunparse( + ParseResult( + scheme='', + netloc='', + path=current_event.path, + params='', + query=urlencode(current_event.query_string_parameters), + fragment='', + ) + ), + safe='', + ) + login_url = f'/login?continue={continue_url}' + + if not id_token: + return Response( + status_code=HTTPStatus.FOUND, + headers={'Location': login_url}, + ) + + try: + user = verify_jwt(id_token) + except Exception as exc: + logger.exception(exc) + return Response( + status_code=HTTPStatus.FOUND, + headers={'Location': login_url}, + ) + try: grant = authorization.get_consent_grant( request=router.current_event, - end_user=user, + end_user={'id': user['sub']}, ) except OAuth2Error as err: + logger.exception(err) return dict(err.get_body()) try: return authorization.create_authorization_response( request=router.current_event, - grant_user=user, + grant_user={'id': user['sub']}, grant=grant, ) - except errors.OAuth2Error: + except errors.OAuth2Error as err: + logger.exception(err) return {} + + +def _parse_cookies(cookies: list[str] | None) -> dict[str, str]: + parsed_cookies = {} + + if not cookies: + return parsed_cookies + + for s in cookies: + c = SimpleCookie() + c.load(s) + parsed_cookies.update({k: morsel.value for k, morsel in c.items()}) + + return parsed_cookies diff --git a/id.saladeaula.digital/app/routes/login.html b/id.saladeaula.digital/app/routes/login.html deleted file mode 100644 index a251453..0000000 --- a/id.saladeaula.digital/app/routes/login.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - -
- -
-
- - -
- -
-
- - Esqueceu sua senha? -
- -
- - -
-
- - diff --git a/id.saladeaula.digital/app/routes/login.py b/id.saladeaula.digital/app/routes/login.py index 86bffcf..4bbb3b7 100644 --- a/id.saladeaula.digital/app/routes/login.py +++ b/id.saladeaula.digital/app/routes/login.py @@ -1,5 +1,4 @@ from http import HTTPStatus -from pathlib import Path from typing import Annotated from aws_lambda_powertools.event_handler import ( @@ -7,8 +6,9 @@ from aws_lambda_powertools.event_handler import ( ) from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.exceptions import ForbiddenError, NotFoundError -from aws_lambda_powertools.event_handler.openapi.params import Form +from aws_lambda_powertools.event_handler.openapi.params import Form, Param from aws_lambda_powertools.shared.cookies import Cookie +from jinja2 import Environment, PackageLoader, select_autoescape from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from passlib.hash import pbkdf2_sha256 @@ -18,15 +18,20 @@ from jose_ import generate_jwt router = Router() oauth2_layer = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client) +templates = Environment( + loader=PackageLoader('app'), + autoescape=select_autoescape(['html']), +) -@router.get('/login') -def login_form(): - html = Path(__file__).with_name('login.html').read_text(encoding='utf-8') +@router.get('/login', compress=True) +def login_form(continue_: Annotated[str, Param(alias='continue')]): + template = templates.get_template('login.html') + html = template.render(**{'continue': continue_}) return Response( body=html, - status_code=HTTPStatus.OK.value, + status_code=HTTPStatus.OK, content_type='text/html', ) @@ -35,6 +40,7 @@ def login_form(): def login( username: Annotated[str, Form()], password: Annotated[str, Form()], + continue_: Annotated[str, Form(alias='continue')], ): user_id, password_hash = _get_user(username) @@ -44,7 +50,10 @@ def login( jwt_token = generate_jwt(user_id, username) return Response( - status_code=HTTPStatus.OK, + status_code=HTTPStatus.FOUND, + headers={ + 'Location': continue_, + }, cookies=[ Cookie( name='id_token', diff --git a/id.saladeaula.digital/app/templates/__init__.py b/id.saladeaula.digital/app/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/id.saladeaula.digital/app/templates/login.html b/id.saladeaula.digital/app/templates/login.html new file mode 100644 index 0000000..5baff2e --- /dev/null +++ b/id.saladeaula.digital/app/templates/login.html @@ -0,0 +1,115 @@ + + + + EDUSEG® + + + + + +
+ + +
+
+ + + + + + +

+ Faça login +

+

+ Não tem uma conta? + Cadastre-se. +

+
+ +
+ +
+ + +
+ +
+
+ + Esqueceu sua senha? +
+ +
+ + +
+ +

+ Ao fazer login, você concorda com nossa + + política de privacidade . +

+
+
+ + diff --git a/id.saladeaula.digital/template.yaml b/id.saladeaula.digital/template.yaml index 6c97a15..20edb4f 100644 --- a/id.saladeaula.digital/template.yaml +++ b/id.saladeaula.digital/template.yaml @@ -14,7 +14,7 @@ Globals: Architectures: - x86_64 Layers: - - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:90 + - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:91 Environment: Variables: TZ: America/Sao_Paulo @@ -51,24 +51,24 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref OAuth2Table Events: + Login: + Type: HttpApi + Properties: + Path: /login + Method: GET + ApiId: !Ref HttpApi + LoginPost: + Type: HttpApi + Properties: + Path: /login + Method: POST + ApiId: !Ref HttpApi Authorize: Type: HttpApi Properties: Path: /authorize Method: GET ApiId: !Ref HttpApi - LoginForm: - Type: HttpApi - Properties: - Path: /login - Method: GET - ApiId: !Ref HttpApi - Login: - Type: HttpApi - Properties: - Path: /login - Method: POST - ApiId: !Ref HttpApi OpenidConfiguration: Type: HttpApi Properties: diff --git a/id.saladeaula.digital/tests/conftest.py b/id.saladeaula.digital/tests/conftest.py index fe34519..4812158 100644 --- a/id.saladeaula.digital/tests/conftest.py +++ b/id.saladeaula.digital/tests/conftest.py @@ -3,6 +3,7 @@ import json import os from dataclasses import dataclass from http import HTTPMethod +from urllib.parse import urlencode import jsonlines import pytest @@ -37,16 +38,22 @@ class HttpApiProxy: body: dict | str | None = None, *, headers: dict = {}, - cookies: dict = {}, + cookies: list[str] = [], query_string_parameters: dict = {}, is_base64_encoded: bool = True, **kwargs, ) -> dict: + if isinstance(body, dict): + body = json.dumps(body) + + if is_base64_encoded and body: + body = _base64_encode(body) + return { 'version': '2.0', 'routeKey': '$default', 'rawPath': raw_path, - 'rawQueryString': 'parameter1=value1¶meter1=value2¶meter2=value', + 'rawQueryString': urlencode(query_string_parameters), 'cookies': cookies, 'headers': headers, 'queryStringParameters': query_string_parameters, @@ -69,17 +76,17 @@ class HttpApiProxy: 'time': '12/Mar/2020:19:03:58 +0000', 'timeEpoch': 1583348638390, }, - 'body': _base64_dict(body) if isinstance(body, dict) else body, + 'body': body, 'pathParameters': {'parameter1': 'value1'}, 'isBase64Encoded': is_base64_encoded, 'stageVariables': {'stageVariable1': 'value1', 'stageVariable2': 'value2'}, } -def _base64_dict(obj: dict = {}) -> str | None: - if not obj: +def _base64_encode(s: str) -> str | None: + if not s: return None - return base64.b64encode(json.dumps(obj).encode()).decode() + return base64.b64encode(s.encode()).decode() @pytest.fixture @@ -128,7 +135,7 @@ def dynamodb_persistence_layer(dynamodb_client): @pytest.fixture() -def dynamodb_seeds(dynamodb_client): +def seeds(dynamodb_client): from layercake.dynamodb import serialize with open('tests/seeds.jsonl', 'rb') as fp: @@ -142,7 +149,7 @@ def dynamodb_seeds(dynamodb_client): @pytest.fixture -def mock_app(): +def app(): import app return app diff --git a/id.saladeaula.digital/tests/routes/test_authorize.py b/id.saladeaula.digital/tests/routes/test_authorize.py index 407580f..b5dc1d8 100644 --- a/id.saladeaula.digital/tests/routes/test_authorize.py +++ b/id.saladeaula.digital/tests/routes/test_authorize.py @@ -2,19 +2,26 @@ from http import HTTPMethod from layercake.dynamodb import DynamoDBPersistenceLayer +from jose_ import generate_jwt + from ..conftest import HttpApiProxy, LambdaContext def test_authorize( - mock_app, - dynamodb_seeds, + app, + seeds, dynamodb_persistence_layer: DynamoDBPersistenceLayer, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, ): client_id = 'd72d4005-1fa7-4430-9754-80d5e2487bb6' - r = mock_app.lambda_handler( + id_token = generate_jwt( + user_id='357db1c5-7442-4075-98a3-fbe5c938a419', + email='sergio@somosbeta.com.br', + ) + + r = app.lambda_handler( http_api_proxy( raw_path='/authorize', method=HTTPMethod.GET, @@ -25,6 +32,9 @@ def test_authorize( 'scope': 'openid', 'nonce': '123', }, + cookies=[ + f'id_token={id_token}; HttpOnly; Secure', + ], ), lambda_context, ) diff --git a/id.saladeaula.digital/tests/routes/test_login.py b/id.saladeaula.digital/tests/routes/test_login.py index b7ee291..1f44c0a 100644 --- a/id.saladeaula.digital/tests/routes/test_login.py +++ b/id.saladeaula.digital/tests/routes/test_login.py @@ -3,16 +3,17 @@ from http import HTTPMethod from ..conftest import HttpApiProxy, LambdaContext -def test_html_page( - mock_app, - dynamodb_seeds, +def test_html( + app, + seeds, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, ): - r = mock_app.lambda_handler( + r = app.lambda_handler( http_api_proxy( raw_path='/login', method=HTTPMethod.GET, + query_string_parameters={'continue': 'http://localhost'}, ), lambda_context, ) @@ -21,20 +22,19 @@ def test_html_page( def test_login( - mock_app, - dynamodb_seeds, + app, + seeds, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, ): - r = mock_app.lambda_handler( + r = app.lambda_handler( http_api_proxy( raw_path='/login', method=HTTPMethod.POST, headers={ 'Content-Type': 'application/x-www-form-urlencoded', }, - body='username=sergio@somosbeta.com.br&password=pytest@123', - is_base64_encoded=False, + body='username=sergio@somosbeta.com.br&password=pytest@123&continue=https://localhost', ), lambda_context, ) diff --git a/id.saladeaula.digital/tests/seeds.jsonl b/id.saladeaula.digital/tests/seeds.jsonl index bd8631b..8e008dd 100644 --- a/id.saladeaula.digital/tests/seeds.jsonl +++ b/id.saladeaula.digital/tests/seeds.jsonl @@ -1,7 +1,11 @@ +// OAuth2 {"id": "OAUTH2_CLIENT", "sk": "CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "secret": "1nFD8alDbGHgc3g1RLY960xyRJVee0SlMoIB0MUlSuiJy28W", "name": "pytest", "scope": "openid profile", "redirect_uris": ["https://localhost/callback"], "response_types": ["code"], "grant_types": ["authorization_code"]} {"id": "OAUTH2_CODE#CLIENT_ID#d72d4005-1fa7-4430-9754-80d5e2487bb6", "sk": "CODE#kyqp3oSuRFTfuBaCmq3XOgGWg67l42Kt3D6xPEj7Yd3MLdi9", "redirect_uri": "https://localhost/callback", "user_id": "0cb0ce87-9df6-40c1-9fa7-7dfdafd7910e", "nonce": "123", "scope": "openid profile email"} + // Post-migration: uncomment the following line // {"id": "EMAIL", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} {"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "357db1c5-7442-4075-98a3-fbe5c938a419"} + +// User data {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br"} {"id": "357db1c5-7442-4075-98a3-fbe5c938a419", "sk": "PASSWORD", "hash": "$pbkdf2-sha256$29000$IuTcm7M2BiAEgPB.b.3dGw$d8xVCbx8zxg7MeQBrOvCOgniiilsIHEMHzoH/OXftLQ"} \ No newline at end of file diff --git a/id.saladeaula.digital/uv.lock b/id.saladeaula.digital/uv.lock index ad15083..b2a8a83 100644 --- a/id.saladeaula.digital/uv.lock +++ b/id.saladeaula.digital/uv.lock @@ -434,6 +434,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -469,7 +481,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.9.6" +version = "0.9.7" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, @@ -478,6 +490,7 @@ dependencies = [ { name = "dictdiffer" }, { name = "ftfy" }, { name = "glom" }, + { name = "jinja2" }, { name = "meilisearch" }, { name = "orjson" }, { name = "passlib" }, @@ -500,6 +513,7 @@ requires-dist = [ { name = "dictdiffer", specifier = ">=0.9.0" }, { name = "ftfy", specifier = ">=6.3.1" }, { name = "glom", specifier = ">=24.11.0" }, + { name = "jinja2", specifier = ">=3.1.6" }, { name = "meilisearch", specifier = ">=0.34.0" }, { name = "orjson", specifier = ">=3.10.15" }, { name = "passlib", specifier = ">=1.7.4" }, @@ -525,6 +539,34 @@ dev = [ { name = "ruff", specifier = ">=0.11.1" }, ] +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + [[package]] name = "meilisearch" version = "0.36.0"