diff options
| author | 2026-09-04 17:09:24 +0200 | |
|---|---|---|
| committer | 2026-09-04 17:09:24 +0200 | |
| commit | 6b6f20a032eb31d747366c8bc270ca81731eaf6a (patch) | |
| tree | ada48080495267bebde4ee1251bbbe77d629745f | |
| parent | 2d22158dd95f76399f5716d556be60c77da04a46 (diff) | |
| download | server-6b6f20a032eb31d747366c8bc270ca81731eaf6a.tar.gz server-6b6f20a032eb31d747366c8bc270ca81731eaf6a.tar.bz2 server-6b6f20a032eb31d747366c8bc270ca81731eaf6a.zip | |
Fixed pre-commit issuesv3
31 files changed, 812 insertions, 442 deletions
| @@ -1,6 +1,37 @@ | |||
| 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,macos,vim | ||
| 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,macos,vim | ||
| 1 | 3 | ||
| 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all | 4 | ### macOS ### |
| 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all | 5 | # General |
| 6 | .DS_Store | ||
| 7 | .AppleDouble | ||
| 8 | .LSOverride | ||
| 9 | |||
| 10 | # Icon must end with two \r | ||
| 11 | Icon | ||
| 12 | |||
| 13 | # Thumbnails | ||
| 14 | ._* | ||
| 15 | |||
| 16 | # Files that might appear in the root of a volume | ||
| 17 | .DocumentRevisions-V100 | ||
| 18 | .fseventsd | ||
| 19 | .Spotlight-V100 | ||
| 20 | .TemporaryItems | ||
| 21 | .Trashes | ||
| 22 | .VolumeIcon.icns | ||
| 23 | .com.apple.timemachine.donotpresent | ||
| 24 | |||
| 25 | # Directories potentially created on remote AFP share | ||
| 26 | .AppleDB | ||
| 27 | .AppleDesktop | ||
| 28 | Network Trash Folder | ||
| 29 | Temporary Items | ||
| 30 | .apdisk | ||
| 31 | |||
| 32 | ### macOS Patch ### | ||
| 33 | # iCloud generated files | ||
| 34 | *.icloud | ||
| 4 | 35 | ||
| 5 | ### PyCharm+all ### | 36 | ### PyCharm+all ### |
| 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider | 37 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider |
| @@ -66,6 +97,9 @@ atlassian-ide-plugin.xml | |||
| 66 | # Cursive Clojure plugin | 97 | # Cursive Clojure plugin |
| 67 | .idea/replstate.xml | 98 | .idea/replstate.xml |
| 68 | 99 | ||
| 100 | # SonarLint plugin | ||
| 101 | .idea/sonarlint/ | ||
| 102 | |||
| 69 | # Crashlytics plugin (for Android Studio and IntelliJ) | 103 | # Crashlytics plugin (for Android Studio and IntelliJ) |
| 70 | com_crashlytics_export_strings.xml | 104 | com_crashlytics_export_strings.xml |
| 71 | crashlytics.properties | 105 | crashlytics.properties |
| @@ -79,20 +113,13 @@ fabric.properties | |||
| 79 | .idea/caches/build_file_checksums.ser | 113 | .idea/caches/build_file_checksums.ser |
| 80 | 114 | ||
| 81 | ### PyCharm+all Patch ### | 115 | ### PyCharm+all Patch ### |
| 82 | # Ignores the whole .idea folder and all .iml files | 116 | # Ignore everything but code style settings and run configurations |
| 83 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 | 117 | # that are supposed to be shared within teams. |
| 84 | |||
| 85 | .idea/ | ||
| 86 | |||
| 87 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 | ||
| 88 | 118 | ||
| 89 | *.iml | 119 | .idea/* |
| 90 | modules.xml | ||
| 91 | .idea/misc.xml | ||
| 92 | *.ipr | ||
| 93 | 120 | ||
| 94 | # Sonarlint plugin | 121 | !.idea/codeStyles |
| 95 | .idea/sonarlint | 122 | !.idea/runConfigurations |
| 96 | 123 | ||
| 97 | ### Python ### | 124 | ### Python ### |
| 98 | # Byte-compiled / optimized / DLL files | 125 | # Byte-compiled / optimized / DLL files |
| @@ -191,7 +218,22 @@ ipython_config.py | |||
| 191 | # install all needed dependencies. | 218 | # install all needed dependencies. |
| 192 | #Pipfile.lock | 219 | #Pipfile.lock |
| 193 | 220 | ||
| 194 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow | 221 | # poetry |
| 222 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. | ||
| 223 | # This is especially recommended for binary packages to ensure reproducibility, and is more | ||
| 224 | # commonly ignored for libraries. | ||
| 225 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control | ||
| 226 | #poetry.lock | ||
| 227 | |||
| 228 | # pdm | ||
| 229 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. | ||
| 230 | #pdm.lock | ||
| 231 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it | ||
| 232 | # in version control. | ||
| 233 | # https://pdm.fming.dev/#use-with-ide | ||
| 234 | .pdm.toml | ||
| 235 | |||
| 236 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm | ||
| 195 | __pypackages__/ | 237 | __pypackages__/ |
| 196 | 238 | ||
| 197 | # Celery stuff | 239 | # Celery stuff |
| @@ -234,6 +276,42 @@ dmypy.json | |||
| 234 | # Cython debug symbols | 276 | # Cython debug symbols |
| 235 | cython_debug/ | 277 | cython_debug/ |
| 236 | 278 | ||
| 237 | # End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all | 279 | # PyCharm |
| 280 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can | ||
| 281 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore | ||
| 282 | # and can be added to the global gitignore or merged into this file. For a more nuclear | ||
| 283 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. | ||
| 284 | #.idea/ | ||
| 238 | 285 | ||
| 239 | .DS_Store | 286 | ### Python Patch ### |
| 287 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration | ||
| 288 | poetry.toml | ||
| 289 | |||
| 290 | # ruff | ||
| 291 | .ruff_cache/ | ||
| 292 | |||
| 293 | # LSP config files | ||
| 294 | pyrightconfig.json | ||
| 295 | |||
| 296 | ### Vim ### | ||
| 297 | # Swap | ||
| 298 | [._]*.s[a-v][a-z] | ||
| 299 | !*.svg # comment out if you don't need vector files | ||
| 300 | [._]*.sw[a-p] | ||
| 301 | [._]s[a-rt-v][a-z] | ||
| 302 | [._]ss[a-gi-z] | ||
| 303 | [._]sw[a-p] | ||
| 304 | |||
| 305 | # Session | ||
| 306 | Session.vim | ||
| 307 | Sessionx.vim | ||
| 308 | |||
| 309 | # Temporary | ||
| 310 | .netrwhist | ||
| 311 | *~ | ||
| 312 | # Auto-generated tag files | ||
| 313 | tags | ||
| 314 | # Persistent undo | ||
| 315 | [._]*.un~ | ||
| 316 | |||
| 317 | # 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 @@ | |||
| 1 | repos: | ||
| 2 | - repo: https://github.com/pre-commit/pre-commit-hooks | ||
| 3 | rev: v6.0.0 | ||
| 4 | hooks: | ||
| 5 | - id: check-yaml | ||
| 6 | - id: end-of-file-fixer | ||
| 7 | - id: trailing-whitespace | ||
| 8 | - repo: https://github.com/astral-sh/ruff-pre-commit | ||
| 9 | # Ruff version. | ||
| 10 | rev: v0.16.6 | ||
| 11 | hooks: | ||
| 12 | # Run the linter. | ||
| 13 | - id: ruff-check | ||
| 14 | args: [ --fix ] | ||
| 15 | # Run the formatter. | ||
| 16 | - 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 @@ | |||
| 1 | import json | 1 | import json |
| 2 | from collections import defaultdict | 2 | from collections import defaultdict |
| 3 | from typing import ClassVar | ||
| 3 | from unittest.mock import patch | 4 | from unittest.mock import patch |
| 4 | 5 | ||
| 5 | from authlib.common.security import generate_token as generate_random_token | 6 | from authlib.common.security import generate_token as generate_random_token |
| 6 | from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer, OAuth2Request | 7 | from authlib.oauth2 import AuthorizationServer as BaseAuthorizationServer |
| 7 | from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant, \ | 8 | from authlib.oauth2 import OAuth2Request |
| 8 | RefreshTokenGrant as BaseRefreshTokenGrant, OAuth2Payload | 9 | from authlib.oauth2.rfc6749 import AuthorizationCodeGrant as BaseAuthorizationCodeGrant |
| 10 | from authlib.oauth2.rfc6749 import OAuth2Payload | ||
| 11 | from authlib.oauth2.rfc6749 import RefreshTokenGrant as BaseRefreshTokenGrant | ||
| 9 | from authlib.oauth2.rfc6750 import BearerTokenGenerator | 12 | from authlib.oauth2.rfc6750 import BearerTokenGenerator |
| 10 | from authlib.oauth2.rfc7636 import CodeChallenge | 13 | from authlib.oauth2.rfc7636 import CodeChallenge |
| 14 | from fastapi import Request, Response | ||
| 11 | from sqlalchemy.exc import NoResultFound | 15 | from sqlalchemy.exc import NoResultFound |
| 12 | |||
| 13 | from sqlmodel import select | 16 | from sqlmodel import select |
| 14 | from fastapi import Request, Response | ||
| 15 | 17 | ||
| 16 | from db.models import ClientApplication, AuthToken, AuthCode, User | 18 | from db.models import AuthCode, AuthToken, ClientApplication, User |
| 17 | from db.session import get_session_context | 19 | from db.session import get_session_context |
| 18 | 20 | ||
| 19 | 21 | ||
| 20 | class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): | 22 | class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): |
| 21 | TOKEN_ENDPOINT_AUTH_METHODS = ['client_secret_basic', 'client_secret_post', 'none'] | 23 | TOKEN_ENDPOINT_AUTH_METHODS: ClassVar[list[str]] = [ |
| 24 | "client_secret_basic", | ||
| 25 | "client_secret_post", | ||
| 26 | "none", | ||
| 27 | ] | ||
| 22 | 28 | ||
| 23 | def save_authorization_code(self, code: str, request: OAuth2Request): | 29 | def save_authorization_code(self, code: str, request: OAuth2Request): |
| 24 | with get_session_context() as db_session: | 30 | with get_session_context() as db_session: |
| 25 | db_session.add(request.user) | 31 | db_session.add(request.user) |
| 26 | 32 | ||
| 27 | code_challenge = request.payload.data.get('code_challenge') | 33 | code_challenge = request.payload.data.get("code_challenge") |
| 28 | code_challenge_method = request.payload.data.get('code_challenge_method') | 34 | code_challenge_method = request.payload.data.get("code_challenge_method") |
| 29 | 35 | ||
| 30 | auth_code = AuthCode( | 36 | auth_code = AuthCode( |
| 31 | code=code, | 37 | code=code, |
| @@ -41,10 +47,16 @@ class AuthorizationCodeGrant(BaseAuthorizationCodeGrant): | |||
| 41 | 47 | ||
| 42 | return auth_code | 48 | return auth_code |
| 43 | 49 | ||
| 44 | def query_authorization_code(self, code: str, client: ClientApplication) -> AuthCode | None: | 50 | def query_authorization_code( |
| 51 | self, code: str, client: ClientApplication | ||
| 52 | ) -> AuthCode | None: | ||
| 45 | try: | 53 | try: |
| 46 | with get_session_context() as db_session: | 54 | with get_session_context() as db_session: |
| 47 | auth_code = db_session.exec(select(AuthCode).where(AuthCode.code == code, AuthCode.client_id == client.client_id)).one() | 55 | auth_code = db_session.exec( |
| 56 | select(AuthCode).where( | ||
| 57 | AuthCode.code == code, AuthCode.client_id == client.client_id | ||
| 58 | ) | ||
| 59 | ).one() | ||
| 48 | except NoResultFound: | 60 | except NoResultFound: |
| 49 | return None | 61 | return None |
| 50 | 62 | ||
| @@ -65,7 +77,9 @@ class RefreshTokenGrant(BaseRefreshTokenGrant): | |||
| 65 | def authenticate_refresh_token(self, refresh_token) -> AuthToken | None: | 77 | def authenticate_refresh_token(self, refresh_token) -> AuthToken | None: |
| 66 | try: | 78 | try: |
| 67 | with get_session_context() as db_session: | 79 | with get_session_context() as db_session: |
| 68 | token = db_session.exec(select(AuthToken).where(AuthToken.refresh_token == refresh_token)).one() | 80 | token = db_session.exec( |
| 81 | select(AuthToken).where(AuthToken.refresh_token == refresh_token) | ||
| 82 | ).one() | ||
| 69 | except NoResultFound: | 83 | except NoResultFound: |
| 70 | return None | 84 | return None |
| 71 | 85 | ||
| @@ -82,6 +96,7 @@ class RefreshTokenGrant(BaseRefreshTokenGrant): | |||
| 82 | credential.revoked = True | 96 | credential.revoked = True |
| 83 | db_session.add(credential) | 97 | db_session.add(credential) |
| 84 | 98 | ||
| 99 | |||
| 85 | async def prepare_oauth_request(request: Request) -> Request: | 100 | async def prepare_oauth_request(request: Request) -> Request: |
| 86 | async with request.form() as form: | 101 | async with request.form() as form: |
| 87 | request.state.form_data = dict(form) | 102 | request.state.form_data = dict(form) |
| @@ -96,7 +111,7 @@ class FastApiOAuth2Payload(OAuth2Payload): | |||
| 96 | def data(self): | 111 | def data(self): |
| 97 | return { | 112 | return { |
| 98 | **self._request.query_params, | 113 | **self._request.query_params, |
| 99 | **getattr(self._request.state, 'form_data', {}), | 114 | **getattr(self._request.state, "form_data", {}), |
| 100 | } | 115 | } |
| 101 | 116 | ||
| 102 | @property | 117 | @property |
| @@ -106,9 +121,10 @@ class FastApiOAuth2Payload(OAuth2Payload): | |||
| 106 | values[k].extend([self.data[k]]) | 121 | values[k].extend([self.data[k]]) |
| 107 | return values | 122 | return values |
| 108 | 123 | ||
| 124 | |||
| 109 | class FastApiOAuth2Request(OAuth2Request): | 125 | class FastApiOAuth2Request(OAuth2Request): |
| 110 | def __init__(self, request: Request): | 126 | def __init__(self, request: Request): |
| 111 | with patch('authlib.oauth2.rfc6749.errors.InsecureTransportError'): | 127 | with patch("authlib.oauth2.rfc6749.errors.InsecureTransportError"): |
| 112 | super().__init__( | 128 | super().__init__( |
| 113 | method=request.method, | 129 | method=request.method, |
| 114 | uri=str(request.url), | 130 | uri=str(request.url), |
| @@ -128,7 +144,7 @@ class FastApiOAuth2Request(OAuth2Request): | |||
| 128 | 144 | ||
| 129 | @property | 145 | @property |
| 130 | def form(self): | 146 | def form(self): |
| 131 | return getattr(self._request.state, 'form_data', {}) | 147 | return getattr(self._request.state, "form_data", {}) |
| 132 | 148 | ||
| 133 | 149 | ||
| 134 | class AuthorizationServer(BaseAuthorizationServer): | 150 | class AuthorizationServer(BaseAuthorizationServer): |
| @@ -136,22 +152,33 @@ class AuthorizationServer(BaseAuthorizationServer): | |||
| 136 | super().__init__(*args, **kwargs) | 152 | super().__init__(*args, **kwargs) |
| 137 | self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()]) | 153 | self.register_grant(AuthorizationCodeGrant, extensions=[CodeChallenge()]) |
| 138 | self.register_grant(RefreshTokenGrant) | 154 | self.register_grant(RefreshTokenGrant) |
| 139 | self.register_token_generator("default", BearerTokenGenerator( | 155 | self.register_token_generator( |
| 140 | access_token_generator=lambda *args, **kwargs: generate_random_token(42), | 156 | "default", |
| 141 | refresh_token_generator=lambda *args, **kwargs: generate_random_token(48), | 157 | BearerTokenGenerator( |
| 142 | )) | 158 | access_token_generator=lambda *args, **kwargs: generate_random_token( |
| 159 | 42 | ||
| 160 | ), | ||
| 161 | refresh_token_generator=lambda *args, **kwargs: generate_random_token( | ||
| 162 | 48 | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | ) | ||
| 143 | 166 | ||
| 144 | def query_client(self, client_id: str) -> ClientApplication: | 167 | def query_client(self, client_id: str) -> ClientApplication: |
| 145 | with get_session_context() as db_session: | 168 | with get_session_context() as db_session: |
| 146 | return db_session.exec(select(ClientApplication).where(ClientApplication.client_id == client_id)).one() | 169 | return db_session.exec( |
| 170 | select(ClientApplication).where( | ||
| 171 | ClientApplication.client_id == client_id | ||
| 172 | ) | ||
| 173 | ).one() | ||
| 147 | 174 | ||
| 148 | def save_token(self, token: dict, request: OAuth2Request): | 175 | def save_token(self, token: dict, request: OAuth2Request): |
| 149 | with get_session_context() as db_session: | 176 | with get_session_context() as db_session: |
| 150 | db_session.add(AuthToken( | 177 | db_session.add( |
| 151 | user_id=request.user.id, | 178 | AuthToken( |
| 152 | client_id=request.client.client_id, | 179 | user_id=request.user.id, client_id=request.client.client_id, **token |
| 153 | **token | 180 | ) |
| 154 | )) | 181 | ) |
| 155 | 182 | ||
| 156 | def create_oauth2_request(self, request: Request) -> OAuth2Request: | 183 | def create_oauth2_request(self, request: Request) -> OAuth2Request: |
| 157 | return FastApiOAuth2Request(request) | 184 | 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 @@ | |||
| 1 | import re | 1 | import re |
| 2 | from typing import Optional | ||
| 3 | 2 | ||
| 4 | from sqlalchemy import update | 3 | from sqlalchemy import update |
| 5 | from sqlmodel import select | 4 | from sqlmodel import select |
| 6 | from starlette.authentication import AuthenticationBackend, AuthCredentials, BaseUser, UnauthenticatedUser | 5 | from starlette.authentication import ( |
| 6 | AuthCredentials, | ||
| 7 | AuthenticationBackend, | ||
| 8 | BaseUser, | ||
| 9 | UnauthenticatedUser, | ||
| 10 | ) | ||
| 7 | from starlette.requests import HTTPConnection | 11 | from starlette.requests import HTTPConnection |
| 8 | 12 | ||
| 9 | from db.models import Session, User, AuthToken | 13 | from db.models import AuthToken, Session, User |
| 10 | from db.session import get_session_context | 14 | from db.session import get_session_context |
| 11 | 15 | ||
| 12 | 16 | ||
| 13 | class SessionAuthBackend(AuthenticationBackend): | 17 | class SessionAuthBackend(AuthenticationBackend): |
| 14 | async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: | 18 | async def authenticate( |
| 19 | self, request: HTTPConnection | ||
| 20 | ) -> tuple[AuthCredentials, BaseUser] | None: | ||
| 15 | if "id" not in request.session: | 21 | if "id" not in request.session: |
| 16 | return None | 22 | return None |
| 17 | 23 | ||
| 18 | query = ( | 24 | query = select(Session).join(User).where(Session.id == request.session["id"]) |
| 19 | select(Session) | ||
| 20 | .join(User) | ||
| 21 | .where( | ||
| 22 | Session.id == request.session['id'] | ||
| 23 | ) | ||
| 24 | ) | ||
| 25 | |||
| 26 | 25 | ||
| 27 | with get_session_context() as db_session: | 26 | with get_session_context() as db_session: |
| 28 | session: Session = db_session.exec(query).first() | 27 | session: Session = db_session.exec(query).first() |
| 29 | 28 | ||
| 30 | if session is not None: | 29 | if session is not None: |
| 31 | db_session.execute( | 30 | db_session.execute(update(Session).where(Session.id == session.id)) |
| 32 | update(Session).where(Session.id == session.id) | ||
| 33 | ) | ||
| 34 | db_session.commit() | 31 | db_session.commit() |
| 35 | 32 | ||
| 36 | |||
| 37 | return ( | 33 | return ( |
| 38 | AuthCredentials( | 34 | AuthCredentials( |
| 39 | ["authenticated", "session"] | 35 | ["authenticated", "session"] if session is not None else [] |
| 40 | if session is not None | ||
| 41 | else [] | ||
| 42 | ), | 36 | ), |
| 43 | session.user if session is not None else UnauthenticatedUser() | 37 | session.user if session is not None else UnauthenticatedUser(), |
| 44 | ) | 38 | ) |
| 45 | 39 | ||
| 46 | 40 | ||
| 47 | class BearerTokenAuthBackend(AuthenticationBackend): | 41 | class BearerTokenAuthBackend(AuthenticationBackend): |
| 48 | regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$") | 42 | regex = re.compile(r"^[Bb]earer\s(?P<token>\S+)$") |
| 49 | async def authenticate(self, request: HTTPConnection) -> Optional[tuple[AuthCredentials, BaseUser]]: | 43 | |
| 44 | async def authenticate( | ||
| 45 | self, request: HTTPConnection | ||
| 46 | ) -> tuple[AuthCredentials, BaseUser] | None: | ||
| 50 | token = None | 47 | token = None |
| 51 | if "Authorization" in request.headers: | 48 | if "Authorization" in request.headers: |
| 52 | match = self.regex.match(request.headers["Authorization"]) | 49 | match = self.regex.match(request.headers["Authorization"]) |
| @@ -54,17 +51,13 @@ class BearerTokenAuthBackend(AuthenticationBackend): | |||
| 54 | query = ( | 51 | query = ( |
| 55 | select(AuthToken) | 52 | select(AuthToken) |
| 56 | .join(User) | 53 | .join(User) |
| 57 | .where(AuthToken.access_token == match.group('token')) | 54 | .where(AuthToken.access_token == match.group("token")) |
| 58 | ) | 55 | ) |
| 59 | 56 | ||
| 60 | with get_session_context() as db_session: | 57 | with get_session_context() as db_session: |
| 61 | token: AuthToken = db_session.exec(query).first() | 58 | token: AuthToken = db_session.exec(query).first() |
| 62 | 59 | ||
| 63 | return ( | 60 | return ( |
| 64 | AuthCredentials( | 61 | AuthCredentials(["authenticated", "token"] if token is not None else []), |
| 65 | ["authenticated", "token"] | 62 | token.user if token is not None else UnauthenticatedUser(), |
| 66 | if token is not None | ||
| 67 | else [] | ||
| 68 | ), | ||
| 69 | token.user if token is not None else UnauthenticatedUser() | ||
| 70 | ) | 63 | ) |
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 | |||
| 4 | from typing import Annotated, Self | 4 | from typing import Annotated, Self |
| 5 | from uuid import uuid7 | 5 | from uuid import uuid7 |
| 6 | 6 | ||
| 7 | from fastapi import FastAPI, Request, Form, Query | 7 | from fastapi import FastAPI, Form, Query, Request |
| 8 | from pydantic import BaseModel, ValidationError, model_validator, EmailStr | 8 | from pydantic import BaseModel, EmailStr, ValidationError, model_validator |
| 9 | from sqlalchemy import func | 9 | from sqlalchemy import func |
| 10 | from sqlmodel import select | 10 | from sqlmodel import select |
| 11 | from starlette.authentication import requires | 11 | from starlette.authentication import requires |
| @@ -15,10 +15,14 @@ from starlette.responses import HTMLResponse, RedirectResponse | |||
| 15 | from starlette.templating import Jinja2Templates | 15 | from starlette.templating import Jinja2Templates |
| 16 | 16 | ||
| 17 | import conf | 17 | import conf |
| 18 | from authentication.authlib import AuthorizationServer, AuthorizationCodeGrant, prepare_oauth_request | 18 | from authentication.authlib import ( |
| 19 | AuthorizationCodeGrant, | ||
| 20 | AuthorizationServer, | ||
| 21 | prepare_oauth_request, | ||
| 22 | ) | ||
| 19 | from authentication.backend import SessionAuthBackend | 23 | from authentication.backend import SessionAuthBackend |
| 20 | from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query | 24 | from authentication.utils import bcrypt_sha256_verify, get_url_path_with_query |
| 21 | from db.models import User, Session, ClientApplication | 25 | from db.models import ClientApplication, Session, User |
| 22 | from db.session import get_session_context | 26 | from db.session import get_session_context |
| 23 | 27 | ||
| 24 | authentication = FastAPI() | 28 | authentication = FastAPI() |
| @@ -31,10 +35,7 @@ oauth.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) | |||
| 31 | auth_server = AuthorizationServer() | 35 | auth_server = AuthorizationServer() |
| 32 | code_grant = partial(AuthorizationCodeGrant, server=auth_server) | 36 | code_grant = partial(AuthorizationCodeGrant, server=auth_server) |
| 33 | 37 | ||
| 34 | templates = Jinja2Templates(directory=[ | 38 | templates = Jinja2Templates(directory=["templates", "authentication/templates"]) |
| 35 | 'templates', | ||
| 36 | 'authentication/templates' | ||
| 37 | ]) | ||
| 38 | 39 | ||
| 39 | 40 | ||
| 40 | class LoginForm(BaseModel): | 41 | class LoginForm(BaseModel): |
| @@ -42,57 +43,75 @@ class LoginForm(BaseModel): | |||
| 42 | password: str | 43 | password: str |
| 43 | 44 | ||
| 44 | 45 | ||
| 45 | async def base_login(request: Request, form: LoginForm | None = None, errors: dict[str, str] | None = None): | 46 | async def base_login( |
| 46 | return templates.TemplateResponse(request, 'login.html', context={ | 47 | request: Request, |
| 47 | 'form': form, | 48 | form: LoginForm | None = None, |
| 48 | 'errors': errors, | 49 | errors: dict[str, str] | None = None, |
| 49 | }) | 50 | ): |
| 51 | return templates.TemplateResponse( | ||
| 52 | request, | ||
| 53 | "login.html", | ||
| 54 | context={ | ||
| 55 | "form": form, | ||
| 56 | "errors": errors, | ||
| 57 | }, | ||
| 58 | ) | ||
| 50 | 59 | ||
| 51 | 60 | ||
| 52 | @authentication.get('/login/', response_class=HTMLResponse) | 61 | @authentication.get("/login/", response_class=HTMLResponse) |
| 53 | async def get_login(request: Request, next: Annotated[str, Query()] = None): | 62 | async def get_login(request: Request, next: Annotated[str | None, Query()] = None): |
| 54 | return await base_login(request) | 63 | return await base_login(request) |
| 55 | 64 | ||
| 56 | 65 | ||
| 57 | @authentication.post('/login/', response_class=HTMLResponse) | 66 | @authentication.post("/login/", response_class=HTMLResponse) |
| 58 | async def post_login(request: Request, username: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', | 67 | async def post_login( |
| 59 | next: Annotated[str, Query()] = None): | 68 | request: Request, |
| 69 | username: Annotated[str, Form()] = "", | ||
| 70 | password: Annotated[str, Form()] = "", | ||
| 71 | next: Annotated[str | None, Query()] = None, | ||
| 72 | ): | ||
| 60 | form = None | 73 | form = None |
| 61 | errors = {} | 74 | errors = {} |
| 62 | 75 | ||
| 63 | try: | 76 | try: |
| 64 | form = LoginForm.model_validate({ | 77 | form = LoginForm.model_validate({"username": username, "password": password}) |
| 65 | 'username': username, | ||
| 66 | 'password': password | ||
| 67 | }) | ||
| 68 | 78 | ||
| 69 | with get_session_context() as db_session: | 79 | with get_session_context() as db_session: |
| 70 | user = db_session.query(User).filter(User.username == form.username).first() | 80 | user = db_session.query(User).filter(User.username == form.username).first() |
| 71 | 81 | ||
| 72 | if not bcrypt_sha256_verify(password, user.password if user is not None else '') or user is None: | 82 | if ( |
| 73 | errors.update({'password': 'Invalid username or password'}) | 83 | not bcrypt_sha256_verify( |
| 84 | password, user.password if user is not None else "" | ||
| 85 | ) | ||
| 86 | or user is None | ||
| 87 | ): | ||
| 88 | errors.update({"password": "Invalid username or password"}) | ||
| 74 | else: | 89 | else: |
| 75 | with get_session_context() as db_session: | 90 | with get_session_context() as db_session: |
| 76 | session = Session(id=uuid7(), user=user) | 91 | session = Session(id=uuid7(), user=user) |
| 77 | db_session.add(session) | 92 | db_session.add(session) |
| 78 | 93 | ||
| 79 | request.session.update({'id': str(session.id)}) | 94 | request.session.update({"id": str(session.id)}) |
| 80 | 95 | ||
| 81 | if next is not None: | 96 | if next is not None: |
| 82 | return RedirectResponse(next, status_code=303) | 97 | return RedirectResponse(next, status_code=303) |
| 83 | except ValidationError as exc: | 98 | except ValidationError as exc: |
| 84 | errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) | 99 | errors.update({e["loc"][0]: e["msg"] for e in exc.errors()}) |
| 85 | 100 | ||
| 86 | return await base_login(request, form, errors) | 101 | return await base_login(request, form, errors) |
| 87 | 102 | ||
| 88 | 103 | ||
| 89 | @authentication.get('/logout/') | 104 | @authentication.get("/logout/") |
| 90 | def logout(request: Request): | 105 | def logout(request: Request): |
| 91 | with get_session_context() as db_session: | 106 | with get_session_context() as db_session: |
| 92 | db_session.query(Session).filter(Session.id == request.session.get('id')).delete() | 107 | db_session.query(Session).filter( |
| 108 | Session.id == request.session.get("id") | ||
| 109 | ).delete() | ||
| 93 | 110 | ||
| 94 | request.session.clear() | 111 | request.session.clear() |
| 95 | return RedirectResponse(request.headers.get('Referer', '/management/dashboard/'), status_code=303) | 112 | return RedirectResponse( |
| 113 | request.headers.get("Referer", "/management/dashboard/"), status_code=303 | ||
| 114 | ) | ||
| 96 | 115 | ||
| 97 | 116 | ||
| 98 | class RegisterForm(BaseModel): | 117 | class RegisterForm(BaseModel): |
| @@ -100,10 +119,10 @@ class RegisterForm(BaseModel): | |||
| 100 | password: str | 119 | password: str |
| 101 | verify_password: str | 120 | verify_password: str |
| 102 | 121 | ||
| 103 | @model_validator(mode='after') | 122 | @model_validator(mode="after") |
| 104 | def check_passwords_match(self) -> Self: | 123 | def check_passwords_match(self) -> Self: |
| 105 | if self.password != self.verify_password: | 124 | if self.password != self.verify_password: |
| 106 | raise ValueError('Passwords do not match') | 125 | raise ValueError("Passwords do not match") |
| 107 | return self | 126 | return self |
| 108 | 127 | ||
| 109 | 128 | ||
| @@ -113,31 +132,42 @@ def is_first_run() -> bool: | |||
| 113 | return user_count.first() == 0 | 132 | return user_count.first() == 0 |
| 114 | 133 | ||
| 115 | 134 | ||
| 116 | async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): | 135 | async def base_register( |
| 117 | return templates.TemplateResponse(request, 'register.html', context={ | 136 | request: Request, |
| 118 | 'form': form, | 137 | form: RegisterForm | None = None, |
| 119 | 'errors': errors, | 138 | errors: dict[str, str] | None = None, |
| 120 | 'is_first_run': is_first_run(), | 139 | ): |
| 121 | }) | 140 | return templates.TemplateResponse( |
| 141 | request, | ||
| 142 | "register.html", | ||
| 143 | context={ | ||
| 144 | "form": form, | ||
| 145 | "errors": errors, | ||
| 146 | "is_first_run": is_first_run(), | ||
| 147 | }, | ||
| 148 | ) | ||
| 122 | 149 | ||
| 123 | 150 | ||
| 124 | @authentication.get('/register/', response_class=HTMLResponse) | 151 | @authentication.get("/register/", response_class=HTMLResponse) |
| 125 | async def get_register(request: Request, next: Annotated[str, Query()] = None): | 152 | async def get_register(request: Request, next: Annotated[str | None, Query()] = None): |
| 126 | return await base_register(request) | 153 | return await base_register(request) |
| 127 | 154 | ||
| 128 | 155 | ||
| 129 | @authentication.post('/register/', response_class=HTMLResponse) | 156 | @authentication.post("/register/", response_class=HTMLResponse) |
| 130 | async def post_register(request: Request, email: Annotated[str, Form()] = '', password: Annotated[str, Form()] = '', | 157 | async def post_register( |
| 131 | verify_password: Annotated[str, Form()] = '', next: Annotated[str, Query()] = None): | 158 | request: Request, |
| 159 | email: Annotated[str, Form()] = "", | ||
| 160 | password: Annotated[str, Form()] = "", | ||
| 161 | verify_password: Annotated[str, Form()] = "", | ||
| 162 | next: Annotated[str | None, Query()] = None, | ||
| 163 | ): | ||
| 132 | form = None | 164 | form = None |
| 133 | errors = {} | 165 | errors = {} |
| 134 | 166 | ||
| 135 | try: | 167 | try: |
| 136 | form = RegisterForm.model_validate({ | 168 | form = RegisterForm.model_validate( |
| 137 | 'email': email, | 169 | {"email": email, "password": password, "verify_password": verify_password} |
| 138 | 'password': password, | 170 | ) |
| 139 | 'verify_password': verify_password | ||
| 140 | }) | ||
| 141 | 171 | ||
| 142 | with get_session_context() as db_session: | 172 | with get_session_context() as db_session: |
| 143 | user = User(username=email, email=email) | 173 | user = User(username=email, email=email) |
| @@ -147,13 +177,18 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa | |||
| 147 | session = Session(id=uuid7(), user=user) | 177 | session = Session(id=uuid7(), user=user) |
| 148 | db_session.add(session) | 178 | db_session.add(session) |
| 149 | 179 | ||
| 150 | request.session.update({'id': str(session.id)}) | 180 | request.session.update({"id": str(session.id)}) |
| 151 | 181 | ||
| 152 | if next is not None: | 182 | if next is not None: |
| 153 | return RedirectResponse(next, status_code=303) | 183 | return RedirectResponse(next, status_code=303) |
| 154 | 184 | ||
| 155 | except ValidationError as exc: | 185 | except ValidationError as exc: |
| 156 | errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()}) | 186 | errors.update( |
| 187 | { | ||
| 188 | (e["loc"][0] if e["loc"] else "verify_password"): e["msg"] | ||
| 189 | for e in exc.errors() | ||
| 190 | } | ||
| 191 | ) | ||
| 157 | 192 | ||
| 158 | return await base_register(request, form, errors) | 193 | return await base_register(request, form, errors) |
| 159 | 194 | ||
| @@ -162,49 +197,59 @@ async def base_authorize(request: Request, user: User | None = None): | |||
| 162 | return auth_server.create_authorization_response(request, request.user, grant=None) | 197 | return auth_server.create_authorization_response(request, request.user, grant=None) |
| 163 | 198 | ||
| 164 | 199 | ||
| 165 | @oauth.get('/authorize/') | 200 | @oauth.get("/authorize/") |
| 166 | @requires(['authenticated', 'session'], redirect='get_login') | 201 | @requires(["authenticated", "session"], redirect="get_login") |
| 167 | async def get_authorize(request: Request): | 202 | async def get_authorize(request: Request): |
| 168 | return await base_authorize(request, request.user) | 203 | return await base_authorize(request, request.user) |
| 169 | 204 | ||
| 170 | 205 | ||
| 171 | @oauth.post('/authorize/') | 206 | @oauth.post("/authorize/") |
| 172 | @requires(['authenticated', 'session'], redirect='get_login') | 207 | @requires(["authenticated", "session"], redirect="get_login") |
| 173 | async def post_authorize(request: Request): | 208 | async def post_authorize(request: Request): |
| 174 | return await base_authorize(request, request.user) | 209 | return await base_authorize(request, request.user) |
| 175 | 210 | ||
| 176 | 211 | ||
| 177 | @oauth.post('/token/') | 212 | @oauth.post("/token/") |
| 178 | async def post_token(request: Request): | 213 | async def post_token(request: Request): |
| 179 | await prepare_oauth_request(request) | 214 | await prepare_oauth_request(request) |
| 180 | return auth_server.create_token_response(request) | 215 | return auth_server.create_token_response(request) |
| 181 | 216 | ||
| 182 | 217 | ||
| 183 | @authentication.get('/connect/') | 218 | @authentication.get("/connect/") |
| 184 | async def connect(request: Request): | 219 | async def connect(request: Request): |
| 185 | origin = request.url.scheme + '://' + request.url.netloc | 220 | origin = request.url.scheme + "://" + request.url.netloc |
| 186 | 221 | ||
| 187 | with get_session_context() as session: | 222 | with get_session_context() as session: |
| 188 | app = session.exec(select(ClientApplication)).first() | 223 | app = session.exec(select(ClientApplication)).first() |
| 189 | 224 | ||
| 190 | return RedirectResponse( | 225 | return RedirectResponse( |
| 191 | '/oauth' + get_url_path_with_query(oauth, 'get_authorize', { | 226 | "/oauth" |
| 192 | 'client_id': app.client_id, | 227 | + get_url_path_with_query( |
| 193 | 'redirect_uri': f'{origin}/oauth' + get_url_path_with_query(oauth, 'callback'), | 228 | oauth, |
| 194 | 'response_type': 'code', | 229 | "get_authorize", |
| 195 | 'state': b64encode(json.dumps({ | 230 | { |
| 196 | 'client_id': app.client_id, | 231 | "client_id": app.client_id, |
| 197 | }).encode()).decode(), | 232 | "redirect_uri": f"{origin}/oauth" |
| 198 | }), | 233 | + get_url_path_with_query(oauth, "callback"), |
| 234 | "response_type": "code", | ||
| 235 | "state": b64encode( | ||
| 236 | json.dumps( | ||
| 237 | { | ||
| 238 | "client_id": app.client_id, | ||
| 239 | } | ||
| 240 | ).encode() | ||
| 241 | ).decode(), | ||
| 242 | }, | ||
| 243 | ), | ||
| 199 | status_code=303, | 244 | status_code=303, |
| 200 | ) | 245 | ) |
| 201 | 246 | ||
| 202 | 247 | ||
| 203 | @oauth.get('/config/') | 248 | @oauth.get("/config/") |
| 204 | async def get_config(request: Request): | 249 | async def get_config(request: Request): |
| 205 | with get_session_context() as session: | 250 | with get_session_context() as session: |
| 206 | app = session.exec(select(ClientApplication)).first() | 251 | app = session.exec(select(ClientApplication)).first() |
| 207 | 252 | ||
| 208 | return { | 253 | return { |
| 209 | 'client_id': app.client_id, | 254 | "client_id": app.client_id, |
| 210 | } | 255 | } |
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 @@ | |||
| 1 | import json | 1 | import json |
| 2 | import time | 2 | import time |
| 3 | 3 | ||
| 4 | from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin, OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, OAuth2TokenMixin as BaseOAuth2TokenMixin | 4 | from authlib.integrations.sqla_oauth2 import ( |
| 5 | OAuth2AuthorizationCodeMixin as BaseOAuth2AuthorizationCodeMixin, | ||
| 6 | ) | ||
| 7 | from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin as BaseOAuth2ClientMixin | ||
| 8 | from authlib.integrations.sqla_oauth2 import OAuth2TokenMixin as BaseOAuth2TokenMixin | ||
| 5 | from sqlalchemy.sql.sqltypes import Text | 9 | from sqlalchemy.sql.sqltypes import Text |
| 6 | from sqlmodel import Field | 10 | from sqlmodel import Field |
| 7 | 11 | ||
| @@ -11,9 +15,9 @@ class OAuth2ClientMixin(BaseOAuth2ClientMixin): | |||
| 11 | client_secret: str = Field(max_length=120, nullable=True) | 15 | client_secret: str = Field(max_length=120, nullable=True) |
| 12 | client_id_issued_at: int = Field(nullable=False, default=0) | 16 | client_id_issued_at: int = Field(nullable=False, default=0) |
| 13 | client_secret_expires_at: int = Field(nullable=False, default=0) | 17 | client_secret_expires_at: int = Field(nullable=False, default=0) |
| 14 | client_metadata_field: str = Field(sa_type=Text, sa_column_kwargs={ | 18 | client_metadata_field: str = Field( |
| 15 | 'name': 'client_metadata' | 19 | sa_type=Text, sa_column_kwargs={"name": "client_metadata"}, nullable=True |
| 16 | }, nullable=True) | 20 | ) |
| 17 | 21 | ||
| 18 | _client_metadata = None | 22 | _client_metadata = None |
| 19 | 23 | ||
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: | |||
| 15 | def bcrypt_sha256_verify(password: str, hashed: str) -> bool: | 15 | def bcrypt_sha256_verify(password: str, hashed: str) -> bool: |
| 16 | digest = hashlib.sha256(password.encode("utf-8")).digest() | 16 | digest = hashlib.sha256(password.encode("utf-8")).digest() |
| 17 | encoded = base64.b64encode(digest) | 17 | encoded = base64.b64encode(digest) |
| 18 | return bcrypt.checkpw(encoded, hashed.encode('utf-8')) | 18 | return bcrypt.checkpw(encoded, hashed.encode("utf-8")) |
| 19 | 19 | ||
| 20 | 20 | ||
| 21 | def get_url_path_with_query(app: FastAPI, name: str, params: dict | None = None, /, **path_params: str): | 21 | def get_url_path_with_query( |
| 22 | app: FastAPI, name: str, params: dict | None = None, /, **path_params: str | ||
| 23 | ): | ||
| 22 | base = app.url_path_for(name, **path_params) | 24 | base = app.url_path_for(name, **path_params) |
| 23 | return base + '?' + urlencode(params) if params is not None else base | 25 | return base + "?" + urlencode(params) if params is not None else base |
| 24 | |||
| 25 | |||
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: | |||
| 7 | except ImportError: | 7 | except ImportError: |
| 8 | pass | 8 | pass |
| 9 | 9 | ||
| 10 | DATABASE_URL = os.environ.get('DATABASE_URL') | 10 | DATABASE_URL = os.environ.get("DATABASE_URL") |
| 11 | SECRET_KEY = os.environ.get('SECRET_KEY') | 11 | SECRET_KEY = os.environ.get("SECRET_KEY") |
| 12 | ENABLE_AUTH = os.environ.get('ENABLE_AUTH', 'false') == 'true' | 12 | 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 | |||
| 2 | 2 | ||
| 3 | IS_LOCAL = True | 3 | IS_LOCAL = True |
| 4 | 4 | ||
| 5 | os.environ.setdefault('DATABASE_URL', 'postgresql:///ttun') | 5 | os.environ.setdefault("TUNNEL_DOMAIN", "localhost") |
| 6 | os.environ.setdefault('SECRET_KEY', 'secret') | 6 | os.environ.setdefault("DATABASE_URL", "postgresql:///ttun") |
| 7 | os.environ.setdefault('ENABLE_AUTH', 'true') | 7 | os.environ.setdefault("SECRET_KEY", "secret") |
| 8 | 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 @@ | |||
| 1 | from logging.config import fileConfig | 1 | from logging.config import fileConfig |
| 2 | 2 | ||
| 3 | from sqlalchemy import engine_from_config | ||
| 4 | from sqlalchemy import pool | ||
| 5 | |||
| 6 | from alembic import context | 3 | from alembic import context |
| 4 | from sqlalchemy import engine_from_config, pool | ||
| 7 | 5 | ||
| 8 | from conf import DATABASE_URL | 6 | from conf import DATABASE_URL |
| 9 | from db.models import * | 7 | from db.models import * |
| @@ -27,7 +25,7 @@ target_metadata = SQLModel.metadata | |||
| 27 | # can be acquired: | 25 | # can be acquired: |
| 28 | # my_important_option = config.get_main_option("my_important_option") | 26 | # my_important_option = config.get_main_option("my_important_option") |
| 29 | # ... etc. | 27 | # ... etc. |
| 30 | config.set_main_option('sqlalchemy.url', DATABASE_URL) | 28 | config.set_main_option("sqlalchemy.url", DATABASE_URL) |
| 31 | 29 | ||
| 32 | 30 | ||
| 33 | def run_migrations_offline() -> None: | 31 | def run_migrations_offline() -> None: |
| @@ -68,9 +66,7 @@ def run_migrations_online() -> None: | |||
| 68 | ) | 66 | ) |
| 69 | 67 | ||
| 70 | with connectable.connect() as connection: | 68 | with connectable.connect() as connection: |
| 71 | context.configure( | 69 | context.configure(connection=connection, target_metadata=target_metadata) |
| 72 | connection=connection, target_metadata=target_metadata | ||
| 73 | ) | ||
| 74 | 70 | ||
| 75 | with context.begin_transaction(): | 71 | with context.begin_transaction(): |
| 76 | context.run_migrations() | 72 | 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 @@ | |||
| 1 | """Added user model | 1 | """Added user model |
| 2 | 2 | ||
| 3 | Revision ID: 115caa5a76d0 | 3 | Revision ID: 115caa5a76d0 |
| 4 | Revises: | 4 | Revises: |
| 5 | Create Date: 2026-07-02 17:19:07.082841 | 5 | Create Date: 2026-07-02 17:19:07.082841 |
| 6 | 6 | ||
| 7 | """ | 7 | """ |
| 8 | from typing import Sequence, Union | ||
| 9 | 8 | ||
| 10 | from alembic import op | 9 | from collections.abc import Sequence |
| 10 | |||
| 11 | import sqlalchemy as sa | 11 | import sqlalchemy as sa |
| 12 | import sqlmodel | 12 | import sqlmodel |
| 13 | 13 | from alembic import op | |
| 14 | 14 | ||
| 15 | # revision identifiers, used by Alembic. | 15 | # revision identifiers, used by Alembic. |
| 16 | revision: str = '115caa5a76d0' | 16 | revision: str = "115caa5a76d0" |
| 17 | down_revision: Union[str, Sequence[str], None] = None | 17 | down_revision: str | Sequence[str] | None = None |
| 18 | branch_labels: Union[str, Sequence[str], None] = None | 18 | branch_labels: str | Sequence[str] | None = None |
| 19 | depends_on: Union[str, Sequence[str], None] = None | 19 | depends_on: str | Sequence[str] | None = None |
| 20 | 20 | ||
| 21 | 21 | ||
| 22 | def upgrade() -> None: | 22 | def upgrade() -> None: |
| 23 | """Upgrade schema.""" | 23 | """Upgrade schema.""" |
| 24 | # ### commands auto generated by Alembic - please adjust! ### | 24 | # ### commands auto generated by Alembic - please adjust! ### |
| 25 | op.create_table('user', | 25 | op.create_table( |
| 26 | sa.Column('id', sa.Uuid(), nullable=False), | 26 | "user", |
| 27 | sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False), | 27 | sa.Column("id", sa.Uuid(), nullable=False), |
| 28 | sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False), | 28 | sa.Column("username", sqlmodel.sql.sqltypes.AutoString(), nullable=False), |
| 29 | sa.PrimaryKeyConstraint('id') | 29 | sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), |
| 30 | sa.PrimaryKeyConstraint("id"), | ||
| 30 | ) | 31 | ) |
| 31 | op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) | 32 | op.create_index(op.f("ix_user_username"), "user", ["username"], unique=True) |
| 32 | # ### end Alembic commands ### | 33 | # ### end Alembic commands ### |
| 33 | 34 | ||
| 34 | 35 | ||
| 35 | def downgrade() -> None: | 36 | def downgrade() -> None: |
| 36 | """Downgrade schema.""" | 37 | """Downgrade schema.""" |
| 37 | # ### commands auto generated by Alembic - please adjust! ### | 38 | # ### commands auto generated by Alembic - please adjust! ### |
| 38 | op.drop_index(op.f('ix_user_username'), table_name='user') | 39 | op.drop_index(op.f("ix_user_username"), table_name="user") |
| 39 | op.drop_table('user') | 40 | op.drop_table("user") |
| 40 | # ### end Alembic commands ### | 41 | # ### 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 | |||
| 5 | Create Date: 2026-07-02 17:40:58.968553 | 5 | Create Date: 2026-07-02 17:40:58.968553 |
| 6 | 6 | ||
| 7 | """ | 7 | """ |
| 8 | |||
| 8 | import json | 9 | import json |
| 9 | import os | 10 | import os |
| 10 | from datetime import datetime, UTC | 11 | from collections.abc import Sequence |
| 12 | from datetime import UTC, datetime | ||
| 11 | from secrets import token_hex | 13 | from secrets import token_hex |
| 12 | from random import randbytes | ||
| 13 | from typing import Sequence, Union | ||
| 14 | from uuid import uuid4, uuid7 | 14 | from uuid import uuid4, uuid7 |
| 15 | 15 | ||
| 16 | from alembic import op | ||
| 17 | import sqlalchemy as sa | 16 | import sqlalchemy as sa |
| 18 | import sqlmodel | 17 | import sqlmodel |
| 19 | from authlib.integrations.sqla_oauth2 import OAuth2ClientMixin | 18 | from alembic import op |
| 20 | 19 | ||
| 21 | # revision identifiers, used by Alembic. | 20 | # revision identifiers, used by Alembic. |
| 22 | revision: str = 'fc6608d3f9cc' | 21 | revision: str = "fc6608d3f9cc" |
| 23 | down_revision: Union[str, Sequence[str], None] = '115caa5a76d0' | 22 | down_revision: str | Sequence[str] | None = "115caa5a76d0" |
| 24 | branch_labels: Union[str, Sequence[str], None] = None | 23 | branch_labels: str | Sequence[str] | None = None |
| 25 | depends_on: Union[str, Sequence[str], None] = None | 24 | depends_on: str | Sequence[str] | None = None |
| 26 | 25 | ||
| 27 | 26 | ||
| 28 | def upgrade() -> None: | 27 | def upgrade() -> None: |
| 29 | """Upgrade schema.""" | 28 | """Upgrade schema.""" |
| 30 | # ### commands auto generated by Alembic - please adjust! ### | 29 | # ### commands auto generated by Alembic - please adjust! ### |
| 31 | client_application = op.create_table('clientapplication', | 30 | client_application = op.create_table( |
| 32 | sa.Column('id', sa.Uuid(), nullable=False), | 31 | "clientapplication", |
| 33 | sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), | 32 | sa.Column("id", sa.Uuid(), nullable=False), |
| 34 | sa.Column('client_id', sa.String(length=48), nullable=True), | 33 | sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), |
| 35 | sa.Column('client_secret', sa.String(length=120), nullable=True), | 34 | sa.Column("client_id", sa.String(length=48), nullable=True), |
| 36 | sa.Column('client_id_issued_at', sa.Integer(), nullable=False), | 35 | sa.Column("client_secret", sa.String(length=120), nullable=True), |
| 37 | sa.Column('client_secret_expires_at', sa.Integer(), nullable=False), | 36 | sa.Column("client_id_issued_at", sa.Integer(), nullable=False), |
| 38 | sa.Column('client_metadata', sa.Text(), nullable=True), | 37 | sa.Column("client_secret_expires_at", sa.Integer(), nullable=False), |
| 39 | sa.PrimaryKeyConstraint('id') | 38 | sa.Column("client_metadata", sa.Text(), nullable=True), |
| 39 | sa.PrimaryKeyConstraint("id"), | ||
| 40 | ) | ||
| 41 | op.create_index( | ||
| 42 | op.f("ix_clientapplication_client_id"), | ||
| 43 | "clientapplication", | ||
| 44 | ["client_id"], | ||
| 45 | unique=False, | ||
| 40 | ) | 46 | ) |
| 41 | op.create_index(op.f('ix_clientapplication_client_id'), 'clientapplication', ['client_id'], unique=False) | 47 | op.create_table( |
| 42 | op.create_table('authcode', | 48 | "authcode", |
| 43 | sa.Column('id', sa.Uuid(), nullable=False), | 49 | sa.Column("id", sa.Uuid(), nullable=False), |
| 44 | sa.Column('user_id', sa.Uuid(), nullable=False), | 50 | sa.Column("user_id", sa.Uuid(), nullable=False), |
| 45 | sa.Column('code', sa.String(length=120), nullable=False), | 51 | sa.Column("code", sa.String(length=120), nullable=False), |
| 46 | sa.Column('client_id', sa.String(length=48), nullable=True), | 52 | sa.Column("client_id", sa.String(length=48), nullable=True), |
| 47 | sa.Column('redirect_uri', sa.Text(), nullable=True), | 53 | sa.Column("redirect_uri", sa.Text(), nullable=True), |
| 48 | sa.Column('response_type', sa.Text(), nullable=True), | 54 | sa.Column("response_type", sa.Text(), nullable=True), |
| 49 | sa.Column('scope', sa.Text(), nullable=True), | 55 | sa.Column("scope", sa.Text(), nullable=True), |
| 50 | sa.Column('nonce', sa.Text(), nullable=True), | 56 | sa.Column("nonce", sa.Text(), nullable=True), |
| 51 | sa.Column('auth_time', sa.Integer(), nullable=False), | 57 | sa.Column("auth_time", sa.Integer(), nullable=False), |
| 52 | sa.Column('acr', sa.Text(), nullable=True), | 58 | sa.Column("acr", sa.Text(), nullable=True), |
| 53 | sa.Column('amr', sa.Text(), nullable=True), | 59 | sa.Column("amr", sa.Text(), nullable=True), |
| 54 | sa.Column('code_challenge', sa.Text(), nullable=True), | 60 | sa.Column("code_challenge", sa.Text(), nullable=True), |
| 55 | sa.Column('code_challenge_method', sa.String(length=48), nullable=True), | 61 | sa.Column("code_challenge_method", sa.String(length=48), nullable=True), |
| 56 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), | 62 | sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), |
| 57 | sa.PrimaryKeyConstraint('id'), | 63 | sa.PrimaryKeyConstraint("id"), |
| 58 | sa.UniqueConstraint('code') | 64 | sa.UniqueConstraint("code"), |
| 59 | ) | 65 | ) |
| 60 | op.create_table('authtoken', | 66 | op.create_table( |
| 61 | sa.Column('id', sa.Uuid(), nullable=False), | 67 | "authtoken", |
| 62 | sa.Column('user_id', sa.Uuid(), nullable=False), | 68 | sa.Column("id", sa.Uuid(), nullable=False), |
| 63 | sa.Column('client_id', sa.String(length=48), nullable=True), | 69 | sa.Column("user_id", sa.Uuid(), nullable=False), |
| 64 | sa.Column('token_type', sa.String(length=40), nullable=True), | 70 | sa.Column("client_id", sa.String(length=48), nullable=True), |
| 65 | sa.Column('access_token', sa.String(length=255), nullable=False), | 71 | sa.Column("token_type", sa.String(length=40), nullable=True), |
| 66 | sa.Column('refresh_token', sa.String(length=255), nullable=True), | 72 | sa.Column("access_token", sa.String(length=255), nullable=False), |
| 67 | sa.Column('scope', sa.Text(), nullable=True), | 73 | sa.Column("refresh_token", sa.String(length=255), nullable=True), |
| 68 | sa.Column('issued_at', sa.Integer(), nullable=False), | 74 | sa.Column("scope", sa.Text(), nullable=True), |
| 69 | sa.Column('access_token_revoked_at', sa.Integer(), nullable=False), | 75 | sa.Column("issued_at", sa.Integer(), nullable=False), |
| 70 | sa.Column('refresh_token_revoked_at', sa.Integer(), nullable=False), | 76 | sa.Column("access_token_revoked_at", sa.Integer(), nullable=False), |
| 71 | sa.Column('expires_in', sa.Integer(), nullable=False), | 77 | sa.Column("refresh_token_revoked_at", sa.Integer(), nullable=False), |
| 72 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), | 78 | sa.Column("expires_in", sa.Integer(), nullable=False), |
| 73 | sa.PrimaryKeyConstraint('id'), | 79 | sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), |
| 74 | sa.UniqueConstraint('access_token') | 80 | sa.PrimaryKeyConstraint("id"), |
| 81 | sa.UniqueConstraint("access_token"), | ||
| 82 | ) | ||
| 83 | op.create_index( | ||
| 84 | op.f("ix_authtoken_refresh_token"), "authtoken", ["refresh_token"], unique=False | ||
| 75 | ) | 85 | ) |
| 76 | op.create_index(op.f('ix_authtoken_refresh_token'), 'authtoken', ['refresh_token'], unique=False) | ||
| 77 | 86 | ||
| 78 | op.bulk_insert(client_application, [ | 87 | op.bulk_insert( |
| 79 | { | 88 | client_application, |
| 80 | 'id': uuid7(), | 89 | [ |
| 81 | 'name': 'TTUN', | 90 | { |
| 82 | 'client_id': uuid4().hex, | 91 | "id": uuid7(), |
| 83 | 'client_secret': token_hex(32), | 92 | "name": "TTUN", |
| 84 | 'client_id_issued_at': datetime.now(tz=UTC).timestamp(), | 93 | "client_id": uuid4().hex, |
| 85 | 'client_secret_expires_at': datetime.now(tz=UTC).timestamp() + 3600, | 94 | "client_secret": token_hex(32), |
| 86 | 'client_metadata': json.dumps({ | 95 | "client_id_issued_at": datetime.now(tz=UTC).timestamp(), |
| 87 | 'redirect_uris': [ | 96 | "client_secret_expires_at": datetime.now(tz=UTC).timestamp() + 3600, |
| 88 | f'//{os.environ['TUNNEL_DOMAIN']}/oauth/callback' | 97 | "client_metadata": json.dumps( |
| 89 | ], | 98 | { |
| 90 | }) | 99 | "redirect_uris": [ |
| 91 | } | 100 | f"//{os.environ['TUNNEL_DOMAIN']}/oauth/callback" |
| 92 | ]) | 101 | ], |
| 102 | } | ||
| 103 | ), | ||
| 104 | } | ||
| 105 | ], | ||
| 106 | ) | ||
| 93 | # ### end Alembic commands ### | 107 | # ### end Alembic commands ### |
| 94 | 108 | ||
| 95 | 109 | ||
| 96 | def downgrade() -> None: | 110 | def downgrade() -> None: |
| 97 | """Downgrade schema.""" | 111 | """Downgrade schema.""" |
| 98 | # ### commands auto generated by Alembic - please adjust! ### | 112 | # ### commands auto generated by Alembic - please adjust! ### |
| 99 | op.drop_index(op.f('ix_authtoken_refresh_token'), table_name='authtoken') | 113 | op.drop_index(op.f("ix_authtoken_refresh_token"), table_name="authtoken") |
| 100 | op.drop_table('authtoken') | 114 | op.drop_table("authtoken") |
| 101 | op.drop_table('authcode') | 115 | op.drop_table("authcode") |
| 102 | op.drop_index(op.f('ix_clientapplication_client_id'), table_name='clientapplication') | 116 | op.drop_index( |
| 103 | op.drop_table('clientapplication') | 117 | op.f("ix_clientapplication_client_id"), table_name="clientapplication" |
| 118 | ) | ||
| 119 | op.drop_table("clientapplication") | ||
| 104 | # ### end Alembic commands ### | 120 | # ### 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 | |||
| 5 | Create Date: 2026-07-02 18:03:17.190700 | 5 | Create Date: 2026-07-02 18:03:17.190700 |
| 6 | 6 | ||
| 7 | """ | 7 | """ |
| 8 | from typing import Sequence, Union | ||
| 9 | 8 | ||
| 10 | from alembic import op | 9 | from collections.abc import Sequence |
| 10 | |||
| 11 | import sqlalchemy as sa | 11 | import sqlalchemy as sa |
| 12 | import sqlmodel | 12 | import sqlmodel |
| 13 | 13 | from alembic import op | |
| 14 | 14 | ||
| 15 | # revision identifiers, used by Alembic. | 15 | # revision identifiers, used by Alembic. |
| 16 | revision: str = 'ac4fbf1fea31' | 16 | revision: str = "ac4fbf1fea31" |
| 17 | down_revision: Union[str, Sequence[str], None] = 'fc6608d3f9cc' | 17 | down_revision: str | Sequence[str] | None = "fc6608d3f9cc" |
| 18 | branch_labels: Union[str, Sequence[str], None] = None | 18 | branch_labels: str | Sequence[str] | None = None |
| 19 | depends_on: Union[str, Sequence[str], None] = None | 19 | depends_on: str | Sequence[str] | None = None |
| 20 | 20 | ||
| 21 | 21 | ||
| 22 | def upgrade() -> None: | 22 | def upgrade() -> None: |
| 23 | """Upgrade schema.""" | 23 | """Upgrade schema.""" |
| 24 | # ### commands auto generated by Alembic - please adjust! ### | 24 | # ### commands auto generated by Alembic - please adjust! ### |
| 25 | op.add_column('user', sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False)) | 25 | op.add_column( |
| 26 | "user", | ||
| 27 | sa.Column("password", sqlmodel.sql.sqltypes.AutoString(), nullable=False), | ||
| 28 | ) | ||
| 26 | # ### end Alembic commands ### | 29 | # ### end Alembic commands ### |
| 27 | 30 | ||
| 28 | 31 | ||
| 29 | def downgrade() -> None: | 32 | def downgrade() -> None: |
| 30 | """Downgrade schema.""" | 33 | """Downgrade schema.""" |
| 31 | # ### commands auto generated by Alembic - please adjust! ### | 34 | # ### commands auto generated by Alembic - please adjust! ### |
| 32 | op.drop_column('user', 'password') | 35 | op.drop_column("user", "password") |
| 33 | # ### end Alembic commands ### | 36 | # ### 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 | |||
| 5 | Create Date: 2026-07-03 14:40:55.628816 | 5 | Create Date: 2026-07-03 14:40:55.628816 |
| 6 | 6 | ||
| 7 | """ | 7 | """ |
| 8 | from typing import Sequence, Union | ||
| 9 | 8 | ||
| 10 | from alembic import op | 9 | from collections.abc import Sequence |
| 11 | import sqlalchemy as sa | ||
| 12 | import sqlmodel | ||
| 13 | 10 | ||
| 11 | import sqlalchemy as sa | ||
| 12 | from alembic import op | ||
| 14 | 13 | ||
| 15 | # revision identifiers, used by Alembic. | 14 | # revision identifiers, used by Alembic. |
| 16 | revision: str = '69d8b4938a12' | 15 | revision: str = "69d8b4938a12" |
| 17 | down_revision: Union[str, Sequence[str], None] = 'ac4fbf1fea31' | 16 | down_revision: str | Sequence[str] | None = "ac4fbf1fea31" |
| 18 | branch_labels: Union[str, Sequence[str], None] = None | 17 | branch_labels: str | Sequence[str] | None = None |
| 19 | depends_on: Union[str, Sequence[str], None] = None | 18 | depends_on: str | Sequence[str] | None = None |
| 20 | 19 | ||
| 21 | 20 | ||
| 22 | def upgrade() -> None: | 21 | def upgrade() -> None: |
| 23 | """Upgrade schema.""" | 22 | """Upgrade schema.""" |
| 24 | # ### commands auto generated by Alembic - please adjust! ### | 23 | # ### commands auto generated by Alembic - please adjust! ### |
| 25 | op.create_table('session', | 24 | op.create_table( |
| 26 | sa.Column('id', sa.Uuid(), nullable=False), | 25 | "session", |
| 27 | sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')), | 26 | sa.Column("id", sa.Uuid(), nullable=False), |
| 28 | sa.Column('updated', sa.DateTime(timezone=True), nullable=True), | 27 | sa.Column( |
| 29 | sa.Column('user_id', sa.Uuid(), nullable=False), | 28 | "created", |
| 30 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), | 29 | sa.DateTime(timezone=True), |
| 31 | sa.PrimaryKeyConstraint('id') | 30 | nullable=False, |
| 31 | server_default=sa.text("now()"), | ||
| 32 | ), | ||
| 33 | sa.Column("updated", sa.DateTime(timezone=True), nullable=True), | ||
| 34 | sa.Column("user_id", sa.Uuid(), nullable=False), | ||
| 35 | sa.ForeignKeyConstraint( | ||
| 36 | ["user_id"], | ||
| 37 | ["user.id"], | ||
| 38 | ), | ||
| 39 | sa.PrimaryKeyConstraint("id"), | ||
| 40 | ) | ||
| 41 | op.add_column( | ||
| 42 | "authcode", | ||
| 43 | sa.Column( | ||
| 44 | "created", | ||
| 45 | sa.DateTime(timezone=True), | ||
| 46 | nullable=False, | ||
| 47 | server_default=sa.text("now()"), | ||
| 48 | ), | ||
| 49 | ) | ||
| 50 | op.add_column( | ||
| 51 | "authcode", sa.Column("updated", sa.DateTime(timezone=True), nullable=True) | ||
| 52 | ) | ||
| 53 | op.add_column( | ||
| 54 | "authtoken", | ||
| 55 | sa.Column( | ||
| 56 | "created", | ||
| 57 | sa.DateTime(timezone=True), | ||
| 58 | nullable=False, | ||
| 59 | server_default=sa.text("now()"), | ||
| 60 | ), | ||
| 61 | ) | ||
| 62 | op.add_column( | ||
| 63 | "authtoken", sa.Column("updated", sa.DateTime(timezone=True), nullable=True) | ||
| 64 | ) | ||
| 65 | op.add_column( | ||
| 66 | "clientapplication", | ||
| 67 | sa.Column( | ||
| 68 | "created", | ||
| 69 | sa.DateTime(timezone=True), | ||
| 70 | nullable=False, | ||
| 71 | server_default=sa.text("now()"), | ||
| 72 | ), | ||
| 73 | ) | ||
| 74 | op.add_column( | ||
| 75 | "clientapplication", | ||
| 76 | sa.Column("updated", sa.DateTime(timezone=True), nullable=True), | ||
| 77 | ) | ||
| 78 | op.add_column( | ||
| 79 | "user", | ||
| 80 | sa.Column( | ||
| 81 | "created", | ||
| 82 | sa.DateTime(timezone=True), | ||
| 83 | nullable=False, | ||
| 84 | server_default=sa.text("now()"), | ||
| 85 | ), | ||
| 86 | ) | ||
| 87 | op.add_column( | ||
| 88 | "user", sa.Column("updated", sa.DateTime(timezone=True), nullable=True) | ||
| 32 | ) | 89 | ) |
| 33 | op.add_column('authcode', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) | ||
| 34 | op.add_column('authcode', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) | ||
| 35 | op.add_column('authtoken', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) | ||
| 36 | op.add_column('authtoken', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) | ||
| 37 | op.add_column('clientapplication', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) | ||
| 38 | op.add_column('clientapplication', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) | ||
| 39 | op.add_column('user', sa.Column('created', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()'))) | ||
| 40 | op.add_column('user', sa.Column('updated', sa.DateTime(timezone=True), nullable=True)) | ||
| 41 | # ### end Alembic commands ### | 90 | # ### end Alembic commands ### |
| 42 | 91 | ||
| 43 | 92 | ||
| 44 | def downgrade() -> None: | 93 | def downgrade() -> None: |
| 45 | """Downgrade schema.""" | 94 | """Downgrade schema.""" |
| 46 | # ### commands auto generated by Alembic - please adjust! ### | 95 | # ### commands auto generated by Alembic - please adjust! ### |
| 47 | op.drop_column('user', 'updated') | 96 | op.drop_column("user", "updated") |
| 48 | op.drop_column('user', 'created') | 97 | op.drop_column("user", "created") |
| 49 | op.drop_column('clientapplication', 'updated') | 98 | op.drop_column("clientapplication", "updated") |
| 50 | op.drop_column('clientapplication', 'created') | 99 | op.drop_column("clientapplication", "created") |
| 51 | op.drop_column('authtoken', 'updated') | 100 | op.drop_column("authtoken", "updated") |
| 52 | op.drop_column('authtoken', 'created') | 101 | op.drop_column("authtoken", "created") |
| 53 | op.drop_column('authcode', 'updated') | 102 | op.drop_column("authcode", "updated") |
| 54 | op.drop_column('authcode', 'created') | 103 | op.drop_column("authcode", "created") |
| 55 | op.drop_table('session') | 104 | op.drop_table("session") |
| 56 | # ### end Alembic commands ### | 105 | # ### 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 @@ | |||
| 1 | import re | 1 | import re |
| 2 | from datetime import datetime, UTC | 2 | from datetime import UTC, datetime |
| 3 | from urllib.parse import urlsplit | 3 | from uuid import UUID, uuid7 |
| 4 | from uuid import uuid4, UUID, uuid7 | ||
| 5 | 4 | ||
| 6 | from sqlalchemy import DateTime | 5 | from sqlalchemy import DateTime |
| 7 | from sqlmodel import SQLModel, Field, Relationship | 6 | from sqlmodel import Field, Relationship, SQLModel |
| 8 | from starlette.authentication import BaseUser | 7 | from starlette.authentication import BaseUser |
| 9 | 8 | ||
| 10 | from authentication.mixins import OAuth2ClientMixin, OAuth2AuthorizationCodeMixin, OAuth2TokenMixin | 9 | from authentication.mixins import ( |
| 10 | OAuth2AuthorizationCodeMixin, | ||
| 11 | OAuth2ClientMixin, | ||
| 12 | OAuth2TokenMixin, | ||
| 13 | ) | ||
| 11 | from authentication.utils import bcrypt_sha256_hash | 14 | from authentication.utils import bcrypt_sha256_hash |
| 12 | 15 | ||
| 13 | 16 | ||
| @@ -21,7 +24,7 @@ class BaseModel(SQLModel): | |||
| 21 | default=None, | 24 | default=None, |
| 22 | nullable=True, | 25 | nullable=True, |
| 23 | sa_column_kwargs={ | 26 | sa_column_kwargs={ |
| 24 | 'onupdate': lambda: datetime.now(tz=UTC), | 27 | "onupdate": lambda: datetime.now(tz=UTC), |
| 25 | }, | 28 | }, |
| 26 | sa_type=DateTime(timezone=True), | 29 | sa_type=DateTime(timezone=True), |
| 27 | ) | 30 | ) |
| @@ -39,10 +42,12 @@ class User(BaseModel, BaseUser, table=True): | |||
| 39 | def is_authenticated(self) -> bool: | 42 | def is_authenticated(self) -> bool: |
| 40 | return True | 43 | return True |
| 41 | 44 | ||
| 45 | |||
| 42 | class Session(BaseModel, table=True): | 46 | class Session(BaseModel, table=True): |
| 43 | user_id: UUID = Field(foreign_key='user.id', exclude=True) | 47 | user_id: UUID = Field(foreign_key="user.id", exclude=True) |
| 44 | user: User = Relationship() | 48 | user: User = Relationship() |
| 45 | 49 | ||
| 50 | |||
| 46 | class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): | 51 | class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): |
| 47 | name: str | 52 | name: str |
| 48 | 53 | ||
| @@ -52,10 +57,12 @@ class ClientApplication(BaseModel, OAuth2ClientMixin, table=True): | |||
| 52 | for pattern in self.redirect_uris | 57 | for pattern in self.redirect_uris |
| 53 | ) | 58 | ) |
| 54 | 59 | ||
| 60 | |||
| 55 | class AuthCode(BaseModel, OAuth2AuthorizationCodeMixin, table=True): | 61 | class AuthCode(BaseModel, OAuth2AuthorizationCodeMixin, table=True): |
| 56 | user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") | 62 | user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") |
| 57 | user: User = Relationship() | 63 | user: User = Relationship() |
| 58 | 64 | ||
| 65 | |||
| 59 | class AuthToken(BaseModel, OAuth2TokenMixin, table=True): | 66 | class AuthToken(BaseModel, OAuth2TokenMixin, table=True): |
| 60 | user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") | 67 | user_id: UUID = Field(foreign_key="user.id", ondelete="CASCADE") |
| 61 | user: User = Relationship() | 68 | 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 @@ | |||
| 1 | from contextlib import contextmanager | 1 | from contextlib import contextmanager |
| 2 | 2 | ||
| 3 | from sqlalchemy.orm import sessionmaker, scoped_session | 3 | from sqlalchemy.orm import scoped_session, sessionmaker |
| 4 | from sqlmodel import create_engine | 4 | from sqlmodel import Session, create_engine |
| 5 | from sqlmodel import Session | ||
| 6 | 5 | ||
| 7 | from conf import DATABASE_URL | 6 | from conf import DATABASE_URL |
| 8 | from utils.json import json_serializer | 7 | from utils.json import json_serializer |
| 9 | 8 | ||
| 10 | 9 | engine = create_engine( | |
| 11 | engine = create_engine(DATABASE_URL, echo=True, future=True, json_serializer=json_serializer, pool_size=100, pool_recycle=3600) | 10 | DATABASE_URL, |
| 11 | echo=True, | ||
| 12 | future=True, | ||
| 13 | json_serializer=json_serializer, | ||
| 14 | pool_size=100, | ||
| 15 | pool_recycle=3600, | ||
| 16 | ) | ||
| 12 | session_factory = sessionmaker(engine, class_=Session) | 17 | session_factory = sessionmaker(engine, class_=Session) |
| 13 | ScopedSession = scoped_session(session_factory=session_factory) | 18 | ScopedSession = scoped_session(session_factory=session_factory) |
| 14 | 19 | ||
| 20 | |||
| 15 | def get_session(): | 21 | def get_session(): |
| 16 | with ScopedSession() as session: | 22 | with ScopedSession() as session: |
| 17 | yield session | 23 | yield session |
| 18 | 24 | ||
| 25 | |||
| 19 | @contextmanager | 26 | @contextmanager |
| 20 | def get_session_context(autocommit=True): | 27 | def get_session_context(autocommit=True): |
| 21 | session = next(get_session()) | 28 | session = next(get_session()) |
| @@ -0,0 +1,3 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | |||
| 3 | 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() | |||
| 18 | management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend()) | 18 | management.add_middleware(AuthenticationMiddleware, backend=SessionAuthBackend()) |
| 19 | management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) | 19 | management.add_middleware(SessionMiddleware, secret_key=conf.SECRET_KEY) |
| 20 | 20 | ||
| 21 | templates = Jinja2Templates(directory=[ | 21 | templates = Jinja2Templates(directory=["templates", "management/templates"]) |
| 22 | 'templates', | ||
| 23 | 'management/templates' | ||
| 24 | ]) | ||
| 25 | 22 | ||
| 26 | 23 | ||
| 27 | def requires_session(func): | 24 | def requires_session(func): |
| @@ -29,38 +26,42 @@ def requires_session(func): | |||
| 29 | which resolves the redirect target via `request.url_for()` against whichever router is | 26 | which resolves the redirect target via `request.url_for()` against whichever router is |
| 30 | bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when | 27 | bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when |
| 31 | `management` happens to be mounted under a shared root app.""" | 28 | `management` happens to be mounted under a shared root app.""" |
| 29 | |||
| 32 | @wraps(func) | 30 | @wraps(func) |
| 33 | async def wrapper(request: Request, *args, **kwargs): | 31 | async def wrapper(request: Request, *args, **kwargs): |
| 34 | if not {'authenticated', 'session'}.issubset(request.auth.scopes): | 32 | if not {"authenticated", "session"}.issubset(request.auth.scopes): |
| 35 | next_param = urlencode({'next': str(request.url)}) | 33 | next_param = urlencode({"next": str(request.url)}) |
| 36 | return RedirectResponse(f'/auth/login/?{next_param}', status_code=303) | 34 | return RedirectResponse(f"/auth/login/?{next_param}", status_code=303) |
| 37 | return await func(request, *args, **kwargs) | 35 | return await func(request, *args, **kwargs) |
| 36 | |||
| 38 | return wrapper | 37 | return wrapper |
| 39 | 38 | ||
| 40 | @management.get('/') | 39 | |
| 40 | @management.get("/") | ||
| 41 | async def index(request: Request): | 41 | async def index(request: Request): |
| 42 | with get_session_context() as session: | 42 | with get_session_context() as session: |
| 43 | query = select(func.count(User.id)) | 43 | query = select(func.count(User.id)) |
| 44 | user_count = session.exec(query) | 44 | user_count = session.exec(query) |
| 45 | 45 | ||
| 46 | if user_count.first() == 0: | 46 | if user_count.first() == 0: |
| 47 | return RedirectResponse('/auth/register/?next=/management/dashboard/', status_code=303) | 47 | return RedirectResponse( |
| 48 | "/auth/register/?next=/management/dashboard/", status_code=303 | ||
| 49 | ) | ||
| 48 | 50 | ||
| 49 | return RedirectResponse('/management/dashboard/', status_code=303) | 51 | return RedirectResponse("/management/dashboard/", status_code=303) |
| 50 | 52 | ||
| 51 | 53 | ||
| 52 | @management.get('/dashboard/') | 54 | @management.get("/dashboard/") |
| 53 | @requires_session | 55 | @requires_session |
| 54 | async def dashboard(request: Request): | 56 | async def dashboard(request: Request): |
| 55 | return templates.TemplateResponse(request, 'dashboard.html') | 57 | return templates.TemplateResponse(request, "dashboard.html") |
| 58 | |||
| 56 | 59 | ||
| 57 | @management.get('/users/') | 60 | @management.get("/users/") |
| 58 | @requires_session | 61 | @requires_session |
| 59 | async def users(request: Request): | 62 | async def users(request: Request): |
| 60 | with get_session_context() as session: | 63 | with get_session_context() as session: |
| 61 | query = select(User) | 64 | query = select(User) |
| 62 | users = session.exec(query).all() | 65 | users = session.exec(query).all() |
| 63 | 66 | ||
| 64 | return templates.TemplateResponse(request, 'users.html', context={ | 67 | return templates.TemplateResponse(request, "users.html", context={"users": users}) |
| 65 | 'users': users | ||
| 66 | }) | ||
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 | |||
| 7 | app = Starlette( | 7 | app = Starlette( |
| 8 | debug=True, | 8 | debug=True, |
| 9 | routes=[ | 9 | routes=[ |
| 10 | Route('/{path:path}', Proxy), | 10 | Route("/{path:path}", Proxy), |
| 11 | WebSocketRoute('/{path:path}', WebsocketProxy), | 11 | WebSocketRoute("/{path:path}", WebsocketProxy), |
| 12 | ] | 12 | ], |
| 13 | ) | 13 | ) |
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 @@ | |||
| 1 | import logging | 1 | import logging |
| 2 | from base64 import b64encode, b64decode | 2 | from base64 import b64decode, b64encode |
| 3 | from uuid import uuid4 | 3 | from uuid import uuid4 |
| 4 | 4 | ||
| 5 | from starlette.endpoints import HTTPEndpoint | 5 | from starlette.endpoints import HTTPEndpoint |
| 6 | from starlette.requests import Request | 6 | from starlette.requests import Request |
| 7 | from starlette.responses import Response | 7 | from starlette.responses import Response |
| 8 | 8 | ||
| 9 | from proxy.utils import get_path_with_query_string | ||
| 10 | from proxy.queue import ProxyQueue | 9 | from proxy.queue import ProxyQueue |
| 10 | from proxy.utils import get_path_with_query_string | ||
| 11 | from ttun_server.types import HttpMessage, HttpMessageType, HttpRequestData | 11 | from ttun_server.types import HttpMessage, HttpMessageType, HttpRequestData |
| 12 | 12 | ||
| 13 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| @@ -18,16 +18,15 @@ class HeaderMapping: | |||
| 18 | self._headers = headers | 18 | self._headers = headers |
| 19 | 19 | ||
| 20 | def items(self): | 20 | def items(self): |
| 21 | for header in self._headers: | 21 | yield from self._headers |
| 22 | yield header | ||
| 23 | 22 | ||
| 24 | 23 | ||
| 25 | class Proxy(HTTPEndpoint): | 24 | class Proxy(HTTPEndpoint): |
| 26 | async def dispatch(self) -> None: | 25 | async def dispatch(self) -> None: |
| 27 | request = Request(self.scope, self.receive) | 26 | request = Request(self.scope, self.receive) |
| 28 | 27 | ||
| 29 | subdomain = request.path_params['subdomain'] | 28 | subdomain = request.path_params["subdomain"] |
| 30 | response = Response(content='Not Found', status_code=404) | 29 | response = Response(content="Not Found", status_code=404) |
| 31 | 30 | ||
| 32 | identifier = str(uuid4()) | 31 | identifier = str(uuid4()) |
| 33 | response_queue = await ProxyQueue.create_for_identifier(identifier) | 32 | response_queue = await ProxyQueue.create_for_identifier(identifier) |
| @@ -35,7 +34,7 @@ class Proxy(HTTPEndpoint): | |||
| 35 | try: | 34 | try: |
| 36 | request_queue = await ProxyQueue.get_for_identifier(subdomain) | 35 | request_queue = await ProxyQueue.get_for_identifier(subdomain) |
| 37 | 36 | ||
| 38 | logger.debug('PROXY %s%s ', subdomain, request.url) | 37 | logger.debug("PROXY %s%s ", subdomain, request.url) |
| 39 | await request_queue.enqueue( | 38 | await request_queue.enqueue( |
| 40 | HttpMessage( | 39 | HttpMessage( |
| 41 | type=HttpMessageType.request.value, | 40 | type=HttpMessageType.request.value, |
| @@ -44,17 +43,17 @@ class Proxy(HTTPEndpoint): | |||
| 44 | method=request.method, | 43 | method=request.method, |
| 45 | path=get_path_with_query_string(request), | 44 | path=get_path_with_query_string(request), |
| 46 | headers=list(request.headers.items()), | 45 | headers=list(request.headers.items()), |
| 47 | body=b64encode(await request.body()).decode() | 46 | body=b64encode(await request.body()).decode(), |
| 48 | ) | 47 | ), |
| 49 | ) | 48 | ) |
| 50 | ) | 49 | ) |
| 51 | 50 | ||
| 52 | _response = await response_queue.dequeue() | 51 | _response = await response_queue.dequeue() |
| 53 | payload = _response['payload'] | 52 | payload = _response["payload"] |
| 54 | response = Response( | 53 | response = Response( |
| 55 | status_code=payload['status'], | 54 | status_code=payload["status"], |
| 56 | headers=HeaderMapping(payload['headers']), | 55 | headers=HeaderMapping(payload["headers"]), |
| 57 | content=b64decode(payload['body'].encode()) | 56 | content=b64decode(payload["body"].encode()), |
| 58 | ) | 57 | ) |
| 59 | except AssertionError: | 58 | except AssertionError: |
| 60 | pass | 59 | 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 | |||
| 2 | import json | 2 | import json |
| 3 | import logging | 3 | import logging |
| 4 | import os | 4 | import os |
| 5 | import traceback | 5 | from typing import ClassVar, Self |
| 6 | from typing import Type | ||
| 7 | 6 | ||
| 8 | from ttun_server.redis import RedisConnectionPool | 7 | from ttun_server.redis import RedisConnectionPool |
| 9 | from ttun_server.types import Message | 8 | from ttun_server.types import Message |
| @@ -16,37 +15,37 @@ class BaseProxyQueue: | |||
| 16 | self.identifier = identifier | 15 | self.identifier = identifier |
| 17 | 16 | ||
| 18 | @classmethod | 17 | @classmethod |
| 19 | async def create_for_identifier(cls, identifier: str) -> 'BaseProxyQueue': | 18 | async def create_for_identifier(cls, identifier: str) -> BaseProxyQueue: |
| 20 | raise NotImplementedError(f'Please implement create_for_identifier') | 19 | raise NotImplementedError("Please implement create_for_identifier") |
| 21 | 20 | ||
| 22 | @classmethod | 21 | @classmethod |
| 23 | async def get_for_identifier(cls, identifier: str) -> Type['self']: | 22 | async def get_for_identifier(cls, identifier: str) -> Self: |
| 24 | assert await cls.has_connection(identifier) | 23 | assert await cls.has_connection(identifier) |
| 25 | return cls(identifier) | 24 | return cls(identifier) |
| 26 | 25 | ||
| 27 | @classmethod | 26 | @classmethod |
| 28 | async def has_connection(cls, identifier) -> bool: | 27 | async def has_connection(cls, identifier) -> bool: |
| 29 | raise NotImplementedError(f'Please implement has_connection') | 28 | raise NotImplementedError("Please implement has_connection") |
| 30 | 29 | ||
| 31 | async def enqueue(self, message: Message): | 30 | async def enqueue(self, message: Message): |
| 32 | raise NotImplementedError(f'Please implement send_request') | 31 | raise NotImplementedError("Please implement send_request") |
| 33 | 32 | ||
| 34 | async def dequeue(self) -> Message: | 33 | async def dequeue(self) -> Message: |
| 35 | raise NotImplementedError(f'Please implement handle_requests') | 34 | raise NotImplementedError("Please implement handle_requests") |
| 36 | 35 | ||
| 37 | async def delete(self): | 36 | async def delete(self): |
| 38 | raise NotImplementedError(f'Please implement delete') | 37 | raise NotImplementedError("Please implement delete") |
| 39 | 38 | ||
| 40 | 39 | ||
| 41 | class MemoryProxyQueue(BaseProxyQueue): | 40 | class MemoryProxyQueue(BaseProxyQueue): |
| 42 | connections: dict[str, asyncio.Queue] = {} | 41 | connections: ClassVar[dict[str, asyncio.Queue]] = {} |
| 43 | 42 | ||
| 44 | @classmethod | 43 | @classmethod |
| 45 | async def has_connection(cls, identifier) -> bool: | 44 | async def has_connection(cls, identifier) -> bool: |
| 46 | return identifier in cls.connections | 45 | return identifier in cls.connections |
| 47 | 46 | ||
| 48 | @classmethod | 47 | @classmethod |
| 49 | async def create_for_identifier(cls, identifier: str) -> 'MemoryProxyQueue': | 48 | async def create_for_identifier(cls, identifier: str) -> MemoryProxyQueue: |
| 50 | instance = cls(identifier) | 49 | instance = cls(identifier) |
| 51 | cls.connections[identifier] = asyncio.Queue() | 50 | cls.connections[identifier] = asyncio.Queue() |
| 52 | 51 | ||
| @@ -66,67 +65,59 @@ class RedisProxyQueue(BaseProxyQueue): | |||
| 66 | def __init__(self, identifier): | 65 | def __init__(self, identifier): |
| 67 | super().__init__(identifier) | 66 | super().__init__(identifier) |
| 68 | 67 | ||
| 69 | self.pubsub = RedisConnectionPool()\ | 68 | self.pubsub = RedisConnectionPool().get_connection().pubsub() |
| 70 | .get_connection()\ | ||
| 71 | .pubsub() | ||
| 72 | 69 | ||
| 73 | self.subscription_queue = asyncio.Queue() | 70 | self.subscription_queue = asyncio.Queue() |
| 74 | 71 | ||
| 75 | @classmethod | 72 | @classmethod |
| 76 | async def create_for_identifier(cls, identifier: str) -> 'BaseProxyQueue': | 73 | async def create_for_identifier(cls, identifier: str) -> BaseProxyQueue: |
| 77 | instance = cls(identifier) | 74 | instance = cls(identifier) |
| 78 | 75 | ||
| 79 | await instance.pubsub.subscribe(f'request_{identifier}') | 76 | await instance.pubsub.subscribe(f"request_{identifier}") |
| 80 | return instance | 77 | return instance |
| 81 | 78 | ||
| 82 | @classmethod | 79 | @classmethod |
| 83 | async def get_for_identifier(cls, identifier: str) -> 'RedisProxyQueue': | 80 | async def get_for_identifier(cls, identifier: str) -> RedisProxyQueue: |
| 84 | instance: 'RedisProxyQueue' = await super().get_for_identifier(identifier) | 81 | instance: RedisProxyQueue = await super().get_for_identifier(identifier) |
| 85 | 82 | ||
| 86 | await instance.pubsub.subscribe(f'response_{identifier}') | 83 | await instance.pubsub.subscribe(f"response_{identifier}") |
| 87 | 84 | ||
| 88 | return instance | 85 | return instance |
| 89 | 86 | ||
| 90 | @classmethod | 87 | @classmethod |
| 91 | async def has_connection(cls, identifier) -> bool: | 88 | async def has_connection(cls, identifier) -> bool: |
| 92 | logger.debug(await RedisConnectionPool.get_connection().pubsub_channels()) | 89 | logger.debug(await RedisConnectionPool.get_connection().pubsub_channels()) |
| 93 | return f'request_{identifier}' in { | 90 | return f"request_{identifier}" in { |
| 94 | channel.decode() | 91 | channel.decode() |
| 95 | for channel | 92 | for channel in await RedisConnectionPool.get_connection().pubsub_channels() |
| 96 | in await RedisConnectionPool \ | ||
| 97 | .get_connection() \ | ||
| 98 | .pubsub_channels() | ||
| 99 | } | 93 | } |
| 100 | 94 | ||
| 101 | async def wait_for_message(self): | 95 | async def wait_for_message(self): |
| 102 | async for message in self.pubsub.listen(): | 96 | async for message in self.pubsub.listen(): |
| 103 | match message['type']: | 97 | match message["type"]: |
| 104 | case 'subscribe': | 98 | case "subscribe": |
| 105 | continue | 99 | continue |
| 106 | case _: | 100 | case _: |
| 107 | return message['data'] | 101 | return message["data"] |
| 108 | 102 | ||
| 109 | async def enqueue(self, message: Message): | 103 | async def enqueue(self, message: Message): |
| 110 | await RedisConnectionPool \ | 104 | await RedisConnectionPool.get_connection().publish( |
| 111 | .get_connection() \ | 105 | f"request_{self.identifier}", json.dumps(message) |
| 112 | .publish(f'request_{self.identifier}', json.dumps(message)) | 106 | ) |
| 113 | 107 | ||
| 114 | async def dequeue(self) -> Message: | 108 | async def dequeue(self) -> Message: |
| 115 | message = await self.wait_for_message() | 109 | message = await self.wait_for_message() |
| 116 | return json.loads(message) | 110 | return json.loads(message) |
| 117 | 111 | ||
| 118 | async def delete(self): | 112 | async def delete(self): |
| 119 | await self.pubsub.unsubscribe(f'request_{self.identifier}') | 113 | await self.pubsub.unsubscribe(f"request_{self.identifier}") |
| 120 | 114 | ||
| 121 | await RedisConnectionPool.get_connection()\ | 115 | await RedisConnectionPool.get_connection().srem("connections", self.identifier) |
| 122 | .srem('connections', self.identifier) | ||
| 123 | 116 | ||
| 124 | 117 | ||
| 125 | class ProxyQueueMeta(type): | 118 | class ProxyQueueMeta(type): |
| 126 | def __new__(cls, name, superclasses, attributes): | 119 | def __new__(cls, name, superclasses, attributes): |
| 127 | return RedisProxyQueue \ | 120 | return RedisProxyQueue if "REDIS_URL" in os.environ else MemoryProxyQueue |
| 128 | if 'REDIS_URL' in os.environ \ | ||
| 129 | else MemoryProxyQueue | ||
| 130 | 121 | ||
| 131 | 122 | ||
| 132 | class ProxyQueue(BaseProxyQueue, metaclass=ProxyQueueMeta): | 123 | 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 | |||
| 3 | 3 | ||
| 4 | def get_path_with_query_string(connection: HTTPConnection) -> str: | 4 | def get_path_with_query_string(connection: HTTPConnection) -> str: |
| 5 | path = connection.url.path | 5 | path = connection.url.path |
| 6 | query_string = '?' + connection.scope['query_string'].decode() if connection.scope['query_string'] else '' | 6 | query_string = ( |
| 7 | "?" + connection.scope["query_string"].decode() | ||
| 8 | if connection.scope["query_string"] | ||
| 9 | else "" | ||
| 10 | ) | ||
| 7 | return f"{path}{query_string}" | 11 | 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 | |||
| 9 | from starlette.endpoints import WebSocketEndpoint | 9 | from starlette.endpoints import WebSocketEndpoint |
| 10 | from starlette.websockets import WebSocket | 10 | from starlette.websockets import WebSocket |
| 11 | 11 | ||
| 12 | from proxy.utils import get_path_with_query_string | ||
| 13 | from proxy.queue import ProxyQueue | 12 | from proxy.queue import ProxyQueue |
| 14 | from ttun_server.types import WebsocketMessage, WebsocketMessageType, WebsocketConnectData, WebsocketMessageData, \ | 13 | from proxy.utils import get_path_with_query_string |
| 15 | WebsocketDisconnectData | 14 | from ttun_server.types import ( |
| 15 | WebsocketConnectData, | ||
| 16 | WebsocketDisconnectData, | ||
| 17 | WebsocketMessage, | ||
| 18 | WebsocketMessageData, | ||
| 19 | WebsocketMessageType, | ||
| 20 | ) | ||
| 16 | 21 | ||
| 17 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 18 | logger.setLevel('DEBUG') | 23 | logger.setLevel("DEBUG") |
| 19 | 24 | ||
| 20 | 25 | ||
| 21 | class WebsocketProxy(WebSocketEndpoint): | 26 | class WebsocketProxy(WebSocketEndpoint): |
| 22 | encoding = 'json' | 27 | encoding = "json" |
| 23 | websocket_listen_task = None | 28 | websocket_listen_task = None |
| 24 | 29 | ||
| 25 | def __init__(self, *args, **kwargs): | 30 | def __init__(self, *args, **kwargs): |
| @@ -28,16 +33,20 @@ class WebsocketProxy(WebSocketEndpoint): | |||
| 28 | 33 | ||
| 29 | @asynccontextmanager | 34 | @asynccontextmanager |
| 30 | async def proxy(self, websocket: WebSocket, message: WebsocketMessage): | 35 | async def proxy(self, websocket: WebSocket, message: WebsocketMessage): |
| 31 | [subdomain, *_] = websocket.url.hostname.split('.') | 36 | [subdomain, *_] = websocket.url.hostname.split(".") |
| 32 | 37 | ||
| 33 | expect_ack = WebsocketMessageType(message['type']) == WebsocketMessageType.connect | 38 | expect_ack = ( |
| 39 | WebsocketMessageType(message["type"]) == WebsocketMessageType.connect | ||
| 40 | ) | ||
| 34 | 41 | ||
| 35 | try: | 42 | try: |
| 36 | request_queue = await ProxyQueue.get_for_identifier(subdomain) | 43 | request_queue = await ProxyQueue.get_for_identifier(subdomain) |
| 37 | await request_queue.enqueue(message) | 44 | await request_queue.enqueue(message) |
| 38 | 45 | ||
| 39 | if expect_ack: | 46 | if expect_ack: |
| 40 | response_queue = await ProxyQueue.create_for_identifier(message["identifier"]) | 47 | response_queue = await ProxyQueue.create_for_identifier( |
| 48 | message["identifier"] | ||
| 49 | ) | ||
| 41 | yield await response_queue.dequeue() | 50 | yield await response_queue.dequeue() |
| 42 | await response_queue.delete() | 51 | await response_queue.delete() |
| 43 | else: | 52 | else: |
| @@ -51,7 +60,9 @@ class WebsocketProxy(WebSocketEndpoint): | |||
| 51 | while True: | 60 | while True: |
| 52 | message: WebsocketMessage = await response_queue.dequeue() | 61 | message: WebsocketMessage = await response_queue.dequeue() |
| 53 | logger.debug(message) | 62 | logger.debug(message) |
| 54 | await websocket.send_text(b64decode(message['payload']['body'].encode()).decode()) | 63 | await websocket.send_text( |
| 64 | b64decode(message["payload"]["body"].encode()).decode() | ||
| 65 | ) | ||
| 55 | 66 | ||
| 56 | async def on_connect(self, websocket: WebSocket) -> None: | 67 | async def on_connect(self, websocket: WebSocket) -> None: |
| 57 | message = WebsocketMessage( | 68 | message = WebsocketMessage( |
| @@ -60,18 +71,21 @@ class WebsocketProxy(WebSocketEndpoint): | |||
| 60 | payload=WebsocketConnectData( | 71 | payload=WebsocketConnectData( |
| 61 | path=get_path_with_query_string(websocket), | 72 | path=get_path_with_query_string(websocket), |
| 62 | headers=[ | 73 | headers=[ |
| 63 | (k.decode(), v.decode()) | 74 | (k.decode(), v.decode()) for k, v in websocket.scope["headers"] |
| 64 | for k, v | ||
| 65 | in websocket.scope['headers'] | ||
| 66 | ], | 75 | ], |
| 67 | ) | 76 | ), |
| 68 | ) | 77 | ) |
| 69 | 78 | ||
| 70 | async with self.proxy(websocket, message) as m: | 79 | async with self.proxy(websocket, message) as m: |
| 71 | if m is not None and WebsocketMessageType(m['type']) == WebsocketMessageType.ack: | 80 | if ( |
| 81 | m is not None | ||
| 82 | and WebsocketMessageType(m["type"]) == WebsocketMessageType.ack | ||
| 83 | ): | ||
| 72 | await super().on_connect(websocket) | 84 | await super().on_connect(websocket) |
| 73 | 85 | ||
| 74 | self.websocket_listen_task = asyncio.create_task(self.listen_for_messages(websocket)) | 86 | self.websocket_listen_task = asyncio.create_task( |
| 87 | self.listen_for_messages(websocket) | ||
| 88 | ) | ||
| 75 | 89 | ||
| 76 | def callback(*args, **kwargs): | 90 | def callback(*args, **kwargs): |
| 77 | self.websocket_listen_task = None | 91 | self.websocket_listen_task = None |
| @@ -92,7 +106,7 @@ class WebsocketProxy(WebSocketEndpoint): | |||
| 92 | identifier=self.id, | 106 | identifier=self.id, |
| 93 | payload=WebsocketMessageData( | 107 | payload=WebsocketMessageData( |
| 94 | body=b64encode(data_bytes).decode(), | 108 | body=b64encode(data_bytes).decode(), |
| 95 | ) | 109 | ), |
| 96 | ) | 110 | ) |
| 97 | 111 | ||
| 98 | async with self.proxy(websocket, message): | 112 | async with self.proxy(websocket, message): |
| @@ -104,7 +118,7 @@ class WebsocketProxy(WebSocketEndpoint): | |||
| 104 | identifier=self.id, | 118 | identifier=self.id, |
| 105 | payload=WebsocketDisconnectData( | 119 | payload=WebsocketDisconnectData( |
| 106 | close_code=close_code, | 120 | close_code=close_code, |
| 107 | ) | 121 | ), |
| 108 | ) | 122 | ) |
| 109 | 123 | ||
| 110 | async with self.proxy(websocket, message): | 124 | 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 = [ | |||
| 17 | "sqlmodel>=0.0.39", | 17 | "sqlmodel>=0.0.39", |
| 18 | "uvicorn[standard]~=0.44.0", | 18 | "uvicorn[standard]~=0.44.0", |
| 19 | ] | 19 | ] |
| 20 | |||
| 21 | [dependency-groups] | ||
| 22 | dev = [ | ||
| 23 | "pre-commit>=4.6.2", | ||
| 24 | ] | ||
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 | |||
| 3 | 3 | ||
| 4 | from fastapi import FastAPI | 4 | from fastapi import FastAPI |
| 5 | from starlette.middleware.authentication import AuthenticationMiddleware | 5 | from starlette.middleware.authentication import AuthenticationMiddleware |
| 6 | from starlette.routing import Host, Route, WebSocketRoute, Mount | 6 | from starlette.routing import Host, Mount, Route, WebSocketRoute |
| 7 | 7 | ||
| 8 | from authentication.backend import BearerTokenAuthBackend | 8 | from authentication.backend import BearerTokenAuthBackend |
| 9 | from authentication.endpoints import authentication, oauth | 9 | from authentication.endpoints import authentication, oauth |
| @@ -12,20 +12,20 @@ from proxy import app as proxy_app | |||
| 12 | from ttun_server.endpoints import health | 12 | from ttun_server.endpoints import health |
| 13 | from ttun_server.websockets import tunnel | 13 | from ttun_server.websockets import tunnel |
| 14 | 14 | ||
| 15 | logging.basicConfig(level=getattr(logging, os.environ.get('LOG_LEVEL', 'INFO'))) | 15 | logging.basicConfig(level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO"))) |
| 16 | 16 | ||
| 17 | app = FastAPI( | 17 | app = FastAPI( |
| 18 | debug=True, | 18 | debug=True, |
| 19 | routes=[ | 19 | routes=[ |
| 20 | Mount('/management/', app=management), | 20 | Mount("/management/", app=management), |
| 21 | Mount('/auth/', app=authentication), | 21 | Mount("/auth/", app=authentication), |
| 22 | Mount('/oauth/', app=oauth), | 22 | Mount("/oauth/", app=oauth), |
| 23 | WebSocketRoute( | 23 | WebSocketRoute( |
| 24 | '/tunnel/', | 24 | "/tunnel/", |
| 25 | endpoint=tunnel, | 25 | endpoint=tunnel, |
| 26 | ), | 26 | ), |
| 27 | Route('/health/', endpoint=health), | 27 | Route("/health/", endpoint=health), |
| 28 | Host(f'{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}', app=proxy_app) | 28 | Host(f"{{subdomain}}.{os.environ['TUNNEL_DOMAIN']}", app=proxy_app), |
| 29 | ], | 29 | ], |
| 30 | ) | 30 | ) |
| 31 | 31 | ||
| @@ -33,6 +33,7 @@ app.add_middleware(AuthenticationMiddleware, backend=BearerTokenAuthBackend()) | |||
| 33 | 33 | ||
| 34 | try: | 34 | try: |
| 35 | from ._version import version | 35 | from ._version import version |
| 36 | |||
| 36 | __version__ = version | 37 | __version__ = version |
| 37 | except ImportError: | 38 | except ImportError: |
| 38 | __version__ = 'development' | 39 | __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 @@ | |||
| 1 | async def health(): | 1 | async def health(): |
| 2 | return 'OK' | 2 | 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: | |||
| 9 | @classmethod | 9 | @classmethod |
| 10 | def get_connection(cls) -> Redis: | 10 | def get_connection(cls) -> Redis: |
| 11 | if cls._pool is None: | 11 | if cls._pool is None: |
| 12 | cls._pool = ConnectionPool.from_url(os.environ.get('REDIS_URL')) | 12 | cls._pool = ConnectionPool.from_url(os.environ.get("REDIS_URL")) |
| 13 | return Redis(connection_pool=cls._pool) \ No newline at end of file | 13 | 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 @@ | |||
| 1 | from asyncio import Queue | 1 | from asyncio import Queue |
| 2 | from enum import Enum | 2 | from enum import Enum |
| 3 | from typing import TypedDict, Optional | 3 | from typing import TypedDict |
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | class HttpMessageType(Enum): | 6 | class HttpMessageType(Enum): |
| 7 | request = 'request' | 7 | request = "request" |
| 8 | response = 'response' | 8 | response = "response" |
| 9 | 9 | ||
| 10 | 10 | ||
| 11 | class Config(TypedDict): | 11 | class Config(TypedDict): |
| @@ -18,13 +18,13 @@ class HttpRequestData(TypedDict): | |||
| 18 | method: str | 18 | method: str |
| 19 | path: str | 19 | path: str |
| 20 | headers: list[tuple[str, str]] | 20 | headers: list[tuple[str, str]] |
| 21 | body: Optional[str] | 21 | body: str | None |
| 22 | 22 | ||
| 23 | 23 | ||
| 24 | class HttpResponseData(TypedDict): | 24 | class HttpResponseData(TypedDict): |
| 25 | status: int | 25 | status: int |
| 26 | headers: list[tuple[str, str]] | 26 | headers: list[tuple[str, str]] |
| 27 | body: Optional[str] | 27 | body: str | None |
| 28 | 28 | ||
| 29 | 29 | ||
| 30 | class HttpMessage(TypedDict): | 30 | class HttpMessage(TypedDict): |
| @@ -34,10 +34,10 @@ class HttpMessage(TypedDict): | |||
| 34 | 34 | ||
| 35 | 35 | ||
| 36 | class WebsocketMessageType(Enum): | 36 | class WebsocketMessageType(Enum): |
| 37 | connect = 'connect' | 37 | connect = "connect" |
| 38 | disconnect = 'disconnect' | 38 | disconnect = "disconnect" |
| 39 | message = 'message' | 39 | message = "message" |
| 40 | ack = 'ack' | 40 | ack = "ack" |
| 41 | 41 | ||
| 42 | 42 | ||
| 43 | class WebsocketConnectData(TypedDict): | 43 | class WebsocketConnectData(TypedDict): |
| @@ -50,7 +50,7 @@ class WebsocketDisconnectData(TypedDict): | |||
| 50 | 50 | ||
| 51 | 51 | ||
| 52 | class WebsocketMessageData(TypedDict): | 52 | class WebsocketMessageData(TypedDict): |
| 53 | body: Optional[str] | 53 | body: str | None |
| 54 | 54 | ||
| 55 | 55 | ||
| 56 | class WebsocketMessage(TypedDict): | 56 | class WebsocketMessage(TypedDict): |
| @@ -60,13 +60,13 @@ class WebsocketMessage(TypedDict): | |||
| 60 | 60 | ||
| 61 | 61 | ||
| 62 | class MessageType(Enum): | 62 | class MessageType(Enum): |
| 63 | request = 'request' | 63 | request = "request" |
| 64 | response = 'response' | 64 | response = "response" |
| 65 | 65 | ||
| 66 | ws_connect = 'connect' | 66 | ws_connect = "connect" |
| 67 | ws_disconnect = 'disconnect' | 67 | ws_disconnect = "disconnect" |
| 68 | ws_message = 'message' | 68 | ws_message = "message" |
| 69 | ws_ack = 'ack' | 69 | ws_ack = "ack" |
| 70 | 70 | ||
| 71 | 71 | ||
| 72 | Message = HttpMessage | WebsocketMessage | 72 | 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 ( | |||
| 16 | ) | 16 | ) |
| 17 | 17 | ||
| 18 | logger = logging.getLogger(__name__) | 18 | logger = logging.getLogger(__name__) |
| 19 | logger.setLevel('DEBUG') | 19 | logger.setLevel("DEBUG") |
| 20 | |||
| 20 | 21 | ||
| 21 | async def assert_compatible_version(websocket: WebSocket, config: Config) -> None: | 22 | async def assert_compatible_version(websocket: WebSocket, config: Config) -> None: |
| 22 | client_version = config.get('version', '1.0.0') | 23 | client_version = config.get("version", "1.0.0") |
| 23 | logger.debug('client_version %s', client_version) | 24 | logger.debug("client_version %s", client_version) |
| 24 | 25 | ||
| 25 | if 'git' not in client_version and ttun_server.__version__ != 'development': | 26 | if "git" not in client_version and ttun_server.__version__ != "development": |
| 26 | [client_major, *_] = [int(i) for i in client_version.split('.')[:3]] | 27 | [client_major, *_] = [int(i) for i in client_version.split(".")[:3]] |
| 27 | [server_major, *_] = [int(i) for i in ttun_server.__version__.split('.')] | 28 | [server_major, *_] = [int(i) for i in ttun_server.__version__.split(".")] |
| 28 | 29 | ||
| 29 | if client_major < server_major: | 30 | if client_major < server_major: |
| 30 | await websocket.close(4000, 'Your client is too old') | 31 | await websocket.close(4000, "Your client is too old") |
| 31 | 32 | ||
| 32 | if client_major > server_major: | 33 | if client_major > server_major: |
| 33 | await websocket.close(4001, 'Your client is too new') | 34 | await websocket.close(4001, "Your client is too new") |
| 34 | 35 | ||
| 35 | 36 | ||
| 36 | @requires(['authenticated', 'token'] if conf.ENABLE_AUTH else []) | 37 | @requires(["authenticated", "token"] if conf.ENABLE_AUTH else []) |
| 37 | async def tunnel(websocket: WebSocket) -> None: | 38 | async def tunnel(websocket: WebSocket) -> None: |
| 38 | request_tasks: dict[str, asyncio.Task] = {} | 39 | request_tasks: dict[str, asyncio.Task] = {} |
| 39 | proxy_queues: dict[str, ProxyQueue] = {} | 40 | proxy_queues: dict[str, ProxyQueue] = {} |
| @@ -43,36 +44,39 @@ async def tunnel(websocket: WebSocket) -> None: | |||
| 43 | 44 | ||
| 44 | await assert_compatible_version(websocket, config) | 45 | await assert_compatible_version(websocket, config) |
| 45 | 46 | ||
| 46 | if 'subdomains' not in config: | 47 | if "subdomains" not in config: |
| 47 | config['subdomains'] = [config['subdomain']] | 48 | config["subdomains"] = [config["subdomain"]] |
| 48 | elif config['subdomains'] is None: | 49 | elif config["subdomains"] is None: |
| 49 | config['subdomains'] = [None] | 50 | config["subdomains"] = [None] |
| 50 | 51 | ||
| 51 | for i, subdomain in enumerate(config['subdomains']): | 52 | for i, subdomain in enumerate(config["subdomains"]): |
| 52 | if subdomain is None or await ProxyQueue.has_connection(subdomain): | 53 | if subdomain is None or await ProxyQueue.has_connection(subdomain): |
| 53 | config['subdomains'][i] = uuid4().hex | 54 | config["subdomains"][i] = uuid4().hex |
| 54 | 55 | ||
| 55 | for subdomain in config['subdomains']: | 56 | for subdomain in config["subdomains"]: |
| 56 | proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain) | 57 | proxy_queues[subdomain] = await ProxyQueue.create_for_identifier(subdomain) |
| 57 | 58 | ||
| 58 | hostname = os.environ.get('TUNNEL_DOMAIN') | 59 | hostname = os.environ.get("TUNNEL_DOMAIN") |
| 59 | protocol = 'https' if os.environ.get('SECURE', False) else 'http' | 60 | protocol = "https" if os.environ.get("SECURE") else "http" |
| 60 | 61 | ||
| 61 | urls = [ | 62 | urls = [ |
| 62 | f'{protocol}://{subdomain}.{hostname}' | 63 | f"{protocol}://{subdomain}.{hostname}" for subdomain in config["subdomains"] |
| 63 | for subdomain in config['subdomains'] | ||
| 64 | ] | 64 | ] |
| 65 | 65 | ||
| 66 | await websocket.send_json({ | 66 | await websocket.send_json( |
| 67 | 'url': urls[0], | 67 | { |
| 68 | 'urls': urls, | 68 | "url": urls[0], |
| 69 | }) | 69 | "urls": urls, |
| 70 | } | ||
| 71 | ) | ||
| 70 | 72 | ||
| 71 | async def handle_requests(subdomain: str) -> None: | 73 | async def handle_requests(subdomain: str) -> None: |
| 72 | while request := await proxy_queues[subdomain].dequeue(): | 74 | while request := await proxy_queues[subdomain].dequeue(): |
| 73 | asyncio.create_task(websocket.send_json(request), name=request['identifier']) | 75 | asyncio.create_task( |
| 76 | websocket.send_json(request), name=request["identifier"] | ||
| 77 | ) | ||
| 74 | 78 | ||
| 75 | for subdomain in config['subdomains']: | 79 | for subdomain in config["subdomains"]: |
| 76 | request_tasks[subdomain] = asyncio.create_task( | 80 | request_tasks[subdomain] = asyncio.create_task( |
| 77 | handle_requests(subdomain), name=subdomain | 81 | handle_requests(subdomain), name=subdomain |
| 78 | ) | 82 | ) |
| @@ -81,8 +85,8 @@ async def tunnel(websocket: WebSocket) -> None: | |||
| 81 | while True: | 85 | while True: |
| 82 | data: Message = await websocket.receive_json() | 86 | data: Message = await websocket.receive_json() |
| 83 | try: | 87 | try: |
| 84 | data['type'] = MessageType(data['type']).value | 88 | data["type"] = MessageType(data["type"]).value |
| 85 | response_queue = await ProxyQueue.get_for_identifier(data['identifier']) | 89 | response_queue = await ProxyQueue.get_for_identifier(data["identifier"]) |
| 86 | await response_queue.enqueue(data) | 90 | await response_queue.enqueue(data) |
| 87 | except AssertionError: | 91 | except AssertionError: |
| 88 | pass | 92 | pass |
| @@ -162,6 +162,15 @@ wheels = [ | |||
| 162 | ] | 162 | ] |
| 163 | 163 | ||
| 164 | [[package]] | 164 | [[package]] |
| 165 | name = "cfgv" | ||
| 166 | version = "3.5.0" | ||
| 167 | source = { registry = "https://pypi.org/simple" } | ||
| 168 | 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" } | ||
| 169 | wheels = [ | ||
| 170 | { 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" }, | ||
| 171 | ] | ||
| 172 | |||
| 173 | [[package]] | ||
| 165 | name = "click" | 174 | name = "click" |
| 166 | version = "8.3.2" | 175 | version = "8.3.2" |
| 167 | source = { registry = "https://pypi.org/simple" } | 176 | source = { registry = "https://pypi.org/simple" } |
| @@ -253,6 +262,15 @@ wheels = [ | |||
| 253 | ] | 262 | ] |
| 254 | 263 | ||
| 255 | [[package]] | 264 | [[package]] |
| 265 | name = "distlib" | ||
| 266 | version = "0.4.3" | ||
| 267 | source = { registry = "https://pypi.org/simple" } | ||
| 268 | 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" } | ||
| 269 | wheels = [ | ||
| 270 | { 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" }, | ||
| 271 | ] | ||
| 272 | |||
| 273 | [[package]] | ||
| 256 | name = "dnspython" | 274 | name = "dnspython" |
| 257 | version = "2.8.0" | 275 | version = "2.8.0" |
| 258 | source = { registry = "https://pypi.org/simple" } | 276 | source = { registry = "https://pypi.org/simple" } |
| @@ -384,6 +402,15 @@ wheels = [ | |||
| 384 | ] | 402 | ] |
| 385 | 403 | ||
| 386 | [[package]] | 404 | [[package]] |
| 405 | name = "filelock" | ||
| 406 | version = "3.32.5" | ||
| 407 | source = { registry = "https://pypi.org/simple" } | ||
| 408 | 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" } | ||
| 409 | wheels = [ | ||
| 410 | { 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" }, | ||
| 411 | ] | ||
| 412 | |||
| 413 | [[package]] | ||
| 387 | name = "greenlet" | 414 | name = "greenlet" |
| 388 | version = "3.5.3" | 415 | version = "3.5.3" |
| 389 | source = { registry = "https://pypi.org/simple" } | 416 | source = { registry = "https://pypi.org/simple" } |
| @@ -509,6 +536,15 @@ wheels = [ | |||
| 509 | ] | 536 | ] |
| 510 | 537 | ||
| 511 | [[package]] | 538 | [[package]] |
| 539 | name = "identify" | ||
| 540 | version = "2.6.19" | ||
| 541 | source = { registry = "https://pypi.org/simple" } | ||
| 542 | 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" } | ||
| 543 | wheels = [ | ||
| 544 | { 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" }, | ||
| 545 | ] | ||
| 546 | |||
| 547 | [[package]] | ||
| 512 | name = "idna" | 548 | name = "idna" |
| 513 | version = "3.11" | 549 | version = "3.11" |
| 514 | source = { registry = "https://pypi.org/simple" } | 550 | source = { registry = "https://pypi.org/simple" } |
| @@ -623,6 +659,15 @@ wheels = [ | |||
| 623 | ] | 659 | ] |
| 624 | 660 | ||
| 625 | [[package]] | 661 | [[package]] |
| 662 | name = "nodeenv" | ||
| 663 | version = "1.10.0" | ||
| 664 | source = { registry = "https://pypi.org/simple" } | ||
| 665 | 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" } | ||
| 666 | wheels = [ | ||
| 667 | { 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" }, | ||
| 668 | ] | ||
| 669 | |||
| 670 | [[package]] | ||
| 626 | name = "passlib" | 671 | name = "passlib" |
| 627 | version = "1.7.4" | 672 | version = "1.7.4" |
| 628 | source = { registry = "https://pypi.org/simple" } | 673 | source = { registry = "https://pypi.org/simple" } |
| @@ -644,6 +689,31 @@ wheels = [ | |||
| 644 | ] | 689 | ] |
| 645 | 690 | ||
| 646 | [[package]] | 691 | [[package]] |
| 692 | name = "platformdirs" | ||
| 693 | version = "4.11.7" | ||
| 694 | source = { registry = "https://pypi.org/simple" } | ||
| 695 | 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" } | ||
| 696 | wheels = [ | ||
| 697 | { 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" }, | ||
| 698 | ] | ||
| 699 | |||
| 700 | [[package]] | ||
| 701 | name = "pre-commit" | ||
| 702 | version = "4.6.2" | ||
| 703 | source = { registry = "https://pypi.org/simple" } | ||
| 704 | dependencies = [ | ||
| 705 | { name = "cfgv" }, | ||
| 706 | { name = "identify" }, | ||
| 707 | { name = "nodeenv" }, | ||
| 708 | { name = "pyyaml" }, | ||
| 709 | { name = "virtualenv" }, | ||
| 710 | ] | ||
| 711 | 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" } | ||
| 712 | wheels = [ | ||
| 713 | { 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" }, | ||
| 714 | ] | ||
| 715 | |||
| 716 | [[package]] | ||
| 647 | name = "psycopg2" | 717 | name = "psycopg2" |
| 648 | version = "2.9.12" | 718 | version = "2.9.12" |
| 649 | source = { registry = "https://pypi.org/simple" } | 719 | source = { registry = "https://pypi.org/simple" } |
| @@ -759,6 +829,18 @@ wheels = [ | |||
| 759 | ] | 829 | ] |
| 760 | 830 | ||
| 761 | [[package]] | 831 | [[package]] |
| 832 | name = "python-discovery" | ||
| 833 | version = "1.6.0" | ||
| 834 | source = { registry = "https://pypi.org/simple" } | ||
| 835 | dependencies = [ | ||
| 836 | { name = "filelock" }, | ||
| 837 | ] | ||
| 838 | 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" } | ||
| 839 | wheels = [ | ||
| 840 | { 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" }, | ||
| 841 | ] | ||
| 842 | |||
| 843 | [[package]] | ||
| 762 | name = "python-dotenv" | 844 | name = "python-dotenv" |
| 763 | version = "1.2.2" | 845 | version = "1.2.2" |
| 764 | source = { registry = "https://pypi.org/simple" } | 846 | source = { registry = "https://pypi.org/simple" } |
| @@ -985,6 +1067,11 @@ dependencies = [ | |||
| 985 | { name = "uvicorn", extra = ["standard"] }, | 1067 | { name = "uvicorn", extra = ["standard"] }, |
| 986 | ] | 1068 | ] |
| 987 | 1069 | ||
| 1070 | [package.dev-dependencies] | ||
| 1071 | dev = [ | ||
| 1072 | { name = "pre-commit" }, | ||
| 1073 | ] | ||
| 1074 | |||
| 988 | [package.metadata] | 1075 | [package.metadata] |
| 989 | requires-dist = [ | 1076 | requires-dist = [ |
| 990 | { name = "alembic", specifier = ">=1.18.5" }, | 1077 | { name = "alembic", specifier = ">=1.18.5" }, |
| @@ -1002,6 +1089,9 @@ requires-dist = [ | |||
| 1002 | { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" }, | 1089 | { name = "uvicorn", extras = ["standard"], specifier = "~=0.44.0" }, |
| 1003 | ] | 1090 | ] |
| 1004 | 1091 | ||
| 1092 | [package.metadata.requires-dev] | ||
| 1093 | dev = [{ name = "pre-commit", specifier = ">=4.6.2" }] | ||
| 1094 | |||
| 1005 | [[package]] | 1095 | [[package]] |
| 1006 | name = "typer" | 1096 | name = "typer" |
| 1007 | version = "0.26.8" | 1097 | version = "0.26.8" |
| @@ -1092,6 +1182,21 @@ wheels = [ | |||
| 1092 | ] | 1182 | ] |
| 1093 | 1183 | ||
| 1094 | [[package]] | 1184 | [[package]] |
| 1185 | name = "virtualenv" | ||
| 1186 | version = "21.7.8" | ||
| 1187 | source = { registry = "https://pypi.org/simple" } | ||
| 1188 | dependencies = [ | ||
| 1189 | { name = "distlib" }, | ||
| 1190 | { name = "filelock" }, | ||
| 1191 | { name = "platformdirs" }, | ||
| 1192 | { name = "python-discovery" }, | ||
| 1193 | ] | ||
| 1194 | 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" } | ||
| 1195 | wheels = [ | ||
| 1196 | { 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" }, | ||
| 1197 | ] | ||
| 1198 | |||
| 1199 | [[package]] | ||
| 1095 | name = "watchfiles" | 1200 | name = "watchfiles" |
| 1096 | version = "1.1.1" | 1201 | version = "1.1.1" |
| 1097 | source = { registry = "https://pypi.org/simple" } | 1202 | source = { registry = "https://pypi.org/simple" } |
