From fa3964332f7951b5f0f94d358b1e1497637dc00c Mon Sep 17 00:00:00 2001 From: llj Date: Thu, 9 Jul 2020 14:15:17 +0800 Subject: [PATCH] Virus scan frontend (#4574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ignore virus scan record * get virus record by has_handle * add has_next_page * change has_handle to has_deleted * change url and view name * delete or ignore virus files in batch * rebase master * add cancel-ignore * optimize code * add translate * [virus scan] added 'All' and 'Unhandled' tabs * rewrote 'all' page (previous 'virus-scan-records') * added 'unhandled' page * [virus scan] 'unhandled' tab: added 'delete & ignore selected items' * [virus scan] 'unhandled' tab: added 'handle selected items' Co-authored-by: 王健辉 <40563566+jianhw@users.noreply.github.com> --- frontend/src/pages/sys-admin/index.js | 36 +- frontend/src/pages/sys-admin/side-panel.js | 6 +- .../src/pages/sys-admin/virus-scan-records.js | 206 --------- .../sys-admin/virus-scan/all-virus-files.js | 313 +++++++++++++ .../src/pages/sys-admin/virus-scan/nav.js | 40 ++ .../virus-scan/unhandled-virus-files.js | 421 ++++++++++++++++++ .../endpoints/admin/virus_scan_records.py | 196 +++++++- seahub/urls.py | 13 +- seahub/utils/__init__.py | 24 +- seahub/views/sysadmin.py | 4 +- 10 files changed, 994 insertions(+), 265 deletions(-) delete mode 100644 frontend/src/pages/sys-admin/virus-scan-records.js create mode 100644 frontend/src/pages/sys-admin/virus-scan/all-virus-files.js create mode 100644 frontend/src/pages/sys-admin/virus-scan/nav.js create mode 100644 frontend/src/pages/sys-admin/virus-scan/unhandled-virus-files.js diff --git a/frontend/src/pages/sys-admin/index.js b/frontend/src/pages/sys-admin/index.js index 9226aa2ad6..b0634725c6 100644 --- a/frontend/src/pages/sys-admin/index.js +++ b/frontend/src/pages/sys-admin/index.js @@ -7,6 +7,12 @@ import MainPanel from './main-panel'; import Info from './info'; +import StatisticFile from './statistic/statistic-file'; +import StatisticStorage from './statistic/statistic-storage'; +import StatisticTraffic from './statistic/statistic-traffic'; +import StatisticUsers from './statistic/statistic-users'; +import StatisticReport from './statistic/statistic-reports'; + import DesktopDevices from './devices/desktop-devices'; import MobileDevices from './devices/mobile-devices'; import DeviceErrors from './devices/devices-errors'; @@ -58,24 +64,19 @@ 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 AdminOperationLogs from './admin-logs/operation-logs'; -import AdminLoginLogs from './admin-logs/login-logs'; - -import TermsAndConditions from './terms-and-conditions/terms-and-conditions'; - import WebSettings from './web-settings/web-settings'; import Notifications from './notifications/notifications'; import FileScanRecords from './file-scan-records'; -import VirusScanRecords from './virus-scan-records'; import WorkWeixinDepartments from './work-weixin-departments'; import DingtalkDepartments from './dingtalk-departments'; import Invitations from './invitations/invitations'; +import TermsAndConditions from './terms-and-conditions/terms-and-conditions'; -import StatisticFile from './statistic/statistic-file'; -import StatisticStorage from './statistic/statistic-storage'; -import StatisticTraffic from './statistic/statistic-traffic'; -import StatisticUsers from './statistic/statistic-users'; -import StatisticReport from './statistic/statistic-reports'; +import AllVirusFiles from './virus-scan/all-virus-files'; +import UnhandledVirusFiles from './virus-scan/unhandled-virus-files'; + +import AdminOperationLogs from './admin-logs/operation-logs'; +import AdminLoginLogs from './admin-logs/login-logs'; import '../../assets/css/fa-solid.css'; import '../../assets/css/fa-regular.css'; @@ -141,6 +142,10 @@ class SysAdmin extends React.Component { tab: 'logs', urlPartList: ['logs/'] }, + { + tab: 'virus-files', + urlPartList: ['virus-files/'] + }, { tab: 'adminLogs', urlPartList: ['admin-logs/'] @@ -244,16 +249,15 @@ class SysAdmin extends React.Component { + + + - + this.props.tabItemClick('virus-scan-records')} + className={`nav-link ellipsis ${this.getActiveClass('virus-files')}`} + to={siteRoot + 'sys/virus-files/all/'} + onClick={() => this.props.tabItemClick('virus-files')} > {gettext('Virus Scan')} diff --git a/frontend/src/pages/sys-admin/virus-scan-records.js b/frontend/src/pages/sys-admin/virus-scan-records.js deleted file mode 100644 index 2374c8f7b7..0000000000 --- a/frontend/src/pages/sys-admin/virus-scan-records.js +++ /dev/null @@ -1,206 +0,0 @@ -import React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { seafileAPI } from '../../utils/seafile-api'; -import { gettext } from '../../utils/constants'; -import toaster from '../../components/toast'; -import Account from '../../components/common/account'; - - -const recordItemPropTypes = { - record: PropTypes.object.isRequired, -}; - -class RecordItem extends Component { - - constructor(props) { - super(props); - this.state = { - handleStatus: this.props.record.has_handle, - errorMsg: '', - }; - } - - deleteVirusScanRecord = () => { - seafileAPI.deleteVirusScanRecord(this.props.record.virus_id).then(() => { - this.setState({ - handleStatus: !this.state.handleStatus, - }); - }).catch((error) => { - if (error.response) { - this.setState({ - errorMsg: error.response.data.error_msg, - }); - toaster.danger(this.state.errorMsg); - } - }); - } - - render() { - let record = this.props.record; - - return ( - - {record.repo_name} - {record.repo_owner} - {record.file_path} - - { - this.state.handleStatus ? - {gettext('Handled')} : - {gettext('Delete')} - } - - - ); - } -} - -RecordItem.propTypes = recordItemPropTypes; - - -const recordListPropTypes = { - loading: PropTypes.bool.isRequired, - isLoadingMore: PropTypes.bool.isRequired, - errorMsg: PropTypes.string.isRequired, - records: PropTypes.array.isRequired, -}; - -class RecordList extends Component { - - render() { - let { loading, isLoadingMore, errorMsg, records } = this.props; - - if (loading) { - return ; - } else if (errorMsg) { - return

{errorMsg}

; - } else { - return ( - - - - - - - - - - - - {records.map((record, index) => { - return ( - - ); - })} - -
{gettext('Library')}{gettext('Owner')}{gettext('Virus File')}{gettext('Operations')}
- {isLoadingMore ? : ''} -
- ); - } - } -} - -RecordList.propTypes = recordListPropTypes; - - -class VirusScanRecords extends Component { - - constructor(props) { - super(props); - this.state = { - loading: true, - isLoadingMore: false, - currentPage: 1, - hasMore: true, - errorMsg: '', - records: [], - }; - } - - getMore() { - let currentPage = this.state.currentPage; - seafileAPI.listVirusScanRecords(currentPage).then((res) => { - this.setState({ - isLoadingMore: false, - records: [...this.state.records, ...res.data.record_list], - currentPage: currentPage + 1, - hasMore: res.data.record_list.length === 0 ? false : true, - }); - }).catch((error) => { - if (error.response) { - this.setState({ - isLoadingMore: false, - errorMsg: error.response.data.error_msg, - }); - toaster.danger(this.state.errorMsg); - } - }); - } - - handleScroll = (event) => { - if (!this.state.isLoadingMore && this.state.hasMore) { - const clientHeight = event.target.clientHeight; - const scrollHeight = event.target.scrollHeight; - const scrollTop = event.target.scrollTop; - const isBottom = (clientHeight + scrollTop + 1 >= scrollHeight); - if (isBottom) { - this.setState({isLoadingMore: true}, () => { - this.getMore(); - }); - } - } - } - - componentDidMount() { - let currentPage = this.state.currentPage; - seafileAPI.listVirusScanRecords(currentPage).then((res) => { - this.setState({ - loading: false, - records: res.data.record_list, - currentPage: currentPage + 1, - hasMore: true, - }); - }).catch((error) => { - if (error.response) { - this.setState({ - loading: false, - errorMsg: error.response.data.error_msg, - }); - toaster.danger(this.state.errorMsg); - } - }); - } - - render() { - return ( - -
-
- -
-
- -
-
-
-
-
-

{gettext('Virus Scan Records')}

-
-
- -
-
-
-
- ); - } -} - -export default VirusScanRecords; diff --git a/frontend/src/pages/sys-admin/virus-scan/all-virus-files.js b/frontend/src/pages/sys-admin/virus-scan/all-virus-files.js new file mode 100644 index 0000000000..b8fabceb0c --- /dev/null +++ b/frontend/src/pages/sys-admin/virus-scan/all-virus-files.js @@ -0,0 +1,313 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { gettext } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; +import OpMenu from '../../../components/dialog/op-menu'; +import Loading from '../../../components/loading'; +import Paginator from '../../../components/paginator'; +import MainPanelTopbar from '../main-panel-topbar'; +import Nav from './nav'; + +const virusFileItemPropTypes = { + virusFile: PropTypes.object.isRequired, + isItemFreezed: PropTypes.bool.isRequired, + onFreezedItem: PropTypes.func.isRequired, + onUnfreezedItem: PropTypes.func.isRequired +}; + +class VirusFileItem extends Component { + + constructor(props) { + super(props); + this.state = { + highlight: false, + isOpIconShown: false + }; + } + + handleMouseEnter = () => { + if (!this.props.isItemFreezed) { + this.setState({ + isOpIconShown: true, + highlight: true + }); + } + } + + handleMouseLeave = () => { + if (!this.props.isItemFreezed) { + this.setState({ + isOpIconShown: false, + highlight: false + }); + } + } + + onUnfreezedItem = () => { + this.setState({ + highlight: false, + isOpIconShow: false + }); + this.props.onUnfreezedItem(); + } + + onMenuItemClick = (operation) => { + this.props.handleFile(this.props.virusFile.virus_id, operation); + } + + translateOperations = (item) => { + let translateResult = ''; + switch(item) { + case 'delete': + translateResult = gettext('Delete'); + break; + case 'ignore': + translateResult = gettext('Ignore'); + break; + case 'do-not-ignore': + translateResult = gettext('Don\'t ignore'); + break; + } + return translateResult; + } + + render() { + const virusFile = this.props.virusFile; + let fileStatus = '', + fileOpList = []; + if (virusFile.has_deleted) { + fileStatus = {gettext('Deleted')}; + } else if (virusFile.has_ignored) { + fileStatus = {gettext('Ignored')}; + fileOpList = ['do-not-ignore']; + } else { + fileStatus = {gettext('Unhandled')}; + fileOpList = ['delete', 'ignore']; + } + + return ( + + {virusFile.repo_name} + {virusFile.repo_owner} + {virusFile.file_path} + {fileStatus} + + {fileOpList.length > 0 && this.state.isOpIconShown && + + } + + + ); + } +} + +VirusFileItem.propTypes = virusFileItemPropTypes; + + +const virusFileListPropTypes = { + loading: PropTypes.bool.isRequired, + errorMsg: PropTypes.string.isRequired, + virusFiles: PropTypes.array.isRequired +}; + +class Content extends Component { + + constructor(props) { + super(props); + this.state = { + isItemFreezed: false + }; + } + + onFreezedItem = () => { + this.setState({isItemFreezed: true}); + } + + onUnfreezedItem = () => { + this.setState({isItemFreezed: false}); + } + + getPreviousPage = () => { + this.props.getListByPage(this.props.currentPage - 1); + } + + getNextPage = () => { + this.props.getListByPage(this.props.currentPage + 1); + } + + render() { + const { + loading, errorMsg, virusFiles, + curPerPage, hasNextPage, currentPage + } = this.props; + + if (loading) { + return ; + } else if (errorMsg) { + return

{errorMsg}

; + } else { + return ( + + + + + + + + + + + + + {virusFiles.map((virusFile, index) => { + return ( + + ); + })} + +
{gettext('Library')}{gettext('Owner')}{gettext('Virus File')}{gettext('Status')}{/* Operations */}
+ {virusFiles.length > 0 && + + } +
+ ); + } + } +} + +Content.propTypes = virusFileListPropTypes; + + +class AllVirusFiles extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + virusFiles: [], + currentPage: 1, + perPage: 25, + hasNextPage: false + }; + } + + 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.getListByPage(this.state.currentPage); + }); + } + + getListByPage = (page) => { + const { perPage } = this.state; + seafileAPI.listVirusFiles(page, perPage).then((res) => { + const data = res.data; + this.setState({ + loading: false, + virusFiles: data.virus_file_list, + hasNextPage: data.has_next_page + }); + }).catch((error) => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403 + }); + }); + } + + resetPerPage = (perPage) => { + this.setState({ + perPage: perPage + }, () => { + this.getListByPage(1); + }); + } + + handleFile = (virusID, op) => { + let request; + switch(op) { + case 'delete': + request = seafileAPI.deleteVirusFile(virusID); + break; + case 'ignore': + request = seafileAPI.toggleIgnoreVirusFile(virusID, true); + break; + case 'do-not-ignore': + request = seafileAPI.toggleIgnoreVirusFile(virusID, false); + break; + } + request.then((res) => { + this.setState({ + virusFiles: this.state.virusFiles.map((item) => { + if (item.virus_id == virusID) { + if (op == 'delete') { + item.has_deleted = true; + } else { + item = res.data.virus_file; + } + } + return item; + }) + }); + }).catch((error) => { + toaster.danger(Utils.getErrorMsg(error)); + }); + } + + render() { + return ( + + +
+
+
+
+
+ ); + } +} + +export default AllVirusFiles; diff --git a/frontend/src/pages/sys-admin/virus-scan/nav.js b/frontend/src/pages/sys-admin/virus-scan/nav.js new file mode 100644 index 0000000000..183aab2d1a --- /dev/null +++ b/frontend/src/pages/sys-admin/virus-scan/nav.js @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from '@reach/router'; +import { siteRoot, gettext } from '../../../utils/constants'; + +const propTypes = { + currentItem: PropTypes.string.isRequired +}; + +class Nav extends React.Component { + + constructor(props) { + super(props); + this.navItems = [ + {name: 'all', urlPart: 'all', text: gettext('All')}, + {name: 'unhandled', urlPart: 'unhandled', text: gettext('Unhandled')} + ]; + } + + render() { + const { currentItem } = this.props; + return ( +
+
    + {this.navItems.map((item, index) => { + return ( +
  • + {item.text} +
  • + ); + })} +
+
+ ); + } +} + +Nav.propTypes = propTypes; + +export default Nav; diff --git a/frontend/src/pages/sys-admin/virus-scan/unhandled-virus-files.js b/frontend/src/pages/sys-admin/virus-scan/unhandled-virus-files.js new file mode 100644 index 0000000000..3c79ef2703 --- /dev/null +++ b/frontend/src/pages/sys-admin/virus-scan/unhandled-virus-files.js @@ -0,0 +1,421 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'reactstrap'; +import { seafileAPI } from '../../../utils/seafile-api'; +import { gettext } from '../../../utils/constants'; +import { Utils } from '../../../utils/utils'; +import toaster from '../../../components/toast'; +import OpMenu from '../../../components/dialog/op-menu'; +import Loading from '../../../components/loading'; +import Paginator from '../../../components/paginator'; +import MainPanelTopbar from '../main-panel-topbar'; +import Nav from './nav'; + +const virusFileItemPropTypes = { + virusFile: PropTypes.object.isRequired, + isItemFreezed: PropTypes.bool.isRequired, + onFreezedItem: PropTypes.func.isRequired, + onUnfreezedItem: PropTypes.func.isRequired +}; + +class VirusFileItem extends Component { + + constructor(props) { + super(props); + this.state = { + highlight: false, + isOpIconShown: false + }; + } + + handleMouseEnter = () => { + if (!this.props.isItemFreezed) { + this.setState({ + isOpIconShown: true, + highlight: true + }); + } + } + + handleMouseLeave = () => { + if (!this.props.isItemFreezed) { + this.setState({ + isOpIconShown: false, + highlight: false + }); + } + } + + onUnfreezedItem = () => { + this.setState({ + highlight: false, + isOpIconShow: false + }); + this.props.onUnfreezedItem(); + } + + onMenuItemClick = (operation) => { + this.props.handleFile(this.props.virusFile.virus_id, operation); + } + + translateOperations = (item) => { + let translateResult = ''; + switch(item) { + case 'delete': + translateResult = gettext('Delete'); + break; + case 'ignore': + translateResult = gettext('Ignore'); + break; + case 'do-not-ignore': + translateResult = gettext('Don\'t ignore'); + break; + } + return translateResult; + } + + toggleItemSelected = (e) => { + this.props.toggleItemSelected(this.props.virusFile, e.target.checked); + } + + render() { + const virusFile = this.props.virusFile; + let fileStatus = '', + fileOpList = []; + if (virusFile.has_deleted) { + fileStatus = {gettext('Deleted')}; + } else if (virusFile.has_ignored) { + fileStatus = {gettext('Ignored')}; + fileOpList = ['do-not-ignore']; + } else { + fileStatus = {gettext('Unhandled')}; + fileOpList = ['delete', 'ignore']; + } + + return ( + + + + + {virusFile.repo_name} + {virusFile.repo_owner} + {virusFile.file_path} + {fileStatus} + + {fileOpList.length > 0 && this.state.isOpIconShown && + + } + + + ); + } +} + +VirusFileItem.propTypes = virusFileItemPropTypes; + + +const virusFileListPropTypes = { + loading: PropTypes.bool.isRequired, + errorMsg: PropTypes.string.isRequired, + virusFiles: PropTypes.array.isRequired +}; + +class Content extends Component { + + constructor(props) { + super(props); + this.state = { + isItemFreezed: false + }; + } + + onFreezedItem = () => { + this.setState({isItemFreezed: true}); + } + + onUnfreezedItem = () => { + this.setState({isItemFreezed: false}); + } + + getPreviousPage = () => { + this.props.getListByPage(this.props.currentPage - 1); + } + + getNextPage = () => { + this.props.getListByPage(this.props.currentPage + 1); + } + + render() { + const { + loading, errorMsg, virusFiles, + curPerPage, hasNextPage, currentPage, + isAllItemsSelected + } = this.props; + + if (loading) { + return ; + } else if (errorMsg) { + return

{errorMsg}

; + } else { + return ( + + + + + + + + + + + + + + {virusFiles.map((virusFile, index) => { + return ( + + ); + })} + +
+ + {gettext('Library')}{gettext('Owner')}{gettext('Virus File')}{gettext('Status')}{/* Operations */}
+ {virusFiles.length > 0 && + + } +
+ ); + } + } +} + +Content.propTypes = virusFileListPropTypes; + + +class UnhandledVirusFiles extends Component { + + constructor(props) { + super(props); + this.state = { + loading: true, + errorMsg: '', + virusFiles: [], + + isAllItemsSelected: false, + selectedItems: [], + + currentPage: 1, + perPage: 25, + hasNextPage: false + }; + } + + 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.getListByPage(this.state.currentPage); + }); + } + + getListByPage = (page) => { + const { perPage } = this.state; + const hasHandled = false; + seafileAPI.listVirusFiles(page, perPage, hasHandled).then((res) => { + const data = res.data; + const items = data.virus_file_list.map(item => { + item.isSelected = false; + return item; + }); + this.setState({ + loading: false, + virusFiles: items, + hasNextPage: data.has_next_page + }); + }).catch((error) => { + this.setState({ + loading: false, + errorMsg: Utils.getErrorMsg(error, true) // true: show login tip if 403 + }); + }); + } + + resetPerPage = (perPage) => { + this.setState({ + perPage: perPage + }, () => { + this.getListByPage(1); + }); + } + + handleFile = (virusID, op) => { + let request; + switch(op) { + case 'delete': + request = seafileAPI.deleteVirusFile(virusID); + break; + case 'ignore': + request = seafileAPI.toggleIgnoreVirusFile(virusID, true); + break; + case 'do-not-ignore': + request = seafileAPI.toggleIgnoreVirusFile(virusID, false); + break; + } + request.then((res) => { + this.setState({ + virusFiles: this.state.virusFiles.map((item) => { + if (item.virus_id == virusID) { + if (op == 'delete') { + item.has_deleted = true; + } else { + item = res.data.virus_file; + } + } + return item; + }) + }); + }).catch((error) => { + toaster.danger(Utils.getErrorMsg(error)); + }); + } + + toggleAllSelected = () => { + this.setState((prevState) => ({ + isAllItemsSelected: !prevState.isAllItemsSelected, + virusFiles: this.state.virusFiles.map((item) => { + item.isSelected = !prevState.isAllItemsSelected; + return item; + }) + })); + } + + toggleItemSelected = (targetItem, isSelected) => { + this.setState({ + virusFiles: this.state.virusFiles.map((item) => { + if (item === targetItem) { + item.isSelected = isSelected; + } + return item; + }) + }, () => { + this.setState({ + isAllItemsSelected: !this.state.virusFiles.some(item => !item.isSelected) + }); + }); + } + + handleSelectedItems = (op) => { + // op: 'delete-virus', 'ignore-virus' + const virusIDs = this.state.virusFiles + .filter(item => { + if (op == 'delete-virus') { + return item.isSelected && !item.has_deleted; + } else { + return item.isSelected && !item.has_ignored; + } + }) + .map(item => item.virus_id); + seafileAPI.batchProcessVirusFiles(virusIDs, op).then((res) => { + let fileList = this.state.virusFiles; + res.data.success.forEach(item => { + let file = fileList.find(file => file.virus_id == item.virus_id); + if (op == 'delete-virus') { + file.has_deleted = true; + } else { + file.has_ignored = true; + } + }); + this.setState({ + virusFiles: fileList + }); + + res.data.failed.forEach(item => { + const file = fileList.find(file => file.virus_id == item.virus_id); + let errMsg = op == 'delete-virus' ? + gettext('Failed to delete %(virus_file) from library %(library): %(error_msg)') : + gettext('Failed to ignore %(virus_file) from library %(library): %(error_msg)'); + errMsg = errMsg.replace('%(virus_file)', file.file_path) + .replace('%(library)', file.repo_name) + .replace('%(error_msg)', item.error_msg); + toaster.danger(errMsg); + }); + }).catch((error) => { + toaster.danger(Utils.getErrorMsg(error)); + }); + } + + deleteSelectedItems = () => { + const op = 'delete-virus'; + this.handleSelectedItems(op); + } + + ignoreSelectedItems = () => { + const op = 'ignore-virus'; + this.handleSelectedItems(op); + } + + render() { + return ( + + {this.state.virusFiles.some(item => item.isSelected) ? ( + + + + + + + ) : + } +
+
+
+
+
+ ); + } +} + +export default UnhandledVirusFiles; diff --git a/seahub/api2/endpoints/admin/virus_scan_records.py b/seahub/api2/endpoints/admin/virus_scan_records.py index f27c0edcfd..2e93313027 100644 --- a/seahub/api2/endpoints/admin/virus_scan_records.py +++ b/seahub/api2/endpoints/admin/virus_scan_records.py @@ -7,26 +7,27 @@ from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import status +from django.utils.translation import ugettext as _ from seaserv import seafile_api from seahub.api2.authentication import TokenAuthentication from seahub.api2.throttling import UserRateThrottle from seahub.api2.permissions import IsProVersion -from seahub.api2.utils import api_error -from seahub.utils import get_virus_record, get_virus_record_by_id, handle_virus_record +from seahub.api2.utils import api_error, to_python_boolean +from seahub.utils import get_virus_files, get_virus_file_by_vid, delete_virus_file, operate_virus_file logger = logging.getLogger(__name__) -class AdminVirusScanRecords(APIView): +class AdminVirusFilesView(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAdminUser, IsProVersion) throttle_classes = (UserRateThrottle,) def get(self, request): - """get virus scan records + """get virus files """ if not request.user.admin_permissions.other_permission(): @@ -42,21 +43,32 @@ class AdminVirusScanRecords(APIView): except ValueError: per_page = 25 + try: + has_handled = to_python_boolean(request.GET.get('has_handled', '')) + except ValueError: + has_handled = None + start = (page - 1) * per_page - count = per_page + count = per_page + 1 try: - virus_records = get_virus_record(start=start, limit=count) + virus_files = get_virus_files(has_handled=has_handled, start=start, limit=count) except Exception as e: logger.error(e) error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) - record_list = list() - for virus_record in virus_records: + if len(virus_files) > per_page: + virus_files = virus_files[:per_page] + has_next_page = True + else: + has_next_page = False + + virus_file_list = list() + for virus_file in virus_files: try: - repo = seafile_api.get_repo(virus_record.repo_id) - repo_owner = seafile_api.get_repo_owner(virus_record.repo_id) + repo = seafile_api.get_repo(virus_file.repo_id) + repo_owner = seafile_api.get_repo_owner(virus_file.repo_id) except Exception as e: logger.error(e) continue @@ -67,40 +79,176 @@ class AdminVirusScanRecords(APIView): record = dict() record["repo_name"] = repo.name record["repo_owner"] = repo_owner - record["file_path"] = virus_record.file_path - record["has_handle"] = virus_record.has_handle - record["virus_id"] = virus_record.vid - record_list.append(record) + record["file_path"] = virus_file.file_path + record["has_deleted"] = virus_file.has_deleted + record["has_ignored"] = virus_file.has_ignored + record["virus_id"] = virus_file.vid + virus_file_list.append(record) - return Response({"record_list": record_list}, status=status.HTTP_200_OK) + return Response({"virus_file_list": virus_file_list, "has_next_page": has_next_page}, status=status.HTTP_200_OK) -class AdminVirusScanRecord(APIView): +class AdminVirusFileView(APIView): authentication_classes = (TokenAuthentication, SessionAuthentication) permission_classes = (IsAdminUser, IsProVersion) throttle_classes = (UserRateThrottle,) def delete(self, request, virus_id): + """delete virus file + """ if not request.user.admin_permissions.other_permission(): return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') - virus_record = get_virus_record_by_id(virus_id) - if not virus_record: - error_msg = 'Virus record %d not found.' % virus_id + virus_file = get_virus_file_by_vid(virus_id) + if not virus_file: + error_msg = 'Virus file %s not found.' % virus_id return api_error(status.HTTP_404_NOT_FOUND, error_msg) - parent_dir = os.path.dirname(virus_record.file_path) - filename = os.path.basename(virus_record.file_path) + parent_dir = os.path.dirname(virus_file.file_path) + filename = os.path.basename(virus_file.file_path) try: seafile_api.del_file( - virus_record.repo_id, parent_dir, filename, request.user.username + virus_file.repo_id, parent_dir, filename, request.user.username ) - handle_virus_record(virus_id) + delete_virus_file(virus_id) except Exception as e: logger.error(e) - error_msg = 'Failed to delete, please try again later.' + error_msg = 'Internal Server Error' return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) return Response({"success": True}, status=status.HTTP_200_OK) + + def put(self, request, virus_id): + """ignore or un-ignore virus file + """ + + if not request.user.admin_permissions.other_permission(): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + ignore = request.data.get('ignore') + if ignore not in ('true', 'false'): + error_msg = 'ignore invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + ignore = to_python_boolean(ignore) + + virus_file = get_virus_file_by_vid(virus_id) + if not virus_file: + error_msg = 'Virus file %s not found.' % virus_id + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + try: + operate_virus_file(virus_id, ignore) + except Exception as e: + logger.error(e) + error_msg = 'Internal Server Error' + return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg) + + virus_file = get_virus_file_by_vid(virus_id) + + repo = seafile_api.get_repo(virus_file.repo_id) + repo_owner = seafile_api.get_repo_owner(virus_file.repo_id) + res = dict(repo_name=repo.name) + res["repo_owner"] = repo_owner + res["file_path"] = virus_file.file_path + res["has_deleted"] = virus_file.has_deleted + res["has_ignored"] = virus_file.has_ignored + res["virus_id"] = virus_file.vid + + return Response({"virus_file": res}, status=status.HTTP_200_OK) + + +class AdminVirusFilesBatchView(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAdminUser, IsProVersion) + throttle_classes = (UserRateThrottle,) + + def post(self, request): + """ Delete virus files, ignore or cancel ignore virus files, in batch. + + Permission checking: + 1. admin user. + """ + + if not request.user.admin_permissions.other_permission(): + return api_error(status.HTTP_403_FORBIDDEN, 'Permission denied.') + + # argument check + virus_ids = request.POST.getlist('virus_ids', None) + if not virus_ids: + error_msg = 'virus_ids invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + operation = request.POST.get('operation', None) + if operation not in ('delete-virus', 'ignore-virus', 'cancel-ignore-virus'): + error_msg = "operation can only be 'delete-virus', 'ignore-virus' or 'cancel-ignore-virus'." + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + result = dict(failed=[]) + result['success'] = [] + + virus_files = [] + for virus_id in virus_ids: + virus_file = get_virus_file_by_vid(int(virus_id)) + if virus_file: + virus_files.append(virus_file) + else: + result['failed'].append({ + 'virus_id': virus_id, + 'error_msg': _('Virus file not found.') + }) + continue + + if operation == 'delete-virus': + for virus_file in virus_files: + parent_dir = os.path.dirname(virus_file.file_path) + filename = os.path.basename(virus_file.file_path) + virus_id = int(virus_file.vid) + try: + seafile_api.del_file( + virus_file.repo_id, parent_dir, filename, request.user.username + ) + delete_virus_file(virus_id) + except Exception as e: + logger.error(e) + result['failed'].append({ + 'virus_id': virus_id, + 'error_msg': _('Internal Server Error') + }) + continue + + result['success'].append({'virus_id': virus_id}) + + if operation == 'ignore-virus': + for virus_file in virus_files: + virus_id = int(virus_file.vid) + try: + operate_virus_file(virus_id, True) + except Exception as e: + logger.error(e) + result['failed'].append({ + 'virus_id': virus_id, + 'error_msg': _('Internal Server Error') + }) + continue + + result['success'].append({'virus_id': virus_id}) + + if operation == 'cancel-ignore-virus': + for virus_file in virus_files: + virus_id = int(virus_file.vid) + try: + operate_virus_file(virus_id, False) + except Exception as e: + logger.error(e) + result['failed'].append({ + 'virus_id': virus_id, + 'error_msg': _('Internal Server Error') + }) + continue + + result['success'].append({'virus_id': virus_id}) + + return Response(result) diff --git a/seahub/urls.py b/seahub/urls.py index 2959d482c1..34e976c261 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -171,7 +171,8 @@ from seahub.api2.endpoints.admin.work_weixin import AdminWorkWeixinDepartments, from seahub.api2.endpoints.admin.dingtalk import AdminDingtalkDepartments, \ AdminDingtalkDepartmentMembers, AdminDingtalkUsersBatch, \ AdminDingtalkDepartmentsImport -from seahub.api2.endpoints.admin.virus_scan_records import AdminVirusScanRecords, AdminVirusScanRecord +from seahub.api2.endpoints.admin.virus_scan_records import AdminVirusFilesView, AdminVirusFileView, \ + AdminVirusFilesBatchView from seahub.api2.endpoints.file_participants import FileParticipantsView, FileParticipantView from seahub.api2.endpoints.repo_related_users import RepoRelatedUsersView @@ -614,9 +615,10 @@ urlpatterns = [ ## admin::file-scan-records url(r'^api/v2.1/admin/file-scan-records/$', AdminFileScanRecords.as_view(), name='api-v2.1-admin-file-scan-records'), - # admin::virus-scan-records - url(r'^api/v2.1/admin/virus-scan-records/$', AdminVirusScanRecords.as_view(), name='api-v2.1-admin-virus-scan-records'), - url(r'^api/v2.1/admin/virus-scan-records/(?P\d+)/$', AdminVirusScanRecord.as_view(), name='api-v2.1-admin-virus-scan-record'), + # admin::virus-files + url(r'^api/v2.1/admin/virus-files/$', AdminVirusFilesView.as_view(), name='api-v2.1-admin-virus-files'), + url(r'^api/v2.1/admin/virus-files/(?P\d+)/$', AdminVirusFileView.as_view(), name='api-v2.1-admin-virus-file'), + url(r'^api/v2.1/admin/virus-files/batch/$', AdminVirusFilesBatchView.as_view(), name='api-v2.1-admin-virus-files-batch'), ## admin::notifications url(r'^api/v2.1/admin/notifications/$', AdminNotificationsView.as_view(), name='api-2.1-admin-notifications'), @@ -731,7 +733,8 @@ if ENABLE_FILE_SCAN: from seahub.utils import EVENTS_ENABLED if EVENTS_ENABLED: urlpatterns += [ - url(r'^sys/virus-scan-records/$', sysadmin_react_fake_view, name='sys_virus_scan_records'), + url(r'^sys/virus-files/all/$', sysadmin_react_fake_view, name='sys_virus_scan_records'), + url(r'^sys/virus-files/unhandled/$', sysadmin_react_fake_view, name='sys_virus_scan_records'), ] if settings.SERVE_STATIC: diff --git a/seahub/utils/__init__.py b/seahub/utils/__init__.py index 0ec17b50ec..f9570a8434 100644 --- a/seahub/utils/__init__.py +++ b/seahub/utils/__init__.py @@ -823,18 +823,22 @@ if EVENTS_CONFIG_FILE: return events if events else None - def get_virus_record(repo_id=None, start=-1, limit=-1): + def get_virus_files(repo_id=None, has_handled=None, start=-1, limit=-1): with _get_seafevents_session() as session: - r = seafevents.get_virus_record(session, repo_id, start, limit) + r = seafevents.get_virus_files(session, repo_id, has_handled, start, limit) return r if r else [] - def handle_virus_record(vid): + def delete_virus_file(vid): with _get_seafevents_session() as session: - return True if seafevents.handle_virus_record(session, vid) == 0 else False + return True if seafevents.delete_virus_file(session, vid) == 0 else False - def get_virus_record_by_id(vid): + def operate_virus_file(vid, ignore): with _get_seafevents_session() as session: - return seafevents.get_virus_record_by_id(session, vid) + return True if seafevents.operate_virus_file(session, vid, ignore) == 0 else False + + def get_virus_file_by_vid(vid): + with _get_seafevents_session() as session: + return seafevents.get_virus_file_by_vid(session, vid) def get_file_scan_record(start=-1, limit=-1): records = seafevents_api.get_content_scan_results(start, limit) @@ -876,11 +880,13 @@ else: pass def get_perm_audit_events(): pass - def get_virus_record(): + def get_virus_files(): pass - def handle_virus_record(): + def delete_virus_file(): pass - def get_virus_record_by_id(vid): + def operate_virus_file(): + pass + def get_virus_file_by_vid(vid): pass def get_file_scan_record(): pass diff --git a/seahub/views/sysadmin.py b/seahub/views/sysadmin.py index a9a02fd61f..db4e15775e 100644 --- a/seahub/views/sysadmin.py +++ b/seahub/views/sysadmin.py @@ -48,8 +48,8 @@ from seahub.role_permissions.models import AdminRole from seahub.two_factor.models import default_device from seahub.utils import IS_EMAIL_CONFIGURED, string2list, is_valid_username, \ is_pro_version, send_html_email, \ - get_server_id, handle_virus_record, get_virus_record_by_id, \ - get_virus_record, FILE_AUDIT_ENABLED, get_max_upload_file_size, \ + get_server_id, delete_virus_file, get_virus_file_by_vid, \ + get_virus_files, FILE_AUDIT_ENABLED, get_max_upload_file_size, \ get_site_name, seafevents_api from seahub.utils.ip import get_remote_ip from seahub.utils.file_size import get_file_size_unit