From 8e2be63d3f10874bc9b5bab7097ff0d405699dfb Mon Sep 17 00:00:00 2001 From: zhichaona <1255628593@qq.com> Date: Wed, 23 Apr 2025 14:10:11 +0800 Subject: [PATCH] Add excl draw module 4 (#7740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add exdraw apis * temporarily submit * rebase exdraw_apis * Interacting with the exdraw-server * update * optimize code * optimize code --------- Co-authored-by: ‘JoinTyang’ Co-authored-by: 小强 --- frontend/package-lock.json | 222 +++++++++++------- frontend/package.json | 1 + .../collab/exdraw-server-api.js | 28 +++ .../src/pages/excalidraw-editor/editor-api.js | 6 + frontend/src/pages/excalidraw-editor/index.js | 29 ++- .../pages/excalidraw-editor/simple-editor.js | 17 +- frontend/src/utils/seafile-api.js | 15 ++ seahub/api2/endpoints/file.py | 4 + seahub/exdraw/__init__.py | 0 seahub/exdraw/apis.py | 200 ++++++++++++++++ seahub/exdraw/urls.py | 10 + seahub/exdraw/utils.py | 120 ++++++++++ seahub/seadoc/utils.py | 16 +- seahub/settings.py | 10 +- .../templates/excalidraw_file_view_react.html | 1 + seahub/urls.py | 6 +- seahub/utils/__init__.py | 18 +- seahub/views/file.py | 7 +- 18 files changed, 589 insertions(+), 121 deletions(-) create mode 100644 frontend/src/pages/excalidraw-editor/collab/exdraw-server-api.js create mode 100644 seahub/exdraw/__init__.py create mode 100644 seahub/exdraw/apis.py create mode 100644 seahub/exdraw/urls.py create mode 100644 seahub/exdraw/utils.py diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7410d5847e..6caba4daf9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -55,6 +55,7 @@ "react-select": "5.9.0", "react-transition-group": "4.4.5", "reactstrap": "9.2.3", + "socket.io-client": "^4.8.1", "svg-sprite-loader": "^6.0.11", "svgo-loader": "^3.0.1", "unified": "^7.0.0", @@ -5638,42 +5639,6 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" }, - "node_modules/@seafile/sdoc-editor/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@seafile/sdoc-editor/node_modules/engine.io-client": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", - "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/@seafile/sdoc-editor/node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@seafile/sdoc-editor/node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -5686,32 +5651,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@seafile/sdoc-editor/node_modules/socket.io-client": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", - "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@seafile/sdoc-editor/node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@seafile/sdoc-editor/node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", @@ -5741,34 +5680,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/@seafile/sdoc-editor/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/@seafile/sdoc-editor/node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/@seafile/seafile-calendar": { "version": "0.0.28", "resolved": "https://registry.npmmirror.com/@seafile/seafile-calendar/-/seafile-calendar-0.0.28.tgz", @@ -12237,6 +12148,71 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.0", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", @@ -27066,6 +27042,64 @@ "node": ">=0.10.0" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmmirror.com/sockjs/-/sockjs-0.3.24.tgz", @@ -31257,6 +31291,14 @@ "node": ">=0.1" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2cf781ac78..72a8acb62e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "react-select": "5.9.0", "react-transition-group": "4.4.5", "reactstrap": "9.2.3", + "socket.io-client": "^4.8.1", "svg-sprite-loader": "^6.0.11", "svgo-loader": "^3.0.1", "unified": "^7.0.0", diff --git a/frontend/src/pages/excalidraw-editor/collab/exdraw-server-api.js b/frontend/src/pages/excalidraw-editor/collab/exdraw-server-api.js new file mode 100644 index 0000000000..d01269a55e --- /dev/null +++ b/frontend/src/pages/excalidraw-editor/collab/exdraw-server-api.js @@ -0,0 +1,28 @@ +import axios from 'axios'; + +class ExdrawServerApi { + + constructor(options) { + this.server = options.exdrawServer; + this.docUuid = options.exdrawUuid; + this.accessToken = options.accessToken; + } + + getSceneContent() { + const { server, docUuid, accessToken } = this; + const url = `${server}/api/v1/exdraw/${docUuid}/`; + + return axios.get(url, { headers: { Authorization: `Token ${accessToken}` } }); + } + + saveSceneContent(content) { + const { server, docUuid, accessToken } = this; + const url = `${server}/api/v1/exdraw/${docUuid}/`; + const formData = new FormData(); + formData.append('doc_content', content); + + return axios.post(url, formData, { headers: { Authorization: `Token ${accessToken}` } }); + } +} + +export default ExdrawServerApi; diff --git a/frontend/src/pages/excalidraw-editor/editor-api.js b/frontend/src/pages/excalidraw-editor/editor-api.js index f2524cca2f..fb92916139 100644 --- a/frontend/src/pages/excalidraw-editor/editor-api.js +++ b/frontend/src/pages/excalidraw-editor/editor-api.js @@ -19,6 +19,12 @@ class EditorApi { return seafileAPI.getFileContent(downLoadUrl); }); }; + + getExdrawToken = () => { + return seafileAPI.getExdrawToken(repoID, filePath).then((res => { + return res.data.access_token; + })); + }; } const editorApi = new EditorApi(); diff --git a/frontend/src/pages/excalidraw-editor/index.js b/frontend/src/pages/excalidraw-editor/index.js index 28524de502..1e1c330b2b 100644 --- a/frontend/src/pages/excalidraw-editor/index.js +++ b/frontend/src/pages/excalidraw-editor/index.js @@ -6,23 +6,35 @@ import { gettext } from '../../utils/constants'; import toaster from '../../components/toast'; import { SAVE_INTERVAL_TIME } from './constants'; import { Utils } from '../../utils/utils'; +import ExdrawServerApi from './collab/exdraw-server-api'; import './index.css'; +const { docUuid, excalidrawServerUrl } = window.app.pageOptions; + const ExcaliEditor = () => { const [fileContent, setFileContent] = useState(null); const editorRef = useRef(null); const isChangedRef = useRef(false); const [isFetching, setIsFetching] = useState(true); + const exdrawServerConfigRef = useRef({ + exdrawServer: '', + exdrawUuid: '', + accessToken: '' + }); useEffect(() => { - editorApi.getFileContent().then(res => { - if (res.data?.appState?.collaborators && !Array.isArray(res.data.appState.collaborators)) { - // collaborators.forEach is not a function - res.data['appState']['collaborators'] = []; - } - setFileContent(res.data); - setIsFetching(false); + editorApi.getExdrawToken().then(res => { + exdrawServerConfigRef.current = { + exdrawServer: excalidrawServerUrl, + exdrawUuid: docUuid, + accessToken: res + }; + const exdrawServerApi = new ExdrawServerApi(exdrawServerConfigRef.current); + exdrawServerApi.getSceneContent().then(res => { + setFileContent(res.data); + setIsFetching(false); + }); }); onSetFavicon(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -31,7 +43,8 @@ const ExcaliEditor = () => { const saveSceneContent = useCallback(async () => { if (isChangedRef.current) { try { - await editorApi.saveContent(JSON.stringify(editorRef.current)); + const exdrawServerApi = new ExdrawServerApi(exdrawServerConfigRef.current); + await exdrawServerApi.saveSceneContent(JSON.stringify(editorRef.current)); isChangedRef.current = false; toaster.success(gettext('Successfully saved'), { duration: 2 }); } catch { diff --git a/frontend/src/pages/excalidraw-editor/simple-editor.js b/frontend/src/pages/excalidraw-editor/simple-editor.js index 827f287478..77a19b466c 100644 --- a/frontend/src/pages/excalidraw-editor/simple-editor.js +++ b/frontend/src/pages/excalidraw-editor/simple-editor.js @@ -21,7 +21,6 @@ const SimpleEditor = ({ }, tools: { image: false }, }; - const handleChange = () => { const elements = excalidrawAPI.getSceneElements(); if (hasChanged(elements, prevElementsRef.current)) { @@ -69,6 +68,22 @@ const SimpleEditor = ({ onChange={handleChange} UIOptions={UIOptions} langCode={langList[window.app.config.lang] || 'en'} + renderTopRightUI={() => { + return ( + + ); + }} > diff --git a/frontend/src/utils/seafile-api.js b/frontend/src/utils/seafile-api.js index faa330c76f..b362d27c36 100644 --- a/frontend/src/utils/seafile-api.js +++ b/frontend/src/utils/seafile-api.js @@ -2222,6 +2222,21 @@ class SeafileAPI { return this._sendPostRequest(url, formData); } + sysAdminListFileTransferLogs(page, perPage) { + const url = this.server + '/api/v2.1/admin/logs/repo-transfer-logs/'; + let params = { + page: page, + per_page: perPage + }; + return this.req.get(url, { params: params }); + } + + getExdrawToken(repoID, filePath) { + const url = `/api/v2.1/exdraw/access-token/${repoID}/?p=${filePath}`; + + return this.req.get(url); + } + } let seafileAPI = new SeafileAPI(); diff --git a/seahub/api2/endpoints/file.py b/seahub/api2/endpoints/file.py index 65e2a0beac..1d7b9482dc 100644 --- a/seahub/api2/endpoints/file.py +++ b/seahub/api2/endpoints/file.py @@ -37,6 +37,7 @@ from seahub.base.models import FileComment from seahub.settings import MAX_UPLOAD_FILE_NAME_LEN, OFFICE_TEMPLATE_ROOT from seahub.api2.endpoints.utils import convert_file, sdoc_convert_to_docx from seahub.seadoc.utils import get_seadoc_file_uuid +from seahub.exdraw.utils import get_exdraw_file_uuid from seaserv import seafile_api from pysearpc import SearpcError @@ -266,6 +267,9 @@ class FileView(APIView): if new_file_name.endswith('.sdoc'): doc_uuid = get_seadoc_file_uuid(repo, new_file_path) file_info['doc_uuid'] = doc_uuid + if new_file_name.endswith('.exdraw'): + file_uuid = get_exdraw_file_uuid(repo, new_file_path) + file_info['file_uuid'] = file_uuid return Response(file_info) if operation == 'rename': diff --git a/seahub/exdraw/__init__.py b/seahub/exdraw/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/seahub/exdraw/apis.py b/seahub/exdraw/apis.py new file mode 100644 index 0000000000..eec94218dd --- /dev/null +++ b/seahub/exdraw/apis.py @@ -0,0 +1,200 @@ +import os +import logging +import requests +import posixpath + +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status +from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import IsAuthenticated + +from seaserv import seafile_api + +from seahub.views import check_folder_permission +from seahub.api2.authentication import TokenAuthentication +from seahub.api2.utils import api_error +from seahub.api2.throttling import UserRateThrottle +from seahub.exdraw.utils import is_valid_exdraw_access_token, get_exdraw_upload_link, get_exdraw_download_link, \ + get_exdraw_file_uuid, gen_exdraw_access_token + +from seahub.utils.file_types import EXCALIDRAW +from seahub.utils.file_op import if_locked_by_online_office +from seahub.utils import get_file_type_and_ext, normalize_file_path, is_pro_version +from seahub.tags.models import FileUUIDMap + + +logger = logging.getLogger(__name__) + + +class ExdrawAccessToken(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + throttle_classes = (UserRateThrottle, ) + + def get(self, request, repo_id): + username = request.user.username + # argument check + path = request.GET.get('p', None) + if not path: + error_msg = 'p invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + path = normalize_file_path(path) + parent_dir = os.path.dirname(path) + filename = os.path.basename(path) + + filetype, fileext = get_file_type_and_ext(filename) + if filetype != EXCALIDRAW: + error_msg = 'exdraw file type %s invalid.' % filetype + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # resource check + repo = seafile_api.get_repo(repo_id) + if not repo: + error_msg = 'Library %s not found.' % repo_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + obj_id = seafile_api.get_file_id_by_path(repo_id, path) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + if not obj_id: + error_msg = 'File %s not found.' % path + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + permission = check_folder_permission(request, repo_id, parent_dir) + if not permission: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + file_uuid = get_exdraw_file_uuid(repo, path) + access_token = gen_exdraw_access_token(file_uuid, filename, username, permission=permission) + + return Response({'access_token': access_token}) + + +class ExdrawUploadFile(APIView): + authentication_classes = () + throttle_classes = (UserRateThrottle, ) + + def post(self, request, file_uuid): + # jwt permission check + auth = request.headers.get('authorization', '').split() + if not is_valid_exdraw_access_token(auth, file_uuid): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + file = request.FILES.get('file', None) + if not file: + error_msg = 'file not found.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + uuid_map = FileUUIDMap.objects.get_fileuuidmap_by_uuid(file_uuid) + if not uuid_map: + error_msg = 'exdraw uuid %s not found.' % file_uuid + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + filetype, fileext = get_file_type_and_ext(uuid_map.filename) + if filetype != EXCALIDRAW: + error_msg = 'exdraw file type %s invalid.' % filetype + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + file_path = posixpath.join(uuid_map.parent_path, uuid_map.filename) + file_path = os.path.normpath(file_path) + file_id = seafile_api.get_file_id_by_path(uuid_map.repo_id, file_path) + if not file_id: # save file anyway + seafile_api.post_empty_file( + uuid_map.repo_id, uuid_map.parent_path, uuid_map.filename, '') + + last_modify_user = request.POST.get('last_modify_user', '') + upload_link = get_exdraw_upload_link(uuid_map, last_modify_user) + if not upload_link: + error_msg = 'exdraw file %s not found.' % uuid_map.filename + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # update file + files = {'file': file} + data = {'filename': uuid_map.filename, 'target_file': file_path} + resp = requests.post(upload_link, files=files, data=data) + if not resp.ok: + logger.error('save exdraw failed %s, %s' % (file_uuid, resp.text)) + return api_error(resp.status_code, resp.content) + + return Response({'success': True}) + + +class ExdrawDownloadLink(APIView): + authentication_classes = () + throttle_classes = (UserRateThrottle,) + + def get(self, request, file_uuid): + # jwt permission check + auth = request.headers.get('authorization', '').split() + if not is_valid_exdraw_access_token(auth, file_uuid): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + uuid_map = FileUUIDMap.objects.get_fileuuidmap_by_uuid(file_uuid) + if not uuid_map: + error_msg = 'exdraw uuid %s not found.' % file_uuid + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + filetype, fileext = get_file_type_and_ext(uuid_map.filename) + if filetype != EXCALIDRAW: + error_msg = 'exdraw file type %s invalid.' % filetype + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + download_link = get_exdraw_download_link(uuid_map) + if not download_link: + error_msg = 'exdraw file %s not found.' % uuid_map.filename + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + return Response({'download_link': download_link}) + + +class ExdrawEditorCallBack(APIView): + authentication_classes = () + throttle_classes = (UserRateThrottle,) + + def post(self, request, file_uuid): + # jwt permission check + auth = request.headers.get('authorization', '').split() + if not is_valid_exdraw_access_token(auth, file_uuid): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # file info check + uuid_map = FileUUIDMap.objects.get_fileuuidmap_by_uuid(file_uuid) + if not uuid_map: + error_msg = 'exdraw uuid %s not found.' % file_uuid + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + filetype, fileext = get_file_type_and_ext(uuid_map.filename) + if filetype != EXCALIDRAW: + error_msg = 'exdraw file type %s invalid.' % filetype + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # currently only implement unlock file + exdraw_status = request.POST.get('status', '') + if exdraw_status != 'no_write': + error_msg = 'status invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + # unlock file + repo_id = uuid_map.repo_id + file_path = posixpath.join(uuid_map.parent_path, uuid_map.filename) + file_path = os.path.normpath(file_path) + try: + if is_pro_version() and if_locked_by_online_office(repo_id, file_path): + seafile_api.unlock_file(repo_id, file_path) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + return Response({'success': True}) diff --git a/seahub/exdraw/urls.py b/seahub/exdraw/urls.py new file mode 100644 index 0000000000..1fdf9d8eea --- /dev/null +++ b/seahub/exdraw/urls.py @@ -0,0 +1,10 @@ +from django.urls import re_path +from .apis import ExdrawAccessToken, ExdrawDownloadLink, ExdrawUploadFile, ExdrawEditorCallBack + + +urlpatterns = [ + re_path(r'^access-token/(?P[-0-9a-f]{36})/$', ExdrawAccessToken.as_view(), name='exdraw_access_token'), + re_path(r'^upload-file/(?P[-0-9a-f]{36})/$', ExdrawUploadFile.as_view(), name='exdraw_upload_file'), + re_path(r'^download-link/(?P[-0-9a-f]{36})/$', ExdrawDownloadLink.as_view(), name='exdraw_download_link'), + re_path(r'^editor-status-callback/(?P[-0-9a-f]{36})/$', ExdrawEditorCallBack.as_view(), name='exdraw_editor_callback'), +] diff --git a/seahub/exdraw/utils.py b/seahub/exdraw/utils.py new file mode 100644 index 0000000000..eacc5748ca --- /dev/null +++ b/seahub/exdraw/utils.py @@ -0,0 +1,120 @@ +import os +import jwt +import json +import time +import logging +import posixpath + +from seaserv import seafile_api + +from seahub.tags.models import FileUUIDMap +from seahub.settings import EXCALIDRAW_PRIVATE_KEY +from seahub.utils import normalize_file_path, gen_file_get_url, gen_file_upload_url, gen_inner_file_get_url +from seahub.utils.auth import AUTHORIZATION_PREFIX +from seahub.base.templatetags.seahub_tags import email2nickname +from seahub.avatar.templatetags.avatar_tags import api_avatar_url +from seahub.utils import uuid_str_to_36_chars + +logger = logging.getLogger(__name__) + + +def gen_exdraw_access_token(file_uuid, filename, username, permission='rw'): + name = email2nickname(username) + url, is_default, date_uploaded = api_avatar_url(username) + access_token = jwt.encode({ + 'file_uuid': file_uuid, + 'filename': filename, + 'username': username, + 'name': name, + 'avatar_url': url, + 'permission': permission, + 'exp': int(time.time()) + 86400 * 3, # 3 days + }, + EXCALIDRAW_PRIVATE_KEY, + algorithm='HS256' + ) + return access_token + + +def is_valid_exdraw_access_token(auth, file_uuid, return_payload=False): + """ + can decode a valid jwt payload + """ + is_valid, payload = False, None + if not auth or auth[0].lower() not in AUTHORIZATION_PREFIX or len(auth) != 2: + return (is_valid, payload) if return_payload else is_valid + + token = auth[1] + if not token or not file_uuid: + return (is_valid, payload) if return_payload else is_valid + + try: + payload = jwt.decode(token, EXCALIDRAW_PRIVATE_KEY, algorithms=['HS256']) + except Exception as e: + logger.error('Failed to decode jwt: %s' % e) + is_valid = False + else: + file_uuid_in_payload = payload.get('file_uuid') + + if not file_uuid_in_payload: + is_valid = False + elif uuid_str_to_36_chars(file_uuid_in_payload) != uuid_str_to_36_chars(file_uuid): + is_valid = False + else: + is_valid = True + + if return_payload: + return is_valid, payload + return is_valid + + +def get_exdraw_file_uuid(repo, path): + repo_id = repo.repo_id + if repo.is_virtual: + repo_id = repo.origin_repo_id + path = posixpath.join(repo.origin_path, path.strip('/')) + + path = normalize_file_path(path) + parent_dir = os.path.dirname(path) + filename = os.path.basename(path) + + uuid_map = FileUUIDMap.objects.get_or_create_fileuuidmap( + repo_id, parent_dir, filename, is_dir=False) + + file_uuid = str(uuid_map.uuid) # 36 chars str + return file_uuid + + +def get_exdraw_upload_link(uuid_map, last_modify_user=''): + repo_id = uuid_map.repo_id + parent_path = uuid_map.parent_path + + obj_id = json.dumps({'online_office_update': True, 'parent_dir': parent_path}) + token = seafile_api.get_fileserver_access_token( + repo_id, obj_id, 'update', last_modify_user, use_onetime=True) + if not token: + return None + upload_link = gen_file_upload_url(token, 'update-api') + return upload_link + + +def get_exdraw_download_link(uuid_map, is_inner=False): + repo_id = uuid_map.repo_id + parent_path = uuid_map.parent_path + filename = uuid_map.filename + file_path = posixpath.join(parent_path, filename) + + obj_id = seafile_api.get_file_id_by_path(repo_id, file_path) + if not obj_id: + return None + token = seafile_api.get_fileserver_access_token( + repo_id, obj_id, 'view', '', use_onetime=False) + if not token: + return None + + if is_inner: + download_link = gen_inner_file_get_url(token, filename) + else: + download_link = gen_file_get_url(token, filename) + + return download_link diff --git a/seahub/seadoc/utils.py b/seahub/seadoc/utils.py index eefd79bb38..7c3f928e33 100644 --- a/seahub/seadoc/utils.py +++ b/seahub/seadoc/utils.py @@ -3,7 +3,6 @@ import io import jwt import json import time -import uuid import logging import posixpath import shutil @@ -22,26 +21,13 @@ from seahub.base.templatetags.seahub_tags import email2nickname from seahub.avatar.templatetags.avatar_tags import api_avatar_url from seahub.seadoc.models import SeadocRevision from seahub.seadoc.settings import SDOC_REVISIONS_DIR, SDOC_IMAGES_DIR +from seahub.utils import uuid_str_to_36_chars logger = logging.getLogger(__name__) ZSDOC = 'sdoczip' -def uuid_str_to_32_chars(file_uuid): - if len(file_uuid) == 36: - return uuid.UUID(file_uuid).hex - else: - return file_uuid - - -def uuid_str_to_36_chars(file_uuid): - if len(file_uuid) == 32: - return str(uuid.UUID(file_uuid)) - else: - return file_uuid - - def gen_seadoc_access_token(file_uuid, filename, username, permission='rw', default_title=None): name = email2nickname(username) url, is_default, date_uploaded = api_avatar_url(username) diff --git a/seahub/settings.py b/seahub/settings.py index cb8a107d80..0c604f4a68 100644 --- a/seahub/settings.py +++ b/seahub/settings.py @@ -287,6 +287,7 @@ INSTALLED_APPS = [ 'seahub.django_cas_ng', 'seahub.seadoc', 'seahub.subscription', + 'seahub.exdraw', ] @@ -962,11 +963,12 @@ ENABLE_WHITEBOARD = False ########################## ENABLE_EXCALIDRAW = False +EXCALIDRAW_SERVER_URL = 'http://127.0.0.1:7070' ###################################### # Settings for notification server # ###################################### - + NOTIFICATION_SERVER_URL = os.environ.get('NOTIFICATION_SERVER_URL', '') ############################ @@ -1173,7 +1175,7 @@ JWT_PRIVATE_KEY = os.environ.get('JWT_PRIVATE_KEY', '') or JWT_PRIVATE_KEY # For database conf., now Seafile only support MySQL, skip for other engine if 'default' in DATABASES and 'mysql' in DATABASES['default'].get('ENGINE', ''): - + ## For dtable_db _rewrite_db_env_key_map = { 'HOST': 'SEAFILE_MYSQL_DB_HOST', @@ -1245,6 +1247,10 @@ SEADOC_PRIVATE_KEY = JWT_PRIVATE_KEY SEADOC_SERVER_URL = os.environ.get('SEADOC_SERVER_URL', '') or SEADOC_SERVER_URL FILE_CONVERTER_SERVER_URL = SEADOC_SERVER_URL.rstrip('/') + '/converter' +if os.environ.get('ENABLE_EXCALIDRAW', ''): + ENABLE_EXCALIDRAW = os.environ.get('ENABLE_EXCALIDRAW', '').lower() == 'true' +EXCALIDRAW_PRIVATE_KEY = JWT_PRIVATE_KEY + if os.environ.get('SITE_ROOT', ''): SITE_ROOT = os.environ.get('SITE_ROOT', '') SEAFILE_SERVER_PROTOCOL = os.environ.get('SEAFILE_SERVER_PROTOCOL', '') diff --git a/seahub/templates/excalidraw_file_view_react.html b/seahub/templates/excalidraw_file_view_react.html index 17805eb736..7f0f7eba73 100644 --- a/seahub/templates/excalidraw_file_view_react.html +++ b/seahub/templates/excalidraw_file_view_react.html @@ -12,6 +12,7 @@ docName: '{{ filename|escapejs }}', docUuid: '{{ file_uuid }}', lang: '{{ lang }}', rawPath: '{{ raw_path|escapejs }}', +excalidrawServerUrl: '{{excalidraw_server_url}}' {% endblock %} {% block render_bundle %} diff --git a/seahub/urls.py b/seahub/urls.py index 95175444c7..ced7cdf767 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -586,7 +586,7 @@ urlpatterns = [ re_path(r'^api/v2.1/wiki2/search/$', WikiSearch.as_view(), name='api-v2.1-wiki2-search'), re_path(r'^api/v2.1/convert-wiki/$', WikiConvertView.as_view(), name='api-v2.1-wiki-convert'), ## user::drafts - + ## user::activities re_path(r'^api/v2.1/activities/$', ActivitiesView.as_view(), name='api-v2.1-acitvity'), @@ -1049,6 +1049,10 @@ if getattr(settings, 'ENABLE_SEADOC', False): re_path(r'^api/v2.1/seadoc/', include('seahub.seadoc.urls')), ] +if getattr(settings, 'ENABLE_EXCALIDRAW', False): + urlpatterns += [ + re_path(r'^api/v2.1/exdraw/', include('seahub.exdraw.urls')), + ] if getattr(settings, 'CLIENT_SSO_VIA_LOCAL_BROWSER', False): urlpatterns += [ diff --git a/seahub/utils/__init__.py b/seahub/utils/__init__.py index 1fc06bcb93..9cfe252f8b 100644 --- a/seahub/utils/__init__.py +++ b/seahub/utils/__init__.py @@ -78,7 +78,7 @@ else: def is_pro_version(): return getattr(seahub.settings, 'IS_PRO_VERSION', False) is True - + def is_cluster_mode(): cfg = configparser.ConfigParser() if 'SEAFILE_CENTRAL_CONF_DIR' in os.environ: @@ -738,7 +738,7 @@ if EVENTS_CONFIG_FILE: events = seafevents_api.get_file_audit_events(session, email, org_id, repo_id, start, limit) return events if events else None - + def get_log_events_by_users_and_repos(type, emails, repo_ids, start, limit): with _get_seafevents_session() as session: events = seafevents_api.get_events_by_users_and_repos(session, type, emails, repo_ids, start, limit) @@ -1497,3 +1497,17 @@ def transfer_repo(repo_id, new_owner, is_share, org_id=None): seafile_api.transfer_repo_to_group(repo_id, group_id, PERMISSION_READ_WRITE) else: seafile_api.set_repo_owner(repo_id, new_owner) + + +def uuid_str_to_32_chars(file_uuid): + if len(file_uuid) == 36: + return uuid.UUID(file_uuid).hex + else: + return file_uuid + + +def uuid_str_to_36_chars(file_uuid): + if len(file_uuid) == 32: + return str(uuid.UUID(file_uuid)) + else: + return file_uuid diff --git a/seahub/views/file.py b/seahub/views/file.py index f214b9219a..0b84cd97ff 100644 --- a/seahub/views/file.py +++ b/seahub/views/file.py @@ -69,6 +69,7 @@ from seahub.views import check_folder_permission, \ from seahub.utils.repo import is_repo_owner, parse_repo_perm, is_repo_admin from seahub.group.utils import is_group_member from seahub.seadoc.utils import get_seadoc_file_uuid, gen_seadoc_access_token, is_seadoc_revision +from seahub.exdraw.utils import get_exdraw_file_uuid from seahub.seadoc.models import SeadocRevision import seahub.settings as settings @@ -78,7 +79,7 @@ from seahub.settings import FILE_ENCODING_LIST, FILE_PREVIEW_MAX_SIZE, \ SHARE_LINK_FORCE_USE_PASSWORD, SHARE_LINK_PASSWORD_STRENGTH_LEVEL, \ SHARE_LINK_EXPIRE_DAYS_DEFAULT, ENABLE_SHARE_LINK_REPORT_ABUSE, SEADOC_SERVER_URL, \ ENABLE_METADATA_MANAGEMENT, BAIDU_MAP_KEY, GOOGLE_MAP_KEY, GOOGLE_MAP_ID, ENABLE_MULTIPLE_OFFICE_SUITE, \ - OFFICE_SUITE_LIST + OFFICE_SUITE_LIST, EXCALIDRAW_SERVER_URL from seahub.constants import PERMISSION_INVISIBLE # wopi @@ -787,11 +788,13 @@ def view_lib_file(request, repo_id, path): return render(request, template, return_dict) elif filetype == EXCALIDRAW: - + file_uuid = get_exdraw_file_uuid(repo, path) + return_dict['file_uuid'] = file_uuid return_dict['protocol'] = request.is_secure() and 'https' or 'http' return_dict['domain'] = get_current_site(request).domain return_dict['serviceUrl'] = get_service_url().rstrip('/') return_dict['language_code'] = get_language() + return_dict['excalidraw_server_url'] = EXCALIDRAW_SERVER_URL return_dict['share_link_expire_days_Default'] = SHARE_LINK_EXPIRE_DAYS_DEFAULT return_dict['share_link_expire_days_min'] = SHARE_LINK_EXPIRE_DAYS_MIN return_dict['share_link_expire_days_max'] = SHARE_LINK_EXPIRE_DAYS_MAX