Arena Agent commited on
Commit
a250d07
·
1 Parent(s): e6da5aa

Replace remote browser with safe screenshot testing

Browse files
Files changed (6) hide show
  1. Dockerfile +11 -21
  2. README.md +12 -9
  3. app.py +512 -0
  4. browser-ui/index.html +0 -55
  5. requirements.txt +3 -0
  6. start-browser.sh +0 -57
Dockerfile CHANGED
@@ -1,31 +1,21 @@
1
- FROM debian:bookworm-slim
2
 
3
- ENV DEBIAN_FRONTEND=noninteractive \
4
- DISPLAY=:99 \
5
- LANG=C.UTF-8 \
6
- LC_ALL=C.UTF-8
7
 
8
  WORKDIR /app
9
 
10
  RUN apt-get update \
11
- && apt-get install -y --no-install-recommends \
12
- ca-certificates \
13
- chromium \
14
- fluxbox \
15
- fonts-liberation \
16
- fonts-noto-color-emoji \
17
- novnc \
18
- websockify \
19
- x11vnc \
20
- xvfb \
21
  && rm -rf /var/lib/apt/lists/*
22
 
23
- RUN mkdir -p /opt/browser-ui \
24
- && cp -a /usr/share/novnc/. /opt/browser-ui/
 
 
25
 
26
- COPY browser-ui/index.html /opt/browser-ui/index.html
27
- COPY start-browser.sh /usr/local/bin/start-browser.sh
28
- RUN chmod +x /usr/local/bin/start-browser.sh
29
 
30
  EXPOSE 7860
31
- CMD ["/usr/local/bin/start-browser.sh"]
 
1
+ FROM python:3.11-slim
2
 
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
 
6
 
7
  WORKDIR /app
8
 
9
  RUN apt-get update \
10
+ && apt-get install -y --no-install-recommends ca-certificates \
 
 
 
 
 
 
 
 
 
11
  && rm -rf /var/lib/apt/lists/*
12
 
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir --upgrade pip \
15
+ && pip install --no-cache-dir -r requirements.txt \
16
+ && python -m playwright install --with-deps chromium
17
 
18
+ COPY app.py .
 
 
19
 
20
  EXPOSE 7860
21
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "90", "app:app"]
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- title: Live Browser Lab
3
- emoji: 🌐
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
@@ -8,14 +8,17 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- # Live Browser Lab
12
 
13
- An interactive remote Chromium browser streamed through noVNC.
14
 
15
- ## Use
16
 
17
- 1. Open the Space and click **Open live browser**.
18
- 2. Use the visible Chromium address bar to search, type, click, scroll, and navigate.
19
- 3. Optional: add `BROWSER_PASSWORD` or `VNC_PASSWORD` in **Settings → Variables and secrets** to protect the VNC connection. Without a password, anyone with this public URL can control the browser.
 
 
 
20
 
21
- The browser starts on DuckDuckGo. This Space is public, so do not enter sensitive credentials or private information. Use it only for websites you are authorized to access.
 
1
  ---
2
+ title: Web Test Lab
3
+ emoji: 🧪
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
 
8
  pinned: false
9
  ---
10
 
11
+ # Web Test Lab
12
 
13
+ A safe screenshot-based website testing dashboard powered by Playwright and Chromium.
14
 
15
+ ## Features
16
 
17
+ - Test public HTTP and HTTPS websites
18
+ - Desktop, tablet, and mobile viewport profiles
19
+ - HTTP status, page title, load time, final URL, and screenshot
20
+ - Search page at `/search`
21
+ - Browser test page at `/`
22
+ - Health check at `/health`
23
 
24
+ The service returns screenshots and search results; it does not provide a remote interactive browser. Localhost and private-network addresses are blocked. Use it only on websites you own or are authorized to test.
app.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import ipaddress
3
+ import socket
4
+ import time
5
+ from threading import Lock
6
+ from time import perf_counter
7
+ from urllib.parse import parse_qs, urlencode, urlparse
8
+
9
+ from flask import Flask, jsonify, request
10
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
11
+ from playwright.sync_api import sync_playwright
12
+
13
+ app = Flask(__name__)
14
+ app.config["MAX_CONTENT_LENGTH"] = 32 * 1024
15
+
16
+ TEST_TIMEOUT_MS = 20_000
17
+ RATE_WINDOW_SECONDS = 60
18
+ RATE_LIMIT = 12
19
+ RATE_LOCK = Lock()
20
+ RATE_BUCKETS = {}
21
+ BROWSER_LOCK = Lock()
22
+ PLAYWRIGHT = None
23
+ BROWSER = None
24
+
25
+ DEVICES = {
26
+ "desktop": {"viewport": {"width": 1440, "height": 900}},
27
+ "tablet": {
28
+ "viewport": {"width": 834, "height": 1112},
29
+ "is_mobile": True,
30
+ "has_touch": True,
31
+ },
32
+ "mobile": {
33
+ "viewport": {"width": 390, "height": 844},
34
+ "is_mobile": True,
35
+ "has_touch": True,
36
+ },
37
+ }
38
+
39
+ INDEX_HTML = r"""
40
+ <!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width, initial-scale=1">
45
+ <title>Web Test Lab</title>
46
+ <style>
47
+ :root {
48
+ color-scheme: dark;
49
+ --bg: #090d18;
50
+ --panel: rgba(20, 27, 49, .82);
51
+ --panel-strong: #151d35;
52
+ --line: rgba(148, 163, 184, .18);
53
+ --text: #f8fafc;
54
+ --muted: #93a4c3;
55
+ --blue: #6d8cff;
56
+ --cyan: #39d9c4;
57
+ --danger: #ff7b91;
58
+ --shadow: 0 24px 70px rgba(0, 0, 0, .35);
59
+ }
60
+ * { box-sizing: border-box; }
61
+ body {
62
+ margin: 0;
63
+ min-height: 100vh;
64
+ color: var(--text);
65
+ font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
66
+ background:
67
+ radial-gradient(circle at 15% 0%, rgba(109, 140, 255, .24), transparent 33rem),
68
+ radial-gradient(circle at 95% 15%, rgba(57, 217, 196, .13), transparent 28rem),
69
+ var(--bg);
70
+ }
71
+ a { color: #9bb5ff; }
72
+ .shell { width: min(1160px, calc(100% - 36px)); margin: 0 auto; padding: 34px 0 54px; }
73
+ .topbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 46px; }
74
+ .brand { display: flex; align-items: center; gap: 12px; font-weight: 800; letter-spacing: .04em; }
75
+ .brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; color: #07111e; background: linear-gradient(135deg, var(--cyan), var(--blue)); box-shadow: 0 8px 26px rgba(57, 217, 196, .22); }
76
+ .brand small { display: block; color: var(--muted); font-weight: 500; letter-spacing: 0; }
77
+ .engine { display: inline-flex; align-items: center; gap: 8px; color: #b6c4de; font-size: 13px; }
78
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 14px var(--cyan); }
79
+ .hero { max-width: 760px; margin-bottom: 30px; }
80
+ .eyebrow { margin: 0 0 12px; color: var(--cyan); font-size: 12px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
81
+ h1 { max-width: 680px; margin: 0; font-size: clamp(2.4rem, 6vw, 4.8rem); line-height: .98; letter-spacing: -.065em; }
82
+ .hero p { max-width: 620px; margin: 20px 0 0; color: var(--muted); font-size: 17px; }
83
+ .workspace { display: grid; grid-template-columns: 360px minmax(0, 1fr); gap: 18px; align-items: start; }
84
+ .card { border: 1px solid var(--line); border-radius: 22px; background: var(--panel); box-shadow: var(--shadow); backdrop-filter: blur(18px); }
85
+ .controls { padding: 22px; position: sticky; top: 18px; }
86
+ .card-title { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; font-weight: 750; }
87
+ .badge { padding: 5px 9px; border: 1px solid rgba(57, 217, 196, .26); border-radius: 999px; color: var(--cyan); background: rgba(57, 217, 196, .08); font-size: 11px; font-weight: 700; }
88
+ label { display: block; margin: 18px 0 8px; color: #c8d4ea; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
89
+ input[type=url] { width: 100%; padding: 13px 14px; border: 1px solid var(--line); border-radius: 12px; outline: 0; color: var(--text); background: rgba(5, 10, 22, .64); font: inherit; }
90
+ input[type=url]:focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(109, 140, 255, .14); }
91
+ .presets { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
92
+ .preset, .device { border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: transparent; cursor: pointer; font: inherit; }
93
+ .preset { padding: 6px 9px; font-size: 12px; }
94
+ .preset:hover, .device:hover { border-color: var(--blue); color: var(--text); }
95
+ .devices { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
96
+ .device { padding: 10px 5px; }
97
+ .device.active { border-color: var(--blue); color: white; background: rgba(109, 140, 255, .16); }
98
+ .run { width: 100%; margin-top: 24px; padding: 14px 16px; border: 0; border-radius: 12px; color: #07111e; background: linear-gradient(100deg, var(--cyan), #85a0ff); box-shadow: 0 12px 26px rgba(66, 137, 255, .2); cursor: pointer; font: inherit; font-weight: 850; }
99
+ .run:disabled { opacity: .55; cursor: wait; }
100
+ .notice { margin: 18px 0 0; color: var(--muted); font-size: 12px; }
101
+ .results { padding: 22px; min-height: 480px; }
102
+ .result-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
103
+ .result-head h2 { margin: 0; font-size: 18px; }
104
+ #status { color: var(--muted); font-size: 13px; }
105
+ #status.error { color: var(--danger); }
106
+ #status.success { color: var(--cyan); }
107
+ .metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 18px; }
108
+ .metric { min-width: 0; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: rgba(7, 12, 27, .42); }
109
+ .metric-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; }
110
+ .metric-value { overflow: hidden; margin-top: 4px; font-size: 17px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
111
+ .preview { display: grid; place-items: center; min-height: 330px; overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: #0a1020; }
112
+ .preview.empty::before { content: "Your browser screenshot will appear here"; color: #60708f; }
113
+ #screenshot { display: block; width: 100%; max-height: 610px; object-fit: contain; }
114
+ .result-foot { display: flex; justify-content: space-between; gap: 12px; margin-top: 14px; color: var(--muted); font-size: 12px; }
115
+ .download { display: none; color: #b9c8ff; font-weight: 700; text-decoration: none; }
116
+ .download.show { display: inline; }
117
+ @media (max-width: 820px) { .workspace { grid-template-columns: 1fr; } .controls { position: static; } .topbar { margin-bottom: 32px; } }
118
+ @media (max-width: 520px) { .shell { width: min(100% - 24px, 1160px); padding-top: 20px; } .engine { display: none; } .metrics { grid-template-columns: 1fr; } .result-foot { flex-direction: column; } }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <main class="shell">
123
+ <header class="topbar">
124
+ <div class="brand"><div class="brand-mark">✦</div><div>WEB TEST LAB<small>Browser-powered checks</small></div></div>
125
+ <div class="engine"><a href="/search">Search web ↗</a><span class="dot"></span> Chromium ready</div>
126
+ </header>
127
+ <section class="hero">
128
+ <p class="eyebrow">Live website testing</p>
129
+ <h1>See how your site behaves before users do.</h1>
130
+ <p>Enter a public URL, choose a device profile, and run a real headless Chromium check with a visual screenshot. Need to find something? <a href="/search">Search the web</a>.</p>
131
+ </section>
132
+ <section class="workspace">
133
+ <aside class="card controls">
134
+ <div class="card-title"><span>Test setup</span><span class="badge">SECURE PREVIEW</span></div>
135
+ <form id="test-form">
136
+ <label for="url">Website URL</label>
137
+ <input id="url" type="url" value="https://example.com" placeholder="https://your-site.com" required>
138
+ <div class="presets">
139
+ <button class="preset" type="button" data-url="https://example.com">Example</button>
140
+ <button class="preset" type="button" data-url="https://huggingface.co">Hugging Face</button>
141
+ </div>
142
+ <label>Viewport</label>
143
+ <div class="devices">
144
+ <button class="device active" type="button" data-device="desktop">Desktop</button>
145
+ <button class="device" type="button" data-device="tablet">Tablet</button>
146
+ <button class="device" type="button" data-device="mobile">Mobile</button>
147
+ </div>
148
+ <button class="run" id="run" type="submit">Run browser test →</button>
149
+ </form>
150
+ <p class="notice">Only public HTTP and HTTPS websites are allowed. Use this tool only on sites you own or are authorized to test.</p>
151
+ </aside>
152
+ <section class="card results">
153
+ <div class="result-head"><h2>Test result</h2><span id="status">Ready when you are</span></div>
154
+ <div class="metrics">
155
+ <div class="metric"><div class="metric-label">HTTP status</div><div class="metric-value" id="http-status">—</div></div>
156
+ <div class="metric"><div class="metric-label">Load time</div><div class="metric-value" id="load-time">—</div></div>
157
+ <div class="metric"><div class="metric-label">Page title</div><div class="metric-value" id="page-title">—</div></div>
158
+ </div>
159
+ <div class="preview empty" id="preview"><img id="screenshot" alt="Website screenshot" hidden></div>
160
+ <div class="result-foot"><span id="final-url">No test run yet</span><a class="download" id="download" download="website-screenshot.png">Download screenshot</a></div>
161
+ </section>
162
+ </section>
163
+ </main>
164
+ <script>
165
+ const form = document.getElementById('test-form');
166
+ const urlInput = document.getElementById('url');
167
+ const runButton = document.getElementById('run');
168
+ const status = document.getElementById('status');
169
+ const preview = document.getElementById('preview');
170
+ const screenshot = document.getElementById('screenshot');
171
+ const download = document.getElementById('download');
172
+ const httpStatus = document.getElementById('http-status');
173
+ const loadTime = document.getElementById('load-time');
174
+ const pageTitle = document.getElementById('page-title');
175
+ const finalUrl = document.getElementById('final-url');
176
+ let device = 'desktop';
177
+ document.querySelectorAll('.device').forEach((button) => button.addEventListener('click', () => {
178
+ device = button.dataset.device;
179
+ document.querySelectorAll('.device').forEach((item) => item.classList.toggle('active', item === button));
180
+ }));
181
+ document.querySelectorAll('.preset').forEach((button) => button.addEventListener('click', () => { urlInput.value = button.dataset.url; }));
182
+ form.addEventListener('submit', async (event) => {
183
+ event.preventDefault();
184
+ runButton.disabled = true;
185
+ status.className = '';
186
+ status.textContent = 'Opening Chromium…';
187
+ preview.classList.add('empty');
188
+ screenshot.hidden = true;
189
+ download.classList.remove('show');
190
+ try {
191
+ const response = await fetch('/api/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: urlInput.value, device }) });
192
+ const data = await response.json();
193
+ if (!response.ok) throw new Error(data.error || `Request failed: ${response.status}`);
194
+ httpStatus.textContent = data.status ?? '—';
195
+ loadTime.textContent = `${data.load_time_ms} ms`;
196
+ pageTitle.textContent = data.title || '(untitled)';
197
+ finalUrl.textContent = data.url;
198
+ screenshot.src = `data:image/png;base64,${data.screenshot}`;
199
+ screenshot.hidden = false;
200
+ preview.classList.remove('empty');
201
+ download.href = screenshot.src;
202
+ download.classList.add('show');
203
+ status.className = data.ok ? 'success' : 'error';
204
+ status.textContent = data.ok ? 'Test completed' : 'Page returned an error status';
205
+ } catch (error) {
206
+ status.className = 'error';
207
+ status.textContent = error.message;
208
+ httpStatus.textContent = '—';
209
+ loadTime.textContent = '—';
210
+ pageTitle.textContent = '—';
211
+ finalUrl.textContent = 'Test did not complete';
212
+ } finally {
213
+ runButton.disabled = false;
214
+ }
215
+ });
216
+ </script>
217
+ </body>
218
+ </html>
219
+ """
220
+
221
+ SEARCH_HTML = r"""
222
+ <!doctype html>
223
+ <html lang="en">
224
+ <head>
225
+ <meta charset="utf-8">
226
+ <meta name="viewport" content="width=device-width, initial-scale=1">
227
+ <title>Web Search · Web Test Lab</title>
228
+ <style>
229
+ :root { color-scheme: dark; --bg:#080c17; --panel:#121a30; --line:rgba(148,163,184,.18); --text:#f8fafc; --muted:#92a2bf; --blue:#7b96ff; --cyan:#43ddc6; --pink:#ff8eaa; }
230
+ * { box-sizing:border-box; }
231
+ body { margin:0; min-height:100vh; color:var(--text); font:15px/1.55 Inter,ui-sans-serif,system-ui,sans-serif; background:radial-gradient(circle at 20% 0%,rgba(123,150,255,.24),transparent 34rem),radial-gradient(circle at 90% 15%,rgba(67,221,198,.13),transparent 30rem),var(--bg); }
232
+ a { color:#abc0ff; }
233
+ .wrap { width:min(940px,calc(100% - 32px)); margin:0 auto; padding:28px 0 56px; }
234
+ nav { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:68px; }
235
+ .brand { display:flex; align-items:center; gap:10px; color:var(--text); font-weight:850; letter-spacing:.05em; text-decoration:none; }
236
+ .mark { display:grid; place-items:center; width:35px; height:35px; border-radius:11px; color:#07111e; background:linear-gradient(135deg,var(--cyan),var(--blue)); }
237
+ .engine { color:var(--muted); font-size:13px; }
238
+ .hero { text-align:center; }
239
+ .eyebrow { margin:0 0 14px; color:var(--cyan); font-size:12px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; }
240
+ h1 { margin:0; font-size:clamp(2.5rem,8vw,5.2rem); line-height:.95; letter-spacing:-.07em; }
241
+ .hero p { max-width:600px; margin:20px auto 0; color:var(--muted); font-size:17px; }
242
+ .search-box { display:flex; gap:10px; max-width:760px; margin:32px auto 0; padding:8px; border:1px solid rgba(123,150,255,.38); border-radius:16px; background:rgba(11,17,34,.8); box-shadow:0 20px 60px rgba(0,0,0,.25); }
243
+ input { min-width:0; flex:1; padding:13px 14px; border:0; outline:0; color:var(--text); background:transparent; font:inherit; }
244
+ button { padding:0 22px; border:0; border-radius:11px; color:#07111e; background:linear-gradient(100deg,var(--cyan),#8da6ff); cursor:pointer; font:inherit; font-weight:850; }
245
+ button:disabled { opacity:.55; cursor:wait; }
246
+ .chips { display:flex; justify-content:center; flex-wrap:wrap; gap:8px; margin-top:16px; }
247
+ .chip { padding:7px 11px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:transparent; cursor:pointer; font:inherit; font-size:12px; }
248
+ .chip:hover { border-color:var(--blue); color:var(--text); }
249
+ .meta { min-height:23px; margin:42px 0 14px; color:var(--muted); font-size:13px; }
250
+ .results { display:grid; gap:10px; }
251
+ .result { padding:18px 20px; border:1px solid var(--line); border-radius:16px; background:rgba(18,26,48,.78); transition:transform .16s,border-color .16s; }
252
+ .result:hover { transform:translateY(-2px); border-color:rgba(123,150,255,.55); }
253
+ .result-top { display:flex; gap:13px; align-items:flex-start; }
254
+ .num { flex:0 0 auto; display:grid; place-items:center; width:27px; height:27px; border-radius:9px; color:var(--cyan); background:rgba(67,221,198,.1); font-size:12px; font-weight:800; }
255
+ .title { display:block; color:#c4d1ff; font-size:17px; font-weight:750; text-decoration:none; }
256
+ .title:hover { text-decoration:underline; }
257
+ .url { overflow:hidden; margin-top:3px; color:#6fcdbd; font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
258
+ .snippet { margin:10px 0 0 40px; color:var(--muted); }
259
+ .empty { padding:42px 20px; border:1px dashed var(--line); border-radius:16px; color:var(--muted); text-align:center; }
260
+ footer { margin-top:42px; color:#6d7b96; font-size:12px; text-align:center; }
261
+ @media (max-width:560px) { .wrap{width:min(100% - 22px,940px);padding-top:18px;} nav{margin-bottom:48px;} .engine{display:none;} .search-box{margin-top:25px;} button{padding:0 15px;} .snippet{margin-left:0;} }
262
+ </style>
263
+ </head>
264
+ <body>
265
+ <main class="wrap">
266
+ <nav><a class="brand" href="/"><span class="mark">✦</span> WEB TEST LAB</a><span class="engine">Chromium-powered web search</span></nav>
267
+ <section class="hero">
268
+ <p class="eyebrow">Search the open web</p>
269
+ <h1>Find what you need.</h1>
270
+ <p>Search from a clean headless Chromium browser and open results in a new tab.</p>
271
+ <form class="search-box" id="search-form">
272
+ <input id="query" type="search" placeholder="Search the web…" autocomplete="off" required>
273
+ <button id="search-button" type="submit">Search</button>
274
+ </form>
275
+ <div class="chips"><button class="chip" type="button" data-query="web development news">Web development</button><button class="chip" type="button" data-query="AI tools">AI tools</button><button class="chip" type="button" data-query="Bengaluru weather">Bengaluru weather</button></div>
276
+ </section>
277
+ <div class="meta" id="meta">Search results will appear here</div>
278
+ <section class="results" id="results"><div class="empty">Enter a search query to begin.</div></section>
279
+ <footer>Search uses public web results. Use this service responsibly and follow each website's terms.</footer>
280
+ </main>
281
+ <script>
282
+ const form = document.getElementById('search-form');
283
+ const queryInput = document.getElementById('query');
284
+ const button = document.getElementById('search-button');
285
+ const meta = document.getElementById('meta');
286
+ const results = document.getElementById('results');
287
+ document.querySelectorAll('.chip').forEach((chip) => chip.addEventListener('click', () => { queryInput.value = chip.dataset.query; form.requestSubmit(); }));
288
+ form.addEventListener('submit', async (event) => {
289
+ event.preventDefault();
290
+ const query = queryInput.value.trim();
291
+ if (!query) return;
292
+ button.disabled = true;
293
+ button.textContent = 'Searching…';
294
+ meta.textContent = 'Opening Chromium and searching…';
295
+ results.textContent = '';
296
+ try {
297
+ const response = await fetch('/api/search', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({query}) });
298
+ const data = await response.json();
299
+ if (!response.ok) throw new Error(data.error || `Search failed: ${response.status}`);
300
+ meta.textContent = `${data.results.length} results for “${data.query}” · ${data.elapsed_ms} ms`;
301
+ if (!data.results.length) { results.innerHTML = '<div class="empty">No results found. Try a different search.</div>'; return; }
302
+ data.results.forEach((item, index) => {
303
+ const card = document.createElement('article'); card.className = 'result';
304
+ const top = document.createElement('div'); top.className = 'result-top';
305
+ const num = document.createElement('span'); num.className = 'num'; num.textContent = String(index + 1);
306
+ const body = document.createElement('div');
307
+ const title = document.createElement('a'); title.className = 'title'; title.href = item.url; title.target = '_blank'; title.rel = 'noopener noreferrer'; title.textContent = item.title || item.url;
308
+ const url = document.createElement('div'); url.className = 'url'; url.textContent = item.url;
309
+ const snippet = document.createElement('p'); snippet.className = 'snippet'; snippet.textContent = item.snippet || 'Open this result in a new tab.';
310
+ body.append(title, url); top.append(num, body); card.append(top, snippet); results.append(card);
311
+ });
312
+ } catch (error) {
313
+ meta.textContent = error.message;
314
+ results.innerHTML = '<div class="empty">Search could not be completed. Please try again.</div>';
315
+ } finally { button.disabled = false; button.textContent = 'Search'; }
316
+ });
317
+ </script>
318
+ </body>
319
+ </html>
320
+ """
321
+
322
+
323
+ def _client_key():
324
+ forwarded = request.headers.get("X-Forwarded-For", "")
325
+ return (forwarded.split(",")[0].strip() or request.remote_addr or "unknown")
326
+
327
+
328
+ def _within_rate_limit():
329
+ now = time.time()
330
+ key = _client_key()
331
+ with RATE_LOCK:
332
+ recent = [stamp for stamp in RATE_BUCKETS.get(key, []) if now - stamp < RATE_WINDOW_SECONDS]
333
+ if len(recent) >= RATE_LIMIT:
334
+ RATE_BUCKETS[key] = recent
335
+ return False
336
+ recent.append(now)
337
+ RATE_BUCKETS[key] = recent
338
+ return True
339
+
340
+
341
+ def _public_url(value):
342
+ if not isinstance(value, str) or len(value.strip()) > 2048:
343
+ raise ValueError("Enter a valid public URL")
344
+ value = value.strip()
345
+ parsed = urlparse(value)
346
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
347
+ raise ValueError("URL must start with http:// or https://")
348
+ if parsed.username or parsed.password:
349
+ raise ValueError("URLs with embedded credentials are not allowed")
350
+ host = parsed.hostname.lower().rstrip(".")
351
+ if host in {"localhost", "localhost.localdomain"} or host.endswith(".local"):
352
+ raise ValueError("Local network URLs are not allowed")
353
+ try:
354
+ addresses = {info[4][0] for info in socket.getaddrinfo(host, None)}
355
+ except socket.gaierror as exc:
356
+ raise ValueError("The hostname could not be resolved") from exc
357
+ if not addresses or any(not ipaddress.ip_address(address).is_global for address in addresses):
358
+ raise ValueError("Only public internet URLs are allowed")
359
+ return value
360
+
361
+
362
+ def _route_guard(route):
363
+ target = route.request.url
364
+ if target.startswith(("data:", "blob:", "about:", "chrome:")):
365
+ route.continue_()
366
+ return
367
+ try:
368
+ _public_url(target)
369
+ except ValueError:
370
+ route.abort()
371
+ else:
372
+ route.continue_()
373
+
374
+
375
+ def _browser_test(target_url, device):
376
+ global PLAYWRIGHT, BROWSER
377
+ with BROWSER_LOCK:
378
+ if BROWSER is None or not BROWSER.is_connected():
379
+ if PLAYWRIGHT is not None:
380
+ PLAYWRIGHT.stop()
381
+ PLAYWRIGHT = sync_playwright().start()
382
+ BROWSER = PLAYWRIGHT.chromium.launch(
383
+ headless=True,
384
+ args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
385
+ )
386
+ context = BROWSER.new_context(**DEVICES[device])
387
+ page = context.new_page()
388
+ page.route("**/*", _route_guard)
389
+ started = perf_counter()
390
+ try:
391
+ response = page.goto(target_url, wait_until="domcontentloaded", timeout=TEST_TIMEOUT_MS)
392
+ page.wait_for_timeout(500)
393
+ screenshot = base64.b64encode(page.screenshot(type="png", full_page=False)).decode("ascii")
394
+ status_code = response.status if response is not None else None
395
+ return {
396
+ "ok": status_code is not None and 200 <= status_code < 400,
397
+ "status": status_code,
398
+ "title": page.title(),
399
+ "url": page.url,
400
+ "load_time_ms": round((perf_counter() - started) * 1000),
401
+ "screenshot": screenshot,
402
+ }
403
+ finally:
404
+ context.close()
405
+
406
+
407
+ def _web_search(query):
408
+ global PLAYWRIGHT, BROWSER
409
+ search_url = "https://html.duckduckgo.com/html/?" + urlencode({"q": query})
410
+ with BROWSER_LOCK:
411
+ if BROWSER is None or not BROWSER.is_connected():
412
+ if PLAYWRIGHT is not None:
413
+ PLAYWRIGHT.stop()
414
+ PLAYWRIGHT = sync_playwright().start()
415
+ BROWSER = PLAYWRIGHT.chromium.launch(
416
+ headless=True,
417
+ args=["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
418
+ )
419
+ context = BROWSER.new_context(viewport={"width": 1280, "height": 900})
420
+ page = context.new_page()
421
+ page.route("**/*", _route_guard)
422
+ started = perf_counter()
423
+ try:
424
+ page.goto(search_url, wait_until="domcontentloaded", timeout=TEST_TIMEOUT_MS)
425
+ page.wait_for_timeout(700)
426
+ rows = page.locator("a.result__a, a[data-testid='result-title-a']").evaluate_all(
427
+ """
428
+ (links) => links.slice(0, 8).map((link) => {
429
+ const result = link.closest('.result') || link.closest('[data-testid="result"]') || link.parentElement;
430
+ const snippet = result?.querySelector('.result__snippet')?.innerText || '';
431
+ return { title: link.innerText.trim(), url: link.href, snippet: snippet.trim() };
432
+ })
433
+ """
434
+ )
435
+ results = []
436
+ for row in rows:
437
+ href = row.get("url", "")
438
+ parsed = urlparse(href)
439
+ if parsed.hostname in {"duckduckgo.com", "www.duckduckgo.com"} and parsed.path.startswith("/l/"):
440
+ href = parse_qs(parsed.query).get("uddg", [href])[0]
441
+ try:
442
+ href = _public_url(href)
443
+ except ValueError:
444
+ continue
445
+ results.append({
446
+ "title": row.get("title") or href,
447
+ "url": href,
448
+ "snippet": row.get("snippet", ""),
449
+ })
450
+ return {
451
+ "query": query,
452
+ "results": results,
453
+ "elapsed_ms": round((perf_counter() - started) * 1000),
454
+ }
455
+ finally:
456
+ context.close()
457
+
458
+
459
+ @app.get("/")
460
+ def home():
461
+ return INDEX_HTML
462
+
463
+
464
+ @app.get("/search")
465
+ def search_page():
466
+ return SEARCH_HTML
467
+
468
+
469
+ @app.post("/api/search")
470
+ def search_api():
471
+ if not _within_rate_limit():
472
+ return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
473
+ payload = request.get_json(silent=True) or {}
474
+ query = payload.get("query", "")
475
+ if not isinstance(query, str) or not 2 <= len(query.strip()) <= 200:
476
+ return jsonify({"error": "Enter a search query between 2 and 200 characters."}), 400
477
+ try:
478
+ return jsonify(_web_search(query.strip()))
479
+ except PlaywrightTimeoutError:
480
+ return jsonify({"error": "The search took too long to complete."}), 504
481
+ except Exception:
482
+ app.logger.exception("Web search failed")
483
+ return jsonify({"error": "The web search could not be completed."}), 502
484
+
485
+
486
+ @app.post("/api/test")
487
+ def run_test():
488
+ if not _within_rate_limit():
489
+ return jsonify({"error": "Rate limit reached. Please wait a minute."}), 429
490
+ payload = request.get_json(silent=True) or {}
491
+ device = payload.get("device", "desktop")
492
+ if device not in DEVICES:
493
+ return jsonify({"error": "Unknown viewport"}), 400
494
+ try:
495
+ target_url = _public_url(payload.get("url"))
496
+ return jsonify(_browser_test(target_url, device))
497
+ except ValueError as exc:
498
+ return jsonify({"error": str(exc)}), 400
499
+ except PlaywrightTimeoutError:
500
+ return jsonify({"error": "The page took too long to load."}), 504
501
+ except Exception:
502
+ app.logger.exception("Browser test failed")
503
+ return jsonify({"error": "The browser test could not be completed."}), 502
504
+
505
+
506
+ @app.get("/health")
507
+ def health_check():
508
+ return jsonify({"status": "healthy", "browser": "chromium"}), 200
509
+
510
+
511
+ if __name__ == "__main__":
512
+ app.run(host="0.0.0.0", port=7860)
browser-ui/index.html DELETED
@@ -1,55 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Live Browser Lab</title>
7
- <style>
8
- :root { color-scheme: dark; --bg:#090d18; --panel:#151d35; --line:rgba(148,163,184,.18); --muted:#96a7c4; --text:#f8fafc; --cyan:#43ddc6; --blue:#8198ff; }
9
- * { box-sizing:border-box; }
10
- body { min-height:100vh; margin:0; color:var(--text); font:15px/1.55 system-ui,sans-serif; background:radial-gradient(circle at 15% 0%,rgba(129,152,255,.26),transparent 34rem),radial-gradient(circle at 95% 20%,rgba(67,221,198,.14),transparent 30rem),var(--bg); }
11
- .wrap { width:min(900px,calc(100% - 32px)); margin:0 auto; padding:42px 0; }
12
- .brand { display:flex; align-items:center; gap:11px; color:var(--text); font-weight:850; letter-spacing:.06em; }
13
- .mark { display:grid; place-items:center; width:40px; height:40px; border-radius:13px; color:#07111e; background:linear-gradient(135deg,var(--cyan),var(--blue)); box-shadow:0 10px 30px rgba(67,221,198,.2); }
14
- .hero { margin-top:76px; max-width:700px; }
15
- .eyebrow { color:var(--cyan); font-size:12px; font-weight:800; letter-spacing:.15em; text-transform:uppercase; }
16
- h1 { margin:12px 0 0; font-size:clamp(2.8rem,8vw,5.8rem); line-height:.92; letter-spacing:-.075em; }
17
- .hero p { max-width:620px; margin:22px 0 0; color:var(--muted); font-size:18px; }
18
- .panel { margin-top:34px; padding:24px; border:1px solid var(--line); border-radius:24px; background:rgba(21,29,53,.78); box-shadow:0 25px 80px rgba(0,0,0,.3); backdrop-filter:blur(15px); }
19
- .panel-head { display:flex; align-items:center; justify-content:space-between; gap:14px; }
20
- .panel-head h2 { margin:0; font-size:19px; }
21
- .live { display:inline-flex; align-items:center; gap:8px; color:var(--cyan); font-size:12px; font-weight:750; }
22
- .dot { width:8px; height:8px; border-radius:50%; background:var(--cyan); box-shadow:0 0 15px var(--cyan); }
23
- .steps { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:22px 0; }
24
- .step { padding:14px; border:1px solid var(--line); border-radius:14px; color:var(--muted); }
25
- .step strong { display:block; margin-bottom:4px; color:var(--text); }
26
- .step span { font-size:13px; }
27
- .launch { display:inline-flex; align-items:center; justify-content:center; width:100%; padding:15px 18px; border-radius:12px; color:#07111e; background:linear-gradient(100deg,var(--cyan),#91a6ff); font-weight:850; text-decoration:none; box-shadow:0 12px 28px rgba(67,221,198,.15); }
28
- .launch:hover { filter:brightness(1.08); }
29
- .note { margin:16px 0 0; color:var(--muted); font-size:12px; }
30
- footer { margin-top:24px; color:#71809a; font-size:12px; }
31
- @media (max-width:600px) { .wrap{width:min(100% - 22px,900px);padding-top:25px;} .hero{margin-top:52px;} .steps{grid-template-columns:1fr;} }
32
- </style>
33
- </head>
34
- <body>
35
- <main class="wrap">
36
- <div class="brand"><span class="mark">✦</span> LIVE BROWSER LAB</div>
37
- <section class="hero">
38
- <div class="eyebrow">Interactive remote Chromium</div>
39
- <h1>A real browser, live in your tab.</h1>
40
- <p>Launch a private streamed browser session. Use the address bar, search the web, click links, type, scroll, and navigate just like a normal browser.</p>
41
- </section>
42
- <section class="panel">
43
- <div class="panel-head"><h2>Ready to connect</h2><span class="live"><span class="dot"></span> Browser online</span></div>
44
- <div class="steps">
45
- <div class="step"><strong>1 · Launch</strong><span>Open the live session below.</span></div>
46
- <div class="step"><strong>2 · Connect</strong><span>Enter your Space secret when asked.</span></div>
47
- <div class="step"><strong>3 · Browse</strong><span>Search and use the browser normally.</span></div>
48
- </div>
49
- <a class="launch" href="/vnc.html?autoconnect=true&resize=scale">Open live browser →</a>
50
- <p class="note">The browser starts on DuckDuckGo. No password is required in the current configuration, so anyone with this public URL may control the session. Add <code>BROWSER_PASSWORD</code> later if you want to turn protection on.</p>
51
- </section>
52
- <footer>Only browse websites you are authorized to access. Do not enter banking, email, or other sensitive credentials into a shared remote browser.</footer>
53
- </main>
54
- </body>
55
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Flask>=3.0,<4
2
+ playwright>=1.45,<2
3
+ gunicorn>=22,<24
start-browser.sh DELETED
@@ -1,57 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -Eeuo pipefail
3
-
4
- # Password protection is optional. If no secret is configured, the live browser
5
- # will be public to anyone who can open this Space URL.
6
- PASSWORD="${BROWSER_PASSWORD:-${VNC_PASSWORD:-}}"
7
- if [ -n "$PASSWORD" ]; then
8
- if [ "${#PASSWORD}" -lt 8 ]; then
9
- echo "The browser password must contain at least 8 characters. x11vnc uses the first 8 characters." >&2
10
- exit 1
11
- fi
12
- x11vnc -storepasswd "${PASSWORD:0:8}" /tmp/vnc.pass >/dev/null
13
- AUTH_ARGS=(-rfbauth /tmp/vnc.pass)
14
- else
15
- echo "WARNING: no browser password configured; the public Space can control this browser." >&2
16
- AUTH_ARGS=(-nopw)
17
- fi
18
-
19
- export DISPLAY=:99
20
- export HOME=/tmp/browser-home
21
- mkdir -p "$HOME" /tmp/browser-profile
22
-
23
- Xvfb :99 -screen 0 1440x900x24 -ac +extension GLX +render -noreset >/tmp/xvfb.log 2>&1 &
24
- fluxbox -display :99 >/tmp/fluxbox.log 2>&1 &
25
- sleep 1
26
-
27
- x11vnc \
28
- -display :99 \
29
- -forever \
30
- -shared \
31
- -localhost \
32
- -rfbport 5900 \
33
- "${AUTH_ARGS[@]}" \
34
- -noxdamage \
35
- -repeat >/tmp/x11vnc.log 2>&1 &
36
-
37
- chromium \
38
- --display=:99 \
39
- --no-sandbox \
40
- --disable-dev-shm-usage \
41
- --disable-gpu \
42
- --start-maximized \
43
- --no-first-run \
44
- --no-default-browser-check \
45
- --disable-session-crashed-bubble \
46
- --user-data-dir=/tmp/browser-profile \
47
- https://duckduckgo.com/ >/tmp/chromium.log 2>&1 &
48
-
49
- NOVNC_PROXY=/usr/share/novnc/utils/novnc_proxy
50
- if [ ! -x "$NOVNC_PROXY" ]; then
51
- NOVNC_PROXY="$(command -v novnc_proxy)"
52
- fi
53
-
54
- exec "$NOVNC_PROXY" \
55
- --vnc localhost:5900 \
56
- --listen 7860 \
57
- --web /opt/browser-ui