This commit is contained in:
Timothy Jaeryang Baek
2026-03-23 21:14:22 -05:00
parent a3238aa79f
commit 6890618221
4 changed files with 378 additions and 21 deletions
+6
View File
@@ -31,14 +31,20 @@ def _sanitize_proxy_path(path: str) -> str | None:
"""Sanitize a proxy path to prevent directory traversal / SSRF.
Returns the cleaned path, or None if the path is invalid.
Trailing slashes are preserved — many upstream frameworks treat
``/path`` and ``/path/`` differently.
"""
decoded = unquote(path)
had_trailing_slash = decoded.endswith('/')
normalized = posixpath.normpath(decoded)
# Remove any leading slashes that would reset the base
cleaned = normalized.lstrip('/')
# Reject if normpath resolved to parent traversal or current-dir only
if cleaned.startswith('..') or cleaned == '.':
return None
# Restore trailing slash if the original path had one
if had_trailing_slash and cleaned and not cleaned.endswith('/'):
cleaned += '/'
return cleaned
+22 -3
View File
@@ -40,6 +40,7 @@
import FilePreview from './FileNav/FilePreview.svelte';
import FileEntryRow from './FileNav/FileEntryRow.svelte';
import PortList from './FileNav/PortList.svelte';
import PortPreview from './FileNav/PortPreview.svelte';
import XTerminal from './XTerminal.svelte';
const i18n = getContext('i18n');
@@ -90,6 +91,7 @@
// ── File preview state ───────────────────────────────────────────────
let selectedFile: string | null = null;
let previewPort: number | null = null;
let fileContent: string | null = null;
let fileImageUrl: string | null = null;
let fileVideoUrl: string | null = null;
@@ -253,6 +255,7 @@
loading = true;
error = null;
selectedFile = null;
previewPort = null;
clearFilePreview();
currentPath = path;
savedPath = path;
@@ -632,6 +635,7 @@
</div>
{/if}
{#if previewPort === null}
<FileNavToolbar
breadcrumbs={buildBreadcrumbs(currentPath)}
{selectedFile}
@@ -911,10 +915,17 @@
</button>
</Tooltip>
</FileNavToolbar>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto min-h-0 min-w-0">
{#if selectedFile !== null}
{#if previewPort !== null}
<PortPreview
baseUrl={selectedTerminal?.url ?? ''}
port={previewPort}
onClose={() => { previewPort = null; }}
/>
{:else if selectedFile !== null}
<FilePreview
bind:this={filePreviewRef}
bind:editing
@@ -1039,9 +1050,17 @@
</div>
<!-- Port detection -->
{#if selectedTerminal && !selectedFile}
{#if selectedTerminal && !selectedFile && previewPort === null}
<div class="shrink-0 border-t border-gray-100 dark:border-gray-800">
<PortList baseUrl={selectedTerminal.url} apiKey={selectedTerminal.key} />
<PortList
baseUrl={selectedTerminal.url}
apiKey={selectedTerminal.key}
on:previewPort={(e) => {
selectedFile = null;
clearFilePreview();
previewPort = e.detail;
}}
/>
</div>
{/if}
+29 -18
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { onDestroy, getContext } from 'svelte';
import { onDestroy, getContext, createEventDispatcher } from 'svelte';
import type { ListeningPort } from '$lib/apis/terminal';
import { getListeningPorts, getPortProxyUrl } from '$lib/apis/terminal';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher<{ previewPort: number }>();
export let baseUrl: string;
export let apiKey: string;
@@ -33,7 +34,11 @@
}
};
const openPort = (port: number) => {
const previewPort = (port: number) => {
dispatch('previewPort', port);
};
const openPortExternal = (port: number) => {
const url = getPortProxyUrl(baseUrl, port);
window.open(url, '_blank', 'noopener,noreferrer');
};
@@ -107,7 +112,7 @@
{#each ports as port}
<button
class="flex items-center w-full gap-2 px-1.5 py-1 text-xs rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition group"
on:click={() => openPort(port.port)}
on:click={() => previewPort(port.port)}
>
<span class="font-mono text-blue-500 dark:text-blue-400 shrink-0">
:{port.port}
@@ -115,22 +120,28 @@
<span class="text-gray-500 dark:text-gray-400 truncate flex-1 text-left">
{port.process ?? ''}
</span>
<span
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition shrink-0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
<Tooltip content={$i18n.t('Open in new tab')}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
role="button"
tabindex="-1"
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 transition shrink-0 p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
on:click|stopPropagation={() => openPortExternal(port.port)}
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
</span>
</Tooltip>
</button>
{/each}
{/if}
@@ -0,0 +1,321 @@
<script lang="ts">
import { getContext } from 'svelte';
import { getPortProxyUrl } from '$lib/apis/terminal';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let baseUrl: string;
export let port: number;
export let path: string = '';
export let onClose: () => void = () => {};
let iframeEl: HTMLIFrameElement;
let urlInput: string = '';
let iframeKey = 0;
let isLoading = false;
// ── Navigation history ──────────────────────────────────────────────
let history: string[] = [path];
let historyIndex = 0;
$: canGoBack = historyIndex > 0;
$: canGoForward = historyIndex < history.length - 1;
const pushHistory = (newPath: string) => {
// Trim forward history when navigating from a non-tip position
if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1);
}
history = [...history, newPath];
historyIndex = history.length - 1;
};
const goBack = () => {
if (!canGoBack) return;
historyIndex -= 1;
path = history[historyIndex];
iframeKey += 1;
};
const goForward = () => {
if (!canGoForward) return;
historyIndex += 1;
path = history[historyIndex];
iframeKey += 1;
};
// ── URLs ─────────────────────────────────────────────────────────────
$: proxyUrl = getPortProxyUrl(baseUrl, port, path);
// The proxy path prefix, e.g. "/proxy/8080/"
$: proxyPathPrefix = (() => {
try {
return new URL(getPortProxyUrl(baseUrl, port, '')).pathname;
} catch {
return `/proxy/${port}/`;
}
})();
// Display-friendly localhost URL for the address bar
$: displayUrl = `localhost:${port}${path ? '/' + path : ''}`;
$: urlInput = displayUrl;
const refresh = () => {
iframeKey += 1;
};
const openExternal = () => {
window.open(proxyUrl, '_blank', 'noopener,noreferrer');
};
const navigateUrl = () => {
const localhostPrefix = `localhost:${port}`;
const stripped = urlInput.trim();
let newPath = '';
if (stripped.startsWith(localhostPrefix)) {
newPath = stripped.slice(localhostPrefix.length).replace(/^\//, '');
} else if (stripped.startsWith('/') || !stripped.includes(':')) {
newPath = stripped.replace(/^\//, '');
}
if (newPath !== path) {
path = newPath;
pushHistory(path);
}
iframeKey += 1;
};
/**
* After each iframe load:
* 1. Sync the address bar with the current iframe path
* 2. Inject a click interceptor to rewrite links BEFORE navigation
* 3. Redirect if the page already escaped the proxy (fallback)
*/
const onIframeLoad = () => {
isLoading = false;
if (!iframeEl) return;
try {
const doc = iframeEl.contentDocument;
const win = iframeEl.contentWindow;
if (!doc || !win) return;
const loc = win.location;
const iframePath = loc?.pathname ?? '';
const iframeSearch = loc?.search ?? '';
const iframeHash = loc?.hash ?? '';
if (iframePath.startsWith(proxyPathPrefix)) {
// Still inside the proxy — sync address bar
const relativePath = iframePath.slice(proxyPathPrefix.length) + iframeSearch + iframeHash;
if (relativePath !== path) {
path = relativePath;
pushHistory(path);
urlInput = displayUrl;
}
// Inject click interceptor to rewrite links before navigation
injectLinkInterceptor(doc, win);
} else if (iframePath && iframePath !== 'about:blank') {
// Escaped the proxy — redirect back through it
const escapedPath = iframePath.replace(/^\//, '') + iframeSearch + iframeHash;
const correctedUrl = getPortProxyUrl(baseUrl, port, escapedPath);
loc?.replace(correctedUrl);
}
} catch {
// Cross-origin — can't access
}
};
/**
* Inject a click interceptor into the iframe document that rewrites
* anchor hrefs to stay within the proxy path.
*/
const injectLinkInterceptor = (doc: Document, win: Window) => {
// Skip if we already injected
if ((win as any).__portPreviewIntercepted) return;
(win as any).__portPreviewIntercepted = true;
const prefix = proxyPathPrefix;
// Intercept all click events on anchors
doc.addEventListener('click', (e: MouseEvent) => {
const anchor = (e.target as HTMLElement)?.closest?.('a');
if (!anchor || !anchor.href) return;
try {
const url = new URL(anchor.href, win.location.href);
// Only intercept same-origin links that escaped the proxy
if (url.origin === win.location.origin && !url.pathname.startsWith(prefix)) {
e.preventDefault();
e.stopPropagation();
// Rewrite to go through proxy
const rewrittenPath = prefix + url.pathname.replace(/^\//, '') + url.search + url.hash;
win.location.href = url.origin + rewrittenPath;
}
} catch {
// Invalid URL — let browser handle it
}
}, true); // Use capture phase to intercept before SPA routers
// Also intercept form submissions
doc.addEventListener('submit', (e: Event) => {
const form = e.target as HTMLFormElement;
if (!form?.action) return;
try {
const url = new URL(form.action, win.location.href);
if (url.origin === win.location.origin && !url.pathname.startsWith(prefix)) {
form.action = url.origin + prefix + url.pathname.replace(/^\//, '') + url.search;
}
} catch {}
}, true);
// Intercept window.open and programmatic navigation
const origOpen = win.open.bind(win);
win.open = function(url?: string | URL, ...args: any[]) {
if (url && typeof url === 'string') {
try {
const parsed = new URL(url, win.location.href);
if (parsed.origin === win.location.origin && !parsed.pathname.startsWith(prefix)) {
url = parsed.origin + prefix + parsed.pathname.replace(/^\//, '') + parsed.search + parsed.hash;
}
} catch {}
}
return origOpen(url, ...args);
};
};
const onIframeLoadStart = () => {
isLoading = true;
};
</script>
<div class="flex flex-col h-full min-h-0">
<!-- Browser chrome -->
<div
class="flex items-center gap-1 px-1.5 py-1 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 shrink-0"
>
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="p-1 rounded transition {canGoBack
? 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
: 'text-gray-300 dark:text-gray-700 cursor-default'}"
on:click={goBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="p-1 rounded transition {canGoForward
? 'text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300'
: 'text-gray-300 dark:text-gray-700 cursor-default'}"
on:click={goForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</Tooltip>
<!-- Refresh / Stop -->
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={refresh}
aria-label={$i18n.t('Refresh')}
>
{#if isLoading}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5 animate-spin">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z" clip-rule="evenodd" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z" clip-rule="evenodd" />
</svg>
{/if}
</button>
</Tooltip>
<!-- URL bar -->
<form
class="flex-1 min-w-0"
on:submit|preventDefault={navigateUrl}
>
<input
type="text"
bind:value={urlInput}
class="w-full text-[11px] font-mono bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full px-3 py-1 outline-none focus:border-blue-400 dark:focus:border-blue-500 focus:ring-1 focus:ring-blue-400/20 text-gray-600 dark:text-gray-300 transition"
placeholder="localhost:{port}"
/>
</form>
<!-- Open in new tab -->
<Tooltip content={$i18n.t('Open in new tab')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={openExternal}
aria-label={$i18n.t('Open in new tab')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z" clip-rule="evenodd" />
</svg>
</button>
</Tooltip>
<!-- Close preview -->
<Tooltip content={$i18n.t('Close')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={onClose}
aria-label={$i18n.t('Close')}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</Tooltip>
</div>
<!-- Loading bar -->
{#if isLoading}
<div class="h-0.5 bg-gray-100 dark:bg-gray-800 shrink-0 overflow-hidden">
<div class="h-full bg-blue-500 animate-loading-bar rounded-full" />
</div>
{/if}
<!-- Iframe content -->
{#key iframeKey}
<iframe
bind:this={iframeEl}
src={proxyUrl}
title="Port {port} preview"
class="flex-1 w-full min-h-0 border-0 bg-white"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads"
on:load={onIframeLoad}
/>
{/key}
</div>
<style>
@keyframes loading-bar {
0% { width: 0; margin-left: 0; }
50% { width: 60%; margin-left: 20%; }
100% { width: 0; margin-left: 100%; }
}
.animate-loading-bar {
animation: loading-bar 1.5s ease-in-out infinite;
}
</style>