mirror of
https://github.com/open-webui/open-terminal.git
synced 2026-11-04 08:10:53 +00:00
refac
This commit is contained in:
1 parent
13afaba925
commit
1c894e0e8a
4 files changed
+461
-90
No files matched your search
+224
-40
@@ -1,4 +1,5 @@
|
||||
"""Headless Chrome tabs. CDP stays on private pipes, never a shared TCP port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -21,8 +22,20 @@ log = logging.getLogger(__name__)
|
||||
def find_browser():
|
||||
configured = os.environ.get("OPEN_TERMINAL_BROWSER_EXECUTABLE")
|
||||
candidates = [configured] if configured else []
|
||||
candidates += [shutil.which(name) for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "msedge")]
|
||||
candidates += ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"]
|
||||
candidates += [
|
||||
shutil.which(name)
|
||||
for name in (
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"msedge",
|
||||
)
|
||||
]
|
||||
candidates += [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
]
|
||||
return next((p for p in candidates if p and Path(p).is_file()), None)
|
||||
|
||||
|
||||
@@ -43,7 +56,9 @@ class Host:
|
||||
future = self.pending.pop(message["id"], None)
|
||||
if future and not future.done():
|
||||
if "error" in message:
|
||||
future.set_exception(RuntimeError(message["error"]["message"]))
|
||||
future.set_exception(
|
||||
RuntimeError(message["error"]["message"])
|
||||
)
|
||||
else:
|
||||
future.set_result(message.get("result", {}))
|
||||
elif handler := self.handlers.get(message.get("sessionId")):
|
||||
@@ -91,6 +106,7 @@ class Host:
|
||||
self.reader.cancel()
|
||||
await asyncio.gather(self.reader, return_exceptions=True)
|
||||
|
||||
|
||||
hosts = {}
|
||||
host_lock = asyncio.Lock()
|
||||
|
||||
@@ -101,71 +117,118 @@ async def get_host(session):
|
||||
return hosts[session.owner]
|
||||
binary = find_browser()
|
||||
if not binary:
|
||||
raise RuntimeError("Chrome mode requires Chromium. Install it or set OPEN_TERMINAL_BROWSER_EXECUTABLE.")
|
||||
raise RuntimeError(
|
||||
"Chrome mode requires Chromium. Install it or set OPEN_TERMINAL_BROWSER_EXECUTABLE."
|
||||
)
|
||||
if os.name == "nt":
|
||||
raise RuntimeError("Managed Chrome currently requires a POSIX host")
|
||||
# Let Chrome create its own profile as the same OS user as the terminal.
|
||||
suffix = hashlib.sha256(session.owner.encode()).hexdigest()[:24]
|
||||
profile = str(Path(session.fs.home) / ".local/state/open-terminal/browser" / suffix)
|
||||
args = [binary, "--headless=new", "--remote-debugging-pipe", f"--user-data-dir={profile}",
|
||||
"--no-first-run", "--no-default-browser-check", "--disable-dev-shm-usage", "about:blank"]
|
||||
profile = str(
|
||||
Path(session.fs.home) / ".local/state/open-terminal/browser" / suffix
|
||||
)
|
||||
args = [
|
||||
binary,
|
||||
"--headless=new",
|
||||
"--remote-debugging-pipe",
|
||||
f"--user-data-dir={profile}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-dev-shm-usage",
|
||||
"about:blank",
|
||||
]
|
||||
# Chrome's pipe transport uses fd 3/4. A tiny exec shim maps subprocess stdin/stdout.
|
||||
shim = "import os,sys; os.dup2(0,3); os.dup2(1,4); os.execv(sys.argv[1],sys.argv[1:])"
|
||||
command = [sys.executable, "-c", shim, *args]
|
||||
kwargs = {}
|
||||
if session.fs.username:
|
||||
import pwd
|
||||
|
||||
account = pwd.getpwnam(session.fs.username)
|
||||
if os.getuid() == 0:
|
||||
kwargs.update(user=account.pw_uid, group=account.pw_gid, extra_groups=[])
|
||||
kwargs.update(
|
||||
user=account.pw_uid, group=account.pw_gid, extra_groups=[]
|
||||
)
|
||||
else:
|
||||
command = ["sudo", "-H", "-n", "-u", session.fs.username, "--", *command]
|
||||
process = await asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
||||
env=sanitized_environ({"HOME": session.fs.home}), limit=16 * 1024 * 1024, **kwargs)
|
||||
command = [
|
||||
"sudo",
|
||||
"-H",
|
||||
"-n",
|
||||
"-u",
|
||||
session.fs.username,
|
||||
"--",
|
||||
*command,
|
||||
]
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
env=sanitized_environ({"HOME": session.fs.home}),
|
||||
limit=16 * 1024 * 1024,
|
||||
**kwargs,
|
||||
)
|
||||
host = Host(process)
|
||||
try:
|
||||
await host.send("Browser.getVersion")
|
||||
except Exception:
|
||||
await host.close()
|
||||
raise RuntimeError("Chrome could not start. Check its installation, OS user, and sandbox support.")
|
||||
raise RuntimeError(
|
||||
"Chrome could not start. Check its installation, OS user, and sandbox support."
|
||||
)
|
||||
hosts[session.owner] = host
|
||||
return host
|
||||
|
||||
|
||||
async def shutdown_hosts():
|
||||
await asyncio.gather(*(host.close() for host in hosts.values()), return_exceptions=True)
|
||||
await asyncio.gather(
|
||||
*(host.close() for host in hosts.values()), return_exceptions=True
|
||||
)
|
||||
hosts.clear()
|
||||
|
||||
|
||||
class Chrome:
|
||||
def __init__(self, session, host, target, cdp_session):
|
||||
self.session, self.host, self.target, self.cdp_session = session, host, target, cdp_session
|
||||
self.session, self.host, self.target, self.cdp_session = (
|
||||
session,
|
||||
host,
|
||||
target,
|
||||
cdp_session,
|
||||
)
|
||||
self.peer = None
|
||||
self.frame = asyncio.Queue(maxsize=1)
|
||||
self.state_changed = asyncio.Event()
|
||||
self.width, self.height = 1024, 768
|
||||
self.quality = 65
|
||||
self.visible = True
|
||||
self.navigation = None
|
||||
self.error = ""
|
||||
host.handlers[cdp_session] = self.event
|
||||
|
||||
@classmethod
|
||||
async def create(cls, session):
|
||||
host = await get_host(session)
|
||||
chrome = None
|
||||
target = (await host.send("Target.createTarget", {"url": "about:blank"}))["targetId"]
|
||||
target = (await host.send("Target.createTarget", {"url": "about:blank"}))[
|
||||
"targetId"
|
||||
]
|
||||
try:
|
||||
attached = await host.send("Target.attachToTarget", {"targetId": target, "flatten": True})
|
||||
attached = await host.send(
|
||||
"Target.attachToTarget", {"targetId": target, "flatten": True}
|
||||
)
|
||||
chrome = cls(session, host, target, attached["sessionId"])
|
||||
await chrome.send("Page.enable")
|
||||
await chrome.send("Runtime.enable")
|
||||
await chrome.send("Page.addScriptToEvaluateOnNewDocument", {"source": """
|
||||
await chrome.send(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
{"source": """
|
||||
window.open = url => { if (url) location.assign(url); return null; };
|
||||
addEventListener('click', e => {const a=e.target.closest?.('a[target]');
|
||||
if(a && a.target !== '_self'){e.preventDefault(); location.assign(a.href);}},true);
|
||||
"""})
|
||||
"""},
|
||||
)
|
||||
if session.url:
|
||||
await chrome.send("Page.navigate", {"url": target_url(session.url)})
|
||||
chrome.navigate(session.url)
|
||||
return chrome
|
||||
except BaseException:
|
||||
if chrome:
|
||||
@@ -177,51 +240,105 @@ class Chrome:
|
||||
async def send(self, method, params=None):
|
||||
return await self.host.send(method, params, self.cdp_session)
|
||||
|
||||
def navigate(self, url):
|
||||
url = target_url(url)
|
||||
if self.navigation:
|
||||
self.navigation.cancel()
|
||||
self.error = ""
|
||||
|
||||
async def load():
|
||||
try:
|
||||
result = await self.send("Page.navigate", {"url": url})
|
||||
self.error = result.get("errorText", "")
|
||||
except RuntimeError as exc:
|
||||
self.error = str(exc)
|
||||
finally:
|
||||
self.state_changed.set()
|
||||
|
||||
# Slow sites must not block opening the viewer or its navigation controls.
|
||||
self.navigation = asyncio.create_task(load())
|
||||
|
||||
def event(self, method, params):
|
||||
if method == "Page.screencastFrame":
|
||||
# Chrome applies backpressure until the frame is acknowledged.
|
||||
if self.frame.full():
|
||||
self.frame.get_nowait()
|
||||
self.frame.put_nowait(params)
|
||||
elif method in ("Page.frameNavigated", "Page.navigatedWithinDocument", "Page.loadEventFired"):
|
||||
elif method in (
|
||||
"Page.frameNavigated",
|
||||
"Page.navigatedWithinDocument",
|
||||
"Page.loadEventFired",
|
||||
):
|
||||
if method == "Page.loadEventFired":
|
||||
self.error = ""
|
||||
self.state_changed.set()
|
||||
|
||||
async def state(self):
|
||||
history = await self.send("Page.getNavigationHistory")
|
||||
entries, index = history["entries"], history["currentIndex"]
|
||||
entry = entries[index] if entries else {}
|
||||
self.session.url = entry.get("url", self.session.url)
|
||||
url = entry.get("url", "")
|
||||
if url.startswith(("http://", "https://")):
|
||||
self.session.url = url
|
||||
self.session.title = entry.get("title", "")
|
||||
return {"type": "state", "url": self.session.url, "title": self.session.title,
|
||||
"canGoBack": index > 0, "canGoForward": index < len(entries)-1}
|
||||
return {
|
||||
"type": "state",
|
||||
"url": self.session.url,
|
||||
"title": self.session.title,
|
||||
"error": self.error,
|
||||
"canGoBack": index > 0,
|
||||
"canGoForward": index < len(entries) - 1,
|
||||
}
|
||||
|
||||
async def capture(self):
|
||||
await self.send("Page.stopScreencast")
|
||||
if not self.visible:
|
||||
return
|
||||
await self.send("Page.startScreencast", {"format": "jpeg", "quality": self.quality,
|
||||
"maxWidth": self.width, "maxHeight": self.height, "everyNthFrame": 1})
|
||||
await self.send(
|
||||
"Page.startScreencast",
|
||||
{
|
||||
"format": "jpeg",
|
||||
"quality": self.quality,
|
||||
"maxWidth": self.width,
|
||||
"maxHeight": self.height,
|
||||
"everyNthFrame": 1,
|
||||
},
|
||||
)
|
||||
|
||||
async def stream(self, ws: WebSocket):
|
||||
await ws.accept()
|
||||
if self.peer:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.peer.close(code=4000, reason="Browser opened in another viewer")
|
||||
await self.peer.close(
|
||||
code=4000, reason="Browser opened in another viewer"
|
||||
)
|
||||
self.peer = ws
|
||||
tasks = []
|
||||
try:
|
||||
await ws.send_json(await self.state())
|
||||
await self.capture()
|
||||
|
||||
async def frames():
|
||||
while True:
|
||||
frame = await self.frame.get()
|
||||
await ws.send_json({"type": "frame", "data": frame["data"], "width": self.width, "height": self.height})
|
||||
await self.send("Page.screencastFrameAck", {"sessionId": frame["sessionId"]})
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "frame",
|
||||
"data": frame["data"],
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
)
|
||||
await self.send(
|
||||
"Page.screencastFrameAck", {"sessionId": frame["sessionId"]}
|
||||
)
|
||||
|
||||
async def states():
|
||||
while True:
|
||||
await self.state_changed.wait()
|
||||
self.state_changed.clear()
|
||||
await ws.send_json(await self.state())
|
||||
|
||||
async def commands():
|
||||
while True:
|
||||
msg = await ws.receive_json()
|
||||
@@ -229,12 +346,15 @@ class Chrome:
|
||||
continue
|
||||
kind = msg.get("type")
|
||||
if kind == "navigate":
|
||||
await self.send("Page.navigate", {"url": target_url(str(msg.get("url", "")))})
|
||||
self.navigate(str(msg.get("url", "")))
|
||||
elif kind in ("back", "forward"):
|
||||
history = await self.send("Page.getNavigationHistory")
|
||||
index = history["currentIndex"] + (-1 if kind == "back" else 1)
|
||||
if 0 <= index < len(history["entries"]):
|
||||
await self.send("Page.navigateToHistoryEntry", {"entryId": history["entries"][index]["id"]})
|
||||
await self.send(
|
||||
"Page.navigateToHistoryEntry",
|
||||
{"entryId": history["entries"][index]["id"]},
|
||||
)
|
||||
elif kind == "reload":
|
||||
await self.send("Page.reload")
|
||||
elif kind == "visibility":
|
||||
@@ -244,31 +364,92 @@ class Chrome:
|
||||
self.width = max(240, min(2560, int(msg.get("width", 1024))))
|
||||
self.height = max(200, min(2160, int(msg.get("height", 768))))
|
||||
mobile = msg.get("mobile") is True
|
||||
await self.send("Emulation.setDeviceMetricsOverride", {"width": self.width, "height": self.height, "deviceScaleFactor": 1, "mobile": mobile})
|
||||
await self.send("Emulation.setTouchEmulationEnabled", {"enabled": mobile})
|
||||
await self.send("Emulation.setUserAgentOverride", {"userAgent": "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36" if mobile else ""})
|
||||
await self.send(
|
||||
"Emulation.setDeviceMetricsOverride",
|
||||
{
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"deviceScaleFactor": 1,
|
||||
"mobile": mobile,
|
||||
},
|
||||
)
|
||||
await self.send(
|
||||
"Emulation.setTouchEmulationEnabled", {"enabled": mobile}
|
||||
)
|
||||
await self.send(
|
||||
"Emulation.setUserAgentOverride",
|
||||
{
|
||||
"userAgent": (
|
||||
"Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36"
|
||||
if mobile
|
||||
else ""
|
||||
)
|
||||
},
|
||||
)
|
||||
await self.capture()
|
||||
elif kind == "quality":
|
||||
self.quality = {"low": 40, "balanced": 65, "crisp": 90}.get(msg.get("quality"), 65)
|
||||
self.quality = {"low": 40, "balanced": 65, "crisp": 90}.get(
|
||||
msg.get("quality"), 65
|
||||
)
|
||||
await self.capture()
|
||||
elif kind == "mouse":
|
||||
event = {k: msg[k] for k in ("type", "x", "y", "button", "buttons", "clickCount", "deltaX", "deltaY", "modifiers") if k in msg}
|
||||
event = {
|
||||
k: msg[k]
|
||||
for k in (
|
||||
"type",
|
||||
"x",
|
||||
"y",
|
||||
"button",
|
||||
"buttons",
|
||||
"clickCount",
|
||||
"deltaX",
|
||||
"deltaY",
|
||||
"modifiers",
|
||||
)
|
||||
if k in msg
|
||||
}
|
||||
event["type"] = msg.get("event")
|
||||
if event["type"] in ("mousePressed", "mouseReleased", "mouseMoved", "mouseWheel"):
|
||||
if event["type"] in (
|
||||
"mousePressed",
|
||||
"mouseReleased",
|
||||
"mouseMoved",
|
||||
"mouseWheel",
|
||||
):
|
||||
await self.send("Input.dispatchMouseEvent", event)
|
||||
elif kind == "key":
|
||||
event = {k: msg[k] for k in ("key", "code", "text", "modifiers", "windowsVirtualKeyCode", "commands") if k in msg}
|
||||
event = {
|
||||
k: msg[k]
|
||||
for k in (
|
||||
"key",
|
||||
"code",
|
||||
"text",
|
||||
"modifiers",
|
||||
"windowsVirtualKeyCode",
|
||||
"commands",
|
||||
)
|
||||
if k in msg
|
||||
}
|
||||
event["type"] = msg.get("event")
|
||||
if event["type"] in ("keyDown", "keyUp"):
|
||||
await self.send("Input.dispatchKeyEvent", event)
|
||||
elif kind == "paste":
|
||||
await self.send("Input.insertText", {"text": str(msg.get("text", ""))[:1_000_000]})
|
||||
tasks = [asyncio.create_task(frames()), asyncio.create_task(states()), asyncio.create_task(commands())]
|
||||
await self.send(
|
||||
"Input.insertText",
|
||||
{"text": str(msg.get("text", ""))[:1_000_000]},
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(frames()),
|
||||
asyncio.create_task(states()),
|
||||
asyncio.create_task(commands()),
|
||||
]
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
except (WebSocketDisconnect, RuntimeError, ValueError, TypeError):
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.close(code=1011)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.close()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -278,6 +459,9 @@ class Chrome:
|
||||
await self.send("Page.stopScreencast")
|
||||
|
||||
async def close(self):
|
||||
if self.navigation:
|
||||
self.navigation.cancel()
|
||||
await asyncio.gather(self.navigation, return_exceptions=True)
|
||||
if self.peer:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.peer.close()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Browser resource rewriting, adapted from Computer's browser proxy."""
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
@@ -8,10 +9,16 @@ from urllib.parse import quote, urljoin, urlsplit, urlunsplit
|
||||
_URL_ATTRIBUTES = {"href", "src", "action", "poster", "data", "formaction"}
|
||||
_SKIP_SCHEMES = ("data:", "javascript:", "mailto:", "tel:", "about:", "blob:")
|
||||
|
||||
|
||||
def proxy_path(prefix: str, url: str) -> str:
|
||||
"""Return the same-origin URL used by the Browser iframe for an upstream URL."""
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise ValueError("Only HTTP(S) URLs can be opened in Browser")
|
||||
host = quote(parsed.netloc, safe=":[]")
|
||||
path = quote(parsed.path or "/", safe="/%:@!$&'()*+,;=-._~")
|
||||
@@ -22,10 +29,23 @@ def proxy_path(prefix: str, url: str) -> str:
|
||||
|
||||
def target_url(url: str) -> str:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise ValueError("Only HTTP(S) URLs can be opened in Browser")
|
||||
parsed.port # Validate malformed port numbers before dispatching a request.
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", parsed.query, parsed.fragment))
|
||||
return urlunsplit(
|
||||
(
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
parsed.path or "/",
|
||||
parsed.query,
|
||||
parsed.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def rewrite_url(value: str, base_url: str, prefix: str) -> str:
|
||||
@@ -92,7 +112,9 @@ class _HtmlRewriter(HTMLParser):
|
||||
r"(?i)(url\s*=\s*)([^;]+)",
|
||||
lambda match: (
|
||||
match.group(1)
|
||||
+ rewrite_url(match.group(2).strip(), self.base_url, self.prefix)
|
||||
+ rewrite_url(
|
||||
match.group(2).strip(), self.base_url, self.prefix
|
||||
)
|
||||
),
|
||||
value,
|
||||
)
|
||||
@@ -112,13 +134,17 @@ def rewrite_html(content: str, base_url: str, prefix: str, final_url: str) -> st
|
||||
parser.feed(content)
|
||||
parser.close()
|
||||
config = json.dumps({"prefix": prefix, "url": final_url}).replace("<", "\\u003c")
|
||||
runtime = (f"<script>window.__terminalBrowser={config};</script>"
|
||||
f'<script src="{html.escape(prefix, quote=True)}/runtime.js"></script>')
|
||||
runtime = (
|
||||
f"<script>window.__terminalBrowser={config};</script>"
|
||||
f'<script src="{html.escape(prefix, quote=True)}/runtime.js"></script>'
|
||||
)
|
||||
rendered = "".join(parser.output)
|
||||
if urlsplit(final_url).hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||
rendered = rewrite_javascript(rendered, final_url, prefix)
|
||||
if re.search(r"<head(?:\s[^>]*)?>", rendered, re.I):
|
||||
return re.sub(r"(<head(?:\s[^>]*)?>)", r"\1" + runtime, rendered, count=1, flags=re.I)
|
||||
return re.sub(
|
||||
r"(<head(?:\s[^>]*)?>)", r"\1" + runtime, rendered, count=1, flags=re.I
|
||||
)
|
||||
return runtime + rendered
|
||||
|
||||
|
||||
@@ -127,7 +153,9 @@ def rewrite_css(content: str, base_url: str, prefix: str) -> str:
|
||||
quote, value = match.group(1), match.group(2)
|
||||
return f"url({quote}{rewrite_url(value, base_url, prefix)}{quote})"
|
||||
|
||||
content = re.sub(r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", replace_url, content, flags=re.I)
|
||||
content = re.sub(
|
||||
r"url\(\s*(['\"]?)([^'\")]+)\1\s*\)", replace_url, content, flags=re.I
|
||||
)
|
||||
return re.sub(
|
||||
r"(@import\s+(?:url\()?['\"])([^'\"]+)",
|
||||
lambda match: match.group(1) + rewrite_url(match.group(2), base_url, prefix),
|
||||
@@ -139,10 +167,11 @@ def rewrite_css(content: str, base_url: str, prefix: str) -> str:
|
||||
def rewrite_javascript(content: str, base_url: str, prefix: str) -> str:
|
||||
"""Rewrite root-relative ES module imports used by local dev servers."""
|
||||
# Vite builds hot-update imports dynamically from `base`, outside import declarations.
|
||||
if urlsplit(base_url).path.endswith('/@vite/client'):
|
||||
if urlsplit(base_url).path.endswith("/@vite/client"):
|
||||
content = re.sub(
|
||||
r'(\bconst base\s*=\s*)(["\'])(/[^"\']*)\2',
|
||||
lambda match: match[1] + json.dumps(proxy_path(prefix, urljoin(base_url, match[3]))),
|
||||
lambda match: match[1]
|
||||
+ json.dumps(proxy_path(prefix, urljoin(base_url, match[3]))),
|
||||
content,
|
||||
)
|
||||
return re.sub(
|
||||
@@ -150,4 +179,3 @@ def rewrite_javascript(content: str, base_url: str, prefix: str) -> str:
|
||||
lambda match: match.group(1) + rewrite_url(match.group(2), base_url, prefix),
|
||||
content,
|
||||
)
|
||||
|
||||
+196
-37
@@ -1,11 +1,12 @@
|
||||
"""Owned browser sessions and capability-scoped iframe/stream endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import posixpath
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -13,17 +14,31 @@ from urllib.parse import urljoin, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from open_terminal.dependencies import get_filesystem, verify_api_key
|
||||
from open_terminal.utils.fs import UserFS
|
||||
from .rewrite import proxy_path, rewrite_css, rewrite_html, rewrite_javascript, target_url
|
||||
from .rewrite import (
|
||||
proxy_path,
|
||||
rewrite_css,
|
||||
rewrite_html,
|
||||
rewrite_javascript,
|
||||
target_url,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/browser", include_in_schema=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
id: str
|
||||
@@ -39,6 +54,7 @@ class Session:
|
||||
peers: set = field(default_factory=set)
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
class BrowserCORSMiddleware(CORSMiddleware):
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("path", "").startswith("/browser/view/"):
|
||||
@@ -69,9 +85,28 @@ async def reap_idle():
|
||||
if time.monotonic() - session.touched > IDLE_TIMEOUT:
|
||||
await remove(session)
|
||||
|
||||
|
||||
profiles: dict[str, httpx.AsyncClient] = {}
|
||||
HOP = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"}
|
||||
SKIP_RESPONSE = HOP | {"content-encoding", "content-length", "content-security-policy", "content-security-policy-report-only", "x-frame-options", "set-cookie", "access-control-allow-origin", "access-control-allow-credentials"}
|
||||
HOP = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
}
|
||||
SKIP_RESPONSE = HOP | {
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"x-frame-options",
|
||||
"set-cookie",
|
||||
"access-control-allow-origin",
|
||||
"access-control-allow-credentials",
|
||||
}
|
||||
|
||||
|
||||
def owner(request):
|
||||
@@ -80,7 +115,11 @@ def owner(request):
|
||||
|
||||
def owned(request, sid):
|
||||
session = sessions.get(sid)
|
||||
if not session or session.owner != owner(request) or session.chat != request.headers.get("x-session-id", ""):
|
||||
if (
|
||||
not session
|
||||
or session.owner != owner(request)
|
||||
or session.chat != request.headers.get("x-session-id", "")
|
||||
):
|
||||
raise HTTPException(404, "Browser session not found")
|
||||
session.touched = time.monotonic()
|
||||
return session
|
||||
@@ -96,13 +135,21 @@ def capability(sid, token):
|
||||
|
||||
def payload(session):
|
||||
from .chrome import find_browser
|
||||
return {"id": session.id, "token": session.token, "url": session.url, "title": session.title,
|
||||
"mode": session.mode, "chrome_available": find_browser() is not None}
|
||||
|
||||
return {
|
||||
"id": session.id,
|
||||
"token": session.token,
|
||||
"url": session.url,
|
||||
"title": session.title,
|
||||
"mode": session.mode,
|
||||
"chrome_available": find_browser() is not None,
|
||||
}
|
||||
|
||||
|
||||
class CreateSession(BaseModel):
|
||||
url: str = Field(default="", max_length=16384)
|
||||
|
||||
|
||||
class UpdateSession(BaseModel):
|
||||
url: str | None = Field(default=None, max_length=16384)
|
||||
title: str | None = Field(default=None, max_length=512)
|
||||
@@ -117,7 +164,9 @@ def valid_url(url):
|
||||
|
||||
|
||||
@router.post("/sessions", dependencies=[Depends(verify_api_key)])
|
||||
async def create(request: Request, body: CreateSession, fs: UserFS = Depends(get_filesystem)):
|
||||
async def create(
|
||||
request: Request, body: CreateSession, fs: UserFS = Depends(get_filesystem)
|
||||
):
|
||||
global reaper
|
||||
if reaper is None or reaper.done():
|
||||
reaper = asyncio.create_task(reap_idle())
|
||||
@@ -125,10 +174,19 @@ async def create(request: Request, body: CreateSession, fs: UserFS = Depends(get
|
||||
raise HTTPException(409, "Close a browser tab before opening another")
|
||||
if body.url:
|
||||
valid_url(body.url)
|
||||
session = Session(secrets.token_hex(16), secrets.token_urlsafe(32), owner(request), request.headers.get("x-session-id", ""), fs, url=body.url)
|
||||
session = Session(
|
||||
secrets.token_hex(16),
|
||||
secrets.token_urlsafe(32),
|
||||
owner(request),
|
||||
request.headers.get("x-session-id", ""),
|
||||
fs,
|
||||
url=body.url,
|
||||
)
|
||||
sessions[session.id] = session
|
||||
if session.owner not in profiles:
|
||||
profiles[session.owner] = httpx.AsyncClient(follow_redirects=False, timeout=httpx.Timeout(60, connect=10))
|
||||
profiles[session.owner] = httpx.AsyncClient(
|
||||
follow_redirects=False, timeout=httpx.Timeout(60, connect=10)
|
||||
)
|
||||
return payload(session)
|
||||
|
||||
|
||||
@@ -151,6 +209,7 @@ async def update(request: Request, sid: str, body: UpdateSession):
|
||||
session.title = body.title
|
||||
if body.mode == "chrome" and session.chrome is None:
|
||||
from .chrome import Chrome
|
||||
|
||||
try:
|
||||
session.chrome = await Chrome.create(session)
|
||||
except Exception as exc:
|
||||
@@ -176,53 +235,109 @@ async def shutdown():
|
||||
reaper.cancel()
|
||||
await asyncio.gather(reaper, return_exceptions=True)
|
||||
reaper = None
|
||||
await asyncio.gather(*(s.chrome.close() for s in sessions.values() if s.chrome), return_exceptions=True)
|
||||
await asyncio.gather(
|
||||
*(s.chrome.close() for s in sessions.values() if s.chrome),
|
||||
return_exceptions=True,
|
||||
)
|
||||
sessions.clear()
|
||||
await asyncio.gather(*(p.aclose() for p in profiles.values()), return_exceptions=True)
|
||||
await asyncio.gather(
|
||||
*(p.aclose() for p in profiles.values()), return_exceptions=True
|
||||
)
|
||||
profiles.clear()
|
||||
from .chrome import shutdown_hosts
|
||||
|
||||
await shutdown_hosts()
|
||||
|
||||
|
||||
def view_prefix(request, sid, token):
|
||||
# The authenticated WebUI relay supplies the public prefix; direct clients use this route.
|
||||
return request.headers.get("x-browser-prefix") or f"/browser/view/{sid}/{token}"
|
||||
# Relative references survive both direct access and the WebUI relay unchanged.
|
||||
return posixpath.relpath(
|
||||
f"/browser/view/{sid}/{token}", posixpath.dirname(request.url.path)
|
||||
)
|
||||
|
||||
|
||||
def response_headers(upstream):
|
||||
return {**{k: v for k, v in upstream.headers.items() if k.lower() not in SKIP_RESPONSE},
|
||||
"access-control-allow-origin": "*", "referrer-policy": "no-referrer", "cache-control": "no-store",
|
||||
# Keep remote pages isolated even when their URL is opened outside the iframe.
|
||||
"content-security-policy": "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"}
|
||||
return {
|
||||
**{k: v for k, v in upstream.headers.items() if k.lower() not in SKIP_RESPONSE},
|
||||
"access-control-allow-origin": "*",
|
||||
"referrer-policy": "no-referrer",
|
||||
"cache-control": "no-store",
|
||||
# Keep remote pages isolated even when their URL is opened outside the iframe.
|
||||
"content-security-policy": "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/view/{sid}/{token}/runtime.js")
|
||||
async def runtime(sid: str, token: str):
|
||||
capability(sid, token)
|
||||
return Response(Path(__file__).with_name("runtime.js").read_text(), media_type="application/javascript", headers={"access-control-allow-origin": "*", "cache-control": "no-store"})
|
||||
return Response(
|
||||
Path(__file__).with_name("runtime.js").read_text(),
|
||||
media_type="application/javascript",
|
||||
headers={"access-control-allow-origin": "*", "cache-control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/view/{sid}/{token}/frame/{scheme}/{host}/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
||||
async def frame(request: Request, sid: str, token: str, scheme: str, host: str, path: str):
|
||||
@router.api_route(
|
||||
"/view/{sid}/{token}/frame/{scheme}/{host}/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
||||
)
|
||||
async def frame(
|
||||
request: Request, sid: str, token: str, scheme: str, host: str, path: str
|
||||
):
|
||||
session = capability(sid, token)
|
||||
url = valid_url(urlunsplit((scheme, host, "/" + path, str(request.query_params), "")))
|
||||
url = valid_url(
|
||||
urlunsplit((scheme, host, "/" + path, str(request.query_params), ""))
|
||||
)
|
||||
prefix = view_prefix(request, sid, token)
|
||||
if request.method == "OPTIONS" and request.headers.get("access-control-request-method"):
|
||||
return Response(headers={"access-control-allow-origin": "*", "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS", "access-control-allow-headers": request.headers.get("access-control-request-headers", "")})
|
||||
headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP | {"host", "cookie", "authorization", "accept-encoding", "content-length", "origin", "referer"} and not k.lower().startswith("x-")}
|
||||
if request.method == "OPTIONS" and request.headers.get(
|
||||
"access-control-request-method"
|
||||
):
|
||||
return Response(
|
||||
headers={
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS",
|
||||
"access-control-allow-headers": request.headers.get(
|
||||
"access-control-request-headers", ""
|
||||
),
|
||||
}
|
||||
)
|
||||
headers = {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower()
|
||||
not in HOP
|
||||
| {
|
||||
"host",
|
||||
"cookie",
|
||||
"authorization",
|
||||
"accept-encoding",
|
||||
"content-length",
|
||||
"origin",
|
||||
"referer",
|
||||
}
|
||||
and not k.lower().startswith("x-")
|
||||
}
|
||||
headers["accept-encoding"] = "gzip, deflate"
|
||||
if request.headers.get("origin"):
|
||||
headers["origin"] = f"{scheme}://{host}"
|
||||
client = profiles[session.owner]
|
||||
try:
|
||||
upstream = await client.send(client.build_request(request.method, url, headers=headers, content=request.stream()), stream=True)
|
||||
upstream = await client.send(
|
||||
client.build_request(
|
||||
request.method, url, headers=headers, content=request.stream()
|
||||
),
|
||||
stream=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, "Cannot connect to this website") from exc
|
||||
output_headers = response_headers(upstream)
|
||||
if location := upstream.headers.get("location"):
|
||||
output_headers["location"] = proxy_path(prefix, urljoin(url, location))
|
||||
content_type = upstream.headers.get("content-type", "")
|
||||
rewrite = any(t in content_type for t in ("text/html", "application/xhtml", "text/css", "javascript"))
|
||||
rewrite = any(
|
||||
t in content_type
|
||||
for t in ("text/html", "application/xhtml", "text/css", "javascript")
|
||||
)
|
||||
if rewrite:
|
||||
try:
|
||||
data = bytearray()
|
||||
@@ -237,17 +352,25 @@ async def frame(request: Request, sid: str, token: str, scheme: str, host: str,
|
||||
text = rewrite_css(text, url, prefix)
|
||||
else:
|
||||
text = rewrite_javascript(text, url, prefix)
|
||||
output_headers["content-type"] = content_type.split(";")[0] + "; charset=utf-8"
|
||||
return Response(text, status_code=upstream.status_code, headers=output_headers)
|
||||
output_headers["content-type"] = (
|
||||
content_type.split(";")[0] + "; charset=utf-8"
|
||||
)
|
||||
return Response(
|
||||
text, status_code=upstream.status_code, headers=output_headers
|
||||
)
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
|
||||
async def stream():
|
||||
try:
|
||||
async for chunk in upstream.aiter_bytes():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
return StreamingResponse(stream(), status_code=upstream.status_code, headers=output_headers)
|
||||
|
||||
return StreamingResponse(
|
||||
stream(), status_code=upstream.status_code, headers=output_headers
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/view/{sid}/{token}/ws")
|
||||
@@ -266,26 +389,62 @@ async def proxy_socket(ws: WebSocket, sid: str, token: str):
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in ("ws", "wss"):
|
||||
raise ValueError("Invalid WebSocket URL")
|
||||
http_url = valid_url(urlunsplit(("https" if parsed.scheme == "wss" else "http", parsed.netloc, parsed.path, parsed.query, "")))
|
||||
http_url = valid_url(
|
||||
urlunsplit(
|
||||
(
|
||||
"https" if parsed.scheme == "wss" else "http",
|
||||
parsed.netloc,
|
||||
parsed.path,
|
||||
parsed.query,
|
||||
"",
|
||||
)
|
||||
)
|
||||
)
|
||||
protocols = message.get("protocols") or []
|
||||
if isinstance(protocols, str):
|
||||
protocols = [protocols]
|
||||
prepared = profiles[session.owner].build_request("GET", http_url)
|
||||
headers = {"cookie": prepared.headers["cookie"]} if "cookie" in prepared.headers else None
|
||||
async with websockets.connect(url, subprotocols=protocols, additional_headers=headers, max_size=16 * 1024 * 1024) as upstream:
|
||||
headers = (
|
||||
{"cookie": prepared.headers["cookie"]}
|
||||
if "cookie" in prepared.headers
|
||||
else None
|
||||
)
|
||||
async with websockets.connect(
|
||||
url,
|
||||
subprotocols=protocols,
|
||||
additional_headers=headers,
|
||||
max_size=16 * 1024 * 1024,
|
||||
) as upstream:
|
||||
await ws.send_json({"type": "open", "protocol": upstream.subprotocol or ""})
|
||||
|
||||
async def send():
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
return
|
||||
await upstream.send(msg.get("bytes") if msg.get("bytes") is not None else msg["text"])
|
||||
await upstream.send(
|
||||
msg.get("bytes")
|
||||
if msg.get("bytes") is not None
|
||||
else msg["text"]
|
||||
)
|
||||
|
||||
async def receive():
|
||||
async for msg in upstream:
|
||||
await (ws.send_bytes(msg) if isinstance(msg, bytes) else ws.send_text(msg))
|
||||
await (
|
||||
ws.send_bytes(msg)
|
||||
if isinstance(msg, bytes)
|
||||
else ws.send_text(msg)
|
||||
)
|
||||
|
||||
tasks = [asyncio.create_task(send()), asyncio.create_task(receive())]
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
except (WebSocketDisconnect, ValueError, TimeoutError, httpx.HTTPError, websockets.WebSocketException):
|
||||
except (
|
||||
WebSocketDisconnect,
|
||||
ValueError,
|
||||
TimeoutError,
|
||||
httpx.HTTPError,
|
||||
websockets.WebSocketException,
|
||||
):
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.close(code=1011)
|
||||
finally:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
const config = window.__terminalBrowser;
|
||||
if (!config) return;
|
||||
|
||||
const prefix = `${config.prefix}/frame`;
|
||||
const api = config.prefix;
|
||||
const api = new URL(config.prefix + "/", location.href).pathname.replace(/\/$/, "");
|
||||
const prefix = `${api}/frame`;
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
const nativeWebSocket = window.WebSocket;
|
||||
|
||||
|
||||
Reference in new issue
Block a user