diff --git a/frontend/src/models/org-logs-file-transfer.js b/frontend/src/models/org-logs-file-transfer.js new file mode 100644 index 0000000000..c0a2914bb0 --- /dev/null +++ b/frontend/src/models/org-logs-file-transfer.js @@ -0,0 +1,29 @@ +import { lang } from '../utils/constants'; +import dayjs from 'dayjs'; + +dayjs.locale(lang); + +class OrgLogsFileTransferEvent { + constructor(object) { + this.from_group_id = object.from_group_id; + this.from_group_name = object.from_group_name; + this.from_type = object.from_type; + this.from_user_contact_email = object.from_user_contact_email; + this.from_user_email = object.from_user_email; + this.from_user_name = object.from_user_name; + this.repo_id = object.repo_id; + this.repo_name = object.repo_name; + this.to_group_id = object.to_group_id; + this.to_group_name = object.to_group_name; + this.to_type = object.to_type; + this.to_user_contact_email = object.to_user_contact_email; + this.to_user_email = object.to_user_email; + this.to_user_name = object.to_user_name; + this.operator_email = object.operator_email; + this.operator_name = object.operator_name; + this.operator_contact_email = object.operator_contact_email; + this.time = dayjs(object.date).format('YYYY-MM-DD HH:mm:ss'); + } +} + +export default OrgLogsFileTransferEvent; diff --git a/frontend/src/pages/org-admin/index.js b/frontend/src/pages/org-admin/index.js index ead5a173ea..3d0888d2ce 100644 --- a/frontend/src/pages/org-admin/index.js +++ b/frontend/src/pages/org-admin/index.js @@ -37,6 +37,7 @@ import OrgLogs from './org-logs'; import OrgLogsFileAudit from './org-logs-file-audit'; import OrgLogsFileUpdate from './org-logs-file-update'; import OrgLogsPermAudit from './org-logs-perm-audit'; +import OrgLogsFileTransfer from './org-logs-file-transfer'; import OrgSAMLConfig from './org-saml-config'; import OrgSubscription from './org-subscription'; @@ -126,6 +127,7 @@ class Org extends React.Component { + {enableMultiADFS && diff --git a/frontend/src/pages/org-admin/libraries/org-all-repos.js b/frontend/src/pages/org-admin/libraries/org-all-repos.js index 2b8d78c85b..9069c68800 100644 --- a/frontend/src/pages/org-admin/libraries/org-all-repos.js +++ b/frontend/src/pages/org-admin/libraries/org-all-repos.js @@ -215,7 +215,7 @@ class RepoItem extends React.Component { onTransferRepo = (email, reshare) => { let repo = this.props.repo; orgAdminAPI.orgAdminTransferOrgRepo(orgID, repo.repoID, email, reshare).then(res => { - const { owner_name, owner_email } = res; + const { owner_name, owner_email } = res.data; this.props.transferRepoItem(repo.repoID, owner_name, owner_email); let msg = gettext('Successfully transferred the library.'); toaster.success(msg); diff --git a/frontend/src/pages/org-admin/org-logs-file-transfer.js b/frontend/src/pages/org-admin/org-logs-file-transfer.js new file mode 100644 index 0000000000..34e59908bf --- /dev/null +++ b/frontend/src/pages/org-admin/org-logs-file-transfer.js @@ -0,0 +1,178 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { orgAdminAPI } from '../../utils/org-admin-api'; +import { siteRoot, gettext, lang } from '../../utils/constants'; +import { Utils } from '../../utils/utils'; +import toaster from '../../components/toast'; +import OrgLogsFileTransferEvent from '../../models/org-logs-file-transfer'; +import '../../css/org-logs.css'; +import UserLink from './user-link'; +import { Link } from '@gatsbyjs/reach-router'; + +dayjs.locale(lang); + +class OrgLogsFileTransfer extends React.Component { + + constructor(props) { + super(props); + this.state = { + page: 1, + perPage: 25, + pageNext: false, + eventList: [], + isItemFreezed: false + }; + } + + componentDidMount() { + let page = this.state.page; + let perPage = this.state.perPage; + this.initData(page, perPage); + } + + initData = (page, perPage) => { + orgAdminAPI.orgAdminListFileTransfer(page, perPage).then(res => { + let eventList = res.data.log_list.map(item => { + return new OrgLogsFileTransferEvent(item); + }); + + this.setState({ + eventList: eventList, + pageNext: res.data.page_next, + page: res.data.page, + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + }; + + + onChangePageNum = (e, num) => { + e.preventDefault(); + let page = this.state.page; + let perPage = this.state.perPage; + if (num == 1) { + page = page + 1; + } else { + page = page - 1; + } + this.initData(page, perPage); + }; + + render() { + let eventList = this.state.eventList; + return ( +
+ + + + + + + + + + + + {eventList.map((item, index) => { + return ( + + ); + })} + +
{gettext('Transfer From')}{gettext('Transfer To')}{gettext('Operator')}{gettext('Library')}{gettext('Date')}
+
+ {this.state.page != 1 && this.onChangePageNum(e, -1)}>{gettext('Previous')}} + {(this.state.page != 1 && this.state.pageNext) && | } + {this.state.pageNext && this.onChangePageNum(e, 1)}>{gettext('Next')}} +
+
+ ); + } +} + +const propTypes = { + isItemFreezed: PropTypes.bool.isRequired, + fileEvent: PropTypes.object.isRequired, +}; + +class FileTransferItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + highlight: false, + showMenu: false, + isItemMenuShow: false, + }; + } + + onMouseEnter = () => { + if (!this.props.isItemFreezed) { + this.setState({ + showMenu: true, + highlight: true, + }); + } + }; + + onMouseLeave = () => { + if (!this.props.isItemFreezed) { + this.setState({ + showMenu: false, + highlight: false + }); + } + }; + + + getTransferTo = (item) => { + switch (item.to_type) { + case 'user': + return ; + case 'group': + return {item.to_group_name}; + default: + return gettext('Deleted'); + } + }; + + getTransferFrom = (item) => { + switch (item.from_type) { + case 'user': + return ; + case 'group': + return {item.from_group_name}; + default: + return gettext('Deleted'); + } + }; + + getOperator = (item) => { + return ; + }; + + render() { + let { fileEvent } = this.props; + return ( + + {this.getTransferFrom(fileEvent)} + {this.getTransferTo(fileEvent)} + {this.getOperator(fileEvent)} + {fileEvent.repo_name ? fileEvent.repo_name : gettext('Deleted')} + {dayjs(fileEvent.time).fromNow()} + + ); + } +} + +FileTransferItem.propTypes = propTypes; + +export default OrgLogsFileTransfer; diff --git a/frontend/src/pages/org-admin/org-logs.js b/frontend/src/pages/org-admin/org-logs.js index 87be378395..72ab1edb26 100644 --- a/frontend/src/pages/org-admin/org-logs.js +++ b/frontend/src/pages/org-admin/org-logs.js @@ -40,9 +40,13 @@ class OrgLogs extends Component { const { isExportExcelDialogOpen, logType } = this.state; return ( - - - + {this.props.currentTab === 'repo-transfer' ? + + : + + + + }
@@ -65,6 +69,12 @@ class OrgLogs extends Component { to={siteRoot + 'org/logadmin/perm-audit/'} title={gettext('Permission')}>{gettext('Permission')} +
  • this.tabItemClick('repo-transfer')}> + {gettext('Repo Transfer')} + +
  • diff --git a/frontend/src/pages/sys-admin/index.js b/frontend/src/pages/sys-admin/index.js index eda20d8b29..91cd6d2b84 100644 --- a/frontend/src/pages/sys-admin/index.js +++ b/frontend/src/pages/sys-admin/index.js @@ -67,6 +67,7 @@ import LoginLogs from './logs-page/login-logs'; import FileAccessLogs from './logs-page/file-access-logs'; import FileUpdateLogs from './logs-page/file-update-logs'; import SharePermissionLogs from './logs-page/share-permission-logs'; +import FIleTransferLogs from './logs-page/file-transfer-log'; import WebSettings from './web-settings/web-settings'; import Notifications from './notifications/notifications'; @@ -251,6 +252,7 @@ class SysAdmin extends React.Component { + diff --git a/frontend/src/pages/sys-admin/logs-page/file-transfer-log.js b/frontend/src/pages/sys-admin/logs-page/file-transfer-log.js new file mode 100644 index 0000000000..2ecc60394c --- /dev/null +++ b/frontend/src/pages/sys-admin/logs-page/file-transfer-log.js @@ -0,0 +1,230 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@gatsbyjs/reach-router'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { gettext, siteRoot } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import EmptyTip from '../../../components/empty-tip'; +import Loading from '../../../components/loading'; +import Paginator from '../../../components/paginator'; +import MainPanelTopbar from '../main-panel-topbar'; +import UserLink from '../user-link'; +import LogsNav from './logs-nav'; + +dayjs.extend(relativeTime); + +class Content extends Component { + + getPreviousPage = () => { + this.props.getLogsByPage(this.props.currentPage - 1); + }; + + getNextPage = () => { + this.props.getLogsByPage(this.props.currentPage + 1); + }; + + render() { + const { loading, errorMsg, items, perPage, currentPage, hasNextPage } = this.props; + if (loading) { + return ; + } else if (errorMsg) { + return

    {errorMsg}

    ; + } else { + const emptyTip = ( + + + ); + const table = ( + + + + + + + + + + + + {items && + + {items.map((item, index) => { + return (); + })} + + } +
    {gettext('Transfer From')}{gettext('Transfer To')}{gettext('Operator')}{gettext('Library')}{gettext('Date')}
    + +
    + ); + return items.length ? table : emptyTip; + } + } +} + +Content.propTypes = { + loading: PropTypes.bool.isRequired, + errorMsg: PropTypes.string.isRequired, + items: PropTypes.array.isRequired, + getLogsByPage: PropTypes.func, + resetPerPage: PropTypes.func, + currentPage: PropTypes.number, + perPage: PropTypes.number, + pageInfo: PropTypes.object, + hasNextPage: PropTypes.bool, +}; + +class Item extends Component { + + constructor(props) { + super(props); + this.state = { + isOpIconShown: false, + }; + } + + handleMouseOver = () => { + this.setState({ + isOpIconShown: true + }); + }; + + handleMouseOut = () => { + this.setState({ + isOpIconShown: false + }); + }; + + getTransferTo = (item) => { + switch (item.to_type) { + case 'user': + return ; + case 'group': + return {item.to_group_name}; + default: + return gettext('Deleted'); + } + }; + + getTransferFrom = (item) => { + switch (item.from_type) { + case 'user': + return ; + case 'group': + return {item.from_group_name}; + default: + return gettext('Deleted'); + } + }; + + getOperator = (item) => { + return ; + }; + + render() { + let { item } = this.props; + return ( + + {this.getTransferFrom(item)} + {this.getTransferTo(item)} + {this.getOperator(item)} + {item.repo_name ? item.repo_name : gettext('Deleted')} + {dayjs(item.date).fromNow()} + + ); + } +} + +Item.propTypes = { + item: PropTypes.object.isRequired, +}; + +class FIleTransferLogs extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + logList: [], + perPage: 100, + currentPage: 1, + hasNextPage: false, + }; + this.initPage = 1; + } + + componentDidMount() { + let urlParams = (new URL(window.location)).searchParams; + const { currentPage, perPage } = this.state; + this.setState({ + perPage: parseInt(urlParams.get('per_page') || perPage), + currentPage: parseInt(urlParams.get('page') || currentPage) + }, () => { + this.getLogsByPage(this.state.currentPage); + }); + } + + getLogsByPage = (page) => { + let { perPage } = this.state; + seafileAPI.sysAdminListFileTransferLogs(page, perPage).then((res) => { + this.setState({ + logList: res.data.repo_transfer_log_list, + loading: false, + currentPage: page, + hasNextPage: res.data.has_next_page, + }); + }).catch((error) => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403 + }); + }); + }; + + resetPerPage = (newPerPage) => { + this.setState({ + perPage: newPerPage, + }, () => this.getLogsByPage(this.initPage)); + }; + + render() { + let { logList, currentPage, perPage, hasNextPage } = this.state; + return ( + + +
    +
    + +
    + +
    +
    +
    +
    + ); + } +} + +export default FIleTransferLogs; diff --git a/frontend/src/pages/sys-admin/logs-page/logs-nav.js b/frontend/src/pages/sys-admin/logs-page/logs-nav.js index c85a25c411..370228d67c 100644 --- a/frontend/src/pages/sys-admin/logs-page/logs-nav.js +++ b/frontend/src/pages/sys-admin/logs-page/logs-nav.js @@ -16,6 +16,7 @@ class Nav extends React.Component { { name: 'fileAccessLogs', urlPart: 'logs/file-access', text: gettext('File Access') }, { name: 'fileUpdateLogs', urlPart: 'logs/file-update', text: gettext('File Update') }, { name: 'sharePermissionLogs', urlPart: 'logs/share-permission', text: gettext('Permission') }, + { name: 'fileTransfer', urlPart: 'logs/repo-transfer', text: gettext('Repo Transfer') }, ]; } diff --git a/frontend/src/tests/utils/utils.test.js b/frontend/src/tests/utils/utils.test.js index 8602d61b8a..da5996a450 100644 --- a/frontend/src/tests/utils/utils.test.js +++ b/frontend/src/tests/utils/utils.test.js @@ -1,4 +1,4 @@ -import { Utils } from "../../utils/utils"; +import { Utils } from '../../utils/utils'; describe('getFileExtension', () => { it('should return the file extension with dot', () => { diff --git a/frontend/src/utils/org-admin-api.js b/frontend/src/utils/org-admin-api.js index 170b934917..bafe855c08 100644 --- a/frontend/src/utils/org-admin-api.js +++ b/frontend/src/utils/org-admin-api.js @@ -481,6 +481,15 @@ class OrgAdminAPI { } // org admin logs + orgAdminListFileTransfer(page, perPage) { + let url = this.server + '/api/v2.1/org/admin/logs/repo-transfer/'; + let params = { + page: page, + per_page: perPage + }; + return this.req.get(url, { params: params }); + } + orgAdminListFileAudit(email, repoID, page) { let url = this.server + '/api/v2.1/org/admin/logs/file-access/?page=' + page; if (email) { diff --git a/frontend/src/utils/seafile-api.js b/frontend/src/utils/seafile-api.js index 050fb73229..1b165bb672 100644 --- a/frontend/src/utils/seafile-api.js +++ b/frontend/src/utils/seafile-api.js @@ -2169,6 +2169,15 @@ 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 }); + } + } let seafileAPI = new SeafileAPI(); diff --git a/seahub/api2/endpoints/admin/libraries.py b/seahub/api2/endpoints/admin/libraries.py index a1df543756..4a988d9071 100644 --- a/seahub/api2/endpoints/admin/libraries.py +++ b/seahub/api2/endpoints/admin/libraries.py @@ -28,6 +28,7 @@ from seahub.utils.timeutils import timestamp_to_isoformat_timestr from seahub.api2.endpoints.group_owned_libraries import get_group_id_by_repo_owner from seahub.constants import PERMISSION_READ_WRITE +from seahub.base.models import RepoTransfer try: from seahub.settings import MULTI_TENANCY @@ -423,6 +424,11 @@ class AdminLibrary(APIView): # transfer repo try: transfer_repo(repo_id, new_owner, is_share) + RepoTransfer.objects.create(from_user=repo_owner, + to=new_owner, + repo_id=repo_id, + org_id=-1, + operator=request.user.username) except Exception as e: logger.error(e) error_msg = 'Internal Server Error' diff --git a/seahub/api2/endpoints/admin/logs.py b/seahub/api2/endpoints/admin/logs.py index 8b20d4e417..c6976ec47c 100644 --- a/seahub/api2/endpoints/admin/logs.py +++ b/seahub/api2/endpoints/admin/logs.py @@ -20,6 +20,7 @@ from seahub.utils import get_file_audit_events, generate_file_audit_event_type, get_file_update_events, get_perm_audit_events, is_valid_email from seahub.utils.timeutils import datetime_to_isoformat_timestr, utc_datetime_to_isoformat_timestr from seahub.utils.repo import is_valid_repo_id_format +from seahub.base.models import RepoTransfer logger = logging.getLogger(__name__) @@ -420,3 +421,146 @@ class AdminLogsSharePermissionLogs(APIView): } return Response(resp) + + +class AdminLogsFileTransferLogs(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, IsProVersion) + throttle_classes = (UserRateThrottle,) + + def get(self, request): + """ Get all transfer repo logs. + + Permission checking: + 1. only admin can perform this action. + """ + + if not request.user.admin_permissions.can_view_user_log(): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + try: + current_page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '100')) + except ValueError: + current_page = 1 + per_page = 100 + + start = per_page * (current_page - 1) + limit = per_page + + if start < 0: + error_msg = 'start invalid' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if limit < 0: + error_msg = 'limit invalid' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + events = RepoTransfer.objects.all().order_by('-timestamp')[start:start+limit+1] + if len(events) > limit: + has_next_page = True + events = events[:limit] + else: + has_next_page = False + + # Use dict to reduce memcache fetch cost in large for-loop. + nickname_dict = {} + contact_email_dict = {} + repo_dict = {} + group_name_dict = {} + + user_email_set = set() + repo_id_set = set() + group_id_set = set() + + for event in events: + repo_id_set.add(event.repo_id) + if is_valid_email(event.from_user): + user_email_set.add(event.from_user) + if is_valid_email(event.to): + user_email_set.add(event.to) + if is_valid_email(event.operator): + user_email_set.add(event.operator) + if '@seafile_group' in event.to: + group_id = int(event.to.split('@')[0]) + group_id_set.add(group_id) + if '@seafile_group' in event.from_user: + group_id = int(event.from_user.split('@')[0]) + group_id_set.add(group_id) + + for e in user_email_set: + if e not in nickname_dict: + nickname_dict[e] = email2nickname(e) + if e not in contact_email_dict: + contact_email_dict[e] = email2contact_email(e) + for e in repo_id_set: + if e not in repo_dict: + repo_dict[e] = seafile_api.get_repo(e) + + for group_id in group_id_set: + if group_id not in group_name_dict: + group = ccnet_api.get_group(int(group_id)) + if group: + group_name_dict[group_id] = group.group_name + + events_info = [] + for ev in events: + data = { + 'from_user_email': '', + 'from_user_name': '', + 'from_user_contact_email': '', + 'from_group_id': '', + 'from_group_name': '', + 'to_user_email': '', + 'to_user_name': '', + 'to_user_contact_email': '', + 'to_group_id': '', + 'to_group_name': '', + 'operator_email': '', + 'operator_name': '', + 'operator_contact_email': '', + } + from_user_email = ev.from_user + data['from_user_email'] = from_user_email + data['from_user_name'] = nickname_dict.get(from_user_email, '') + data['from_user_contact_email'] = contact_email_dict.get(from_user_email, '') + + operator_email = ev.operator + data['operator_email'] = operator_email + data['operator_name'] = nickname_dict.get(operator_email, '') + data['operator_contact_email'] = contact_email_dict.get(operator_email, '') + + if is_valid_email(from_user_email): + data['from_type'] = 'user' + if '@seafile_group' in from_user_email: + from_group_id = int(from_user_email.split('@')[0]) + data['from_group_id'] = from_group_id + data['from_group_name'] = group_name_dict.get(from_group_id, '') + data['from_type'] = 'group' + + repo_id = ev.repo_id + data['repo_id'] = repo_id + repo = repo_dict.get(repo_id, None) + data['repo_name'] = repo.name if repo else '' + data['date'] = datetime_to_isoformat_timestr(ev.timestamp) + + if is_valid_email(ev.to): + to_user_email = ev.to + data['to_user_email'] = to_user_email + data['to_user_name'] = nickname_dict.get(to_user_email, '') + data['to_user_contact_email'] = contact_email_dict.get(to_user_email, '') + data['to_type'] = 'user' + if '@seafile_group' in ev.to: + to_group_id = int(ev.to.split('@')[0]) + data['to_group_id'] = to_group_id + data['to_group_name'] = group_name_dict.get(to_group_id, '') + data['to_type'] = 'group' + + events_info.append(data) + + resp = { + 'repo_transfer_log_list': events_info, + 'has_next_page': has_next_page, + } + + return Response(resp) diff --git a/seahub/api2/endpoints/group_owned_libraries.py b/seahub/api2/endpoints/group_owned_libraries.py index d1d138e370..268d830bb3 100644 --- a/seahub/api2/endpoints/group_owned_libraries.py +++ b/seahub/api2/endpoints/group_owned_libraries.py @@ -50,6 +50,7 @@ from seahub.views import check_folder_permission from seahub.settings import ENABLE_STORAGE_CLASSES, STORAGE_CLASS_MAPPING_POLICY, \ ENCRYPTED_LIBRARY_VERSION, ENCRYPTED_LIBRARY_PWD_HASH_ALGO, \ ENCRYPTED_LIBRARY_PWD_HASH_PARAMS +from seahub.base.models import RepoTransfer logger = logging.getLogger(__name__) @@ -1497,6 +1498,12 @@ class GroupOwnedLibraryTransferView(APIView): # transfer repo try: transfer_repo(repo_id, new_owner, is_share, org_id) + org_id = seafile_api.get_org_id_by_repo_id(repo_id) + RepoTransfer.objects.create(from_user=repo_owner, + to=new_owner, + repo_id=repo_id, + org_id=org_id, + operator=request.user.username) except Exception as e: logger.error(e) error_msg = 'Internal Server Error' diff --git a/seahub/api2/endpoints/internal_api.py b/seahub/api2/endpoints/internal_api.py index 2e49777c40..7e87db0b13 100644 --- a/seahub/api2/endpoints/internal_api.py +++ b/seahub/api2/endpoints/internal_api.py @@ -14,6 +14,7 @@ from seahub.share.models import UploadLinkShare, FileShare, check_share_link_acc from seaserv import seafile_api from seahub.utils.repo import parse_repo_perm from seahub.views.file import send_file_access_msg +from seahub.utils import normalize_file_path logger = logging.getLogger(__name__) @@ -146,6 +147,7 @@ class InternalCheckFileOperationAccess(APIView): return api_error(status.HTTP_403_FORBIDDEN, error_msg) file_path = request.data.get('path', '/') + file_path = normalize_file_path(file_path) repo = seafile_api.get_repo(repo_id) if not repo: return api_error(status.HTTP_404_NOT_FOUND, 'Library %s not found.' % repo_id) diff --git a/seahub/api2/views.py b/seahub/api2/views.py index eae8d215c6..c1c380197c 100644 --- a/seahub/api2/views.py +++ b/seahub/api2/views.py @@ -46,7 +46,7 @@ from seahub.avatar.templatetags.avatar_tags import api_avatar_url, avatar from seahub.avatar.templatetags.group_avatar_tags import api_grp_avatar_url, \ grp_avatar from seahub.base.accounts import User -from seahub.base.models import UserStarredFiles, DeviceToken, RepoSecretKey, FileComment +from seahub.base.models import UserStarredFiles, DeviceToken, RepoSecretKey, FileComment, RepoTransfer from seahub.share.models import ExtraSharePermission, ExtraGroupsSharePermission from seahub.share.utils import is_repo_admin, check_group_share_in_permission, normalize_custom_permission_name from seahub.base.templatetags.seahub_tags import email2nickname, \ @@ -1871,6 +1871,12 @@ class RepoOwner(APIView): # transfer repo try: transfer_repo(repo_id, new_owner, is_share, org_id) + org_id = seafile_api.get_org_id_by_repo_id(repo_id) + RepoTransfer.objects.create(from_user=repo_owner, + to=new_owner, + repo_id=repo_id, + operator=username, + org_id=org_id) except SearpcError as e: logger.error(e) error_msg = 'Internal Server Error' diff --git a/seahub/base/models.py b/seahub/base/models.py index c8acc9890f..052c5e8819 100644 --- a/seahub/base/models.py +++ b/seahub/base/models.py @@ -451,3 +451,15 @@ class ClientSSOToken(models.Model): if not self.token: self.token = self.gen_token() return super(ClientSSOToken, self).save(*args, **kwargs) + + +class RepoTransfer(models.Model): + repo_id = models.CharField(max_length=36) + org_id = models.IntegerField(db_index=True) + from_user = models.CharField(max_length=255) + to = models.CharField(max_length=255) + operator = models.CharField(max_length=255) + timestamp = models.DateTimeField(default=timezone.now, db_index=True) + + class Meta: + db_table = 'RepoTransfer' diff --git a/seahub/organizations/api/admin/logs.py b/seahub/organizations/api/admin/logs.py index 7251b6d31c..0ec0a23c62 100644 --- a/seahub/organizations/api/admin/logs.py +++ b/seahub/organizations/api/admin/logs.py @@ -17,7 +17,8 @@ from seahub.api2.endpoints.utils import get_user_name_dict, \ get_user_contact_email_dict, get_repo_dict, get_group_dict from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email -from seahub.utils import EVENTS_ENABLED, get_file_audit_events, get_file_update_events, get_perm_audit_events +from seahub.base.models import RepoTransfer +from seahub.utils import EVENTS_ENABLED, get_file_audit_events, get_file_update_events, get_perm_audit_events, is_valid_email from seahub.utils.timeutils import timestamp_to_isoformat_timestr, datetime_to_isoformat_timestr from seahub.organizations.api.permissions import IsOrgAdmin @@ -263,3 +264,140 @@ class OrgAdminLogsPermAudit(APIView): 'page': page, 'page_next': page_next, }) + + +class OrgAdminLogsFileTransfer(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + throttle_classes = (UserRateThrottle,) + permission_classes = (IsProVersion, IsOrgAdmin) + + def get(self, request): + """List organization file transfer in logs + """ + try: + page = int(request.GET.get('page', '1')) + per_page = int(request.GET.get('per_page', '25')) + except ValueError: + page = 1 + per_page = 25 + + start = per_page * (page - 1) + limit = per_page + + org_id = request.user.org.org_id + events = RepoTransfer.objects.filter(org_id=org_id).all().order_by('-timestamp')[start:start+limit+1] + if len(events) > limit: + page_next = True + events = events[:limit] + else: + page_next = False + + event_list = [] + if not events: + return Response({ + 'log_list': event_list, + 'page': page, + 'page_next': False + }) + + # Use dict to reduce memcache fetch cost in large for-loop. + nickname_dict = {} + contact_email_dict = {} + repo_dict = {} + group_name_dict = {} + + user_email_set = set() + repo_id_set = set() + group_id_set = set() + + for event in events: + repo_id_set.add(event.repo_id) + if is_valid_email(event.from_user): + user_email_set.add(event.from_user) + if is_valid_email(event.to): + user_email_set.add(event.to) + if is_valid_email(event.operator): + user_email_set.add(event.operator) + if '@seafile_group' in event.to: + group_id = int(event.to.split('@')[0]) + group_id_set.add(group_id) + if '@seafile_group' in event.from_user: + group_id = int(event.from_user.split('@')[0]) + group_id_set.add(group_id) + + for e in user_email_set: + if e not in nickname_dict: + nickname_dict[e] = email2nickname(e) + if e not in contact_email_dict: + contact_email_dict[e] = email2contact_email(e) + for e in repo_id_set: + if e not in repo_dict: + repo_dict[e] = seafile_api.get_repo(e) + + for group_id in group_id_set: + if group_id not in group_name_dict: + group = ccnet_api.get_group(int(group_id)) + if group: + group_name_dict[group_id] = group.group_name + + event_list = [] + for ev in events: + data = { + 'from_user_email': '', + 'from_user_name': '', + 'from_user_contact_email': '', + 'from_group_id': '', + 'from_group_name': '', + 'to_user_email': '', + 'to_user_name': '', + 'to_user_contact_email': '', + 'to_group_id': '', + 'to_group_name': '', + 'operator_email': '', + 'operator_name': '', + 'operator_contact_email': '', + } + from_user_email = ev.from_user + data['from_user_email'] = from_user_email + data['from_user_name'] = nickname_dict.get(from_user_email, '') + data['from_user_contact_email'] = contact_email_dict.get(from_user_email, '') + + operator_email = ev.operator + data['operator_email'] = operator_email + data['operator_name'] = nickname_dict.get(operator_email, '') + data['operator_contact_email'] = contact_email_dict.get(operator_email, '') + + if is_valid_email(from_user_email): + data['from_type'] = 'user' + if '@seafile_group' in from_user_email: + from_group_id = int(from_user_email.split('@')[0]) + data['from_group_id'] = from_group_id + data['from_group_name'] = group_name_dict.get(from_group_id, '') + data['from_type'] = 'group' + + repo_id = ev.repo_id + data['repo_id'] = repo_id + repo = repo_dict.get(repo_id, None) + data['repo_name'] = repo.name if repo else '' + data['date'] = datetime_to_isoformat_timestr(ev.timestamp) + + if is_valid_email(ev.to): + to_user_email = ev.to + data['to_user_email'] = to_user_email + data['to_user_name'] = nickname_dict.get(to_user_email, '') + data['to_user_contact_email'] = contact_email_dict.get(to_user_email, '') + data['to_type'] = 'user' + if '@seafile_group' in ev.to: + to_group_id = int(ev.to.split('@')[0]) + data['to_group_id'] = to_group_id + data['to_group_name'] = group_name_dict.get(to_group_id, '') + data['to_type'] = 'group' + + event_list.append(data) + + return Response({ + 'log_list': event_list, + 'page': page, + 'page_next': page_next, + }) diff --git a/seahub/organizations/api/admin/repos.py b/seahub/organizations/api/admin/repos.py index 4873665952..7e0274fa67 100644 --- a/seahub/organizations/api/admin/repos.py +++ b/seahub/organizations/api/admin/repos.py @@ -15,6 +15,7 @@ from seahub.api2.throttling import UserRateThrottle from seahub.api2.authentication import TokenAuthentication from seahub.api2.utils import api_error from seahub.base.templatetags.seahub_tags import email2nickname, email2contact_email +from seahub.base.models import RepoTransfer from seahub.group.utils import group_id_to_name from seahub.utils.timeutils import timestamp_to_isoformat_timestr from seahub.utils import is_valid_email, transfer_repo @@ -166,6 +167,11 @@ class OrgAdminRepo(APIView): # transfer repo try: transfer_repo(repo_id, new_owner, is_share, org_id) + RepoTransfer.objects.create(from_user=repo_owner, + to=new_owner, + repo_id=repo_id, + org_id=org_id, + operator=request.user.username) except Exception as e: logger.error(e) error_msg = 'Internal Server Error' @@ -182,9 +188,15 @@ class OrgAdminRepo(APIView): break repo_info = {} + repo_info['owner_email'] = new_owner - repo_info['owner_name'] = email2nickname(new_owner) - repo_info['encrypted'] = repo.encrypted + if '@seafile_group' in new_owner: + group_id = get_group_id_by_repo_owner(new_owner) + repo_info['group_name'] = group_id_to_name(group_id) + repo_info['owner_name'] = group_id_to_name(group_id) + else: + repo_info['owner_name'] = email2nickname(new_owner) + repo_info['encrypted'] = repo.encrypted repo_info['repo_id'] = repo.repo_id repo_info['repo_name'] = repo.name repo_info['is_department_repo'] = False diff --git a/seahub/organizations/api_urls.py b/seahub/organizations/api_urls.py index 55eaff63bf..a636af921a 100644 --- a/seahub/organizations/api_urls.py +++ b/seahub/organizations/api_urls.py @@ -20,7 +20,7 @@ from .api.admin.trash_libraries import OrgAdminTrashLibraries, OrgAdminTrashLibr from .api.admin.info import OrgAdminInfo from .api.admin.links import OrgAdminLinks, OrgAdminLink from .api.admin.web_settings import OrgAdminWebSettings -from .api.admin.logs import OrgAdminLogsFileAccess, OrgAdminLogsFileUpdate, OrgAdminLogsPermAudit +from .api.admin.logs import OrgAdminLogsFileAccess, OrgAdminLogsFileUpdate, OrgAdminLogsPermAudit, OrgAdminLogsFileTransfer from .api.admin.user_repos import OrgAdminUserRepos, OrgAdminUserBesharedRepos from .api.admin.devices import OrgAdminDevices, OrgAdminDevicesErrors @@ -102,6 +102,7 @@ urlpatterns = [ path('admin/logs/file-access/', OrgAdminLogsFileAccess.as_view(), name='api-v2.1-org-admin-logs-file-access'), path('admin/logs/file-update/', OrgAdminLogsFileUpdate.as_view(), name='api-v2.1-org-admin-logs-file-update'), path('admin/logs/repo-permission/', OrgAdminLogsPermAudit.as_view(), name='api-v2.1-org-admin-logs-repo-permission'), + path('admin/logs/repo-transfer/', OrgAdminLogsFileTransfer.as_view(), name='api-v2.1-org-admin-logs-repo-transfer'), path('/admin/departments/', OrgAdminDepartments.as_view(), name='api-v2.1-org-admin-departments'), path('/admin/logs/export-excel/', OrgLogsExport.as_view(), name='api-v2.1-org-logs-export-excel'), path('admin/log/export-excel/', org_log_export_excel, name='org_log_export_excel'), diff --git a/seahub/organizations/urls.py b/seahub/organizations/urls.py index 4d38ae03fd..a6b71b0cda 100644 --- a/seahub/organizations/urls.py +++ b/seahub/organizations/urls.py @@ -35,6 +35,7 @@ urlpatterns = [ path('logadmin/', react_fake_view, name='org_log_file_audit'), path('logadmin/file-update/', react_fake_view, name='org_log_file_update'), path('logadmin/perm-audit/', react_fake_view, name='org_log_perm_audit'), + path('logadmin/repo-transfer/', react_fake_view, name='org_log_file_transfer'), path('info/', react_fake_view, name='org_info'), path('settings/', react_fake_view, name='org_settings'), diff --git a/seahub/urls.py b/seahub/urls.py index 111ec8e2a6..cb44117b19 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -193,7 +193,7 @@ from seahub.api2.endpoints.admin.file_scan_records import AdminFileScanRecords from seahub.api2.endpoints.admin.notifications import AdminNotificationsView from seahub.api2.endpoints.admin.sys_notifications import AdminSysNotificationsView, AdminSysNotificationView from seahub.api2.endpoints.admin.logs import AdminLogsLoginLogs, AdminLogsFileAccessLogs, AdminLogsFileUpdateLogs, \ - AdminLogsSharePermissionLogs + AdminLogsSharePermissionLogs, AdminLogsFileTransferLogs from seahub.api2.endpoints.admin.terms_and_conditions import AdminTermsAndConditions, AdminTermAndCondition from seahub.api2.endpoints.admin.work_weixin import AdminWorkWeixinDepartments, \ AdminWorkWeixinDepartmentMembers, AdminWorkWeixinUsersBatch, AdminWorkWeixinDepartmentsImport @@ -691,6 +691,7 @@ urlpatterns = [ re_path(r'^api/v2.1/admin/logs/file-access-logs/$', AdminLogsFileAccessLogs.as_view(), name='api-v2.1-admin-logs-file-access-logs'), re_path(r'^api/v2.1/admin/logs/file-update-logs/$', AdminLogsFileUpdateLogs.as_view(), name='api-v2.1-admin-logs-file-update-logs'), re_path(r'^api/v2.1/admin/logs/share-permission-logs/$', AdminLogsSharePermissionLogs.as_view(), name='api-v2.1-admin-logs-share-permission-logs'), + re_path(r'^api/v2.1/admin/logs/repo-transfer-logs/$', AdminLogsFileTransferLogs.as_view(), name='api-v2.1-admin-logs-repo-transfer-logs'), ## admin::admin logs re_path(r'^api/v2.1/admin/admin-logs/$', AdminOperationLogs.as_view(), name='api-v2.1-admin-admin-operation-logs'), @@ -865,6 +866,7 @@ urlpatterns = [ path('sys/logs/file-access/', sysadmin_react_fake_view, name="sys_logs_file_access"), path('sys/logs/file-update/', sysadmin_react_fake_view, name="sys_logs_file_update"), path('sys/logs/share-permission/', sysadmin_react_fake_view, name="sys_logs_share_permission"), + path('sys/logs/repo-transfer/', sysadmin_react_fake_view, name="sys_logs_file_transfer"), path('sys/admin-logs/operation/', sysadmin_react_fake_view, name="sys_admin_logs_operation"), path('sys/admin-logs/login/', sysadmin_react_fake_view, name="sys_admin_logs_login"), path('sys/organizations/', sysadmin_react_fake_view, name="sys_organizations"), diff --git a/sql/mysql.sql b/sql/mysql.sql index d9c81a89e1..90c67ab9f5 100644 --- a/sql/mysql.sql +++ b/sql/mysql.sql @@ -1575,3 +1575,16 @@ CREATE TABLE `wiki_wiki2_publish` ( UNIQUE KEY `publish_url` (`publish_url`), KEY `ix_wiki2_publish_repo_id` (`repo_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +CREATE TABLE `RepoTransfer` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `repo_id` varchar(36) NOT NULL, + `org_id` int(11) NOT NULL, + `from_user` varchar(255) NOT NULL, + `to` varchar(255) NOT NULL, + `timestamp` datetime NOT NULL, + `operator` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_file_transfer_org_id` (`org_id`), + KEY `idx_file_transfer_timestamp` (`timestamp`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;