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: , + content: msg, + centered: true, + okText: '重新连接', + cancelText: '关闭页面', + onOk() { + window.location.reload(); + }, + onCancel() { + window.close(); + }, + }); + } + + const onError = (status) => { + console.log('通道异常。', status); + switch (status.code) { + case 256: + showMessage('未支持的访问'); + break; + case 512: + showMessage('远程服务异常,请检查目标设备能否正常访问。'); + break; + case 513: + showMessage('服务器忙碌'); + break; + case 514: + showMessage('服务器连接超时'); + break; + case 515: + showMessage('远程服务异常'); + break; + case 516: + showMessage('资源未找到'); + break; + case 517: + showMessage('资源冲突'); + break; + case 518: + showMessage('资源已关闭'); + break; + case 519: + showMessage('远程服务未找到'); + break; + case 520: + showMessage('远程服务不可用'); + break; + case 521: + showMessage('会话冲突'); + break; + case 522: + showMessage('会话连接超时'); + break; + case 523: + showMessage('会话已关闭'); + break; + case 768: + showMessage('网络不可达'); + break; + case 769: + showMessage('服务器密码验证失败'); + break; + case 771: + showMessage('客户端被禁止'); + break; + case 776: + showMessage('客户端连接超时'); + break; + case 781: + showMessage('客户端异常'); + break; + case 783: + showMessage('错误的请求类型'); + break; + case 800: + showMessage('会话不存在'); + break; + case 801: + showMessage('创建隧道失败,请检查Guacd服务是否正常。'); + break; + case 802: + showMessage('管理员强制关闭了此会话'); + break; + default: + if (status.message) { + // guacd 无法处理中文字符,所以进行了base64编码。 + showMessage(Base64.decode(status.message)); + } else { + showMessage('未知错误。'); + } + + } + }; + + const fullScreen = () => { + if (fullScreened) { + exitFull(); + setFullScreened(false); + } else { + requestFullScreen(document.documentElement); + setFullScreened(true); + } + focus(); + } + + const hotKeyMenu = ( + + sendCombinationKey(['65507', '65513', '65535'])}>Ctrl+Alt+Delete + sendCombinationKey(['65507', '65513', '65288'])}>Ctrl+Alt+Backspace + sendCombinationKey(['65515', '100'])}>Windows+D + sendCombinationKey(['65515', '101'])}>Windows+E + sendCombinationKey(['65515', '114'])}>Windows+R + sendCombinationKey(['65515', '120'])}>Windows+X + sendCombinationKey(['65515'])}>Windows + + ); + + return ( +
+ +
+
+
+ + + +
+ ); +}; + +export default Guacd; \ No newline at end of file diff --git a/web/src/components/access/GuacdClipboard.js b/web/src/components/access/GuacdClipboard.js new file mode 100644 index 0000000..9aae1f6 --- /dev/null +++ b/web/src/components/access/GuacdClipboard.js @@ -0,0 +1,48 @@ +import React, {useEffect, useState} from 'react'; +import {Form, Input, Modal} from "antd"; + +const GuacdClipboard = ({visible, clipboardText, handleOk, handleCancel}) => { + + const [form] = Form.useForm(); + let [confirmLoading, setConfirmLoading] = useState(false); + + useEffect(() => { + form.setFieldsValue({ + 'clipboard': clipboardText + }) + }, [visible]); + + return ( +
+ { + form.validateFields() + .then(values => { + setConfirmLoading(true); + try { + handleOk(values['clipboard']); + } finally { + setConfirmLoading(false); + } + }) + .catch(info => { + + }); + }} + confirmLoading={confirmLoading} + onCancel={handleCancel} + > +
+ + + +
+
+
+ ); +}; + +export default GuacdClipboard; \ No newline at end of file