From 6b6f20a032eb31d747366c8bc270ca81731eaf6a Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Fri, 4 Sep 2026 17:09:24 +0200 Subject: Fixed pre-commit issues --- .gitignore | 114 ++++++++++--- .pre-commit-config.yaml | 16 ++ authentication/authlib.py | 77 ++++++--- authentication/backend.py | 49 +++--- authentication/endpoints.py | 177 +++++++++++++-------- authentication/mixins.py | 12 +- authentication/utils.py | 10 +- conf/__init__.py | 6 +- conf/local.py | 7 +- db/migrations/README | 2 +- db/migrations/env.py | 10 +- .../202607021719-115caa5a76d0_added_user_model.py | 33 ++-- ...202607021740-fc6608d3f9cc_added_oauth_models.py | 160 ++++++++++--------- .../202607021803-ac4fbf1fea31_added_password.py | 21 +-- ...0-69d8b4938a12_added_more_fields_and_session.py | 115 +++++++++---- db/models.py | 21 ++- db/session.py | 17 +- dev.sh | 3 + management/endpoints.py | 33 ++-- proxy/__init__.py | 6 +- proxy/endpoints.py | 25 ++- proxy/queue.py | 63 ++++---- proxy/utils.py | 6 +- proxy/websockets.py | 48 ++++-- pyproject.toml | 5 + ttun_server/__init__.py | 19 +-- ttun_server/endpoints.py | 2 +- ttun_server/redis.py | 4 +- ttun_server/types.py | 32 ++-- ttun_server/websockets.py | 60 +++---- uv.lock | 105 ++++++++++++ 31 files changed, 814 insertions(+), 444 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100755 dev.sh diff --git a/.gitignore b/.gitignore index a9d7b09..69d3fbc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,37 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,vim +# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,macos,vim -# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all -# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud ### PyCharm+all ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider @@ -66,6 +97,9 @@ atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml +# SonarLint plugin +.idea/sonarlint/ + # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties @@ -79,20 +113,13 @@ fabric.properties .idea/caches/build_file_checksums.ser ### PyCharm+all Patch ### -# Ignores the whole .idea folder and all .iml files -# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 - -.idea/ +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. -# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 +.idea/* -*.iml -modules.xml -.idea/misc.xml -*.ipr - -# Sonarlint plugin -.idea/sonarlint +!.idea/codeStyles +!.idea/runConfigurations ### Python ### # Byte-compiled / optimized / DLL files @@ -191,7 +218,22 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# PEP 582; used by e.g. github.com/David-OConnor/pyflow +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff @@ -234,6 +276,42 @@ dmypy.json # Cython debug symbols cython_debug/ -# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all - -.DS_Store +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +# End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,vim diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..25e095a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.16.6 + hooks: + # Run the linter. + - id: ruff-check + args: [ --fix ] + # Run the formatter. + - id: ruff-format diff --git a/authentication/authlib.py b/authentication/authlib.py index 8472062..a37d68f 100644 --- a/authentication/authlib.py +++ b/authentication/authlib.py @@ -1,31 +1,37 @@ import json from collections import defaultdict +from typing import ClassVar from unittest.mock import patch from authlib.common.security import generate_token as generate_random_token -from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer, OAuth2Request -from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant, \ - RefreshTokenGrant as BaseRefreshTokenGrant, OAuth2Payload +from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer +from authlib.oauth2 import OAuth2Request +from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant +from authlib.oauth2.rfc6749 import OAuth2Payload +from authlib.oauth2.rfc6749 import RefreshTokenGrant as BaseRefreshTokenGrant from authlib.oauth2.rfc6750 import BearerTokenGenerator from authlib.oauth2.rfc7636 import CodeChallenge +from fastapi import Request, Response from sqlalchemy.exc import NoResultFound - from sqlmodel import select -from fastapi import Request, Response -from db.models import ClientApplication, AuthToken, AuthCode, User +from db.models import AuthCode, AuthToken, ClientApplication, User from db.session import get_session_context class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): - TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none'] + TOKEN_ENDPOINT_AUTH_METHODS: ClassVar[list[str]] = [ + "client_secret_basic", + "client_secret_post", + "none", + ] def save_authorization_code(self, code: str, request: OAuth2Request): with get_session_context() as db_session: db_session.add(request.user) - code_challenge = request.payload.data.get('code_challenge') - code_challenge_method = request.payload.data.get('code_challenge_method') + code_challenge = request.payload.data.get("code_challenge") + code_challenge_method = request.payload.data.get("code_challenge_method") auth_code = AuthCode( code=code, @@ -41,10 +47,16 @@ class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): return auth_code - def query_authorization_code(self, code: str, client: ClientApplication) -> AuthCode | None: + def query_authorization_code( + self, code: str, client: ClientApplication + ) -> AuthCode | None: try: with get_session_context() as db_session: - auth_code = db_session.exec(select(AuthCode).where(AuthCode.code == code, AuthCode.client_id == client.client_id)).one() + auth_code = db_session.exec( + select(AuthCode).where( + AuthCode.code == code, AuthCode.client_id == client.client_id + ) + ).one() except NoResultFound: return None @@ -65,7 +77,9 @@ class RefreshTokenGrant(BaseRefreshTokenGrant): def authenticate_refresh_token(self, refresh_token) -> AuthToken | None: try: with get_session_context() as db_session: - token = db_session.exec(select(AuthToken).where(AuthToken.refresh_token == refresh_token)).one() + token = db_session.exec( + select(AuthToken).where(AuthToken.refresh_token == refresh_token) + ).one() except NoResultFound: return None @@ -82,6 +96,7 @@ class RefreshTokenGrant(BaseRefreshTokenGrant): credential.revoked = True db_session.add(credential) + async def prepare_oauth_request(request: Request) -> Request: async with request.form() as form: request.state.form_data = dict(form) @@ -96,7 +111,7 @@ class FastApiOAuth2Payload(OAuth2Payload): def data(self): return { **self._request.query_params, - **getattr(self._request.state, 'form_data', {}), + **getattr(self._request.state, "form_data", {}), } @property @@ -106,9 +121,10 @@ class FastApiOAuth2Payload(OAuth2Payload): values[k].extend([self.data[k]]) return values + class FastApiOAuth2Request(OAuth2Request): def __init__(self, request: Request): - with patch('authlib.oauth2.rfc6749.errors.InsecureTransportError'): + with patch("authlib.oauth2.rfc6749.errors.InsecureTransportError"): super().__init__( method=request.method, uri=str(request.url), @@ -128,7 +144,7 @@ class FastApiOAuth2Request(OAuth2Request): @property def form(self): - return getattr(self._request.state, 'form_data', {}) + return getattr(self._request.state, "form_data", {}) class AuthorizationServer(BaseAuthorizationServer): @@ -136,22 +152,33 @@ class AuthorizationServer(BaseAuthorizationServer): super().__init__(*args, **kwargs) self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()]) self.register_grant(RefreshTokenGrant) - self.register_token_generator("default", BearerTokenGenerator( - access_token_generator=lambda *args, **kwargs: generate_random_token(42), - refresh_token_generator=lambda *args, **kwargs: generate_random_token(48), - )) + self.register_token_generator( + "default", + BearerTokenGenerator( + access_token_generator=lambda *args, **kwargs: generate_random_token( + 42 + ), + refresh_token_generator=lambda *args, **kwargs: generate_random_token( + 48 + ), + ), + ) def query_client(self, client_id: str) -> ClientApplication: with get_session_context() as db_session: - return db_session.exec(select(ClientApplication).where(ClientApplication.client_id == client_id)).one() + return db_session.exec( + select(ClientApplication).where( + ClientApplication.client_id == client_id + ) + ).one() def save_token(self, token: dict, request: OAuth2Request): with get_session_context() as db_session: - db_session.add(AuthToken( - user_id=request.user.id, - client_id=request.client.client_id, - **token - )) + db_session.add( + AuthToken( + user_id=request.user.id, client_id=request.client.client_id, **token + ) + ) def create_oauth2_request(self, request: Request) -> OAuth2Request: return FastApiOAuth2Request(request) diff --git a/authentication/backend.py b/authentication/backend.py index 9434906..58842de 100644 --- a/authentication/backend.py +++ b/authentication/backend.py @@ -1,52 +1,49 @@ import re -from typing import Optional from sqlalchemy import update from sqlmodel import select -from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser +from starlette.authentication import ( + AuthCredentials, + AuthenticationBackend, + BaseUser, + UnauthenticatedUser, +) from starlette.requests import HTTPConnection -from db.models import Session, User, AuthToken +from db.models import AuthToken, Session, User from db.session import get_session_context class SessionAuthBackend(AuthenticationBackend): - async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: + async def authenticate( + self, request: HTTPConnection + ) -> tuple[AuthCredentials, BaseUser] | None: if "id" not in request.session: return None - query = ( - select(Session) - .join(User) - .where( - Session.id == request.session['id'] - ) - ) - + query = select(Session).join(User).where(Session.id == request.session["id"]) with get_session_context() as db_session: session: Session = db_session.exec(query).first() if session is not None: - db_session.execute( - update(Session).where(Session.id == session.id) - ) + db_session.execute(update(Session).where(Session.id == session.id)) db_session.commit() - return ( AuthCredentials( - ["authenticated", "session"] - if session is not None - else [] + ["authenticated", "session"] if session is not None else [] ), - session.user if session is not None else UnauthenticatedUser() + session.user if session is not None else UnauthenticatedUser(), ) class BearerTokenAuthBackend(AuthenticationBackend): regex = re.compile(r"^[Bb]earer\s(?P\S+)$") - async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: + + async def authenticate( + self, request: HTTPConnection + ) -> tuple[AuthCredentials, BaseUser] | None: token = None if "Authorization" in request.headers: match = self.regex.match(request.headers["Authorization"]) @@ -54,17 +51,13 @@ class BearerTokenAuthBackend(AuthenticationBackend): query = ( select(AuthToken) .join(User) - .where(AuthToken.access_token == match.group('token')) + .where(AuthToken.access_token == match.group("token")) ) with get_session_context() as db_session: token: AuthToken = db_session.exec(query).first() return ( - AuthCredentials( - ["authenticated", "token"] - if token is not None - else [] - ), - token.user if token is not None else UnauthenticatedUser() + AuthCredentials(["authenticated", "token"] if token is not None else []), + token.user if token is not None else UnauthenticatedUser(), ) diff --git a/authentication/endpoints.py b/authentication/endpoints.py index 3af49fc..17d49d7 100644 --- a/authentication/endpoints.py +++ b/authentication/endpoints.py @@ -4,8 +4,8 @@ from functools import partial from typing import Annotated, Self from uuid import uuid7 -from fastapi import FastAPI, Request, Form, Query -from pydantic import BaseModel, ValidationError, model_validator, EmailStr +from fastapi import FastAPI, Form, Query, Request +from pydantic import BaseModel, EmailStr, ValidationError, model_validator from sqlalchemy import func from sqlmodel import select from starlette.authentication import requires @@ -15,10 +15,14 @@ from starlette.responses import HTMLResponse, RedirectResponse from starlette.templating import Jinja2Templates import conf -from authentication.authlib import AuthorizationServer, AuthorizationCodeGrant, prepare_oauth_request +from authentication.authlib import ( + AuthorizationCodeGrant, + AuthorizationServer, + prepare_oauth_request, +) from authentication.backend import SessionAuthBackend from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query -from db.models import User, Session, ClientApplication +from db.models import ClientApplication, Session, User from db.session import get_session_context authentication = FastAPI() @@ -31,10 +35,7 @@ oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) auth_server = AuthorizationServer() code_grant = partial(AuthorizationCodeGrant, server=auth_server) -templates = Jinja2Templates(directory=[ - 'templates', - 'authentication/templates' -]) +templates = Jinja2Templates(directory=["templates", "authentication/templates"]) class LoginForm(BaseModel): @@ -42,57 +43,75 @@ class LoginForm(BaseModel): password: str -async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): - return templates.TemplateResponse(request, 'login.html', context={ - 'form': form, - 'errors': errors, - }) +async def base_login( + request: Request, + form: LoginForm | None = None, + errors: dict[str, str] | None = None, +): + return templates.TemplateResponse( + request, + "login.html", + context={ + "form": form, + "errors": errors, + }, + ) -@authentication.get('/login/', response_class=HTMLResponse) -async def get_login(request: Request, next: Annotated[str, Query()] = None): +@authentication.get("/login/", response_class=HTMLResponse) +async def get_login(request: Request, next: Annotated[str | None, Query()] = None): return await base_login(request) -@authentication.post('/login/', response_class=HTMLResponse) -async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', - next: Annotated[str, Query()] = None): +@authentication.post("/login/", response_class=HTMLResponse) +async def post_login( + request: Request, + username: Annotated[str, Form()] = "", + password: Annotated[str, Form()] = "", + next: Annotated[str | None, Query()] = None, +): form = None errors = {} try: - form = LoginForm.model_validate({ - 'username': username, - 'password': password - }) + form = LoginForm.model_validate({"username": username, "password": password}) with get_session_context() as db_session: user = db_session.query(User).filter(User.username == form.username).first() - if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: - errors.update({'password': 'Invalid username or password'}) + if ( + not bcrypt_sha256_verify( + password, user.password if user is not None else "" + ) + or user is None + ): + errors.update({"password": "Invalid username or password"}) else: with get_session_context() as db_session: session = Session(id=uuid7(), user=user) db_session.add(session) - request.session.update({'id': str(session.id)}) + request.session.update({"id": str(session.id)}) if next is not None: return RedirectResponse(next, status_code=303) except ValidationError as exc: - errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) + errors.update({e["loc"][0]: e["msg"] for e in exc.errors()}) return await base_login(request, form, errors) -@authentication.get('/logout/') +@authentication.get("/logout/") def logout(request: Request): with get_session_context() as db_session: - db_session.query(Session).filter(Session.id == request.session.get('id')).delete() + db_session.query(Session).filter( + Session.id == request.session.get("id") + ).delete() request.session.clear() - return RedirectResponse(request.headers.get('Referer', '/management/dashboard/'), status_code=303) + return RedirectResponse( + request.headers.get("Referer", "/management/dashboard/"), status_code=303 + ) class RegisterForm(BaseModel): @@ -100,10 +119,10 @@ class RegisterForm(BaseModel): password: str verify_password: str - @model_validator(mode='after') + @model_validator(mode="after") def check_passwords_match(self) -> Self: if self.password != self.verify_password: - raise ValueError('Passwords do not match') + raise ValueError("Passwords do not match") return self @@ -113,31 +132,42 @@ def is_first_run() -> bool: return user_count.first() == 0 -async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): - return templates.TemplateResponse(request, 'register.html', context={ - 'form': form, - 'errors': errors, - 'is_first_run': is_first_run(), - }) +async def base_register( + request: Request, + form: RegisterForm | None = None, + errors: dict[str, str] | None = None, +): + return templates.TemplateResponse( + request, + "register.html", + context={ + "form": form, + "errors": errors, + "is_first_run": is_first_run(), + }, + ) -@authentication.get('/register/', response_class=HTMLResponse) -async def get_register(request: Request, next: Annotated[str, Query()] = None): +@authentication.get("/register/", response_class=HTMLResponse) +async def get_register(request: Request, next: Annotated[str | None, Query()] = None): return await base_register(request) -@authentication.post('/register/', response_class=HTMLResponse) -async def post_register(request: Request, email: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', - verify_password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): +@authentication.post("/register/", response_class=HTMLResponse) +async def post_register( + request: Request, + email: Annotated[str, Form()] = "", + password: Annotated[str, Form()] = "", + verify_password: Annotated[str, Form()] = "", + next: Annotated[str | None, Query()] = None, +): form = None errors = {} try: - form = RegisterForm.model_validate({ - 'email': email, - 'password': password, - 'verify_password': verify_password - }) + form = RegisterForm.model_validate( + {"email": email, "password": password, "verify_password": verify_password} + ) with get_session_context() as db_session: user = User(username=email, email=email) @@ -147,13 +177,18 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa session = Session(id=uuid7(), user=user) db_session.add(session) - request.session.update({'id': str(session.id)}) + request.session.update({"id": str(session.id)}) if next is not None: return RedirectResponse(next, status_code=303) except ValidationError as exc: - errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()}) + errors.update( + { + (e["loc"][0] if e["loc"] else "verify_password"): e["msg"] + for e in exc.errors() + } + ) return await base_register(request, form, errors) @@ -162,49 +197,59 @@ async def base_authorize(request: Request, user: User | None = None): return auth_server.create_authorization_response(request, request.user, grant=None) -@oauth.get('/authorize/') -@requires(['authenticated', 'session'], redirect='get_login') +@oauth.get("/authorize/") +@requires(["authenticated", "session"], redirect="get_login") async def get_authorize(request: Request): return await base_authorize(request, request.user) -@oauth.post('/authorize/') -@requires(['authenticated', 'session'], redirect='get_login') +@oauth.post("/authorize/") +@requires(["authenticated", "session"], redirect="get_login") async def post_authorize(request: Request): return await base_authorize(request, request.user) -@oauth.post('/token/') +@oauth.post("/token/") async def post_token(request: Request): await prepare_oauth_request(request) return auth_server.create_token_response(request) -@authentication.get('/connect/') +@authentication.get("/connect/") async def connect(request: Request): - origin = request.url.scheme + '://' + request.url.netloc + origin = request.url.scheme + "://" + request.url.netloc with get_session_context() as session: app = session.exec(select(ClientApplication)).first() return RedirectResponse( - '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { - 'client_id': app.client_id, - 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'), - 'response_type': 'code', - 'state': b64encode(json.dumps({ - 'client_id': app.client_id, - }).encode()).decode(), - }), + "/oauth" + + get_url_path_with_query( + oauth, + "get_authorize", + { + "client_id": app.client_id, + "redirect_uri": f"{origin}/oauth" + + get_url_path_with_query(oauth, "callback"), + "response_type": "code", + "state": b64encode( + json.dumps( + { + "client_id": app.client_id, + } + ).encode() + ).decode(), + }, + ), status_code=303, ) -@oauth.get('/config/') +@oauth.get("/config/") async def get_config(request: Request): with get_session_context() as session: app = session.exec(select(ClientApplication)).first() return { - 'client_id': app.client_id, + "client_id": app.client_id, } diff --git a/authentication/mixins.py b/authentication/mixins.py index 3d6a91f..70816a1 100644 --- a/authentication/mixins.py +++ b/authentication/mixins.py @@ -1,7 +1,11 @@ import json import time -from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin, OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, OAuth2TokenMixin as BaseOAuth2TokenMixin +from authlib.integrations.sqla_oauth2 import ( + OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, +) +from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin +from authlib.integrations.sqla_oauth2 import OAuth2TokenMixin as BaseOAuth2TokenMixin from sqlalchemy.sql.sqltypes import Text from sqlmodel import Field @@ -11,9 +15,9 @@ class OAuth2ClientMixin(BaseOAuth2ClientMixin): client_secret: str = Field(max_length=120, nullable=True) client_id_issued_at: int = Field(nullable=False, default=0) client_secret_expires_at: int = Field(nullable=False, default=0) - client_metadata_field: str = Field(sa_type=Text, sa_column_kwargs={ - 'name': 'client_metadata' - }, nullable=True) + client_metadata_field: str = Field( + sa_type=Text, sa_column_kwargs={"name": "client_metadata"}, nullable=True + ) _client_metadata = None diff --git a/authentication/utils.py b/authentication/utils.py index 4f19bc3..b0c9989 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -15,11 +15,11 @@ def bcrypt_sha256_hash(password: str) -> str: def bcrypt_sha256_verify(password: str, hashed: str) -> bool: digest = hashlib.sha256(password.encode("utf-8")).digest() encoded = base64.b64encode(digest) - return bcrypt.checkpw(encoded, hashed.encode('utf-8')) + return bcrypt.checkpw(encoded, hashed.encode("utf-8")) -def get_url_path_with_query(app: FastAPI, name: str, params: dict | None = None, /, **path_params: str): +def get_url_path_with_query( + app: FastAPI, name: str, params: dict | None = None, /, **path_params: str +): base = app.url_path_for(name, **path_params) - return base + '?' + urlencode(params) if params is not None else base - - + return base + "?" + urlencode(params) if params is not None else base diff --git a/conf/__init__.py b/conf/__init__.py index 744747f..213ff07 100644 --- a/conf/__init__.py +++ b/conf/__init__.py @@ -7,6 +7,6 @@ try: except ImportError: pass -DATABASE_URL = os.environ.get('DATABASE_URL') -SECRET_KEY = os.environ.get('SECRET_KEY') -ENABLE_AUTH = os.environ.get('ENABLE_AUTH', 'false') == 'true' +DATABASE_URL = os.environ.get("DATABASE_URL") +SECRET_KEY = os.environ.get("SECRET_KEY") +ENABLE_AUTH = os.environ.get("ENABLE_AUTH", "false") == "true" diff --git a/conf/local.py b/conf/local.py index 23f4005..8350831 100644 --- a/conf/local.py +++ b/conf/local.py @@ -2,6 +2,7 @@ import os IS_LOCAL = True -os.environ.setdefault('DATABASE_URL', 'postgresql:///ttun') -os.environ.setdefault('SECRET_KEY', 'secret') -os.environ.setdefault('ENABLE_AUTH', 'true') +os.environ.setdefault("TUNNEL_DOMAIN", "localhost") +os.environ.setdefault("DATABASE_URL", "postgresql:///ttun") +os.environ.setdefault("SECRET_KEY", "secret") +os.environ.setdefault("ENABLE_AUTH", "true") diff --git a/db/migrations/README b/db/migrations/README index 98e4f9c..2500aa1 100644 --- a/db/migrations/README +++ b/db/migrations/README @@ -1 +1 @@ -Generic single-database configuration. \ No newline at end of file +Generic single-database configuration. diff --git a/db/migrations/env.py b/db/migrations/env.py index f1b3156..9c901da 100644 --- a/db/migrations/env.py +++ b/db/migrations/env.py @@ -1,9 +1,7 @@ from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool - from alembic import context +from sqlalchemy import engine_from_config, pool from conf import DATABASE_URL from db.models import * @@ -27,7 +25,7 @@ target_metadata = SQLModel.metadata # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. -config.set_main_option('sqlalchemy.url', DATABASE_URL) +config.set_main_option("sqlalchemy.url", DATABASE_URL) def run_migrations_offline() -> None: @@ -68,9 +66,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py b/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py index 3ccb92d..0bb3076 100644 --- a/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py +++ b/db/migrations/versions/202607021719-115caa5a76d0_added_user_model.py @@ -1,40 +1,41 @@ """Added user model Revision ID: 115caa5a76d0 -Revises: +Revises: Create Date: 2026-07-02 17:19:07.082841 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa import sqlmodel - +from alembic import op # revision identifiers, used by Alembic. -revision: str = '115caa5a76d0' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "115caa5a76d0" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "user", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) + op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_user_username'), table_name='user') - op.drop_table('user') + op.drop_index(op.f("ix_user_username"), table_name="user") + op.drop_table("user") # ### end Alembic commands ### diff --git a/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py b/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py index 5498291..b94ffb8 100644 --- a/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py +++ b/db/migrations/versions/202607021740-fc6608d3f9cc_added_oauth_models.py @@ -5,100 +5,116 @@ Revises: 115caa5a76d0 Create Date: 2026-07-02 17:40:58.968553 """ + import json import os -from datetime import datetime, UTC +from collections.abc import Sequence +from datetime import UTC, datetime from secrets import token_hex -from random import randbytes -from typing import Sequence, Union from uuid import uuid4, uuid7 -from alembic import op import sqlalchemy as sa import sqlmodel -from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'fc6608d3f9cc' -down_revision: Union[str, Sequence[str], None] = '115caa5a76d0' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "fc6608d3f9cc" +down_revision: str | Sequence[str] | None = "115caa5a76d0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - client_application = op.create_table('clientapplication', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column('client_id', sa.String(length=48), nullable=True), - sa.Column('client_secret', sa.String(length=120), nullable=True), - sa.Column('client_id_issued_at', sa.Integer(), nullable=False), - sa.Column('client_secret_expires_at', sa.Integer(), nullable=False), - sa.Column('client_metadata', sa.Text(), nullable=True), - sa.PrimaryKeyConstraint('id') + client_application = op.create_table( + "clientapplication", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("client_id", sa.String(length=48), nullable=True), + sa.Column("client_secret", sa.String(length=120), nullable=True), + sa.Column("client_id_issued_at", sa.Integer(), nullable=False), + sa.Column("client_secret_expires_at", sa.Integer(), nullable=False), + sa.Column("client_metadata", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_clientapplication_client_id"), + "clientapplication", + ["client_id"], + unique=False, ) - op.create_index(op.f('ix_clientapplication_client_id'), 'clientapplication', ['client_id'], unique=False) - op.create_table('authcode', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('code', sa.String(length=120), nullable=False), - sa.Column('client_id', sa.String(length=48), nullable=True), - sa.Column('redirect_uri', sa.Text(), nullable=True), - sa.Column('response_type', sa.Text(), nullable=True), - sa.Column('scope', sa.Text(), nullable=True), - sa.Column('nonce', sa.Text(), nullable=True), - sa.Column('auth_time', sa.Integer(), nullable=False), - sa.Column('acr', sa.Text(), nullable=True), - sa.Column('amr', sa.Text(), nullable=True), - sa.Column('code_challenge', sa.Text(), nullable=True), - sa.Column('code_challenge_method', sa.String(length=48), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('code') + op.create_table( + "authcode", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("code", sa.String(length=120), nullable=False), + sa.Column("client_id", sa.String(length=48), nullable=True), + sa.Column("redirect_uri", sa.Text(), nullable=True), + sa.Column("response_type", sa.Text(), nullable=True), + sa.Column("scope", sa.Text(), nullable=True), + sa.Column("nonce", sa.Text(), nullable=True), + sa.Column("auth_time", sa.Integer(), nullable=False), + sa.Column("acr", sa.Text(), nullable=True), + sa.Column("amr", sa.Text(), nullable=True), + sa.Column("code_challenge", sa.Text(), nullable=True), + sa.Column("code_challenge_method", sa.String(length=48), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("code"), ) - op.create_table('authtoken', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('client_id', sa.String(length=48), nullable=True), - sa.Column('token_type', sa.String(length=40), nullable=True), - sa.Column('access_token', sa.String(length=255), nullable=False), - sa.Column('refresh_token', sa.String(length=255), nullable=True), - sa.Column('scope', sa.Text(), nullable=True), - sa.Column('issued_at', sa.Integer(), nullable=False), - sa.Column('access_token_revoked_at', sa.Integer(), nullable=False), - sa.Column('refresh_token_revoked_at', sa.Integer(), nullable=False), - sa.Column('expires_in', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('access_token') + op.create_table( + "authtoken", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("client_id", sa.String(length=48), nullable=True), + sa.Column("token_type", sa.String(length=40), nullable=True), + sa.Column("access_token", sa.String(length=255), nullable=False), + sa.Column("refresh_token", sa.String(length=255), nullable=True), + sa.Column("scope", sa.Text(), nullable=True), + sa.Column("issued_at", sa.Integer(), nullable=False), + sa.Column("access_token_revoked_at", sa.Integer(), nullable=False), + sa.Column("refresh_token_revoked_at", sa.Integer(), nullable=False), + sa.Column("expires_in", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("access_token"), + ) + op.create_index( + op.f("ix_authtoken_refresh_token"), "authtoken", ["refresh_token"], unique=False ) - op.create_index(op.f('ix_authtoken_refresh_token'), 'authtoken', ['refresh_token'], unique=False) - op.bulk_insert(client_application, [ - { - 'id': uuid7(), - 'name': 'TTUN', - 'client_id': uuid4().hex, - 'client_secret': token_hex(32), - 'client_id_issued_at': datetime.now(tz=UTC).timestamp(), - 'client_secret_expires_at': datetime.now(tz=UTC).timestamp() + 3600, - 'client_metadata': json.dumps({ - 'redirect_uris': [ - f'//{os.environ['TUNNEL_DOMAIN']}/oauth/callback' - ], - }) - } - ]) + op.bulk_insert( + client_application, + [ + { + "id": uuid7(), + "name": "TTUN", + "client_id": uuid4().hex, + "client_secret": token_hex(32), + "client_id_issued_at": datetime.now(tz=UTC).timestamp(), + "client_secret_expires_at": datetime.now(tz=UTC).timestamp() + 3600, + "client_metadata": json.dumps( + { + "redirect_uris": [ + f"//{os.environ['TUNNEL_DOMAIN']}/oauth/callback" + ], + } + ), + } + ], + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_authtoken_refresh_token'), table_name='authtoken') - op.drop_table('authtoken') - op.drop_table('authcode') - op.drop_index(op.f('ix_clientapplication_client_id'), table_name='clientapplication') - op.drop_table('clientapplication') + op.drop_index(op.f("ix_authtoken_refresh_token"), table_name="authtoken") + op.drop_table("authtoken") + op.drop_table("authcode") + op.drop_index( + op.f("ix_clientapplication_client_id"), table_name="clientapplication" + ) + op.drop_table("clientapplication") # ### end Alembic commands ### diff --git a/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py b/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py index 116d27e..5ad10c9 100644 --- a/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py +++ b/db/migrations/versions/202607021803-ac4fbf1fea31_added_password.py @@ -5,29 +5,32 @@ Revises: fc6608d3f9cc Create Date: 2026-07-02 18:03:17.190700 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa import sqlmodel - +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'ac4fbf1fea31' -down_revision: Union[str, Sequence[str], None] = 'fc6608d3f9cc' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "ac4fbf1fea31" +down_revision: str | Sequence[str] | None = "fc6608d3f9cc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.add_column('user', sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) + op.add_column( + "user", + sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('user', 'password') + op.drop_column("user", "password") # ### end Alembic commands ### diff --git a/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py b/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py index ec5031b..40b8a70 100644 --- a/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py +++ b/db/migrations/versions/202607031440-69d8b4938a12_added_more_fields_and_session.py @@ -5,52 +5,101 @@ Revises: ac4fbf1fea31 Create Date: 2026-07-03 14:40:55.628816 """ -from typing import Sequence, Union -from alembic import op -import sqlalchemy as sa -import sqlmodel +from collections.abc import Sequence +import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '69d8b4938a12' -down_revision: Union[str, Sequence[str], None] = 'ac4fbf1fea31' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "69d8b4938a12" +down_revision: str | Sequence[str] | None = "ac4fbf1fea31" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('session', - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), - sa.Column('updated', sa.DateTime(timezone=True), nullable=True), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.add_column('authcode', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) - op.add_column('authcode', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) - op.add_column('authtoken', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) - op.add_column('authtoken', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) - op.add_column('clientapplication', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) - op.add_column('clientapplication', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) - op.add_column('user', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) - op.add_column('user', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) + op.create_table( + "session", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "created", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("updated", sa.DateTime(timezone=True), nullable=True), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "authcode", + sa.Column( + "created", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + op.add_column( + "authcode", sa.Column("updated", sa.DateTime(timezone=True), nullable=True) + ) + op.add_column( + "authtoken", + sa.Column( + "created", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + op.add_column( + "authtoken", sa.Column("updated", sa.DateTime(timezone=True), nullable=True) + ) + op.add_column( + "clientapplication", + sa.Column( + "created", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + op.add_column( + "clientapplication", + sa.Column("updated", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "user", + sa.Column( + "created", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + ) + op.add_column( + "user", sa.Column("updated", sa.DateTime(timezone=True), nullable=True) + ) # ### end Alembic commands ### def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('user', 'updated') - op.drop_column('user', 'created') - op.drop_column('clientapplication', 'updated') - op.drop_column('clientapplication', 'created') - op.drop_column('authtoken', 'updated') - op.drop_column('authtoken', 'created') - op.drop_column('authcode', 'updated') - op.drop_column('authcode', 'created') - op.drop_table('session') + op.drop_column("user", "updated") + op.drop_column("user", "created") + op.drop_column("clientapplication", "updated") + op.drop_column("clientapplication", "created") + op.drop_column("authtoken", "updated") + op.drop_column("authtoken", "created") + op.drop_column("authcode", "updated") + op.drop_column("authcode", "created") + op.drop_table("session") # ### end Alembic commands ### diff --git a/db/models.py b/db/models.py index 8a01c8c..8946bd2 100644 --- a/db/models.py +++ b/db/models.py @@ -1,13 +1,16 @@ import re -from datetime import datetime, UTC -from urllib.parse import urlsplit -from uuid import uuid4, UUID, uuid7 +from datetime import UTC, datetime +from uuid import UUID, uuid7 from sqlalchemy import DateTime -from sqlmodel import SQLModel, Field, Relationship +from sqlmodel import Field, Relationship, SQLModel from starlette.authentication import BaseUser -from authentication.mixins import OAuth2ClientMixin, OAuth2AuthorizationCodeMixin, OAuth2TokenMixin +from authentication.mixins import ( + OAuth2AuthorizationCodeMixin, + OAuth2ClientMixin, + OAuth2TokenMixin, +) from authentication.utils import bcrypt_sha256_hash @@ -21,7 +24,7 @@ class BaseModel(SQLModel): default=None, nullable=True, sa_column_kwargs={ - 'onupdate': lambda: datetime.now(tz=UTC), + "onupdate": lambda: datetime.now(tz=UTC), }, sa_type=DateTime(timezone=True), ) @@ -39,10 +42,12 @@ class User(BaseModel, BaseUser, table=True): def is_authenticated(self) -> bool: return True + class Session(BaseModel, table=True): - user_id: UUID = Field(foreign_key='user.id', exclude=True) + user_id: UUID = Field(foreign_key="user.id", exclude=True) user: User = Relationship() + class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): name: str @@ -52,10 +57,12 @@ class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): for pattern in self.redirect_uris ) + class AuthCode(BaseModel, OAuth2AuthorizationCodeMixin, table=True): user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") user: User = Relationship() + class AuthToken(BaseModel, OAuth2TokenMixin, table=True): user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") user: User = Relationship() diff --git a/db/session.py b/db/session.py index 82c0211..48ef3b6 100644 --- a/db/session.py +++ b/db/session.py @@ -1,21 +1,28 @@ from contextlib import contextmanager -from sqlalchemy.orm import sessionmaker, scoped_session -from sqlmodel import create_engine -from sqlmodel import Session +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlmodel import Session, create_engine from conf import DATABASE_URL from utils.json import json_serializer - -engine = create_engine(DATABASE_URL, echo=True, future=True, json_serializer=json_serializer, pool_size=100, pool_recycle=3600) +engine = create_engine( + DATABASE_URL, + echo=True, + future=True, + json_serializer=json_serializer, + pool_size=100, + pool_recycle=3600, +) session_factory = sessionmaker(engine, class_=Session) ScopedSession = scoped_session(session_factory=session_factory) + def get_session(): with ScopedSession() as session: yield session + @contextmanager def get_session_context(autocommit=True): session = next(get_session()) diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..1795bf7 --- /dev/null +++ b/dev.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +uv run fastapi dev ttun_server diff --git a/management/endpoints.py b/management/endpoints.py index ef44114..2dc77d4 100644 --- a/management/endpoints.py +++ b/management/endpoints.py @@ -18,10 +18,7 @@ management = FastAPI() management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend()) management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) -templates = Jinja2Templates(directory=[ - 'templates', - 'management/templates' -]) +templates = Jinja2Templates(directory=["templates", "management/templates"]) def requires_session(func): @@ -29,38 +26,42 @@ def requires_session(func): which resolves the redirect target via `request.url_for()` against whichever router is bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when `management` happens to be mounted under a shared root app.""" + @wraps(func) async def wrapper(request: Request, *args, **kwargs): - if not {'authenticated', 'session'}.issubset(request.auth.scopes): - next_param = urlencode({'next': str(request.url)}) - return RedirectResponse(f'/auth/login/?{next_param}', status_code=303) + if not {"authenticated", "session"}.issubset(request.auth.scopes): + next_param = urlencode({"next": str(request.url)}) + return RedirectResponse(f"/auth/login/?{next_param}", status_code=303) return await func(request, *args, **kwargs) + return wrapper -@management.get('/') + +@management.get("/") async def index(request: Request): with get_session_context() as session: query = select(func.count(User.id)) user_count = session.exec(query) if user_count.first() == 0: - return RedirectResponse('/auth/register/?next=/management/dashboard/', status_code=303) + return RedirectResponse( + "/auth/register/?next=/management/dashboard/", status_code=303 + ) - return RedirectResponse('/management/dashboard/', status_code=303) + return RedirectResponse("/management/dashboard/", status_code=303) -@management.get('/dashboard/') +@management.get("/dashboard/") @requires_session async def dashboard(request: Request): - return templates.TemplateResponse(request, 'dashboard.html') + return templates.TemplateResponse(request, "dashboard.html") + -@management.get('/users/') +@management.get("/users/") @requires_session async def users(request: Request): with get_session_context() as session: query = select(User) users = session.exec(query).all() - return templates.TemplateResponse(request, 'users.html', context={ - 'users': users - }) + return templates.TemplateResponse(request, "users.html", context={"users": users}) diff --git a/proxy/__init__.py b/proxy/__init__.py index fcdb23b..aba8ad2 100644 --- a/proxy/__init__.py +++ b/proxy/__init__.py @@ -7,7 +7,7 @@ from proxy.websockets import WebsocketProxy app = Starlette( debug=True, routes=[ - Route('/{path:path}', Proxy), - WebSocketRoute('/{path:path}', WebsocketProxy), - ] + Route("/{path:path}", Proxy), + WebSocketRoute("/{path:path}", WebsocketProxy), + ], ) diff --git a/proxy/endpoints.py b/proxy/endpoints.py index 0a23e7b..f514226 100644 --- a/proxy/endpoints.py +++ b/proxy/endpoints.py @@ -1,13 +1,13 @@ import logging -from base64 import b64encode, b64decode +from base64 import b64decode, b64encode from uuid import uuid4 from starlette.endpoints import HTTPEndpoint from starlette.requests import Request from starlette.responses import Response -from proxy.utils import get_path_with_query_string from proxy.queue import ProxyQueue +from proxy.utils import get_path_with_query_string from ttun_server.types import HttpMessage, HttpMessageType, HttpRequestData logger = logging.getLogger(__name__) @@ -18,16 +18,15 @@ class HeaderMapping: self._headers = headers def items(self): - for header in self._headers: - yield header + yield from self._headers class Proxy(HTTPEndpoint): async def dispatch(self) -> None: request = Request(self.scope, self.receive) - subdomain = request.path_params['subdomain'] - response = Response(content='Not Found', status_code=404) + subdomain = request.path_params["subdomain"] + response = Response(content="Not Found", status_code=404) identifier = str(uuid4()) response_queue = await ProxyQueue.create_for_identifier(identifier) @@ -35,7 +34,7 @@ class Proxy(HTTPEndpoint): try: request_queue = await ProxyQueue.get_for_identifier(subdomain) - logger.debug('PROXY %s%s ', subdomain, request.url) + logger.debug("PROXY %s%s ", subdomain, request.url) await request_queue.enqueue( HttpMessage( type=HttpMessageType.request.value, @@ -44,17 +43,17 @@ class Proxy(HTTPEndpoint): method=request.method, path=get_path_with_query_string(request), headers=list(request.headers.items()), - body=b64encode(await request.body()).decode() - ) + body=b64encode(await request.body()).decode(), + ), ) ) _response = await response_queue.dequeue() - payload = _response['payload'] + payload = _response["payload"] response = Response( - status_code=payload['status'], - headers=HeaderMapping(payload['headers']), - content=b64decode(payload['body'].encode()) + status_code=payload["status"], + headers=HeaderMapping(payload["headers"]), + content=b64decode(payload["body"].encode()), ) except AssertionError: pass diff --git a/proxy/queue.py b/proxy/queue.py index cfa0f3c..70fe3d0 100644 --- a/proxy/queue.py +++ b/proxy/queue.py @@ -2,8 +2,7 @@ import asyncio import json import logging import os -import traceback -from typing import Type +from typing import ClassVar, Self from ttun_server.redis import RedisConnectionPool from ttun_server.types import Message @@ -16,37 +15,37 @@ class BaseProxyQueue: self.identifier = identifier @classmethod - async def create_for_identifier(cls, identifier: str) -> 'BaseProxyQueue': - raise NotImplementedError(f'Please implement create_for_identifier') + async def create_for_identifier(cls, identifier: str) -> BaseProxyQueue: + raise NotImplementedError("Please implement create_for_identifier") @classmethod - async def get_for_identifier(cls, identifier: str) -> Type['self']: + async def get_for_identifier(cls, identifier: str) -> Self: assert await cls.has_connection(identifier) return cls(identifier) @classmethod async def has_connection(cls, identifier) -> bool: - raise NotImplementedError(f'Please implement has_connection') + raise NotImplementedError("Please implement has_connection") async def enqueue(self, message: Message): - raise NotImplementedError(f'Please implement send_request') + raise NotImplementedError("Please implement send_request") async def dequeue(self) -> Message: - raise NotImplementedError(f'Please implement handle_requests') + raise NotImplementedError("Please implement handle_requests") async def delete(self): - raise NotImplementedError(f'Please implement delete') + raise NotImplementedError("Please implement delete") class MemoryProxyQueue(BaseProxyQueue): - connections: dict[str, asyncio.Queue] = {} + connections: ClassVar[dict[str, asyncio.Queue]] = {} @classmethod async def has_connection(cls, identifier) -> bool: return identifier in cls.connections @classmethod - async def create_for_identifier(cls, identifier: str) -> 'MemoryProxyQueue': + async def create_for_identifier(cls, identifier: str) -> MemoryProxyQueue: instance = cls(identifier) cls.connections[identifier] = asyncio.Queue() @@ -66,67 +65,59 @@ class RedisProxyQueue(BaseProxyQueue): def __init__(self, identifier): super().__init__(identifier) - self.pubsub = RedisConnectionPool()\ - .get_connection()\ - .pubsub() + self.pubsub = RedisConnectionPool().get_connection().pubsub() self.subscription_queue = asyncio.Queue() @classmethod - async def create_for_identifier(cls, identifier: str) -> 'BaseProxyQueue': + async def create_for_identifier(cls, identifier: str) -> BaseProxyQueue: instance = cls(identifier) - await instance.pubsub.subscribe(f'request_{identifier}') + await instance.pubsub.subscribe(f"request_{identifier}") return instance @classmethod - async def get_for_identifier(cls, identifier: str) -> 'RedisProxyQueue': - instance: 'RedisProxyQueue' = await super().get_for_identifier(identifier) + async def get_for_identifier(cls, identifier: str) -> RedisProxyQueue: + instance: RedisProxyQueue = await super().get_for_identifier(identifier) - await instance.pubsub.subscribe(f'response_{identifier}') + await instance.pubsub.subscribe(f"response_{identifier}") return instance @classmethod async def has_connection(cls, identifier) -> bool: logger.debug(await RedisConnectionPool.get_connection().pubsub_channels()) - return f'request_{identifier}' in { + return f"request_{identifier}" in { channel.decode() - for channel - in await RedisConnectionPool \ - .get_connection() \ - .pubsub_channels() + for channel in await RedisConnectionPool.get_connection().pubsub_channels() } async def wait_for_message(self): async for message in self.pubsub.listen(): - match message['type']: - case 'subscribe': + match message["type"]: + case "subscribe": continue case _: - return message['data'] + return message["data"] async def enqueue(self, message: Message): - await RedisConnectionPool \ - .get_connection() \ - .publish(f'request_{self.identifier}', json.dumps(message)) + await RedisConnectionPool.get_connection().publish( + f"request_{self.identifier}", json.dumps(message) + ) async def dequeue(self) -> Message: message = await self.wait_for_message() return json.loads(message) async def delete(self): - await self.pubsub.unsubscribe(f'request_{self.identifier}') + await self.pubsub.unsubscribe(f"request_{self.identifier}") - await RedisConnectionPool.get_connection()\ - .srem('connections', self.identifier) + await RedisConnectionPool.get_connection().srem("connections", self.identifier) class ProxyQueueMeta(type): def __new__(cls, name, superclasses, attributes): - return RedisProxyQueue \ - if 'REDIS_URL' in os.environ \ - else MemoryProxyQueue + return RedisProxyQueue if "REDIS_URL" in os.environ else MemoryProxyQueue class ProxyQueue(BaseProxyQueue, metaclass=ProxyQueueMeta): diff --git a/proxy/utils.py b/proxy/utils.py index 2b80f43..3fff388 100644 --- a/proxy/utils.py +++ b/proxy/utils.py @@ -3,5 +3,9 @@ from starlette.requests import HTTPConnection def get_path_with_query_string(connection: HTTPConnection) -> str: path = connection.url.path - query_string = '?' + connection.scope['query_string'].decode() if connection.scope['query_string'] else '' + query_string = ( + "?" + connection.scope["query_string"].decode() + if connection.scope["query_string"] + else "" + ) return f"{path}{query_string}" diff --git a/proxy/websockets.py b/proxy/websockets.py index ec65e6d..a09e261 100644 --- a/proxy/websockets.py +++ b/proxy/websockets.py @@ -9,17 +9,22 @@ from uuid import uuid4 from starlette.endpoints import WebSocketEndpoint from starlette.websockets import WebSocket -from proxy.utils import get_path_with_query_string from proxy.queue import ProxyQueue -from ttun_server.types import WebsocketMessage, WebsocketMessageType, WebsocketConnectData, WebsocketMessageData, \ - WebsocketDisconnectData +from proxy.utils import get_path_with_query_string +from ttun_server.types import ( + WebsocketConnectData, + WebsocketDisconnectData, + WebsocketMessage, + WebsocketMessageData, + WebsocketMessageType, +) logger = logging.getLogger(__name__) -logger.setLevel('DEBUG') +logger.setLevel("DEBUG") class WebsocketProxy(WebSocketEndpoint): - encoding = 'json' + encoding = "json" websocket_listen_task = None def __init__(self, *args, **kwargs): @@ -28,16 +33,20 @@ class WebsocketProxy(WebSocketEndpoint): @asynccontextmanager async def proxy(self, websocket: WebSocket, message: WebsocketMessage): - [subdomain, *_] = websocket.url.hostname.split('.') + [subdomain, *_] = websocket.url.hostname.split(".") - expect_ack = WebsocketMessageType(message['type']) == WebsocketMessageType.connect + expect_ack = ( + WebsocketMessageType(message["type"]) == WebsocketMessageType.connect + ) try: request_queue = await ProxyQueue.get_for_identifier(subdomain) await request_queue.enqueue(message) if expect_ack: - response_queue = await ProxyQueue.create_for_identifier(message["identifier"]) + response_queue = await ProxyQueue.create_for_identifier( + message["identifier"] + ) yield await response_queue.dequeue() await response_queue.delete() else: @@ -51,7 +60,9 @@ class WebsocketProxy(WebSocketEndpoint): while True: message: WebsocketMessage = await response_queue.dequeue() logger.debug(message) - await websocket.send_text(b64decode(message['payload']['body'].encode()).decode()) + await websocket.send_text( + b64decode(message["payload"]["body"].encode()).decode() + ) async def on_connect(self, websocket: WebSocket) -> None: message = WebsocketMessage( @@ -60,18 +71,21 @@ class WebsocketProxy(WebSocketEndpoint): payload=WebsocketConnectData( path=get_path_with_query_string(websocket), headers=[ - (k.decode(), v.decode()) - for k, v - in websocket.scope['headers'] + (k.decode(), v.decode()) for k, v in websocket.scope["headers"] ], - ) + ), ) async with self.proxy(websocket, message) as m: - if m is not None and WebsocketMessageType(m['type']) == WebsocketMessageType.ack: + if ( + m is not None + and WebsocketMessageType(m["type"]) == WebsocketMessageType.ack + ): await super().on_connect(websocket) - self.websocket_listen_task = asyncio.create_task(self.listen_for_messages(websocket)) + self.websocket_listen_task = asyncio.create_task( + self.listen_for_messages(websocket) + ) def callback(*args, **kwargs): self.websocket_listen_task = None @@ -92,7 +106,7 @@ class WebsocketProxy(WebSocketEndpoint): identifier=self.id, payload=WebsocketMessageData( body=b64encode(data_bytes).decode(), - ) + ), ) async with self.proxy(websocket, message): @@ -104,7 +118,7 @@ class WebsocketProxy(WebSocketEndpoint): identifier=self.id, payload=WebsocketDisconnectData( close_code=close_code, - ) + ), ) async with self.proxy(websocket, message): diff --git a/pyproject.toml b/pyproject.toml index 6a3117f..0a21d3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,3 +17,8 @@ dependencies = [ "sqlmodel>=0.0.39", "uvicorn[standard]~=0.44.0", ] + +[dependency-groups] +dev = [ + "pre-commit>=4.6.2", +] diff --git a/ttun_server/__init__.py b/ttun_server/__init__.py index 388dd9e..6e1eabf 100644 --- a/ttun_server/__init__.py +++ b/ttun_server/__init__.py @@ -3,7 +3,7 @@ import os from fastapi import FastAPI from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.routing import Host, Route, WebSocketRoute, Mount +from starlette.routing import Host, Mount, Route, WebSocketRoute from authentication.backend import BearerTokenAuthBackend from authentication.endpoints import authentication, oauth @@ -12,20 +12,20 @@ from proxy import app as proxy_app from ttun_server.endpoints import health from ttun_server.websockets import tunnel -logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) +logging.basicConfig(level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO"))) app = FastAPI( debug=True, routes=[ - Mount('/management/', app=management), - Mount('/auth/', app=authentication), - Mount('/oauth/', app=oauth), + Mount("/management/", app=management), + Mount("/auth/", app=authentication), + Mount("/oauth/", app=oauth), WebSocketRoute( - '/tunnel/', + "/tunnel/", endpoint=tunnel, ), - Route('/health/', endpoint=health), - Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) + Route("/health/", endpoint=health), + Host(f"{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}", app=proxy_app), ], ) @@ -33,6 +33,7 @@ app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend()) try: from ._version import version + __version__ = version except ImportError: - __version__ = 'development' + __version__ = "development" diff --git a/ttun_server/endpoints.py b/ttun_server/endpoints.py index 61e3f21..e058009 100644 --- a/ttun_server/endpoints.py +++ b/ttun_server/endpoints.py @@ -1,2 +1,2 @@ async def health(): - return 'OK' + return "OK" diff --git a/ttun_server/redis.py b/ttun_server/redis.py index fb9ff81..dabe940 100644 --- a/ttun_server/redis.py +++ b/ttun_server/redis.py @@ -9,5 +9,5 @@ class RedisConnectionPool: @classmethod def get_connection(cls) -> Redis: if cls._pool is None: - cls._pool = ConnectionPool.from_url(os.environ.get('REDIS_URL')) - return Redis(connection_pool=cls._pool) \ No newline at end of file + cls._pool = ConnectionPool.from_url(os.environ.get("REDIS_URL")) + return Redis(connection_pool=cls._pool) diff --git a/ttun_server/types.py b/ttun_server/types.py index a0c22a3..33095ff 100644 --- a/ttun_server/types.py +++ b/ttun_server/types.py @@ -1,11 +1,11 @@ from asyncio import Queue from enum import Enum -from typing import TypedDict, Optional +from typing import TypedDict class HttpMessageType(Enum): - request = 'request' - response = 'response' + request = "request" + response = "response" class Config(TypedDict): @@ -18,13 +18,13 @@ class HttpRequestData(TypedDict): method: str path: str headers: list[tuple[str, str]] - body: Optional[str] + body: str | None class HttpResponseData(TypedDict): status: int headers: list[tuple[str, str]] - body: Optional[str] + body: str | None class HttpMessage(TypedDict): @@ -34,10 +34,10 @@ class HttpMessage(TypedDict): class WebsocketMessageType(Enum): - connect = 'connect' - disconnect = 'disconnect' - message = 'message' - ack = 'ack' + connect = "connect" + disconnect = "disconnect" + message = "message" + ack = "ack" class WebsocketConnectData(TypedDict): @@ -50,7 +50,7 @@ class WebsocketDisconnectData(TypedDict): class WebsocketMessageData(TypedDict): - body: Optional[str] + body: str | None class WebsocketMessage(TypedDict): @@ -60,13 +60,13 @@ class WebsocketMessage(TypedDict): class MessageType(Enum): - request = 'request' - response = 'response' + request = "request" + response = "response" - ws_connect = 'connect' - ws_disconnect = 'disconnect' - ws_message = 'message' - ws_ack = 'ack' + ws_connect = "connect" + ws_disconnect = "disconnect" + ws_message = "message" + ws_ack = "ack" Message = HttpMessage | WebsocketMessage diff --git a/ttun_server/websockets.py b/ttun_server/websockets.py index 88d6cac..a3cbc5f 100644 --- a/ttun_server/websockets.py +++ b/ttun_server/websockets.py @@ -16,24 +16,25 @@ from ttun_server.types import ( ) logger = logging.getLogger(__name__) -logger.setLevel('DEBUG') +logger.setLevel("DEBUG") + async def assert_compatible_version(websocket: WebSocket, config: Config) -> None: - client_version = config.get('version', '1.0.0') - logger.debug('client_version %s', client_version) + client_version = config.get("version", "1.0.0") + logger.debug("client_version %s", client_version) - if 'git' not in client_version and ttun_server.__version__ != 'development': - [client_major, *_] = [int(i) for i in client_version.split('.')[:3]] - [server_major, *_] = [int(i) for i in ttun_server.__version__.split('.')] + if "git" not in client_version and ttun_server.__version__ != "development": + [client_major, *_] = [int(i) for i in client_version.split(".")[:3]] + [server_major, *_] = [int(i) for i in ttun_server.__version__.split(".")] if client_major < server_major: - await websocket.close(4000, 'Your client is too old') + await websocket.close(4000, "Your client is too old") if client_major > server_major: - await websocket.close(4001, 'Your client is too new') + await websocket.close(4001, "Your client is too new") -@requires(['authenticated', 'token'] if conf.ENABLE_AUTH else []) +@requires(["authenticated", "token"] if conf.ENABLE_AUTH else []) async def tunnel(websocket: WebSocket) -> None: request_tasks: dict[str, asyncio.Task] = {} proxy_queues: dict[str, ProxyQueue] = {} @@ -43,36 +44,39 @@ async def tunnel(websocket: WebSocket) -> None: await assert_compatible_version(websocket, config) - if 'subdomains' not in config: - config['subdomains'] = [config['subdomain']] - elif config['subdomains'] is None: - config['subdomains'] = [None] + if "subdomains" not in config: + config["subdomains"] = [config["subdomain"]] + elif config["subdomains"] is None: + config["subdomains"] = [None] - for i, subdomain in enumerate(config['subdomains']): + for i, subdomain in enumerate(config["subdomains"]): if subdomain is None or await ProxyQueue.has_connection(subdomain): - config['subdomains'][i] = uuid4().hex + config["subdomains"][i] = uuid4().hex - for subdomain in config['subdomains']: + for subdomain in config["subdomains"]: proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain) - hostname = os.environ.get('TUNNEL_DOMAIN') - protocol = 'https' if os.environ.get('SECURE', False) else 'http' + hostname = os.environ.get("TUNNEL_DOMAIN") + protocol = "https" if os.environ.get("SECURE") else "http" urls = [ - f'{protocol}://{subdomain}.{hostname}' - for subdomain in config['subdomains'] + f"{protocol}://{subdomain}.{hostname}" for subdomain in config["subdomains"] ] - await websocket.send_json({ - 'url': urls[0], - 'urls': urls, - }) + await websocket.send_json( + { + "url": urls[0], + "urls": urls, + } + ) async def handle_requests(subdomain: str) -> None: while request := await proxy_queues[subdomain].dequeue(): - asyncio.create_task(websocket.send_json(request), name=request['identifier']) + asyncio.create_task( + websocket.send_json(request), name=request["identifier"] + ) - for subdomain in config['subdomains']: + for subdomain in config["subdomains"]: request_tasks[subdomain] = asyncio.create_task( handle_requests(subdomain), name=subdomain ) @@ -81,8 +85,8 @@ async def tunnel(websocket: WebSocket) -> None: while True: data: Message = await websocket.receive_json() try: - data['type'] = MessageType(data['type']).value - response_queue = await ProxyQueue.get_for_identifier(data['identifier']) + data["type"] = MessageType(data["type"]).value + response_queue = await ProxyQueue.get_for_identifier(data["identifier"]) await response_queue.enqueue(data) except AssertionError: pass diff --git a/uv.lock b/uv.lock index d63779e..eff487f 100644 --- a/uv.lock +++ b/uv.lock @@ -161,6 +161,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -252,6 +261,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/34/8cc73273414405086c58852916e4031812a6a30fe04c057e37ad99397b7f/detect_installer-0.1.0-py3-none-any.whl", hash = "sha256:034fb20fd665c36e6ba52b8821525ea07fb4f7f938cac459df889fb33801528a", size = 4539, upload-time = "2026-02-23T10:40:23.807Z" }, ] +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -383,6 +401,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" }, ] +[[package]] +name = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, +] + [[package]] name = "greenlet" version = "3.5.3" @@ -508,6 +535,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -622,6 +658,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "passlib" version = "1.7.4" @@ -643,6 +688,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/14/032895c25726a859bf48b8ed68944c3efc7a3decd920533ed929f12f08a1/Paste-3.10.1-py3-none-any.whl", hash = "sha256:995e9994b6a94a2bdd8bd9654fb70ca3946ffab75442468bacf31b4d06481c3d", size = 289253, upload-time = "2024-05-01T11:41:05.31Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, +] + [[package]] name = "psycopg2" version = "2.9.12" @@ -758,6 +828,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "python-discovery" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -985,6 +1067,11 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.5" }, @@ -1002,6 +1089,9 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pre-commit", specifier = ">=4.6.2" }] + [[package]] name = "typer" version = "0.26.8" @@ -1091,6 +1181,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] +[[package]] +name = "virtualenv" +version = "21.7.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/1c/69faa2e6a83484e2a8227bce5cfaa183941c5720f99c48f204931d286b07/virtualenv-21.7.8.tar.gz", hash = "sha256:1dc49c790072a9072cb1803f9bd62aa69cd583077cada32390f75505cdc64c9b", size = 5347580, upload-time = "2026-09-01T13:36:13.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/34/88d507d4a4030fa559788de9c690a214f9a4053aa1d91cfb60e9b36127c2/virtualenv-21.7.8-py3-none-any.whl", hash = "sha256:3040eb3cbf5d32b10ffd57d167e6a162237ad82ba7d8cf1400a1efed593d85ac", size = 5324617, upload-time = "2026-09-01T13:36:11.248Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" -- cgit v1.3