From 14ea7b964f65460c08f39d42e1621aabd1db22fc Mon Sep 17 00:00:00 2001 From: AAGaming Date: Sat, 4 May 2024 22:39:30 -0400 Subject: implement fetch and external resource request apis --- backend/decky_loader/helpers.py | 21 +++--- backend/decky_loader/plugin/imports/decky.py | 2 +- backend/decky_loader/plugin/imports/decky.pyi | 2 +- backend/decky_loader/plugin/plugin.py | 1 + backend/decky_loader/utilities.py | 94 ++++++++++++++++++++++++++- backend/decky_loader/wsrouter.py | 2 +- 6 files changed, 108 insertions(+), 14 deletions(-) (limited to 'backend/decky_loader') diff --git a/backend/decky_loader/helpers.py b/backend/decky_loader/helpers.py index f4005cc5..21ba5ce5 100644 --- a/backend/decky_loader/helpers.py +++ b/backend/decky_loader/helpers.py @@ -1,3 +1,4 @@ +from platform import version import re import ssl import uuid @@ -14,7 +15,7 @@ from aiohttp import ClientSession from .localplatform import localplatform from .enums import UserType from logging import getLogger -from packaging.version import Version +from packaging.version import Version # type: ignore REMOTE_DEBUGGER_UNIT = "steam-web-debug-portforward.service" @@ -36,12 +37,13 @@ def get_csrf_token(): @middleware async def csrf_middleware(request: Request, handler: Handler): if str(request.method) == "OPTIONS" or \ - request.headers.get('Authentication') == csrf_token or \ + request.headers.get('X-Decky-Auth') == csrf_token or \ str(request.rel_url) == "/auth/token" or \ str(request.rel_url).startswith("/plugins/load_main/") or \ str(request.rel_url).startswith("/static/") or \ str(request.rel_url).startswith("/steam_resource/") or \ str(request.rel_url).startswith("/frontend/") or \ + str(request.rel_url.path) == "/fetch" or \ str(request.rel_url.path) == "/ws" or \ assets_regex.match(str(request.rel_url)) or \ dist_regex.match(str(request.rel_url)) or \ @@ -61,24 +63,27 @@ def mkdir_as_user(path: str): localplatform.chown(path) # Fetches the version of loader +# TODO THIS IS ABSOLUTELY TERRIBLE AND NEVER SHOULDVE BEEN MERGED! packaging HAS NO TYPES AND WE COULD LITERALLY JUST USE A REGEX!!!!! REWRITE THIS!!!!!!!!!!!!! def get_loader_version() -> str: try: # Normalize Python-style version to conform to Decky style - v = Version(importlib.metadata.version("decky_loader")) + v = Version(importlib.metadata.version("decky_loader")) # type: ignore - version_str = f'v{v.major}.{v.minor}.{v.micro}' + version_str = f'v{v.major}.{v.minor}.{v.micro}' # type: ignore - if v.pre: - version_str += f'-pre{v.pre[1]}' + if v.pre: # type: ignore + version_str += f'-pre{v.pre[1]}' # type: ignore - if v.post: - version_str += f'-dev{v.post}' + if v.post: # type: ignore + version_str += f'-dev{v.post}' # type: ignore return version_str except Exception as e: logger.warn(f"Failed to execute get_loader_version(): {str(e)}") return "unknown" +user_agent = f"Decky/{get_loader_version()} (https://decky.xyz)" + # returns the appropriate system python paths def get_system_pythonpaths() -> list[str]: try: diff --git a/backend/decky_loader/plugin/imports/decky.py b/backend/decky_loader/plugin/imports/decky.py index 6c5173de..384c3860 100644 --- a/backend/decky_loader/plugin/imports/decky.py +++ b/backend/decky_loader/plugin/imports/decky.py @@ -12,7 +12,7 @@ Some basic migration helpers are available: `migrate_any`, `migrate_settings`, ` A logging facility `logger` is available which writes to the recommended location. """ -__version__ = '0.1.0' +__version__ = '1.0.0' import os import subprocess diff --git a/backend/decky_loader/plugin/imports/decky.pyi b/backend/decky_loader/plugin/imports/decky.pyi index 50a0f66c..a72c74c0 100644 --- a/backend/decky_loader/plugin/imports/decky.pyi +++ b/backend/decky_loader/plugin/imports/decky.pyi @@ -12,7 +12,7 @@ Some basic migration helpers are available: `migrate_any`, `migrate_settings`, ` A logging facility `logger` is available which writes to the recommended location. """ -__version__ = '0.1.0' +__version__ = '1.0.0' import logging diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py index 6fa86858..00b5dad8 100644 --- a/backend/decky_loader/plugin/plugin.py +++ b/backend/decky_loader/plugin/plugin.py @@ -47,6 +47,7 @@ class PluginWrapper: self.emitted_event_callback: EmittedEventCallbackType = emit_callback + # TODO enable this after websocket release self.legacy_method_warning = False def __str__(self) -> str: diff --git a/backend/decky_loader/utilities.py b/backend/decky_loader/utilities.py index 174b7cb0..d7d16f04 100644 --- a/backend/decky_loader/utilities.py +++ b/backend/decky_loader/utilities.py @@ -1,6 +1,8 @@ from __future__ import annotations from os import stat_result import uuid +from urllib.parse import unquote +from json.decoder import JSONDecodeError from os.path import splitext import re from traceback import format_exc @@ -8,6 +10,7 @@ from stat import FILE_ATTRIBUTE_HIDDEN # type: ignore from asyncio import StreamReader, StreamWriter, start_server, gather, open_connection from aiohttp import ClientSession +from aiohttp.web import Request, StreamResponse, Response, json_response, post from typing import TYPE_CHECKING, Callable, Coroutine, Dict, Any, List, TypedDict from logging import getLogger @@ -26,12 +29,17 @@ class FilePickerObj(TypedDict): filest: stat_result is_dir: bool +decky_header_regex = re.compile("X-Decky-(.*)") +extra_header_regex = re.compile("X-Decky-Header-(.*)") + +excluded_default_headers = ["Host", "Origin", "Sec-Fetch-Site", "Sec-Fetch-Mode", "Sec-Fetch-Dest"] + class Utilities: def __init__(self, context: PluginManager) -> None: self.context = context self.legacy_util_methods: Dict[str, Callable[..., Coroutine[Any, Any, Any]]] = { "ping": self.ping, - "http_request": self.http_request, + "http_request": self.http_request_legacy, "install_plugin": self.install_plugin, "install_plugins": self.install_plugins, "cancel_plugin_install": self.cancel_plugin_install, @@ -76,9 +84,33 @@ class Utilities: context.ws.add_route("utilities/enable_rdt", self.enable_rdt) context.ws.add_route("utilities/get_tab_id", self.get_tab_id) context.ws.add_route("utilities/get_user_info", self.get_user_info) - context.ws.add_route("utilities/http_request", self.http_request) + context.ws.add_route("utilities/http_request", self.http_request_legacy) context.ws.add_route("utilities/_call_legacy_utility", self._call_legacy_utility) + context.web_app.add_routes([ + post("/methods/{method_name}", self._handle_legacy_server_method_call) + ]) + + for method in ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'): + context.web_app.router.add_route(method, "/fetch", self.http_request) + + + async def _handle_legacy_server_method_call(self, request: Request) -> Response: + method_name = request.match_info["method_name"] + try: + args = await request.json() + except JSONDecodeError: + args = {} + res = {} + try: + r = await self.legacy_util_methods[method_name](**args) + res["result"] = r + res["success"] = True + except Exception as e: + res["result"] = str(e) + res["success"] = False + return json_response(res) + async def _call_legacy_utility(self, method_name: str, kwargs: Dict[Any, Any]) -> Any: self.logger.debug(f"Calling utility {method_name} with legacy kwargs"); res: Dict[Any, Any] = {} @@ -114,7 +146,63 @@ class Utilities: async def uninstall_plugin(self, name: str): return await self.context.plugin_browser.uninstall_plugin(name) - async def http_request(self, method: str, url: str, extra_opts: Any = {}): + # Loosely based on https://gist.github.com/mosquito/4dbfacd51e751827cda7ec9761273e95#file-proxy-py + async def http_request(self, req: Request) -> StreamResponse: + if req.headers.get('X-Decky-Auth', '') != helpers.get_csrf_token() and req.query.get('auth', '') != helpers.get_csrf_token(): + return Response(text='Forbidden', status=403) + + url = req.headers["X-Decky-Fetch-URL"] if "X-Decky-Fetch-URL" in req.headers else unquote(req.query.get('fetch_url', '')) + self.logger.info(f"Preparing {req.method} request to {url}") + + headers = dict(req.headers) + + headers["User-Agent"] = helpers.user_agent + + for excluded_header in excluded_default_headers: + self.logger.debug(f"Excluding default header {excluded_header}") + if excluded_header in headers: + del headers[excluded_header] + + if "X-Decky-Fetch-Excluded-Headers" in req.headers: + for excluded_header in req.headers["X-Decky-Fetch-Excluded-Headers"].split(", "): + self.logger.debug(f"Excluding header {excluded_header}") + if excluded_header in headers: + del headers[excluded_header] + + for header in req.headers: + match = extra_header_regex.search(header) + if match: + header_name = match.group(1) + header_value = req.headers[header] + self.logger.debug(f"Adding extra header {header_name}: {header_value}") + headers[header_name] = header_value + + for header in list(headers.keys()): + match = decky_header_regex.search(header) + if match: + self.logger.debug(f"Removing decky header {header} from request") + del headers[header] + + self.logger.debug(f"Final request headers: {headers}") + + body = await req.read() # TODO can this also be streamed? + + async with ClientSession() as web: + async with web.request(req.method, url, headers=headers, data=body, ssl=helpers.get_ssl_context()) as web_res: + res = StreamResponse(headers=web_res.headers, status=web_res.status) + if web_res.headers.get('Transfer-Encoding', '').lower() == 'chunked': + res.enable_chunked_encoding() + + await res.prepare(req) + self.logger.debug(f"Starting stream for {url}") + async for data in web_res.content.iter_any(): + await res.write(data) + if data: + await res.drain() + self.logger.debug(f"Finished stream for {url}") + return res + + async def http_request_legacy(self, method: str, url: str, extra_opts: Any = {}): async with ClientSession() as web: res = await web.request(method, url, ssl=helpers.get_ssl_context(), **extra_opts) text = await res.text() diff --git a/backend/decky_loader/wsrouter.py b/backend/decky_loader/wsrouter.py index 9cd98a1c..1ceeedd4 100644 --- a/backend/decky_loader/wsrouter.py +++ b/backend/decky_loader/wsrouter.py @@ -117,7 +117,7 @@ class WSRouter: create_task(self._call_route(data["route"], data["args"], data["id"])) else: error = {"error":f'Route {data["route"]} does not exist.', "name": "RouteNotFoundError", "traceback": None} - create_task(self.write({"type": MessageType.ERROR.value, "id": data["id"], "message": error})) + create_task(self.write({"type": MessageType.ERROR.value, "id": data["id"], "error": error})) case _: self.logger.error("Unknown message type", data) finally: -- cgit v1.2.3