Initial release: cloud browser with auth and one-click deploy on port 32450

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-27 10:57:37 +08:00
commit 65f5caf4d9
20 changed files with 2118 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
const AUTH = {
async me() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) return null;
return res.json();
},
async login(username, password) {
const res = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "登录失败");
return data;
},
async logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
},
async changeCredentials(payload) {
const res = await fetch("/api/auth/change-credentials", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "修改失败");
return data;
},
};
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云端浏览器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="container">
<header class="header">
<h1>云端浏览器</h1>
<p class="subtitle">输入网址,在境外服务器上打开并远程操作</p>
</header>
<!-- 登录 -->
<section id="login-section" class="panel hidden">
<h2>登录</h2>
<form id="login-form" class="stack-form">
<label for="login-user">用户名</label>
<input id="login-user" type="text" autocomplete="username" required>
<label for="login-pass">密码</label>
<input id="login-pass" type="password" autocomplete="current-password" required>
<button type="submit">登录</button>
<p id="login-error" class="error hidden"></p>
</form>
<p class="hint">默认账号:admin / admin,登录后请尽快修改</p>
</section>
<!-- 主界面 -->
<section id="main-section" class="hidden">
<div class="top-bar">
<span id="welcome-user" class="welcome"></span>
<button id="btn-settings" type="button" class="btn-secondary">账号设置</button>
<button id="btn-logout" type="button" class="btn-secondary">退出</button>
</div>
<form id="start-form" class="start-form">
<label for="url-input">目标网址</label>
<div class="input-row">
<input
id="url-input"
type="text"
placeholder="https://example.com"
autocomplete="off"
required
>
<button type="submit" id="start-btn">进入</button>
</div>
<p id="error-msg" class="error hidden"></p>
</form>
<section class="tips">
<h2>使用说明</h2>
<ul>
<li>页面将在云服务器 Chromium 中加载,画面实时回传</li>
<li>可同时登录账号、搜索和浏览你的数据</li>
<li>会话空闲 30 分钟后自动关闭</li>
</ul>
</section>
</section>
<!-- 修改账号 -->
<section id="settings-section" class="panel hidden">
<h2>修改用户名和密码</h2>
<form id="settings-form" class="stack-form">
<label for="cur-user">当前用户名</label>
<input id="cur-user" type="text" required>
<label for="cur-pass">当前密码</label>
<input id="cur-pass" type="password" required>
<label for="new-user">新用户名</label>
<input id="new-user" type="text" required>
<label for="new-pass">新密码</label>
<input id="new-pass" type="password" required>
<div class="btn-row">
<button type="submit">保存</button>
<button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button>
</div>
<p id="settings-error" class="error hidden"></p>
<p id="settings-success" class="success hidden"></p>
</form>
</section>
</main>
<script src="/static/auth.js"></script>
<script src="/static/index.js"></script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
const loginSection = document.getElementById("login-section");
const mainSection = document.getElementById("main-section");
const settingsSection = document.getElementById("settings-section");
const loginForm = document.getElementById("login-form");
const loginError = document.getElementById("login-error");
const welcomeUser = document.getElementById("welcome-user");
const settingsForm = document.getElementById("settings-form");
const settingsError = document.getElementById("settings-error");
const settingsSuccess = document.getElementById("settings-success");
const form = document.getElementById("start-form");
const urlInput = document.getElementById("url-input");
const startBtn = document.getElementById("start-btn");
const errorMsg = document.getElementById("error-msg");
function show(el) {
el.classList.remove("hidden");
}
function hide(el) {
el.classList.add("hidden");
}
function showPanelError(el, message) {
el.textContent = message;
show(el);
}
function hidePanelError(el) {
hide(el);
}
function showMain(username) {
hide(loginSection);
hide(settingsSection);
show(mainSection);
welcomeUser.textContent = `当前用户:${username}`;
document.getElementById("cur-user").value = username;
urlInput.focus();
}
function showLogin() {
hide(mainSection);
hide(settingsSection);
show(loginSection);
}
async function init() {
const user = await AUTH.me();
if (user) {
showMain(user.username);
} else {
showLogin();
}
}
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
hidePanelError(loginError);
const username = document.getElementById("login-user").value.trim();
const password = document.getElementById("login-pass").value;
try {
const data = await AUTH.login(username, password);
showMain(data.username);
} catch (err) {
showPanelError(loginError, err.message);
}
});
document.getElementById("btn-logout").addEventListener("click", async () => {
await AUTH.logout();
showLogin();
});
document.getElementById("btn-settings").addEventListener("click", () => {
hide(mainSection);
hidePanelError(settingsError);
hide(settingsSuccess);
show(settingsSection);
});
document.getElementById("btn-settings-cancel").addEventListener("click", () => {
const user = welcomeUser.textContent.replace("当前用户:", "");
showMain(user);
});
settingsForm.addEventListener("submit", async (e) => {
e.preventDefault();
hidePanelError(settingsError);
hide(settingsSuccess);
try {
const data = await AUTH.changeCredentials({
current_username: document.getElementById("cur-user").value.trim(),
current_password: document.getElementById("cur-pass").value,
new_username: document.getElementById("new-user").value.trim(),
new_password: document.getElementById("new-pass").value,
});
document.getElementById("cur-pass").value = "";
document.getElementById("new-pass").value = "";
settingsSuccess.textContent = "账号已更新,请使用新凭据登录";
show(settingsSuccess);
setTimeout(async () => {
await AUTH.logout();
showLogin();
}, 1500);
welcomeUser.textContent = `当前用户:${data.username}`;
} catch (err) {
showPanelError(settingsError, err.message);
}
});
function showError(message) {
errorMsg.textContent = message;
show(errorMsg);
}
function hideError() {
hide(errorMsg);
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
hideError();
const url = urlInput.value.trim();
if (!url) {
showError("请输入网址");
return;
}
startBtn.disabled = true;
startBtn.textContent = "启动中...";
try {
const response = await fetch("/api/session", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await response.json().catch(() => ({}));
if (response.status === 401) {
showLogin();
showPanelError(loginError, "登录已过期,请重新登录");
return;
}
if (!response.ok) {
showError(data.detail || "创建会话失败");
return;
}
window.location.href = `/view/${data.session_id}`;
} catch (err) {
showError("网络错误,请稍后重试");
} finally {
startBtn.disabled = false;
startBtn.textContent = "进入";
}
});
init();
+269
View File
@@ -0,0 +1,269 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #0f1419;
color: #e7e9ea;
min-height: 100vh;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: 48px 24px;
}
.header h1 {
font-size: 2rem;
margin-bottom: 8px;
}
.subtitle {
color: #8b98a5;
margin-bottom: 32px;
}
.start-form label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
.input-row {
display: flex;
gap: 8px;
}
.input-row input {
flex: 1;
padding: 12px 16px;
border: 1px solid #38444d;
border-radius: 8px;
background: #192734;
color: #e7e9ea;
font-size: 1rem;
}
.input-row input:focus {
outline: none;
border-color: #1d9bf0;
}
button {
padding: 12px 20px;
border: none;
border-radius: 8px;
background: #1d9bf0;
color: #fff;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #1a8cd8;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error {
color: #f4212e;
margin-top: 12px;
font-size: 0.9rem;
}
.hidden {
display: none !important;
}
.tips {
margin-top: 48px;
padding: 24px;
background: #192734;
border-radius: 12px;
border: 1px solid #38444d;
}
.tips h2 {
font-size: 1rem;
margin-bottom: 12px;
}
.tips ul {
padding-left: 20px;
color: #8b98a5;
line-height: 1.8;
}
/* Viewer */
.viewer-body {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #192734;
border-bottom: 1px solid #38444d;
flex-shrink: 0;
}
.toolbar button {
padding: 8px 12px;
min-width: 36px;
}
.toolbar #address-bar {
flex: 1;
padding: 8px 12px;
border: 1px solid #38444d;
border-radius: 6px;
background: #0f1419;
color: #e7e9ea;
font-size: 0.9rem;
}
.toolbar #address-bar:focus {
outline: none;
border-color: #1d9bf0;
}
.status {
font-size: 0.8rem;
color: #8b98a5;
white-space: nowrap;
}
.btn-danger {
background: #f4212e;
}
.btn-danger:hover {
background: #dc1d28;
}
.viewport-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
overflow: hidden;
}
#screen {
max-width: 100%;
max-height: 100%;
cursor: default;
outline: none;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.overlay a {
color: #1d9bf0;
text-decoration: none;
}
.overlay a:hover {
text-decoration: underline;
}
.panel {
margin-bottom: 24px;
padding: 24px;
background: #192734;
border-radius: 12px;
border: 1px solid #38444d;
}
.panel h2 {
font-size: 1.1rem;
margin-bottom: 16px;
}
.stack-form label {
display: block;
margin: 12px 0 6px;
font-weight: 500;
}
.stack-form input {
width: 100%;
padding: 10px 14px;
border: 1px solid #38444d;
border-radius: 8px;
background: #0f1419;
color: #e7e9ea;
font-size: 1rem;
}
.stack-form input:focus {
outline: none;
border-color: #1d9bf0;
}
.stack-form button {
margin-top: 16px;
}
.btn-row {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn-secondary {
background: #38444d;
}
.btn-secondary:hover {
background: #4a5560;
}
.top-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.welcome {
flex: 1;
color: #8b98a5;
font-size: 0.9rem;
}
.hint {
margin-top: 12px;
color: #8b98a5;
font-size: 0.85rem;
}
.success {
color: #00ba7c;
margin-top: 12px;
font-size: 0.9rem;
}
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览中 - 云端浏览器</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="viewer-body">
<div class="toolbar">
<button id="btn-back" title="后退" type="button"></button>
<button id="btn-forward" title="前进" type="button"></button>
<button id="btn-reload" title="刷新" type="button"></button>
<input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off">
<button id="btn-go" type="button">前往</button>
<span id="status" class="status">连接中...</span>
<button id="btn-close" class="btn-danger" type="button">关闭会话</button>
</div>
<div id="viewport-wrap" class="viewport-wrap">
<canvas id="screen" tabindex="0"></canvas>
<div id="overlay" class="overlay hidden">
<p id="overlay-msg">会话已结束</p>
<a href="/">返回首页</a>
</div>
</div>
<script src="/static/viewer.js"></script>
</body>
</html>
+217
View File
@@ -0,0 +1,217 @@
(function () {
const sessionId = window.location.pathname.split("/").pop();
const canvas = document.getElementById("screen");
const ctx = canvas.getContext("2d");
const addressBar = document.getElementById("address-bar");
const statusEl = document.getElementById("status");
const overlay = document.getElementById("overlay");
const overlayMsg = document.getElementById("overlay-msg");
let ws = null;
let viewportWidth = 1280;
let viewportHeight = 720;
let scaleX = 1;
let scaleY = 1;
let pingTimer = null;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
function setStatus(text) {
statusEl.textContent = text;
}
function showOverlay(message) {
overlayMsg.textContent = message;
overlay.classList.remove("hidden");
}
function mapCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const x = (clientX - rect.left) / scaleX;
const y = (clientY - rect.top) / scaleY;
return { x, y };
}
function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}
function drawFrame(blob) {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width;
canvas.height = img.height;
viewportWidth = img.width;
viewportHeight = img.height;
updateScale();
}
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
};
img.src = url;
}
function updateScale() {
const rect = canvas.getBoundingClientRect();
scaleX = rect.width / viewportWidth;
scaleY = rect.height / viewportHeight;
}
function connect() {
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
setStatus("已连接");
pingTimer = setInterval(() => send({ type: "ping" }), 60000);
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
drawFrame(new Blob([event.data], { type: "image/jpeg" }));
return;
}
try {
const msg = JSON.parse(event.data);
if (msg.type === "init") {
viewportWidth = msg.width;
viewportHeight = msg.height;
addressBar.value = msg.url || "";
updateScale();
} else if (msg.type === "url" || msg.type === "url_update") {
addressBar.value = msg.url || "";
} else if (msg.type === "closed") {
showOverlay("会话已结束");
ws.close();
} else if (msg.type === "error") {
setStatus(msg.message);
}
} catch (_) {
/* ignore */
}
};
ws.onclose = () => {
setStatus("已断开");
clearInterval(pingTimer);
};
ws.onerror = () => {
setStatus("连接错误");
};
}
canvas.addEventListener("click", (e) => {
canvas.focus();
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "click", x, y, button: "left" });
});
canvas.addEventListener("dblclick", (e) => {
e.preventDefault();
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "dblclick", x, y });
});
canvas.addEventListener("wheel", (e) => {
e.preventDefault();
send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false });
canvas.addEventListener("mousemove", (e) => {
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "mousemove", x, y });
});
const specialKeys = new Set([
"Enter", "Backspace", "Delete", "Tab", "Escape",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Home", "End", "PageUp", "PageDown",
]);
canvas.addEventListener("keydown", (e) => {
e.preventDefault();
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
send({ action: "type", text: e.key });
} else if (specialKeys.has(e.key)) {
send({ action: "press", key: e.key });
} else {
send({ action: "keydown", key: e.key });
}
});
canvas.addEventListener("keyup", (e) => {
e.preventDefault();
if (e.key.length > 1 || e.ctrlKey || e.metaKey || e.altKey) {
send({ action: "keyup", key: e.key });
}
});
window.addEventListener("resize", updateScale);
async function apiPost(path) {
const res = await fetch(path, { method: "POST", credentials: "include" });
if (res.status === 401) {
window.location.href = "/";
return null;
}
return res.json();
}
async function ensureAuth() {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) {
window.location.href = "/";
return false;
}
return true;
}
document.getElementById("btn-back").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/back`);
if (data) addressBar.value = data.url;
});
document.getElementById("btn-forward").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/forward`);
if (data) addressBar.value = data.url;
});
document.getElementById("btn-reload").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/reload`);
if (data) addressBar.value = data.url;
});
function navigateTo(url) {
send({ type: "navigate", url });
}
document.getElementById("btn-go").addEventListener("click", () => {
navigateTo(addressBar.value.trim());
});
addressBar.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
navigateTo(addressBar.value.trim());
}
});
document.getElementById("btn-close").addEventListener("click", async () => {
await fetch(`/api/session/${sessionId}`, { method: "DELETE", credentials: "include" });
showOverlay("会话已关闭");
if (ws) ws.close();
});
ensureAuth().then((ok) => {
if (ok) {
connect();
canvas.focus();
}
});
})();