fix: harden dashboard auth and terminal flows
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 3m29s

Tighten terminal websocket auth and proxy trust handling while making file-backed auth/RBAC writes atomic to reduce high-impact security and persistence risks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gan, Jimmy
2026-03-06 23:53:58 +08:00
parent c9f06fbbbb
commit 44f0ed5d04
9 changed files with 337 additions and 175 deletions
+42 -16
View File
@@ -1,6 +1,8 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import time
import threading
import tempfile
import jwt
from passlib.context import CryptContext
import pyotp
@@ -78,6 +80,30 @@ import base64
import hashlib
from cryptography.fernet import Fernet
_auth_lock = threading.RLock()
def _atomic_json_write(path: str, data: dict):
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-auth-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def _update_auth_data(mutator):
with _auth_lock:
data = _load_auth_data()
result = mutator(data)
_atomic_json_write(AUTH_FILE, data)
return result
def _get_fernet():
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
@@ -108,6 +134,7 @@ def _decrypt(ciphertext: str) -> str:
return ciphertext # fallback for unencrypted legacy values
def _load_auth_data():
with _auth_lock:
try:
if os.path.exists(AUTH_FILE):
with open(AUTH_FILE, "r") as f:
@@ -117,9 +144,7 @@ def _load_auth_data():
return {}
def _save_auth_data(data):
os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True)
with open(AUTH_FILE, "w") as f:
json.dump(data, f)
_atomic_json_write(AUTH_FILE, data)
def load_totp_secret():
encrypted = _load_auth_data().get("totp_secret", "")
@@ -128,17 +153,17 @@ def load_totp_secret():
return _decrypt(encrypted)
def save_totp_secret(secret: str):
data = _load_auth_data()
def mutator(data):
data["totp_secret"] = _encrypt(secret) if secret else ""
_save_auth_data(data)
_update_auth_data(mutator)
def load_password_hash():
return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH
def save_password_hash(hashed: str):
data = _load_auth_data()
def mutator(data):
data["password_hash"] = hashed
_save_auth_data(data)
_update_auth_data(mutator)
def verify_totp(token: str, secret: str = None):
if secret is None:
@@ -148,41 +173,42 @@ def verify_totp(token: str, secret: str = None):
totp = pyotp.TOTP(secret)
if not totp.verify(token):
return False
# Replay protection: reject reuse within same 30s window
def mutator(data):
current_ts = int(time.time()) // 30
data = _load_auth_data()
if data.get("last_totp_ts") == current_ts:
return False
data["last_totp_ts"] = current_ts
_save_auth_data(data)
return True
return _update_auth_data(mutator)
# Passkey credentials
def load_passkey_credentials():
return _load_auth_data().get("passkey_credentials", [])
def save_passkey_credential(cred):
data = _load_auth_data()
def mutator(data):
creds = data.get("passkey_credentials", [])
creds.append(cred)
data["passkey_credentials"] = creds
_save_auth_data(data)
_update_auth_data(mutator)
def clear_passkey_credentials():
data = _load_auth_data()
def mutator(data):
data["passkey_credentials"] = []
_save_auth_data(data)
_update_auth_data(mutator)
# Token version for refresh token rotation
def _load_token_version() -> int:
return _load_auth_data().get("token_version", 0)
def increment_token_version() -> int:
data = _load_auth_data()
def mutator(data):
v = data.get("token_version", 0) + 1
data["token_version"] = v
_save_auth_data(data)
return v
return _update_auth_data(mutator)
def verify_token_version(tv: int) -> bool:
return tv == _load_token_version()
+48 -30
View File
@@ -1,4 +1,6 @@
import os
import ipaddress
from fastapi import Request
# Dashboard v1.3 — Runner DNS fix applied
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
@@ -63,43 +65,59 @@ OPENCLAW_GATEWAY_TOKEN = os.environ.get("OPENCLAW_GATEWAY_TOKEN", "")
# Networking configs
LAN_IP_RANGES = os.environ.get("LAN_IP_RANGES", "192.168.31.0/24").split(",")
TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.21.0.1").split(",")
def _parse_ip(ip_str: str):
try:
return ipaddress.ip_address((ip_str or "").strip())
except ValueError:
return None
def _ip_matches_ranges(ip_str: str, ranges: list[str]) -> bool:
ip_obj = _parse_ip(ip_str)
if ip_obj is None:
return False
for subnet_str in ranges:
subnet_str = subnet_str.strip()
if not subnet_str:
continue
try:
if "/" in subnet_str:
if ip_obj in ipaddress.ip_network(subnet_str, strict=False):
return True
elif ip_obj == ipaddress.ip_address(subnet_str):
return True
except ValueError:
continue
return False
def is_lan_ip(ip_str: str) -> bool:
"""Check if IP is in local LAN (not Tailscale)"""
import ipaddress
try:
ip_obj = ipaddress.ip_address(ip_str)
for subnet_str in LAN_IP_RANGES:
if ip_obj in ipaddress.ip_network(subnet_str.strip()):
return True
return False
except ValueError:
return False
return _ip_matches_ranges(ip_str, LAN_IP_RANGES)
def is_tailscale_ip(ip_str: str) -> bool:
"""Check if IP is in Tailscale CGNAT range (100.64.0.0/10)"""
import ipaddress
try:
ip_obj = ipaddress.ip_address(ip_str)
return ip_obj in ipaddress.ip_network("100.64.0.0/10")
except ValueError:
return False
return _ip_matches_ranges(ip_str, ["100.64.0.0/10"])
TRUSTED_PROXIES = os.environ.get("TRUSTED_PROXIES", "127.0.0.1,::1,172.16.0.0/12").split(",")
def is_trusted_proxy(ip_str: str) -> bool:
import ipaddress
try:
ip_obj = ipaddress.ip_address(ip_str)
for subnet_str in TRUSTED_PROXIES:
subnet_str = subnet_str.strip()
if "/" in subnet_str:
if ip_obj in ipaddress.ip_network(subnet_str):
return True
else:
if ip_obj == ipaddress.ip_address(subnet_str):
return True
return False
except ValueError:
return False
return _ip_matches_ranges(ip_str, TRUSTED_PROXIES)
def get_forwarded_client_ip(request: Request) -> str | None:
source_ip = request.client.host if request.client else ""
if not is_trusted_proxy(source_ip):
return None
forwarded_for = request.headers.get("x-forwarded-for", "")
forwarded_ip = forwarded_for.split(",", 1)[0].strip()
return forwarded_ip or None
def get_client_ip(request: Request) -> str:
return get_forwarded_client_ip(request) or (request.client.host if request.client else "")
+6 -18
View File
@@ -8,6 +8,7 @@ from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine
import auth as auth_module
import config
from rbac import require_page, require_write
import asyncio
@@ -16,7 +17,7 @@ import logging
import time
import jwt
from datetime import datetime, timezone, timedelta
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT, get_client_ip
_tz_cst = timezone(timedelta(hours=8))
@@ -70,19 +71,11 @@ async def audit_log(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = int((time.time() - start) * 1000)
user = "-"
try:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
payload = jwt.decode(auth_header[7:], SECRET_KEY, algorithms=[ALGORITHM])
user = payload.get("sub", "-")
except jwt.PyJWTError:
user = "Invalid/Malformed Token"
except Exception:
pass
user = getattr(request.state, "user", None)
username = user.username if user else "-"
_audit_logger.info(
"%s %s %s %s %s %d %dms",
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), request.client.host, user,
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), get_client_ip(request), username,
request.method, request.url.path, response.status_code, duration,
)
return response
@@ -128,12 +121,7 @@ async def health():
@app.get("/api/client-ip")
async def client_ip(request: Request):
ip = request.client.host
import config
if config.is_trusted_proxy(ip):
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
ip = forwarded
ip = get_client_ip(request)
# LAN detection logic:
# - True LAN IP (192.168.31.x) → lan=True
+28 -5
View File
@@ -1,5 +1,7 @@
import json
import os
import threading
import tempfile
from typing import Optional
from pydantic import BaseModel
from fastapi import HTTPException, Request, status
@@ -7,6 +9,7 @@ from fastapi import HTTPException, Request, status
import config
RBAC_FILE = os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
_rbac_lock = threading.RLock()
ROLE_GROUP_MAP = {
"dashboard_admin": "admin",
@@ -41,10 +44,30 @@ def load_rbac() -> dict:
return DEFAULT_RBAC.copy()
def save_rbac(data: dict):
os.makedirs(os.path.dirname(RBAC_FILE), exist_ok=True)
with open(RBAC_FILE, "w") as f:
def _atomic_json_write(path: str, data: dict):
os.makedirs(os.path.dirname(path), exist_ok=True)
fd, temp_path = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".tmp-rbac-", suffix=".json")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(temp_path, path)
finally:
if os.path.exists(temp_path):
os.unlink(temp_path)
def update_rbac(mutator):
with _rbac_lock:
data = load_rbac()
result = mutator(data)
_atomic_json_write(RBAC_FILE, data)
return result
def save_rbac(data: dict):
_atomic_json_write(RBAC_FILE, data)
def resolve_role(groups: list[str]) -> Optional[str]:
@@ -77,9 +100,9 @@ def get_sidebar_order(username: str) -> dict | None:
def save_sidebar_order(username: str, order: dict):
rbac = load_rbac()
def mutator(rbac):
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
save_rbac(rbac)
update_rbac(mutator)
def has_page_access(user: User, page: str) -> bool:
+15 -10
View File
@@ -102,7 +102,6 @@ async def login(creds: LoginRequest, request: Request):
async def proxy_auth(request: Request):
"""Auto-login when Authelia has already authenticated the user via forward-auth.
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
import config
from rbac import resolve_role
if not config.is_trusted_proxy(request.client.host):
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}")
@@ -195,16 +194,18 @@ async def rbac_config(current_user = Depends(auth.get_current_user)):
async def rbac_set_override(username: str, request: Request, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac
from rbac import update_rbac
body = await request.json()
pages = body.get("pages")
if pages is None:
raise HTTPException(status_code=400, detail="Missing pages field")
rbac = load_rbac()
def mutator(rbac):
existing = rbac.setdefault("user_overrides", {}).get(username, {})
existing["pages"] = pages
rbac["user_overrides"][username] = existing
save_rbac(rbac)
update_rbac(mutator)
return {"ok": True}
@router.get("/preferences")
@@ -235,17 +236,19 @@ async def save_preferences(request: Request, current_user = Depends(auth.get_cur
async def rbac_delete_override(username: str, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac
rbac = load_rbac()
from rbac import update_rbac
def mutator(rbac):
rbac.get("user_overrides", {}).pop(username, None)
save_rbac(rbac)
update_rbac(mutator)
return {"ok": True}
@router.put("/rbac/roles/{role}")
async def rbac_update_role(role: str, request: Request, current_user = Depends(auth.get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
from rbac import load_rbac, save_rbac
from rbac import update_rbac
body = await request.json()
pages = body.get("pages")
sidebar_links = body.get("sidebar_links")
@@ -255,10 +258,12 @@ async def rbac_update_role(role: str, request: Request, current_user = Depends(a
raise HTTPException(status_code=400, detail="pages must be '*' or a list")
if sidebar_links is not None and sidebar_links != "*" and not isinstance(sidebar_links, list):
raise HTTPException(status_code=400, detail="sidebar_links must be '*' or a list")
rbac = load_rbac()
def mutator(rbac):
role_config = {"pages": pages}
if sidebar_links is not None:
role_config["sidebar_links"] = sidebar_links
rbac.setdefault("role_defaults", {})[role] = role_config
save_rbac(rbac)
update_rbac(mutator)
return {"ok": True}
+16 -1
View File
@@ -100,13 +100,28 @@ async def upload(path: str, file: UploadFile = File(...)):
@router.delete("/delete")
def delete(path: str):
def delete(path: str, recursive: bool = False):
try:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if target == BASE.resolve():
raise HTTPException(status_code=403, detail="Cannot delete base root")
try:
if target.is_dir():
if not recursive:
raise HTTPException(status_code=400, detail="Directory deletion requires recursive=true")
shutil.rmtree(target)
else:
target.unlink()
except HTTPException:
raise
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Path not found")
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True}
+6 -7
View File
@@ -5,6 +5,7 @@ import platform
import time
import httpx
from fastapi import APIRouter, Query, Request, HTTPException
import config
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN
router = APIRouter()
@@ -116,13 +117,11 @@ def _classify(method, path, status, user):
@router.get("/openclaw-token")
def openclaw_token(request: Request):
ip = request.client.host
import config
if config.is_trusted_proxy(ip):
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
ip = forwarded
import config
user = request.state.user
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
ip = config.get_client_ip(request)
if not config.is_lan_ip(ip):
raise HTTPException(status_code=403, detail="Access denied")
return {"token": OPENCLAW_GATEWAY_TOKEN}
+49 -33
View File
@@ -1,16 +1,18 @@
import asyncio
import struct
import logging
import time
from fastapi import WebSocket, WebSocketDisconnect, Query
from collections import Counter
from fastapi import WebSocket, Query
import asyncssh
import jwt
import config
from auth import get_current_user_ws
logger = logging.getLogger(__name__)
MAX_SESSIONS = 5
_active_sessions: set = set()
MAX_SESSIONS_PER_USER = 2
_session_lock = asyncio.Lock()
_active_sessions: dict[int, str] = {}
HOSTS = {
"nas": {"host": config.SSH_HOST, "user": config.SSH_USER, "key": config.SSH_KEY_PATH},
@@ -31,43 +33,57 @@ except Exception:
logger.error("SSH known_hosts file not found at %s — terminal connections will be refused", config.SSH_KNOWN_HOSTS)
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
await websocket.accept()
async def _reserve_session(session_id: int, username: str) -> tuple[bool, str | None, int | None]:
async with _session_lock:
if len(_active_sessions) >= MAX_SESSIONS:
return False, "Too many sessions", 1013
user_sessions = Counter(_active_sessions.values())
if user_sessions[username] >= MAX_SESSIONS_PER_USER:
return False, "Too many sessions for user", 1013
_active_sessions[session_id] = username
return True, None, session_id
async def _release_session(session_id: int | None):
if session_id is None:
return
async with _session_lock:
_active_sessions.pop(session_id, None)
async def ws_endpoint(websocket: WebSocket, host: str = Query("nas"), token: str | None = Query(None)):
if host not in HOSTS:
await websocket.close(code=1008, reason="Unknown host")
return
if not token:
await websocket.close(code=1008, reason="Unauthorized")
return
# Auth: expect token as first text message
try:
first_msg = await asyncio.wait_for(websocket.receive_text(), timeout=5)
payload = jwt.decode(first_msg, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("type") != "access":
await websocket.close(code=1008, reason="Unauthorized")
return
username = payload.get("sub")
role = payload.get("role", "admin")
if username is None:
await websocket.close(code=1008, reason="Unauthorized")
return
# RBAC: check terminal page access
from rbac import get_pages
pages = get_pages(username, role)
if pages != "*" and "terminal" not in pages:
await websocket.close(code=1008, reason="Access denied")
return
user = await get_current_user_ws(token)
except Exception:
await websocket.close(code=1008, reason="Unauthorized")
return
if user.pages != "*" and "terminal" not in user.pages:
await websocket.close(code=1008, reason="Access denied")
return
if not _known_hosts_available:
await websocket.close(code=1011, reason="SSH host verification unavailable")
return
if len(_active_sessions) >= MAX_SESSIONS:
await websocket.close(code=1013, reason="Too many sessions")
return
session_id = id(websocket)
_active_sessions.add(session_id)
reserved, reason, session_or_code = await _reserve_session(session_id, user.username)
if not reserved:
await websocket.close(code=session_or_code, reason=reason)
return
session_id = session_or_code
h = HOSTS.get(host, HOSTS["nas"])
await websocket.accept()
h = HOSTS[host]
try:
conn = await asyncssh.connect(
h["host"], username=h["user"],
@@ -76,7 +92,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
keepalive_count_max=3,
)
except Exception:
_active_sessions.discard(session_id)
await _release_session(session_id)
await websocket.close(code=1011, reason="SSH connection failed")
return
@@ -93,7 +109,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
if not data:
break
await websocket.send_bytes(data)
except (asyncssh.Error, WebSocketDisconnect, asyncio.CancelledError):
except (asyncssh.Error, asyncio.CancelledError):
pass
read_task = asyncio.create_task(read_ssh())
@@ -113,11 +129,11 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
if msg["text"] == "__ping__":
continue
process.stdin.write(msg["text"].encode())
except WebSocketDisconnect:
except Exception:
pass
finally:
read_task.cancel()
process.close()
finally:
conn.close()
_active_sessions.discard(session_id)
await _release_session(session_id)
+87 -15
View File
@@ -120,6 +120,8 @@
function closeTab(id) {
const d = tabData.get(id);
if (d) {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy();
d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);
@@ -157,11 +159,30 @@
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const token = localStorage.getItem("token") || "";
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${tab.hostId}`
);
ws.binaryType = "arraybuffer";
function sendSize() {
let heartbeat = null;
let reconnectTimer = null;
let closedManually = false;
function clearHeartbeat() {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
}
function scheduleReconnect(reason) {
clearHeartbeat();
if (closedManually || reconnectTimer) return;
updateTab(tabId, { connected: false, error: `${reason} — reconnecting...` });
term.write(`\r\n\x1b[90m[${reason} — reconnecting...]\x1b[0m`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!tabData.has(tabId) || closedManually) return;
connect();
}, 2000);
}
function sendSize(ws) {
if (ws.readyState === 1) {
const buf = new Uint8Array(5);
buf[0] = 0x01;
@@ -171,19 +192,62 @@
}
}
ws.onopen = () => { ws.send(token); updateTab(tabId, { connected: true, error: "" }); sendSize(); };
function connect() {
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}&token=${encodeURIComponent(token)}`
);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
updateTab(tabId, { connected: true, error: "" });
sendSize(ws);
clearHeartbeat();
heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__");
}, 25000);
const d = tabData.get(tabId);
if (d) {
d.ws = ws;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.voice?.attach(term, ws);
}
};
ws.onmessage = (e) => term.write(new Uint8Array(e.data));
ws.onclose = (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`;
const d = tabData.get(tabId);
if (d) d.ws = null;
if (!closedManually) scheduleReconnect(reason);
else {
clearHeartbeat();
updateTab(tabId, { connected: false, error: reason });
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
}
};
ws.onerror = () => {
const d = tabData.get(tabId);
if (d) d.ws = null;
if (!closedManually) scheduleReconnect("Connection failed");
else updateTab(tabId, { connected: false, error: "Connection failed" });
};
ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" });
term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
const heartbeat = setInterval(() => {
if (ws.readyState === 1) ws.send("__ping__");
}, 25000);
const d = tabData.get(tabId);
if (d) {
d.ws = ws;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
}
return ws;
}
let ws = connect();
term.onData((data) => {
const currentWs = tabData.get(tabId)?.ws;
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
});
const handleDragOver = (e) => {
e.preventDefault();
@@ -192,19 +256,21 @@
const handleDrop = async (e) => {
e.preventDefault();
if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
for (const file of files) {
await sendDroppedImage(term, ws, file);
await sendDroppedImage(term, currentWs, file);
}
};
const handlePaste = async (e) => {
if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const items = [...(e.clipboardData?.items || [])]
.filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
for (const item of items) {
const file = item.getAsFile();
if (file) await sendDroppedImage(term, ws, file);
if (file) await sendDroppedImage(term, currentWs, file);
}
};
@@ -212,11 +278,15 @@
term.element?.addEventListener("drop", handleDrop);
term.textarea?.addEventListener("paste", handlePaste);
const ro = new ResizeObserver(() => { fit.fit(); sendSize(); });
const ro = new ResizeObserver(() => {
fit.fit();
const currentWs = tabData.get(tabId)?.ws;
if (currentWs) sendSize(currentWs);
});
ro.observe(el);
const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws);
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, handleDragOver, handleDrop, handlePaste });
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, handleDragOver, handleDrop, handlePaste });
})();
}
@@ -225,6 +295,8 @@
onDestroy(() => {
themeObserver.disconnect();
tabData.forEach((d) => {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy();
d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);