diff --git a/server/service/migrate.go b/server/service/migrate.go
new file mode 100644
index 0000000..7617cad
--- /dev/null
+++ b/server/service/migrate.go
@@ -0,0 +1,109 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "next-terminal/server/branding"
+ "next-terminal/server/common"
+ "next-terminal/server/env"
+ "next-terminal/server/model"
+ "next-terminal/server/repository"
+ "next-terminal/server/utils"
+
+ "gorm.io/gorm"
+)
+
+type resourceSharer struct {
+ ID string `gorm:"primary_key,type:varchar(36)" json:"id"`
+ ResourceId string `gorm:"index,type:varchar(36)" json:"resourceId"`
+ ResourceType string `gorm:"index,type:varchar(36)" json:"resourceType"`
+ StrategyId string `gorm:"index,type:varchar(36)" json:"strategyId"`
+ UserId string `gorm:"index,type:varchar(36)" json:"userId"`
+ UserGroupId string `gorm:"index,type:varchar(36)" json:"userGroupId"`
+}
+
+var MigrateService = &migrateService{}
+
+type migrateService struct {
+ baseService
+}
+
+func (s *migrateService) Migrate() error {
+ var needMigrate = false
+ var localVersion = ""
+ property, err := repository.PropertyRepository.FindByName(context.Background(), "version")
+ if err != nil {
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ // 未获取到版本数据
+ needMigrate = true
+ } else {
+ localVersion = property.Value
+ // 数据库版本小于当前版本
+ needMigrate = strings.Compare(localVersion, branding.Version) < 0
+ }
+
+ if !needMigrate {
+ return nil
+ }
+
+ if err := s.migrateFormV127to130(localVersion); err != nil {
+ return err
+ }
+
+ return PropertyService.Update(map[string]interface{}{"version": branding.Version})
+}
+
+func (s *migrateService) migrateFormV127to130(localVersion string) (err error) {
+ if !strings.Contains(localVersion, "beta") && strings.Compare(localVersion, "v1.3.0") > 0 {
+ return nil
+ }
+ err = env.GetDB().Exec(`update strategies set create_dir = 0 where create_dir = ''`).Error
+ if err != nil {
+ return err
+ }
+
+ ctx := context.Background()
+ var results []resourceSharer
+ err = env.GetDB().Raw(`select * from resource_sharers where resource_type = 'asset'`).Find(&results).Error
+ if err != nil {
+ // 数据库不存在
+ return nil
+ }
+
+ // 证明存在旧数据库,执行迁移
+ var items []model.Authorised
+ for _, result := range results {
+ assetId := result.ResourceId
+ strategyId := result.StrategyId
+ userId := result.UserId
+ userGroupId := result.UserGroupId
+
+ id := utils.Sign([]string{assetId, userId, userGroupId})
+
+ if err := repository.AuthorisedRepository.DeleteById(ctx, id); err != nil {
+ return err
+ }
+
+ authorised := model.Authorised{
+ ID: id,
+ AssetId: assetId,
+ CommandFilterId: "",
+ StrategyId: strategyId,
+ UserId: userId,
+ UserGroupId: userGroupId,
+ Created: common.NowJsonTime(),
+ }
+ items = append(items, authorised)
+ }
+
+ err = repository.AuthorisedRepository.CreateInBatches(ctx, items)
+ if err != nil {
+ return err
+ }
+ // 删除旧数据库
+ return env.GetDB().Exec(`drop table resource_sharers`).Error
+}
diff --git a/web/src/components/access/Guacd.js b/web/src/components/access/Guacd.js
new file mode 100644
index 0000000..079bac2
--- /dev/null
+++ b/web/src/components/access/Guacd.js
@@ -0,0 +1,548 @@
+import React, {useEffect, useState} from 'react';
+import {useSearchParams} from "react-router-dom";
+import sessionApi from "../../api/session";
+import strings from "../../utils/strings";
+import Guacamole from "guacamole-common-js";
+import {wsServer} from "../../common/env";
+import {exitFull, getToken, requestFullScreen} from "../../utils/utils";
+import qs from "qs";
+import {Affix, Button, Drawer, Dropdown, Menu, message, Modal} from "antd";
+import {
+ CopyOutlined,
+ ExclamationCircleOutlined,
+ ExpandOutlined,
+ FolderOutlined,
+ WindowsOutlined
+} from "@ant-design/icons";
+import {Base64} from "js-base64";
+import Draggable from "react-draggable";
+import FileSystem from "../devops/FileSystem";
+import GuacdClipboard from "./GuacdClipboard";
+import {debounce} from "../../utils/fun";
+
+let fixedSize = false;
+
+const STATE_IDLE = 0;
+const STATE_CONNECTING = 1;
+const STATE_WAITING = 2;
+const STATE_CONNECTED = 3;
+const STATE_DISCONNECTING = 4;
+const STATE_DISCONNECTED = 5;
+
+const Guacd = () => {
+
+ let [searchParams] = useSearchParams();
+ let assetId = searchParams.get('assetId');
+ let assetName = searchParams.get('assetName');
+ let protocol = searchParams.get('protocol');
+ let width = searchParams.get('width');
+ let height = searchParams.get('height');
+
+ if (width && height) {
+ fixedSize = true;
+ } else {
+ width = window.innerWidth;
+ height = window.innerHeight;
+ }
+
+ const [box, setBox] = useState({width: width, height: height});
+
+ let [guacd, setGuacd] = useState({});
+ let [session, setSession] = useState({});
+ let [clipboardText, setClipboardText] = useState('');
+ let [fullScreened, setFullScreened] = useState(false);
+ let [clientState, setClientState] = useState(STATE_IDLE);
+ let [clipboardVisible, setClipboardVisible] = useState(false);
+ let [fileSystemVisible, setFileSystemVisible] = useState(false);
+
+ useEffect(() => {
+ document.title = assetName;
+
+ const renderDisplay = (sessionId, protocol, width, height) => {
+ let tunnel = new Guacamole.WebSocketTunnel(`${wsServer}/sessions/${sessionId}/tunnel`);
+ let client = new Guacamole.Client(tunnel);
+
+ // 处理从虚拟机收到的剪贴板内容
+ client.onclipboard = handleClipboardReceived;
+
+ // 处理客户端的状态变化事件
+ client.onstatechange = (state) => {
+ onClientStateChange(state, sessionId);
+ };
+
+ client.onerror = onError;
+ tunnel.onerror = onError;
+
+ // Get display div from document
+ const display = document.getElementById("display");
+
+ // Add client to display div
+ const element = client.getDisplay().getElement();
+ display.appendChild(element);
+
+ let scale = 1;
+ let dpi = 96;
+ if (protocol === 'telnet') {
+ dpi = dpi * 2;
+ scale = 0.5;
+ }
+
+ let token = getToken();
+
+ let params = {
+ 'width': width,
+ 'height': height,
+ 'dpi': dpi,
+ 'X-Auth-Token': token
+ };
+
+ let paramStr = qs.stringify(params);
+
+ client.connect(paramStr);
+
+ const mouse = new Guacamole.Mouse(element);
+
+ mouse.onmousedown = mouse.onmouseup = function (mouseState) {
+ client.sendMouseState(mouseState);
+ };
+
+ mouse.onmousemove = function (mouseState) {
+ mouseState.x = mouseState.x / scale;
+ mouseState.y = mouseState.y / scale;
+ client.sendMouseState(mouseState);
+ };
+
+ const sink = new Guacamole.InputSink();
+ display.appendChild(sink.getElement());
+ sink.focus();
+
+ const keyboard = new Guacamole.Keyboard(sink.getElement());
+
+ keyboard.onkeydown = (keysym) => {
+ client.sendKeyEvent(1, keysym);
+ if (keysym === 65288) {
+ return false;
+ }
+ };
+ keyboard.onkeyup = (keysym) => {
+ client.sendKeyEvent(0, keysym);
+ };
+
+ setGuacd({
+ client,
+ scale,
+ sink,
+ });
+ }
+
+ const x = async () => {
+ let session = await sessionApi.create(assetId, 'guacd');
+ if (!strings.hasText(session['id'])) {
+ return;
+ }
+ setSession(session);
+ renderDisplay(session['id'], protocol, width, height);
+ }
+ x();
+ }, [assetId, assetName]);
+
+ useEffect(() => {
+ let resize = debounce(() => {
+ onWindowResize();
+ });
+ window.addEventListener('resize', resize);
+ window.addEventListener('beforeunload', handleUnload);
+ window.addEventListener('focus', handleWindowFocus);
+
+ return () => {
+ window.removeEventListener('resize', resize);
+ window.removeEventListener('beforeunload', handleUnload);
+ window.removeEventListener('focus', handleWindowFocus);
+ };
+ }, [guacd])
+
+ const onWindowResize = () => {
+ console.log(guacd, fixedSize);
+ if (guacd.client && !fixedSize) {
+ const display = guacd.client.getDisplay();
+ let scale = guacd.scale;
+ display.scale(scale);
+ let width = window.innerWidth;
+ let height = window.innerHeight;
+
+ guacd.client.sendSize(width / scale, height / scale);
+
+ setBox({width, height})
+ }
+ }
+
+ const handleUnload = (e) => {
+ const message = "要离开网站吗?";
+ (e || window.event).returnValue = message; //Gecko + IE
+ return message;
+ }
+
+ const focus = () => {
+ if (guacd.sink) {
+ guacd.sink.focus();
+ }
+ }
+
+ const handleWindowFocus = (e) => {
+ if (navigator.clipboard) {
+ try {
+ navigator.clipboard.readText().then((text) => {
+ sendClipboard({
+ 'data': text,
+ 'type': 'text/plain'
+ });
+ })
+ } catch (e) {
+ // console.error(e);
+ }
+ }
+ };
+
+ const handleClipboardReceived = (stream, mimetype) => {
+ if (session['copy'] === '0') {
+ message.warn('禁止复制');
+ return
+ }
+
+ if (/^text\//.exec(mimetype)) {
+ let reader = new Guacamole.StringReader(stream);
+ let data = '';
+ reader.ontext = function textReceived(text) {
+ data += text;
+ };
+ reader.onend = async () => {
+ setClipboardText(data);
+ if (navigator.clipboard) {
+ await navigator.clipboard.writeText(data);
+ }
+ message.info('您选择的内容已复制到您的粘贴板中,在右侧的输入框中可同时查看到。');
+ };
+ } else {
+ let reader = new Guacamole.BlobReader(stream, mimetype);
+ reader.onend = () => {
+ console.log(stream, mimetype, reader)
+ setClipboardText(reader.getBlob());
+ }
+ }
+ };
+
+ const sendClipboard = (data) => {
+ if (clientState !== STATE_CONNECTED) {
+ return;
+ }
+ if (!guacd.client) {
+ return;
+ }
+ if (session['paste'] === '0') {
+ message.warn('禁止粘贴');
+ return
+ }
+ const stream = guacd.client.createClipboardStream(data.type);
+ if (typeof data.data === 'string') {
+ let writer = new Guacamole.StringWriter(stream);
+ writer.sendText(data.data);
+ writer.sendEnd();
+ } else {
+ let writer = new Guacamole.BlobWriter(stream);
+ writer.oncomplete = function clipboardSent() {
+ writer.sendEnd();
+ };
+ writer.sendBlob(data.data);
+ }
+
+ if (data.data && data.data.length > 0) {
+ message.info('您输入的内容已复制到远程服务器上');
+ }
+ }
+
+ const onClientStateChange = (state, sessionId) => {
+ setClientState(state);
+ const key = 'message';
+ switch (state) {
+ case STATE_IDLE:
+ message.destroy(key);
+ message.loading({content: '正在初始化中...', duration: 0, key: key});
+ break;
+ case STATE_CONNECTING:
+ message.destroy(key);
+ message.loading({content: '正在努力连接中...', duration: 0, key: key});
+ break;
+ case STATE_WAITING:
+ message.destroy(key);
+ message.loading({content: '正在等待服务器响应...', duration: 0, key: key});
+ break;
+ case STATE_CONNECTED:
+ Modal.destroyAll();
+ message.destroy(key);
+ message.success({content: '连接成功', duration: 3, key: key});
+ // 向后台发送请求,更新会话的状态
+ sessionApi.connect(sessionId);
+ break;
+ case STATE_DISCONNECTING:
+
+ break;
+ case STATE_DISCONNECTED:
+ message.info({content: '连接已关闭', duration: 3, key: key});
+ break;
+ default:
+ break;
+ }
+ };
+
+ const sendCombinationKey = (keys) => {
+ if (!guacd.client) {
+ return;
+ }
+ for (let i = 0; i < keys.length; i++) {
+ guacd.client.sendKeyEvent(1, keys[i]);
+ }
+ for (let j = 0; j < keys.length; j++) {
+ guacd.client.sendKeyEvent(0, keys[j]);
+ }
+ }
+
+ const showMessage = (msg) => {
+ message.destroy();
+ Modal.confirm({
+ title: '提示',
+ icon: