vibetuner 2.26.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. vibetuner/__init__.py +2 -0
  2. vibetuner/__main__.py +4 -0
  3. vibetuner/cli/__init__.py +141 -0
  4. vibetuner/cli/run.py +160 -0
  5. vibetuner/cli/scaffold.py +187 -0
  6. vibetuner/config.py +143 -0
  7. vibetuner/context.py +28 -0
  8. vibetuner/frontend/__init__.py +107 -0
  9. vibetuner/frontend/deps.py +41 -0
  10. vibetuner/frontend/email.py +45 -0
  11. vibetuner/frontend/hotreload.py +13 -0
  12. vibetuner/frontend/lifespan.py +37 -0
  13. vibetuner/frontend/middleware.py +151 -0
  14. vibetuner/frontend/oauth.py +196 -0
  15. vibetuner/frontend/routes/__init__.py +12 -0
  16. vibetuner/frontend/routes/auth.py +156 -0
  17. vibetuner/frontend/routes/debug.py +414 -0
  18. vibetuner/frontend/routes/health.py +37 -0
  19. vibetuner/frontend/routes/language.py +43 -0
  20. vibetuner/frontend/routes/meta.py +55 -0
  21. vibetuner/frontend/routes/user.py +94 -0
  22. vibetuner/frontend/templates.py +176 -0
  23. vibetuner/logging.py +87 -0
  24. vibetuner/models/__init__.py +14 -0
  25. vibetuner/models/blob.py +89 -0
  26. vibetuner/models/email_verification.py +84 -0
  27. vibetuner/models/mixins.py +76 -0
  28. vibetuner/models/oauth.py +57 -0
  29. vibetuner/models/registry.py +15 -0
  30. vibetuner/models/types.py +16 -0
  31. vibetuner/models/user.py +91 -0
  32. vibetuner/mongo.py +33 -0
  33. vibetuner/paths.py +250 -0
  34. vibetuner/services/__init__.py +0 -0
  35. vibetuner/services/blob.py +175 -0
  36. vibetuner/services/email.py +50 -0
  37. vibetuner/tasks/__init__.py +0 -0
  38. vibetuner/tasks/lifespan.py +28 -0
  39. vibetuner/tasks/worker.py +15 -0
  40. vibetuner/templates/email/magic_link.html.jinja +17 -0
  41. vibetuner/templates/email/magic_link.txt.jinja +5 -0
  42. vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
  43. vibetuner/templates/frontend/base/footer.html.jinja +3 -0
  44. vibetuner/templates/frontend/base/header.html.jinja +0 -0
  45. vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
  46. vibetuner/templates/frontend/base/skeleton.html.jinja +45 -0
  47. vibetuner/templates/frontend/debug/collections.html.jinja +105 -0
  48. vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
  49. vibetuner/templates/frontend/debug/index.html.jinja +85 -0
  50. vibetuner/templates/frontend/debug/info.html.jinja +258 -0
  51. vibetuner/templates/frontend/debug/users.html.jinja +139 -0
  52. vibetuner/templates/frontend/debug/version.html.jinja +55 -0
  53. vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
  54. vibetuner/templates/frontend/email_sent.html.jinja +83 -0
  55. vibetuner/templates/frontend/index.html.jinja +20 -0
  56. vibetuner/templates/frontend/lang/select.html.jinja +4 -0
  57. vibetuner/templates/frontend/login.html.jinja +89 -0
  58. vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
  59. vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
  60. vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
  61. vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
  62. vibetuner/templates/frontend/user/edit.html.jinja +86 -0
  63. vibetuner/templates/frontend/user/profile.html.jinja +157 -0
  64. vibetuner/templates/markdown/.placeholder +0 -0
  65. vibetuner/templates.py +146 -0
  66. vibetuner/time.py +57 -0
  67. vibetuner/versioning.py +12 -0
  68. vibetuner-2.26.6.dist-info/METADATA +241 -0
  69. vibetuner-2.26.6.dist-info/RECORD +71 -0
  70. vibetuner-2.26.6.dist-info/WHEEL +4 -0
  71. vibetuner-2.26.6.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,107 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, Depends as Depends, FastAPI, Request
4
+ from fastapi.responses import HTMLResponse, RedirectResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+
7
+ import vibetuner.frontend.lifespan as lifespan_module
8
+ from vibetuner import paths
9
+ from vibetuner.logging import logger
10
+
11
+ from .deps import LangDep as LangDep, MagicCookieDep as MagicCookieDep
12
+ from .lifespan import ctx
13
+ from .middleware import middlewares
14
+ from .routes import auth, debug, health, language, meta, user
15
+ from .templates import render_template
16
+
17
+
18
+ _registered_routers: list[APIRouter] = []
19
+
20
+
21
+ def register_router(router: APIRouter) -> None:
22
+ _registered_routers.append(router)
23
+
24
+
25
+ try:
26
+ import app.frontend.oauth as _app_oauth # noqa: F401 # type: ignore[unresolved-import]
27
+ import app.frontend.routes as _app_routes # noqa: F401 # type: ignore[unresolved-import]
28
+
29
+ # Register OAuth routes after providers are registered
30
+ from .routes.auth import register_oauth_routes
31
+
32
+ register_oauth_routes()
33
+ except ModuleNotFoundError:
34
+ # Silent pass for missing app.frontend.oauth or app.frontend.routes modules (expected in some projects)
35
+ pass
36
+ except ImportError as e:
37
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
38
+ logger.warning(
39
+ f"Failed to import app.frontend.oauth or app.frontend.routes: {e}. OAuth and custom routes will not be available."
40
+ )
41
+
42
+
43
+ dependencies: list[Any] = [
44
+ # Add any dependencies that should be available globally
45
+ ]
46
+
47
+ app = FastAPI(
48
+ debug=ctx.DEBUG,
49
+ lifespan=lifespan_module.lifespan,
50
+ docs_url=None,
51
+ redoc_url=None,
52
+ openapi_url=None,
53
+ middleware=middlewares,
54
+ dependencies=dependencies,
55
+ )
56
+
57
+ # Static files
58
+ app.mount(f"/static/v{ctx.v_hash}/css", StaticFiles(directory=paths.css), name="css")
59
+ app.mount(f"/static/v{ctx.v_hash}/img", StaticFiles(directory=paths.img), name="img")
60
+ app.mount(f"/static/v{ctx.v_hash}/js", StaticFiles(directory=paths.js), name="js")
61
+
62
+ app.mount("/static/favicons", StaticFiles(directory=paths.favicons), name="favicons")
63
+
64
+
65
+ @app.get("/static/v{v_hash}/css/{subpath:path}", response_class=RedirectResponse)
66
+ @app.get("/static/css/{subpath:path}", response_class=RedirectResponse)
67
+ def css_redirect(request: Request, subpath: str):
68
+ return request.url_for("css", path=subpath).path
69
+
70
+
71
+ @app.get("/static/v{v_hash}/img/{subpath:path}", response_class=RedirectResponse)
72
+ @app.get("/static/img/{subpath:path}", response_class=RedirectResponse)
73
+ def img_redirect(request: Request, subpath: str):
74
+ return request.url_for("img", path=subpath).path
75
+
76
+
77
+ @app.get("/static/v{v_hash}/js/{subpath:path}", response_class=RedirectResponse)
78
+ @app.get("/static/js/{subpath:path}", response_class=RedirectResponse)
79
+ def js_redirect(request: Request, subpath: str):
80
+ return request.url_for("js", path=subpath).path
81
+
82
+
83
+ if ctx.DEBUG:
84
+ from .hotreload import hotreload
85
+
86
+ app.add_websocket_route(
87
+ "/hot-reload",
88
+ route=hotreload, # type: ignore
89
+ name="hot-reload",
90
+ )
91
+
92
+ app.include_router(meta.router)
93
+ app.include_router(auth.router)
94
+ app.include_router(user.router)
95
+ app.include_router(language.router)
96
+
97
+ for router in _registered_routers:
98
+ app.include_router(router)
99
+
100
+
101
+ @app.get("/", name="homepage", response_class=HTMLResponse)
102
+ def default_index(request: Request) -> HTMLResponse:
103
+ return render_template("index.html.jinja", request)
104
+
105
+
106
+ app.include_router(debug.router)
107
+ app.include_router(health.router)
@@ -0,0 +1,41 @@
1
+ from typing import Annotated, Optional
2
+
3
+ from fastapi import Depends, HTTPException, Request
4
+
5
+
6
+ async def require_htmx(request: Request) -> None:
7
+ if not request.state.htmx:
8
+ raise HTTPException(status_code=400, detail="HTMX header not found")
9
+
10
+
11
+ async def enforce_lang(request: Request, lang: Optional[str] = None):
12
+ if lang is None or lang != request.state.language:
13
+ redirect_url = request.url_for(
14
+ request.scope["endpoint"].__name__,
15
+ **{**request.path_params, "lang": request.state.language},
16
+ ).path
17
+ raise HTTPException(
18
+ status_code=307,
19
+ detail=f"Redirecting to canonical language: {request.state.language}",
20
+ headers={"Location": redirect_url},
21
+ )
22
+
23
+ return request.state.language
24
+
25
+
26
+ LangDep = Annotated[str, Depends(enforce_lang)]
27
+
28
+
29
+ MAGIC_COOKIE_NAME = "magic_access"
30
+
31
+
32
+ def require_magic_cookie(request: Request) -> None:
33
+ """Dependency to check if the magic access cookie is present."""
34
+ if MAGIC_COOKIE_NAME not in request.cookies:
35
+ raise HTTPException(status_code=403, detail="Access forbidden")
36
+
37
+ if request.cookies[MAGIC_COOKIE_NAME] != "granted":
38
+ raise HTTPException(status_code=403, detail="Access forbidden")
39
+
40
+
41
+ MagicCookieDep = Depends(require_magic_cookie)
@@ -0,0 +1,45 @@
1
+ from pydantic import EmailStr
2
+ from starlette_babel import gettext_lazy as _
3
+
4
+ from vibetuner.config import settings
5
+ from vibetuner.services.email import SESEmailService
6
+
7
+ from .templates import render_static_template
8
+
9
+
10
+ async def send_magic_link_email(
11
+ ses_service: SESEmailService,
12
+ lang: str,
13
+ to_address: EmailStr,
14
+ login_url: str,
15
+ ) -> None:
16
+ project_name = settings.project.project_name
17
+
18
+ html_body = render_static_template(
19
+ "magic_link.html",
20
+ namespace="email",
21
+ lang=lang,
22
+ context={
23
+ "login_url": str(login_url),
24
+ "project_name": project_name,
25
+ },
26
+ )
27
+
28
+ text_body = render_static_template(
29
+ "magic_link.txt",
30
+ namespace="email",
31
+ lang=lang,
32
+ context={
33
+ "login_url": str(login_url),
34
+ "project_name": project_name,
35
+ },
36
+ )
37
+
38
+ await ses_service.send_email(
39
+ subject=_("Sign in to {project_name}").format(
40
+ project_name=settings.project.project_name
41
+ ),
42
+ html_body=html_body,
43
+ text_body=text_body,
44
+ to_address=to_address,
45
+ )
@@ -0,0 +1,13 @@
1
+ import arel
2
+
3
+ from vibetuner.paths import css as css_path, js as js_path, templates as templates_path
4
+
5
+
6
+ hotreload = arel.HotReload(
7
+ paths=[
8
+ arel.Path(str(js_path)),
9
+ arel.Path(str(css_path)),
10
+ arel.Path(str(templates_path)),
11
+ ],
12
+ reconnect_interval=2,
13
+ )
@@ -0,0 +1,37 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncGenerator
3
+
4
+ from fastapi import FastAPI
5
+
6
+ from vibetuner.context import ctx
7
+ from vibetuner.logging import logger
8
+ from vibetuner.mongo import init_models
9
+
10
+ from .hotreload import hotreload
11
+
12
+
13
+ @asynccontextmanager
14
+ async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
15
+ logger.info("Vibetuner frontend starting")
16
+ if ctx.DEBUG:
17
+ await hotreload.startup()
18
+
19
+ await init_models()
20
+
21
+ yield
22
+
23
+ logger.info("Vibetuner frontend stopping")
24
+ if ctx.DEBUG:
25
+ await hotreload.shutdown()
26
+ logger.info("Vibetuner frontend stopped")
27
+
28
+
29
+ try:
30
+ from app.frontend.lifespan import lifespan # ty: ignore
31
+ except ModuleNotFoundError:
32
+ # Silent pass for missing app.frontend.lifespan module (expected in some projects)
33
+ lifespan = base_lifespan
34
+ except ImportError as e:
35
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
36
+ logger.warning(f"Failed to import app.frontend.lifespan: {e}. Using base lifespan.")
37
+ lifespan = base_lifespan
@@ -0,0 +1,151 @@
1
+ from fastapi import Request, Response
2
+ from fastapi.middleware import Middleware
3
+ from fastapi.requests import HTTPConnection
4
+ from starlette.authentication import AuthCredentials, AuthenticationBackend
5
+ from starlette.middleware.authentication import AuthenticationMiddleware
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.middleware.sessions import SessionMiddleware
8
+ from starlette.middleware.trustedhost import TrustedHostMiddleware
9
+ from starlette.types import ASGIApp, Receive, Scope, Send
10
+ from starlette_babel import (
11
+ LocaleFromCookie,
12
+ LocaleFromQuery,
13
+ LocaleMiddleware,
14
+ get_translator,
15
+ )
16
+ from starlette_htmx.middleware import HtmxMiddleware
17
+
18
+ from vibetuner.config import settings
19
+ from vibetuner.context import ctx
20
+ from vibetuner.paths import locales as locales_path
21
+
22
+ from .oauth import WebUser
23
+
24
+
25
+ def locale_selector(conn: HTTPConnection) -> str | None:
26
+ """
27
+ Selects the locale based on the first part of the path if it matches a 2-letter language code.
28
+ """
29
+
30
+ parts = conn.scope.get("path", "").strip("/").split("/")
31
+
32
+ # Check if first part is a 2-letter lowercase language code
33
+ if parts and len(parts[0]) == 2 and parts[0].islower() and parts[0].isalpha():
34
+ return parts[0]
35
+
36
+ return None
37
+
38
+
39
+ def user_preference_selector(conn: HTTPConnection) -> str | None:
40
+ """
41
+ Selects the locale based on authenticated user's language preference from session.
42
+ This takes priority over all other locale selectors to avoid database queries.
43
+ """
44
+ # Check if session is available in scope
45
+ if "session" not in conn.scope:
46
+ return None
47
+
48
+ session = conn.scope["session"]
49
+ if not session:
50
+ return None
51
+
52
+ user_data = session.get("user")
53
+ if not user_data:
54
+ return None
55
+
56
+ # Get language preference from user settings stored in session
57
+ user_settings = user_data.get("settings")
58
+ if not user_settings:
59
+ return None
60
+
61
+ language = user_settings.get("language")
62
+ if language and isinstance(language, str) and len(language) == 2:
63
+ return language.lower()
64
+
65
+ return None
66
+
67
+
68
+ shared_translator = get_translator()
69
+ if locales_path is not None and locales_path.exists() and locales_path.is_dir():
70
+ # Load translations from the locales directory
71
+ shared_translator.load_from_directories([locales_path])
72
+
73
+
74
+ class AdjustLangCookieMiddleware(BaseHTTPMiddleware):
75
+ async def dispatch(self, request: Request, call_next):
76
+ response: Response = await call_next(request)
77
+
78
+ lang_cookie = request.cookies.get("language")
79
+ if not lang_cookie or lang_cookie != request.state.language:
80
+ response.set_cookie(
81
+ key="language", value=request.state.language, max_age=3600
82
+ )
83
+
84
+ return response
85
+
86
+
87
+ class ForwardedProtocolMiddleware:
88
+ def __init__(self, app: ASGIApp):
89
+ self.app = app
90
+
91
+ # Based on https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py
92
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
93
+ if scope["type"] == "lifespan":
94
+ return await self.app(scope, receive, send)
95
+
96
+ headers = dict(scope["headers"])
97
+
98
+ if b"x-forwarded-proto" in headers:
99
+ x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1").strip()
100
+
101
+ if x_forwarded_proto in {"http", "https", "ws", "wss"}:
102
+ if scope["type"] == "websocket":
103
+ scope["scheme"] = x_forwarded_proto.replace("http", "ws")
104
+ else:
105
+ scope["scheme"] = x_forwarded_proto
106
+
107
+ return await self.app(scope, receive, send)
108
+
109
+
110
+ class AuthBackend(AuthenticationBackend):
111
+ async def authenticate(
112
+ self,
113
+ conn: HTTPConnection,
114
+ ) -> tuple[AuthCredentials, WebUser] | None:
115
+ if user := conn.session.get("user"):
116
+ try:
117
+ return (
118
+ AuthCredentials(["authenticated"]),
119
+ WebUser.model_validate(user),
120
+ )
121
+ except Exception:
122
+ # Clear corrupted session data and continue unauthenticated
123
+ conn.session.pop("user", None)
124
+ return None
125
+
126
+ return None
127
+
128
+
129
+ # Until this line
130
+ middlewares: list[Middleware] = [
131
+ Middleware(TrustedHostMiddleware),
132
+ Middleware(ForwardedProtocolMiddleware),
133
+ Middleware(HtmxMiddleware),
134
+ Middleware(SessionMiddleware, secret_key=settings.session_key.get_secret_value()),
135
+ Middleware(
136
+ LocaleMiddleware,
137
+ locales=list(ctx.supported_languages),
138
+ default_locale=ctx.default_language,
139
+ selectors=[
140
+ LocaleFromQuery(query_param="l"),
141
+ locale_selector,
142
+ user_preference_selector,
143
+ LocaleFromCookie(),
144
+ ],
145
+ ),
146
+ Middleware(AdjustLangCookieMiddleware),
147
+ Middleware(AuthenticationMiddleware, backend=AuthBackend()),
148
+ # Add your middleware below this line
149
+ ]
150
+
151
+ # EOF
@@ -0,0 +1,196 @@
1
+ from typing import Optional
2
+
3
+ from authlib.integrations.base_client.errors import OAuthError
4
+ from authlib.integrations.starlette_client import OAuth # ty: ignore[unresolved-import]
5
+ from fastapi import Request
6
+ from fastapi.responses import RedirectResponse
7
+ from pydantic import BaseModel, Field
8
+ from pydantic_extra_types.language_code import LanguageAlpha2
9
+ from starlette.authentication import BaseUser
10
+
11
+ from vibetuner.frontend.routes import get_homepage_url
12
+ from vibetuner.models.oauth import OAuthAccountModel, OauthProviderModel
13
+ from vibetuner.models.user import UserModel
14
+
15
+
16
+ DEFAULT_AVATAR_IMAGE = "/statics/img/user-avatar.png"
17
+
18
+ _PROVIDERS: dict[str, OauthProviderModel] = {}
19
+
20
+
21
+ def register_oauth_provider(name: str, provider: OauthProviderModel) -> None:
22
+ _PROVIDERS[name] = provider
23
+ PROVIDER_IDENTIFIERS[name] = provider.identifier
24
+ _oauth_config.update(**provider.config)
25
+ register_kwargs = {"client_kwargs": provider.client_kwargs, **provider.params}
26
+ oauth.register(name, overwrite=True, **register_kwargs)
27
+
28
+
29
+ class WebUser(BaseUser, BaseModel):
30
+ id: str
31
+ name: str
32
+ email: str
33
+ picture: Optional[str] = Field(
34
+ default=DEFAULT_AVATAR_IMAGE,
35
+ description="URL to the user's avatar image",
36
+ )
37
+ language: Optional[LanguageAlpha2] = Field(
38
+ default=None,
39
+ description="Preferred language for the user",
40
+ )
41
+
42
+ @property
43
+ def is_authenticated(self) -> bool:
44
+ return True
45
+
46
+ @property
47
+ def display_name(self) -> str:
48
+ return self.name
49
+
50
+
51
+ class Config:
52
+ def __init__(self, **kwargs):
53
+ self._data = kwargs
54
+
55
+ def get(self, key, default=None):
56
+ return self._data.get(key, default)
57
+
58
+ def update(self, **kwargs):
59
+ self._data.update(kwargs)
60
+
61
+
62
+ _oauth_config = Config()
63
+ oauth = OAuth(_oauth_config)
64
+
65
+ PROVIDER_IDENTIFIERS: dict[str, str] = {}
66
+
67
+
68
+ def get_oauth_providers() -> list[str]:
69
+ return list(_PROVIDERS.keys())
70
+
71
+
72
+ async def _handle_user_account(
73
+ provider: str, identifier: str, email: str, name: str, picture: str
74
+ ) -> UserModel:
75
+ """Handle user account creation or OAuth linking."""
76
+ # Check if OAuth account already exists
77
+ oauth_account = await OAuthAccountModel.get_by_provider_and_id(
78
+ provider=provider,
79
+ provider_user_id=identifier,
80
+ )
81
+
82
+ if oauth_account:
83
+ # OAuth account exists, get linked user account
84
+ account = await UserModel.get_by_email(email)
85
+ if not account:
86
+ raise OAuthError("No account linked to this OAuth account")
87
+ return account
88
+
89
+ # OAuth account doesn't exist, check if user exists
90
+
91
+ if account := (await UserModel.get_by_email(email)):
92
+ # User exists, link OAuth account
93
+ await _link_oauth_account(account, provider, identifier, email, name, picture)
94
+ else:
95
+ # New user, create account and OAuth link
96
+ account = await _create_new_user_with_oauth(
97
+ provider, identifier, email, name, picture
98
+ )
99
+
100
+ return account
101
+
102
+
103
+ async def _link_oauth_account(
104
+ account: UserModel,
105
+ provider: str,
106
+ identifier: str,
107
+ email: str,
108
+ name: str,
109
+ picture: str,
110
+ ) -> None:
111
+ """Link OAuth account to existing user."""
112
+ oauth_account = OAuthAccountModel(
113
+ provider=provider,
114
+ provider_user_id=identifier,
115
+ email=email,
116
+ name=name,
117
+ picture=picture,
118
+ )
119
+ await oauth_account.insert()
120
+ account.oauth_accounts.append(oauth_account)
121
+ await account.save()
122
+
123
+
124
+ async def _create_new_user_with_oauth(
125
+ provider: str, identifier: str, email: str, name: str, picture: str
126
+ ) -> UserModel:
127
+ """Create new user account with OAuth linking."""
128
+ # Create user account
129
+ oauth_account = OAuthAccountModel(
130
+ provider=provider,
131
+ provider_user_id=identifier,
132
+ email=email,
133
+ name=name,
134
+ picture=picture,
135
+ )
136
+ await oauth_account.insert()
137
+
138
+ account = UserModel(
139
+ email=email,
140
+ name=name,
141
+ picture=picture,
142
+ oauth_accounts=[oauth_account],
143
+ )
144
+ await account.insert()
145
+
146
+ return account
147
+
148
+
149
+ def _create_auth_login_handler(provider_name: str):
150
+ async def auth_login(request: Request, next: str | None = None):
151
+ redirect_uri = request.url_for(f"auth_with_{provider_name}")
152
+ request.session["next_url"] = next or get_homepage_url(request)
153
+ client = oauth.create_client(provider_name)
154
+ if not client:
155
+ return RedirectResponse(url=get_homepage_url(request))
156
+
157
+ return await client.authorize_redirect(
158
+ request, redirect_uri, hl=request.state.language
159
+ )
160
+
161
+ return auth_login
162
+
163
+
164
+ def _create_auth_handler(provider_name: str):
165
+ async def auth_handler(request: Request):
166
+ """Handle OAuth authentication flow."""
167
+ try:
168
+ # Initialize OAuth client
169
+ client = oauth.create_client(provider_name)
170
+ if not client:
171
+ return get_homepage_url(request)
172
+
173
+ # Get user info from OAuth provider
174
+ token = await client.authorize_access_token(request)
175
+ userinfo = token.get("userinfo")
176
+ if not userinfo:
177
+ raise OAuthError("No userinfo found in token")
178
+
179
+ # Extract user data
180
+ identifier = userinfo.get(PROVIDER_IDENTIFIERS[provider_name])
181
+ email = userinfo.get("email")
182
+ name = userinfo.get("name")
183
+ picture = userinfo.get("picture")
184
+
185
+ # Handle user account creation/linking
186
+ account = await _handle_user_account(
187
+ provider_name, identifier, email, name, picture
188
+ )
189
+
190
+ # Set session and redirect
191
+ request.session["user"] = account.session_dict
192
+ return request.session.pop("next_url", get_homepage_url(request))
193
+ except OAuthError:
194
+ return get_homepage_url(request)
195
+
196
+ return auth_handler
@@ -0,0 +1,12 @@
1
+ from fastapi import Request
2
+
3
+
4
+ def get_homepage_url(request: Request, path_only: bool = True) -> str:
5
+ """Get homepage URL for the current language."""
6
+ try:
7
+ url = request.url_for("homepage", lang=request.state.language)
8
+ except Exception:
9
+ # Fallback to default language if the requested language is not available
10
+ url = request.url_for("homepage")
11
+
12
+ return url.path if path_only else str(url)