mirror of
https://github.com/nezhahq/nezha.git
synced 2025-02-02 09:38:13 -05:00
feat: framed fm for webshell (#411)
* feat: framed fm for webshell * 1MB buffer
This commit is contained in:
parent
9c986d06cb
commit
47f8447a22
@ -46,6 +46,8 @@ func (cp *commonPage) serve() {
|
|||||||
cr.GET("/network", cp.network)
|
cr.GET("/network", cp.network)
|
||||||
cr.GET("/ws", cp.ws)
|
cr.GET("/ws", cp.ws)
|
||||||
cr.POST("/terminal", cp.createTerminal)
|
cr.POST("/terminal", cp.createTerminal)
|
||||||
|
cr.GET("/file", cp.createFM)
|
||||||
|
cr.GET("/file/:id", cp.fm)
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewPasswordForm struct {
|
type viewPasswordForm struct {
|
||||||
@ -257,8 +259,8 @@ func (cp *commonPage) home(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 10240,
|
ReadBufferSize: 32768,
|
||||||
WriteBufferSize: 10240,
|
WriteBufferSize: 32768,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
@ -427,5 +429,130 @@ func (cp *commonPage) createTerminal(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
|
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
|
||||||
"SessionID": streamId,
|
"SessionID": streamId,
|
||||||
"ServerName": server.Name,
|
"ServerName": server.Name,
|
||||||
|
"ServerID": server.ID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *commonPage) fm(c *gin.Context) {
|
||||||
|
streamId := c.Param("id")
|
||||||
|
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "FM会话不存在",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
|
||||||
|
|
||||||
|
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: "NetworkError",
|
||||||
|
}),
|
||||||
|
Msg: "Websocket协议切换失败",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wsConn.Close()
|
||||||
|
conn := websocketx.NewConn(wsConn)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// PING 保活
|
||||||
|
for {
|
||||||
|
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second * 10)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *commonPage) createFM(c *gin.Context) {
|
||||||
|
IdString := c.Query("id")
|
||||||
|
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "用户未登录",
|
||||||
|
Link: "/login",
|
||||||
|
Btn: "去登录",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamId, err := uuid.GenerateUUID()
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: "SystemError",
|
||||||
|
}),
|
||||||
|
Msg: "生成会话ID失败",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc.NezhaHandlerSingleton.CreateStream(streamId)
|
||||||
|
|
||||||
|
serverId, err := strconv.Atoi(IdString)
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "请求参数有误:" + err.Error(),
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
singleton.ServerLock.RLock()
|
||||||
|
server := singleton.ServerList[uint64(serverId)]
|
||||||
|
singleton.ServerLock.RUnlock()
|
||||||
|
if server == nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "服务器不存在或处于离线状态",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmData, _ := utils.Json.Marshal(&model.TaskFM{
|
||||||
|
StreamID: streamId,
|
||||||
|
})
|
||||||
|
if err := server.TaskStream.Send(&proto.Task{
|
||||||
|
Type: model.TaskTypeFM,
|
||||||
|
Data: string(fmData),
|
||||||
|
}); err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "Agent信令下发失败",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/file", mygin.CommonEnvironment(c, gin.H{
|
||||||
|
"SessionID": streamId,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ const (
|
|||||||
TaskTypeTerminalGRPC
|
TaskTypeTerminalGRPC
|
||||||
TaskTypeNAT
|
TaskTypeNAT
|
||||||
TaskTypeReportHostInfo
|
TaskTypeReportHostInfo
|
||||||
|
TaskTypeFM
|
||||||
)
|
)
|
||||||
|
|
||||||
type TerminalTask struct {
|
type TerminalTask struct {
|
||||||
@ -34,6 +35,10 @@ type TaskNAT struct {
|
|||||||
Host string
|
Host string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskFM struct {
|
||||||
|
StreamID string
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MonitorCoverAll = iota
|
MonitorCoverAll = iota
|
||||||
MonitorCoverIgnoreAll
|
MonitorCoverIgnoreAll
|
||||||
|
508
resource/template/dashboard-default/file.html
vendored
Normal file
508
resource/template/dashboard-default/file.html
vendored
Normal file
@ -0,0 +1,508 @@
|
|||||||
|
{{define "dashboard-default/file"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{.Conf.Language}}">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>File List</title>
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/static/logo.svg?v20210804" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" />
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
margin: 5px 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#top-app-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current-directory {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: calc(100% - 100px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="top-app-bar">
|
||||||
|
<mdui-dropdown>
|
||||||
|
<mdui-button-icon slot="trigger" icon="menu"></mdui-button-icon>
|
||||||
|
<mdui-menu>
|
||||||
|
<mdui-menu-item id="refresh">Refresh</mdui-menu-item>
|
||||||
|
<mdui-menu-item id="copy">Copy path</mdui-menu-item>
|
||||||
|
<mdui-menu-item id="goto">Go to</mdui-menu-item>
|
||||||
|
</mdui-menu>
|
||||||
|
</mdui-dropdown>
|
||||||
|
<span id="current-directory"></span>
|
||||||
|
<mdui-button-icon id="upload" icon="upload"></mdui-button-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mdui-list id="file-list" class="file-list"></mdui-list>
|
||||||
|
|
||||||
|
<mdui-dialog id="error-dialog" headline="Error"
|
||||||
|
description="Agent returned an error, please view the console for details. To open a new connection, reopen the FM again."></mdui-dialog>
|
||||||
|
|
||||||
|
<mdui-dialog id="upd-modal" class="modal">
|
||||||
|
<mdui-linear-progress id="upd-progress"></mdui-linear-progress>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<mdui-dialog id="goto-dialog" headline="Go to a folder" close-on-overlay-click>
|
||||||
|
<mdui-text-field id="goto-text" variant="outlined" value=""></mdui-text-field>
|
||||||
|
<mdui-button id="goto-go" slot="action" variant="text">Go</mdui-button>
|
||||||
|
<mdui-button id="goto-close" slot="action" variant="tonal">Close</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentPath = '/opt/nezha/';
|
||||||
|
let fileName = '';
|
||||||
|
let receivedBuffer = []; // 用于缓存数据块
|
||||||
|
let expectedLength = 0;
|
||||||
|
let receivedLength = 0;
|
||||||
|
let isFirstChunk = true;
|
||||||
|
let isUpCompleted = false;
|
||||||
|
|
||||||
|
function updateDirectoryTitle() {
|
||||||
|
const directoryTitle = document.getElementById('current-directory');
|
||||||
|
directoryTitle.textContent = `${currentPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileList(items) {
|
||||||
|
const fileListElement = document.getElementById('file-list');
|
||||||
|
fileListElement.innerHTML = '';
|
||||||
|
|
||||||
|
if (currentPath !== '/') {
|
||||||
|
const upItem = document.createElement('mdui-list-item');
|
||||||
|
upItem.className = 'file-item up-directory';
|
||||||
|
upItem.setAttribute('icon', 'arrow_back');
|
||||||
|
upItem.textContent = "..";
|
||||||
|
upItem.onclick = function () {
|
||||||
|
const lastSlashIndex = currentPath.lastIndexOf('/', currentPath.length - 2);
|
||||||
|
currentPath = currentPath.substring(0, lastSlashIndex + 1) || '/';
|
||||||
|
listFile();
|
||||||
|
};
|
||||||
|
fileListElement.appendChild(upItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.fileType === 'dir' && b.fileType !== 'dir') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.fileType !== 'dir' && b.fileType === 'dir') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const listItem = document.createElement('mdui-list-item');
|
||||||
|
listItem.className = `file-item ${item.fileType.toLowerCase()}`;
|
||||||
|
listItem.setAttribute('nonclickable', 'true');
|
||||||
|
listItem.setAttribute('icon', 'insert_drive_file');
|
||||||
|
listItem.textContent = `${item.name}`;
|
||||||
|
|
||||||
|
if (item.fileType === 'dir') {
|
||||||
|
listItem.setAttribute('nonclickable', 'false');
|
||||||
|
listItem.setAttribute('icon', 'folder');
|
||||||
|
listItem.style.cursor = 'pointer';
|
||||||
|
listItem.onclick = function () {
|
||||||
|
currentPath += `${item.name}/`;
|
||||||
|
listFile();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const downloadButton = document.createElement('mdui-button-icon');
|
||||||
|
downloadButton.setAttribute('slot', 'end-icon');
|
||||||
|
downloadButton.setAttribute('icon', 'download');
|
||||||
|
downloadButton.onclick = function () {
|
||||||
|
const filePath = currentPath + item.name;
|
||||||
|
fileName = item.name;
|
||||||
|
downloadFile(filePath);
|
||||||
|
};
|
||||||
|
listItem.appendChild(downloadButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileListElement.appendChild(listItem);
|
||||||
|
});
|
||||||
|
updateDirectoryTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUpdState() {
|
||||||
|
receivedBuffer = [];
|
||||||
|
expectedLength = 0;
|
||||||
|
receivedLength = 0;
|
||||||
|
isFirstChunk = true;
|
||||||
|
updateProgress(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(filePath) {
|
||||||
|
showUpdModal('d');
|
||||||
|
|
||||||
|
const prefix = new Int8Array([1]); // Request download
|
||||||
|
const filePathMessage = new TextEncoder().encode(filePath);
|
||||||
|
|
||||||
|
const msg = new Int8Array(prefix.length + filePathMessage.length);
|
||||||
|
msg.set(prefix);
|
||||||
|
msg.set(filePathMessage, prefix.length);
|
||||||
|
|
||||||
|
socket.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFile() {
|
||||||
|
const prefix = new Int8Array([0]);
|
||||||
|
const resizeMessage = new TextEncoder().encode(currentPath);
|
||||||
|
|
||||||
|
const msg = new Int8Array(prefix.length + resizeMessage.length);
|
||||||
|
msg.set(prefix);
|
||||||
|
msg.set(resizeMessage, prefix.length);
|
||||||
|
|
||||||
|
socket.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
showUpdModal('u');
|
||||||
|
|
||||||
|
const chunkSize = 1048576; // 1MB chunk
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const filePath = `${currentPath}${file.name}`;
|
||||||
|
const fileSize = file.size;
|
||||||
|
const messageType = 2;
|
||||||
|
|
||||||
|
// Build header (type + file size + path)
|
||||||
|
const filePathBytes = new TextEncoder().encode(filePath);
|
||||||
|
const header = new ArrayBuffer(1 + 8 + filePathBytes.length);
|
||||||
|
const headerView = new DataView(header);
|
||||||
|
|
||||||
|
headerView.setUint8(0, messageType);
|
||||||
|
headerView.setBigUint64(1, BigInt(fileSize), false);
|
||||||
|
|
||||||
|
new Uint8Array(header, 9).set(filePathBytes);
|
||||||
|
|
||||||
|
// Send header
|
||||||
|
socket.send(header);
|
||||||
|
|
||||||
|
// Send data chunks
|
||||||
|
while (offset < fileSize) {
|
||||||
|
const chunk = file.slice(offset, offset + chunkSize);
|
||||||
|
const arrayBuffer = await readFileAsArrayBuffer(chunk);
|
||||||
|
socket.send(arrayBuffer);
|
||||||
|
offset += chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkCompletion = setInterval(() => {
|
||||||
|
if (isUpCompleted) {
|
||||||
|
clearInterval(checkCompletion); // 任务完成后停止检查
|
||||||
|
hideUpdModal();
|
||||||
|
resetUpdState();
|
||||||
|
listFile();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFileList(arrayBuffer) {
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
const items = [];
|
||||||
|
let offset = 4;
|
||||||
|
|
||||||
|
const pathLength = dataView.getUint32(offset);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
const pathArray = new Uint8Array(arrayBuffer, offset, pathLength);
|
||||||
|
currentPath = new TextDecoder('utf-8').decode(pathArray);
|
||||||
|
offset += pathLength;
|
||||||
|
|
||||||
|
while (offset < dataView.byteLength) {
|
||||||
|
const fileType = dataView.getUint8(offset);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
const nameLength = dataView.getUint8(offset);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
if (offset + nameLength > dataView.byteLength) {
|
||||||
|
console.error('Error: Name length exceeds buffer size');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameArray = new Uint8Array(arrayBuffer, offset, nameLength);
|
||||||
|
const name = new TextDecoder('utf-8').decode(nameArray);
|
||||||
|
offset += nameLength;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
fileType: fileType === 0x01 ? 'dir' : 'f',
|
||||||
|
name: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadFile(blob) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
hideUpdModal();
|
||||||
|
resetUpdState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsArrayBuffer(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatenateArrayBuffers(buffers) {
|
||||||
|
let totalLength = 0;
|
||||||
|
buffers.forEach(buf => totalLength += buf.byteLength);
|
||||||
|
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
buffers.forEach(buf => {
|
||||||
|
result.set(new Uint8Array(buf), offset);
|
||||||
|
offset += buf.byteLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a, b) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text to clipboard: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleError(errMsg) {
|
||||||
|
try {
|
||||||
|
console.error('Received error: ', errMsg);
|
||||||
|
hideUpdModal();
|
||||||
|
const errorDialog = document.getElementById('error-dialog');
|
||||||
|
errorDialog.open = true;
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close(1000, 'Closing due to error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error while handling error and closing WebSocket:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdModal(operation) {
|
||||||
|
const modal = document.getElementById('upd-modal');
|
||||||
|
modal.open = true;
|
||||||
|
if (operation === 'd') {
|
||||||
|
modal.setAttribute('headline', 'Downloading...');
|
||||||
|
modal.setAttribute('value', '0');
|
||||||
|
modal.setAttribute('max', '100');
|
||||||
|
} else if (operation === 'u') {
|
||||||
|
modal.setAttribute('headline', 'Uploading...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUpdModal() {
|
||||||
|
const modal = document.getElementById('upd-modal');
|
||||||
|
modal.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress(percentage) {
|
||||||
|
const progressBar = document.getElementById('upd-progress');
|
||||||
|
progressBar.value = percentage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket((window.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/file/' + '{{.SessionID}}');
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
socket.onmessage = async function (event) {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = event.data;
|
||||||
|
|
||||||
|
if (isFirstChunk) {
|
||||||
|
const identifier = new Uint8Array(arrayBuffer, 0, 4);
|
||||||
|
const fileIdentifier = new Uint8Array([0x4E, 0x5A, 0x54, 0x44]); // NZTD
|
||||||
|
const fileNameIdentifier = new Uint8Array([0x4E, 0x5A, 0x46, 0x4E]); // NZFN
|
||||||
|
const errorIdentifier = new Uint8Array([0x4E, 0x45, 0x52, 0x52]); // NERR
|
||||||
|
const completeIdentifier = new Uint8Array([0x4E, 0x5A, 0x55, 0x50]); // NZUP
|
||||||
|
|
||||||
|
if (arraysEqual(identifier, fileIdentifier)) {
|
||||||
|
// Download
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
expectedLength = Number(dataView.getBigUint64(4, false));
|
||||||
|
|
||||||
|
isFirstChunk = false;
|
||||||
|
receivedLength = 0;
|
||||||
|
|
||||||
|
// Initialize writer
|
||||||
|
const stream = new WritableStream({
|
||||||
|
write(chunk) {
|
||||||
|
receivedBuffer.push(chunk);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
// Save to blob
|
||||||
|
const completeBlob = new Blob(receivedBuffer);
|
||||||
|
handleDownloadFile(completeBlob);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writer = stream.getWriter();
|
||||||
|
|
||||||
|
// Read data after 12 bytes (if any)
|
||||||
|
const dataChunk = arrayBuffer.slice(12);
|
||||||
|
writer.write(dataChunk);
|
||||||
|
receivedLength += dataChunk.byteLength;
|
||||||
|
} else if (arraysEqual(identifier, fileNameIdentifier)) {
|
||||||
|
// List files
|
||||||
|
const { items } = await parseFileList(arrayBuffer);
|
||||||
|
updateFileList(items);
|
||||||
|
return;
|
||||||
|
} else if (arraysEqual(identifier, errorIdentifier)) {
|
||||||
|
// Handle error
|
||||||
|
const errBytes = arrayBuffer.slice(4);
|
||||||
|
const errMsg = new TextDecoder('utf-8').decode(errBytes);
|
||||||
|
await handleError(errMsg);
|
||||||
|
return;
|
||||||
|
} else if (arraysEqual(identifier, completeIdentifier)) {
|
||||||
|
// Upload is completed
|
||||||
|
isUpCompleted = true;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log('Unknown identifier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle data chunks
|
||||||
|
receivedLength += arrayBuffer.byteLength;
|
||||||
|
writer.write(arrayBuffer);
|
||||||
|
|
||||||
|
// Update progress bar
|
||||||
|
const percentage = Math.min((receivedLength / expectedLength) * 100, 100);
|
||||||
|
updateProgress(percentage);
|
||||||
|
|
||||||
|
if (receivedLength === expectedLength) {
|
||||||
|
writer.close(); // Close the writer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing received data:', error);
|
||||||
|
if (writer) {
|
||||||
|
writer.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onopen = function (event) {
|
||||||
|
listFile();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function (event) {
|
||||||
|
console.error('WebSocket error:', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function (event) {
|
||||||
|
console.log('WebSocket connection closed:', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('refresh').addEventListener('click', listFile);
|
||||||
|
|
||||||
|
document.getElementById('copy').addEventListener('click', async () => {
|
||||||
|
await copyTextToClipboard(currentPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('goto').addEventListener('click', function () {
|
||||||
|
const dialog = document.getElementById('goto-dialog');
|
||||||
|
const textField = document.getElementById('goto-text');
|
||||||
|
const goButton = document.getElementById('goto-go');
|
||||||
|
const closeButton = document.getElementById('goto-close');
|
||||||
|
|
||||||
|
dialog.open = true;
|
||||||
|
|
||||||
|
// Ensure the path ends with a separator
|
||||||
|
const updateText = function (event) {
|
||||||
|
let text = event.target.value;
|
||||||
|
if (!text.endsWith('/')) {
|
||||||
|
text += '/';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoClick = function () {
|
||||||
|
let text = updateText({ target: textField });
|
||||||
|
currentPath = text;
|
||||||
|
listFile();
|
||||||
|
dialog.open = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
textField.removeEventListener('change', updateText);
|
||||||
|
textField.addEventListener('change', updateText);
|
||||||
|
|
||||||
|
goButton.removeEventListener('click', handleGoClick);
|
||||||
|
goButton.addEventListener('click', handleGoClick);
|
||||||
|
|
||||||
|
closeButton.addEventListener("click", () => dialog.open = false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('upload').addEventListener('click', async function () {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', async function (event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
await uploadFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.click();
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{end}}
|
@ -23,10 +23,43 @@
|
|||||||
body {
|
body {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#file-list-iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 30%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background-color: white;
|
||||||
|
display: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#folder-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body onresize="onResize()">
|
<body onresize="onResize()">
|
||||||
<div id="terminal-container"></div>
|
<div id="terminal-container"></div>
|
||||||
|
<iframe id="file-list-iframe" src=""></iframe>
|
||||||
|
<button id="folder-button">📁</button>
|
||||||
|
|
||||||
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
||||||
<script src="https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
<script src="https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
|
||||||
<script src="https://unpkg.com/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
<script src="https://unpkg.com/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js"></script>
|
||||||
@ -95,6 +128,27 @@
|
|||||||
socket.onerror = () => {
|
socket.onerror = () => {
|
||||||
alert('{{tr "TerminalConnectionFailed"}}')
|
alert('{{tr "TerminalConnectionFailed"}}')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理文件夹按钮点击事件
|
||||||
|
const folderButton = document.getElementById('folder-button');
|
||||||
|
const fileListIframe = document.getElementById('file-list-iframe');
|
||||||
|
let fileListVisible = false;
|
||||||
|
|
||||||
|
folderButton.addEventListener('click', () => {
|
||||||
|
if (!fileListVisible) {
|
||||||
|
// 显示文件列表
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: "{{.ServerID}}"
|
||||||
|
}).toString();
|
||||||
|
fileListIframe.src = `/file?${params}`;
|
||||||
|
fileListIframe.style.display = 'block';
|
||||||
|
fileListVisible = true;
|
||||||
|
} else {
|
||||||
|
// 隐藏文件列表
|
||||||
|
fileListIframe.style.display = 'none';
|
||||||
|
fileListVisible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
@ -117,7 +117,7 @@ LOOP:
|
|||||||
endCh := make(chan struct{})
|
endCh := make(chan struct{})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, innerErr := io.Copy(stream.userIo, stream.agentIo)
|
_, innerErr := io.CopyBuffer(stream.userIo, stream.agentIo, make([]byte, 1048576))
|
||||||
if innerErr != nil {
|
if innerErr != nil {
|
||||||
err = innerErr
|
err = innerErr
|
||||||
}
|
}
|
||||||
@ -126,7 +126,7 @@ LOOP:
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
_, innerErr := io.Copy(stream.agentIo, stream.userIo)
|
_, innerErr := io.CopyBuffer(stream.agentIo, stream.userIo, make([]byte, 1048576))
|
||||||
if innerErr != nil {
|
if innerErr != nil {
|
||||||
err = innerErr
|
err = innerErr
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user