diff --git a/CHANGELOG.md b/CHANGELOG.md index 67f4f61767..1115a2e4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ **Compatibility:** Apps built with `flet build` and the macOS desktop client now require macOS 12 or later, and iOS apps require iOS 15 or later. Custom build templates keep building, because Flutter 3.47 raises the deployment targets during the build; apply the same `Podfile` and Xcode project changes to skip that step. See the [macOS 12 and iOS 15 minimum](/docs/updates/breaking-changes/v1-1-0/minimum-macos-12-ios-15) guide. +* Fix Flet web apps using the `hash` route URL strategy returning `index.html` with a `200 OK` for unknown paths instead of a `404` ([#2796](https://github.com/flet-dev/flet/issues/2796), [#6954](https://github.com/flet-dev/flet/pull/6954)) by @ndonkoHenri. +* Fix `flet.fastapi.app()` and `ft.run(..., export_asgi_app=True)` failing every request with a `500` error when `route_url_strategy` or `web_renderer` is passed as a string, such as `"hash"` ([#6954](https://github.com/flet-dev/flet/pull/6954)) by @ndonkoHenri. +* Fix `page.login(redirect_to_page=True)` not returning to the page's route with the `hash` route URL strategy or under a `proxy_path` ([#6954](https://github.com/flet-dev/flet/pull/6954)) by @ndonkoHenri. * flet-audio: Fix `Audio` failing to play when it is created without `src` and `src` is set later ([#6133](https://github.com/flet-dev/flet/issues/6133), [#6956](https://github.com/flet-dev/flet/pull/6956)) by @ndonkoHenri. * flet-audio: Fix `Audio` ignoring `playback_rate` after `src` changes on web ([#6956](https://github.com/flet-dev/flet/pull/6956)) by @ndonkoHenri. * flet-audio-recorder: Fix `AudioRecorder.start_recording()` not recording to `output_path` on desktop, Android and iOS, where it returned `False`, failed, wrote no file, or recorded into the assets directory ([#5999](https://github.com/flet-dev/flet/issues/5999), [#6955](https://github.com/flet-dev/flet/pull/6955)) by @ndonkoHenri. diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/app.py index d532a890e3..dcc931f671 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/app.py @@ -92,6 +92,18 @@ def app( fastapi_app = FastAPI() + static_files = FletStaticFiles( + proxy_path=proxy_path, + assets_dir=assets_dir, + app_name=app_name, + app_short_name=app_short_name, + app_description=app_description, + web_renderer=web_renderer, + route_url_strategy=route_url_strategy, + websocket_endpoint_path=websocket_endpoint, + no_cdn=no_cdn, + ) + @fastapi_app.websocket(f"/{websocket_endpoint}") async def app_handler(websocket: WebSocket): """ @@ -109,6 +121,8 @@ async def app_handler(websocket: WebSocket): oauth_state_timeout_seconds=oauth_state_timeout_seconds, upload_endpoint_path=upload_endpoint_path, secret_key=secret_key, + route_url_strategy=static_files.route_url_strategy, + proxy_path=proxy_path, ).handle(websocket) if upload_dir: @@ -133,20 +147,7 @@ async def oauth_redirect_handler(request: Request): """ return await FletOAuth().handle(request) - fastapi_app.mount( - path="/", - app=FletStaticFiles( - proxy_path=proxy_path, - assets_dir=assets_dir, - app_name=app_name, - app_short_name=app_short_name, - app_description=app_description, - web_renderer=web_renderer, - route_url_strategy=route_url_strategy, - websocket_endpoint_path=websocket_endpoint, - no_cdn=no_cdn, - ), - ) + fastapi_app.mount(path="/", app=static_files) # Add middleware for custom headers class CustomHeadersMiddleware(BaseHTTPMiddleware): diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py index d81b806a67..a3c32aae94 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py @@ -16,6 +16,7 @@ from flet.controls.base_control import BaseControl from flet.controls.context import _context_page, context from flet.controls.exceptions import FletPageDisconnectedException +from flet.controls.types import RouteUrlStrategy from flet.messaging.connection import Connection from flet.messaging.protocol import ( ControlEventBody, @@ -59,6 +60,9 @@ class FletApp(Connection): and redirecting to OAuth callback URL. upload_endpoint_path: Absolute URL of upload endpoint, e.g. `/upload`. secret_key: Secret key to sign upload requests. + route_url_strategy: Route URL strategy of the served web client. + proxy_path: URL prefix that a proxy in front of the app strips, as given + to `flet_web.fastapi.app()`; part of the page's base path. """ def __init__( @@ -71,8 +75,12 @@ def __init__( oauth_state_timeout_seconds: int = DEFAULT_FLET_OAUTH_STATE_TIMEOUT, upload_endpoint_path: Optional[str] = None, secret_key: Optional[str] = None, + route_url_strategy: RouteUrlStrategy = RouteUrlStrategy.PATH, + proxy_path: Optional[str] = None, ): super().__init__() + self.route_url_strategy = route_url_strategy + self.__proxy_path = proxy_path self.__id = random_string(8) logger.info(f"New FletApp: {self.__id}") @@ -124,6 +132,10 @@ async def handle(self, websocket: WebSocket): self.pubsubhub = app_manager.get_pubsubhub(self.__main, loop=self.loop) self.page_url = str(websocket.url).rsplit("/", 1)[0] self.page_name = websocket.url.path.rsplit("/", 1)[0].lstrip("/") + mount_path = ( + (self.__proxy_path or "") + websocket.scope.get("root_path", "") + ).strip("/") + self.page_base_path = f"/{mount_path}/" if mount_path else "/" if not self.__upload_endpoint_path: self.__upload_endpoint_path = ( diff --git a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_static_files.py b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_static_files.py index 8adde9f207..ea0fe5fd02 100644 --- a/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_static_files.py +++ b/sdk/python/packages/flet-web/src/flet_web/fastapi/flet_static_files.py @@ -69,8 +69,10 @@ class FletStaticFiles(StaticFiles): app_name: PWA application name. app_short_name: PWA application short name. app_description: PWA application description. - web_renderer: Type of web renderer. - route_url_strategy: Routing URL strategy. + web_renderer: Type of web renderer, as a `WebRenderer` or its value, e.g. + `"canvaskit"`. + route_url_strategy: Routing URL strategy, as a `RouteUrlStrategy` or its + value, e.g. `"hash"`. no_cdn: Whether not load CanvasKit, Pyodide, and fonts from CDN. websocket_endpoint_path: Path of Flet app WebSocket handler. A path without a leading `/` is resolved against the app mount path. @@ -97,8 +99,8 @@ def __init__( self.__app_name = app_name self.__app_short_name = app_short_name self.__app_description = app_description - self.__web_renderer = web_renderer - self.__route_url_strategy = route_url_strategy + self.__web_renderer = WebRenderer(web_renderer) + self.__route_url_strategy = RouteUrlStrategy(route_url_strategy) self.__no_cdn = no_cdn self.__websocket_endpoint_path = websocket_endpoint_path self.__once = Once() @@ -119,6 +121,14 @@ def __init__( logger.info(f"Route URL strategy configured: {self.__route_url_strategy}") logger.info(f"No CDN configured: {self.__no_cdn}") + @property + def route_url_strategy(self) -> RouteUrlStrategy: + """ + The route URL strategy written into the served `index.html`: the + `route_url_strategy` argument, or `FLET_WEB_ROUTE_URL_STRATEGY` if set. + """ + return self.__route_url_strategy + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await self.__once.do(self.__config, scope["root_path"]) await super().__call__(scope, receive, send) @@ -126,9 +136,16 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: def lookup_path(self, path: str) -> tuple[str, Optional[os.stat_result]]: """Resolve a static file path, with SPA fallback for client-side routes. - Route-like paths (no extension, or `.html`) that don't match a file - fall back to `index.html` so the Flutter client can handle routing. - Asset-like paths (any other extension) resolve to a real `404`. + With the `path` route URL strategy, route-like paths (no extension, or + `.html`) that don't match a file fall back to `index.html` so the + Flutter client can handle routing. Asset-like paths (any other + extension) resolve to a real `404`. + + With the `hash` strategy, routes live in the URL fragment and never + reach the server, so there is no fallback: any path that doesn't match + a file resolves to a real `404` (with a `404.html` from the assets + directory as its body, if there is one), while the mount root still + serves `index.html`. Args: path: Requested path, relative to the mounted static root @@ -141,7 +158,10 @@ def lookup_path(self, path: str) -> tuple[str, Optional[os.stat_result]]: logger.debug(f"StaticFiles.lookup_path: {self.__app_mount_path} {path}") full_path, stat_result = super().lookup_path(path) - if stat_result is not None: + if ( + stat_result is not None + or self.__route_url_strategy == RouteUrlStrategy.HASH + ): return full_path, stat_result # Not found: SPA fallback only for route-like paths. diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index d937f9a3bb..a7d182503e 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -53,6 +53,7 @@ DeviceOrientation, Locale, PagePlatform, + RouteUrlStrategy, Wrapper, ) from flet.utils import is_pyodide @@ -1204,10 +1205,56 @@ async def login( authorization: "Optional[type[AT]]" = None, ) -> "AT": """ - Starts OAuth flow. + Signs the user in with an OAuth provider. - See [Authentication](https://flet.dev/docs/cookbook/authentication) + Without `saved_token`, this starts the OAuth authorization code flow: it + opens the provider's authorization page and returns without waiting for + the user to sign in. The provider then sends the browser to its + `redirect_url`, and Flet exchanges the authorization code for a token, + fetches the user if requested and calls [`on_login`][flet.Page.on_login]. + Handling that redirect needs the app to be served by Flet's web server. + + With `saved_token`, this restores an earlier sign-in instead, without + opening any page: it refreshes the token if it has expired and can be + refreshed, fetches the user if requested and calls `on_login`. + + See the [Authentication](https://flet.dev/docs/cookbook/authentication) guide for more information and examples. + + Args: + provider: The OAuth provider to sign in with. + fetch_user: Whether to fetch the signed-in user's details, available + as the authorization's `user`. + fetch_groups: Whether to also fetch the user's groups into + `user.groups`. Groups are fetched only together with the user. + Of the built-in providers, only GitHub provides them; with the + others, `groups` is empty. + scope: Scopes to request in addition to the provider's default ones. + saved_token: A token from an earlier sign-in to restore, as returned + by [`OAuthToken.to_json()`][flet.auth.OAuthToken.to_json]. + on_open_authorization_url: Async function that opens the + authorization URL it receives. By default, a web app opens it in + a popup window, and other apps open it with + [`UrlLauncher.launch_url()`][flet.UrlLauncher.launch_url]. + complete_page_html: HTML of the page that the browser shows when the + provider redirects back, if `redirect_to_page` is `False`. By + default, the page tries to close itself and asks the user to + close it. + redirect_to_page: Whether to send the browser back to the app's + current page and route when the provider redirects back, instead + of showing `complete_page_html`. Use it in web apps that open the + authorization page in the same browser tab, for example with + `UrlLauncher().launch_url(url, web_only_window_name="_self")` in + `on_open_authorization_url`. + authorization: Class that performs the authorization. It is created + as `authorization(provider, fetch_user=..., fetch_groups=..., + scope=...)`. Defaults to + [`AuthorizationService`][flet.auth.AuthorizationService]. + + Returns: + The authorization, also available as [`auth`][flet.Page.auth]. + Without `saved_token`, it has no token or user until `on_login` + is called. """ if authorization is None: authorization = _default_authorization_impl() @@ -1218,15 +1265,27 @@ async def login( scope=scope, ) if saved_token is None: + # The server keeps the state and completion options until the + # provider redirects back, then calls _authorize_callback(). authorization_url, state = self.__authorization.get_authorization_data() auth_attrs = {"state": state} if complete_page_html: auth_attrs["completePageHtml"] = complete_page_html if redirect_to_page: - up = urlparse(provider.redirect_url) - auth_attrs["completePageUrl"] = up._replace( - path=f"{self.session.connection.page_name}{self.route}" - ).geturl() + # The current page on the redirect URL's host, with the route + # in the URL path or after `#`, as the client expects it. + connection = self.session.connection + base_path = connection.page_base_path + redirect_url = urlparse(provider.redirect_url) + if connection.route_url_strategy == RouteUrlStrategy.HASH: + complete_page_url = redirect_url._replace( + path=base_path, query="", fragment=self.route + ) + else: + complete_page_url = redirect_url._replace( + path=base_path.rstrip("/") + self.route + ) + auth_attrs["completePageUrl"] = complete_page_url.geturl() self.session.connection.oauth_authorize(auth_attrs) if on_open_authorization_url: await on_open_authorization_url(authorization_url) @@ -1238,6 +1297,7 @@ async def login( else: await UrlLauncher().launch_url(authorization_url) else: + # Restore the saved sign-in; there is no redirect to wait for. await self.__authorization.dehydrate_token(saved_token) e = LoginEvent(name="login", control=self, error="", error_description="") diff --git a/sdk/python/packages/flet/src/flet/messaging/connection.py b/sdk/python/packages/flet/src/flet/messaging/connection.py index fd7a1e7584..4fc87b8139 100644 --- a/sdk/python/packages/flet/src/flet/messaging/connection.py +++ b/sdk/python/packages/flet/src/flet/messaging/connection.py @@ -3,6 +3,7 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional +from flet.controls.types import RouteUrlStrategy from flet.messaging.protocol import Message from flet.pubsub.pubsub_hub import PubSubHub @@ -21,11 +22,16 @@ class Connection: def __init__(self): self.page_name: str = "" self.page_url: Optional[str] = None - # True when the connected client runs on the same machine (or in the - # same process), so DataChannel bytes move at local-IPC / memcpy - # speed. Widgets may use this to prefer uncompressed payloads over - # encoded ones. + self.page_base_path: str = "/" + """Base path of the page in the browser URL, e.g. `/sub-app/`, which is the + `` of the served web client.""" + self.route_url_strategy: RouteUrlStrategy = RouteUrlStrategy.PATH + """Whether the client keeps routes in the URL path (`/store`) + or after `#` (`/#/store`).""" self.local_data_transport: bool = False + """`True` when the connected client runs on the same machine (or in the + same process), so DataChannel bytes move at local-IPC / memcpy speed. + Widgets may use this to prefer uncompressed payloads over encoded ones.""" self.__pubsubhub = None self.__loop: Optional[AbstractEventLoop] = None self.__executor: Optional[ThreadPoolExecutor] = None diff --git a/website/docs/cookbook/navigation-and-routing.md b/website/docs/cookbook/navigation-and-routing.md index 546c39f2b8..80cb042868 100644 --- a/website/docs/cookbook/navigation-and-routing.md +++ b/website/docs/cookbook/navigation-and-routing.md @@ -144,7 +144,7 @@ import flet as ft ft.run(main, route_url_strategy="hash") ``` -For Flet server deployments, you can also set the [`FLET_ROUTE_URL_STRATEGY`](../reference/environment-variables.md#flet_web_route_url_strategy) +For Flet server deployments, you can also set the [`FLET_WEB_ROUTE_URL_STRATEGY`](../reference/environment-variables.md#flet_web_route_url_strategy) environment variable. ## Declarative Router