diff --git a/frontend/src/components/dialog/share-dialog.js b/frontend/src/components/dialog/share-dialog.js index 269098486e..f2e539482a 100644 --- a/frontend/src/components/dialog/share-dialog.js +++ b/frontend/src/components/dialog/share-dialog.js @@ -2,7 +2,7 @@ import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import { Modal, ModalHeader, ModalBody, TabContent, TabPane, Nav, NavItem, NavLink } from 'reactstrap'; import { gettext, username, canGenerateShareLink, canGenerateUploadLink, canInvitePeople, additionalShareDialogNote, enableOCM, isPro } from '../../utils/constants'; -import ShareLinkPanel from './share-link-panel'; +import ShareLinkPanel from '../share-link-panel'; import GenerateUploadLink from './generate-upload-link'; import ShareToUser from './share-to-user'; import ShareToGroup from './share-to-group'; diff --git a/frontend/src/components/dialog/share-link-panel.js b/frontend/src/components/dialog/share-link-panel.js deleted file mode 100644 index 6fe7f3055c..0000000000 --- a/frontend/src/components/dialog/share-link-panel.js +++ /dev/null @@ -1,748 +0,0 @@ -import React, { Fragment } from 'react'; -import PropTypes from 'prop-types'; -import moment from 'moment'; -import copy from 'copy-to-clipboard'; -import { Button, Form, FormGroup, Label, Input, InputGroup, InputGroupAddon, Alert } from 'reactstrap'; -import { isPro, gettext, shareLinkExpireDaysMin, shareLinkExpireDaysMax, shareLinkExpireDaysDefault, shareLinkForceUsePassword, shareLinkPasswordMinLength, shareLinkPasswordStrengthLevel, canSendShareLinkEmail } from '../../utils/constants'; -import ShareLinkPermissionEditor from '../../components/select-editor/share-link-permission-editor'; -import { seafileAPI } from '../../utils/seafile-api'; -import { Utils } from '../../utils/utils'; -import ShareLink from '../../models/share-link'; -import toaster from '../toast'; -import Loading from '../loading'; -import SendLink from '../send-link'; -import SharedLink from '../shared-link'; -import SetLinkExpiration from '../set-link-expiration'; - -const propTypes = { - itemPath: PropTypes.string.isRequired, - repoID: PropTypes.string.isRequired, - closeShareDialog: PropTypes.func.isRequired, - userPerm: PropTypes.string, - itemType: PropTypes.string -}; - -const inputWidth = Utils.isDesktop() ? 250 : 210; - -class ShareLinkPanel extends React.Component { - - constructor(props) { - super(props); - - this.isExpireDaysNoLimit = (shareLinkExpireDaysMin === 0 && shareLinkExpireDaysMax === 0 && shareLinkExpireDaysDefault == 0); - this.defaultExpireDays = this.isExpireDaysNoLimit ? '' : shareLinkExpireDaysDefault; - - this.state = { - isShowPasswordInput: shareLinkForceUsePassword ? true : false, - isPasswordVisible: false, - isExpireChecked: !this.isExpireDaysNoLimit, - expType: 'by-days', - expireDays: this.defaultExpireDays, - expDate: null, - password: '', - passwdnew: '', - errorInfo: '', - sharedLinkInfo: null, - shareLinks: [], - isLoading: true, - permissionOptions: [], - currentPermission: '', - }; - } - - componentDidMount() { - let path = this.props.itemPath; - let repoID = this.props.repoID; - seafileAPI.getShareLink(repoID, path).then((res) => { - this.setState({ - isLoading: false, - shareLinks: res.data.map(item => new ShareLink(item)) - }); - }).catch(error => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - - if (isPro) { - const { itemType, userPerm } = this.props; - if (itemType == 'library') { - let permissionOptions = Utils.getShareLinkPermissionList(itemType, userPerm, path); - this.setState({ - permissionOptions: permissionOptions, - currentPermission: permissionOptions[0], - }); - } else { - let getDirentInfoAPI; - if (this.props.itemType === 'file') { - getDirentInfoAPI = seafileAPI.getFileInfo(repoID, path); - } else if (this.props.itemType === 'dir') { - getDirentInfoAPI = seafileAPI.getDirInfo(repoID, path); - } - getDirentInfoAPI.then((res) => { - let canEdit = res.data.can_edit; - let permission = res.data.permission; - let permissionOptions = Utils.getShareLinkPermissionList(this.props.itemType, permission, path, canEdit); - this.setState({ - permissionOptions: permissionOptions, - currentPermission: permissionOptions[0], - }); - }).catch(error => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - } - } - - setExpType = (e) => { - this.setState({ - expType: e.target.value - }); - } - - onExpDateChanged = (value) => { - this.setState({ - expDate: value - }); - } - - onPasswordInputChecked = () => { - this.setState({ - isShowPasswordInput: !this.state.isShowPasswordInput, - password: '', - passwdnew: '', - errorInfo: '' - }); - } - - togglePasswordVisible = () => { - this.setState({ - isPasswordVisible: !this.state.isPasswordVisible - }); - } - - generatePassword = () => { - let val = Utils.generatePassword(shareLinkPasswordMinLength); - this.setState({ - password: val, - passwdnew: val - }); - } - - inputPassword = (e) => { - let passwd = e.target.value.trim(); - this.setState({password: passwd}); - } - - inputPasswordNew = (e) => { - let passwd = e.target.value.trim(); - this.setState({passwdnew: passwd}); - } - - setPermission = (e) => { - this.setState({currentPermission: e.target.value}); - } - - generateShareLink = () => { - let isValid = this.validateParamsInput(); - if (isValid) { - this.setState({errorInfo: ''}); - let { itemPath, repoID } = this.props; - let { password, isExpireChecked, expType, expireDays, expDate } = this.state; - let permissions; - if (isPro) { - const permissionDetails = Utils.getShareLinkPermissionObject(this.state.currentPermission).permissionDetails; - permissions = JSON.stringify(permissionDetails); - } - let expirationTime = ''; - if (isExpireChecked) { - if (expType == 'by-days') { - expirationTime = moment().add(parseInt(expireDays), 'days').format(); - } else { - expirationTime = expDate.format(); - } - } - seafileAPI.createMultiShareLink(repoID, itemPath, password, expirationTime, permissions).then((res) => { - const links = this.state.shareLinks; - const newLink = new ShareLink(res.data); - links.unshift(newLink); - this.setState({ - password: '', - passwdnew: '', - isShowPasswordInput: shareLinkForceUsePassword ? true : false, - expireDays: this.defaultExpireDays, - expDate: null, - isExpireChecked: !this.isExpireDaysNoLimit, - errorInfo: '', - sharedLinkInfo: newLink, - shareLinks: links - }); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - } - - onExpireChecked = (e) => { - this.setState({isExpireChecked: e.target.checked}); - } - - onExpireDaysChanged = (e) => { - let day = e.target.value.trim(); - this.setState({expireDays: day}); - } - - validateParamsInput = () => { - let { isShowPasswordInput, password, passwdnew, isExpireChecked, expType, expireDays, expDate } = this.state; - - // validate password - if (isShowPasswordInput) { - if (password.length === 0) { - this.setState({errorInfo: gettext('Please enter a password.')}); - return false; - } - if (password.length < shareLinkPasswordMinLength) { - this.setState({errorInfo: gettext('The password is too short.')}); - return false; - } - if (password !== passwdnew) { - this.setState({errorInfo: gettext('Passwords don\'t match')}); - return false; - } - if (Utils.getStrengthLevel(password) < shareLinkPasswordStrengthLevel) { - this.setState({errorInfo: gettext('The password is too weak. It should include at least {passwordStrengthLevel} of the following: number, upper letter, lower letter and other symbols.').replace('{passwordStrengthLevel}', shareLinkPasswordStrengthLevel)}); - return false; - } - } - - if (isExpireChecked) { - if (expType == 'by-date') { - if (!expDate) { - this.setState({errorInfo: gettext('Please select an expiration time')}); - return false; - } - return true; - } - - // by days - let reg = /^\d+$/; - if (!expireDays) { - this.setState({errorInfo: gettext('Please enter days')}); - return false; - } - if (!reg.test(expireDays)) { - this.setState({errorInfo: gettext('Please enter a non-negative integer')}); - return false; - } - - expireDays = parseInt(expireDays); - let minDays = shareLinkExpireDaysMin; - let maxDays = shareLinkExpireDaysMax; - - if (minDays !== 0 && maxDays == 0) { - if (expireDays < minDays) { - this.setState({errorInfo: 'Please enter valid days'}); - return false; - } - } - - if (minDays === 0 && maxDays !== 0 ) { - if (expireDays > maxDays) { - this.setState({errorInfo: 'Please enter valid days'}); - return false; - } - } - - if (minDays !== 0 && maxDays !== 0) { - if (expireDays < minDays || expireDays > maxDays) { - this.setState({errorInfo: 'Please enter valid days'}); - return false; - } - } - - this.setState({expireDays: expireDays}); - } - - return true; - } - - showLinkDetails = (link) => { - this.setState({ - sharedLinkInfo: link - }); - } - - updateLink = (link) => { - const { shareLinks } = this.state; - this.setState({ - sharedLinkInfo: link, - shareLinks: shareLinks.map(item => item.token == link.token ? link : item) - }); - } - - deleteLink = () => { - const { sharedLinkInfo, shareLinks } = this.state; - seafileAPI.deleteShareLink(sharedLinkInfo.token).then(() => { - this.setState({ - sharedLinkInfo: null, - shareLinks: shareLinks.filter(item => item.token !== sharedLinkInfo.token) - }); - toaster.success(gettext('The link is deleted.')); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - - render() { - if (this.state.isLoading) { - return ; - } - - const { userPerm } = this.props; - const { isCustomPermission } = Utils.getUserPermission(userPerm); - const { shareLinks, permissionOptions, sharedLinkInfo } = this.state; - - if (sharedLinkInfo) { - return ( - - ); - } else { - return ( - -
- - {shareLinkForceUsePassword ? ( - - ) : ( - - )} - {this.state.isShowPasswordInput && -
- - - {gettext('(at least {passwordMinLength} characters and includes {passwordStrengthLevel} of the following: number, upper letter, lower letter and other symbols)').replace('{passwordMinLength}', shareLinkPasswordMinLength).replace('{passwordStrengthLevel}', shareLinkPasswordStrengthLevel)} - - - - - - - - - - - - -
- } -
- - - {this.state.isExpireChecked && -
- -
- } -
- {(isPro && !isCustomPermission) && ( - - - {this.state.permissionOptions.map((item, index) => { - return ( - - - - ); - })} - - )} - {this.state.errorInfo && {gettext(this.state.errorInfo)}} - -
- {shareLinks.length > 0 && ( - - - - - - - - - - - { - shareLinks.map((item, index) => { - return ( - - ); - }) - } - -
{gettext('Link')}{gettext('Permission')}{gettext('Expiration')}
- )} -
- ); - } - } -} - -class LinkItem extends React.Component { - - constructor(props) { - super(props); - this.state = { - isItemOpVisible: false - }; - } - - onMouseOver = () => { - this.setState({ - isItemOpVisible: true - }); - } - - onMouseOut = () => { - this.setState({ - isItemOpVisible: false - }); - } - - cutLink = (link) => { - let length = link.length; - return link.slice(0, 9) + '...' + link.slice(length-5); - } - - viewDetails = (e) => { - e.preventDefault(); - this.props.showLinkDetails(this.props.item); - } - - render() { - const { isItemOpVisible } = this.state; - const { item, permissionOptions } = this.props; - const { permissions, link, expire_date } = item; - const currentPermission = Utils.getShareLinkPermissionStr(permissions); - return ( - - {this.cutLink(link)} - - {(isPro && permissions) && ( - {}} - /> - )} - - - {expire_date ? moment(expire_date).format('YYYY-MM-DD HH:mm') : '--'} - - - {gettext('Details')} - - - ); - } -} - -LinkItem.propTypes = { - item: PropTypes.object.isRequired, - permissionOptions: PropTypes.array, - showLinkDetails : PropTypes.func.isRequired -}; - -class LinkDetails extends React.Component { - - constructor(props) { - super(props); - this.state = { - storedPasswordVisible: false, - isEditingExpiration: false, - isExpirationEditIconShow: false, - expType: 'by-days', - expireDays: this.props.defaultExpireDays, - expDate: null, - isOpIconShown: false, - isNoticeMessageShow: false, - isSendLinkShown: false - }; - } - - onCopySharedLink = () => { - const { sharedLinkInfo } = this.props; - copy(sharedLinkInfo.link); - toaster.success(gettext('Share link is copied to the clipboard.')); - this.props.closeShareDialog(); - } - - onCopyDownloadLink = () => { - const { sharedLinkInfo } = this.props; - copy(`${sharedLinkInfo.link}?dl=1`); - toaster.success(gettext('Direct download link is copied to the clipboard.')); - this.props.closeShareDialog(); - } - - toggleStoredPasswordVisible = () => { - this.setState({ - storedPasswordVisible: !this.state.storedPasswordVisible - }); - } - - handleMouseOverExpirationEditIcon = () => { - this.setState({isExpirationEditIconShow: true}); - } - - handleMouseOutExpirationEditIcon = () => { - this.setState({isExpirationEditIconShow: false}); - } - - editingExpirationToggle = () => { - this.setState({isEditingExpiration: !this.state.isEditingExpiration}); - } - - setExpType = (e) => { - this.setState({ - expType: e.target.value - }); - } - - onExpDateChanged = (value) => { - this.setState({ - expDate: value - }); - } - - onExpireDaysChanged = (e) => { - let day = e.target.value.trim(); - this.setState({expireDays: day}); - } - - updateExpiration = () => { - const { sharedLinkInfo } = this.props; - const { expType, expireDays, expDate } = this.state; - let expirationTime = ''; - if (expType == 'by-days') { - expirationTime = moment().add(parseInt(expireDays), 'days').format(); - } else { - expirationTime = expDate.format(); - } - seafileAPI.updateShareLink(sharedLinkInfo.token, '', expirationTime).then((res) => { - this.setState({ - isEditingExpiration: false - }); - this.props.updateLink(new ShareLink(res.data)); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - - handleMouseOver = () => { - this.setState({isOpIconShown: true}); - } - - handleMouseOut = () => { - this.setState({isOpIconShown: false}); - } - - changePerm = (permission) => { - const { sharedLinkInfo } = this.props; - const { permissionDetails } = Utils.getShareLinkPermissionObject(permission); - seafileAPI.updateShareLink(sharedLinkInfo.token, JSON.stringify(permissionDetails)).then((res) => { - this.props.updateLink(new ShareLink(res.data)); - }).catch((error) => { - let errMessage = Utils.getErrorMsg(error); - toaster.danger(errMessage); - }); - } - - onNoticeMessageToggle = () => { - this.setState({isNoticeMessageShow: !this.state.isNoticeMessageShow}); - } - - toggleSendLink = () => { - this.setState({ isSendLinkShown: !this.state.isSendLinkShown }); - } - - render() { - const { sharedLinkInfo, permissionOptions } = this.props; - const { isOpIconShown } = this.state; - const currentPermission = Utils.getShareLinkPermissionStr(sharedLinkInfo.permissions); - return ( -
- -
-
{gettext('Link:')}
-
- -
- {!sharedLinkInfo.is_dir && sharedLinkInfo.permissions.can_download && ( // just for file - <> -
{gettext('Direct Download Link:')}
-
- -
- - )} - {sharedLinkInfo.password && ( - <> -
{gettext('Password:')}
-
-
- - -
-
- - )} - {sharedLinkInfo.expire_date && ( - <> -
{gettext('Expiration Date:')}
- {!this.state.isEditingExpiration && -
- {moment(sharedLinkInfo.expire_date).format('YYYY-MM-DD HH:mm:ss')} - {this.state.isExpirationEditIconShow && ( - - - )} -
- } - {this.state.isEditingExpiration && -
-
- -
- - -
-
-
- } - - )} - {(isPro && sharedLinkInfo.permissions) && ( - <> -
{gettext('Permission:')}
-
- -
- - )} -
- {(canSendShareLinkEmail && !this.state.isSendLinkShown && !this.state.isNoticeMessageShow) && - - } - {this.state.isSendLinkShown && - - } - {(!this.state.isSendLinkShown && !this.state.isNoticeMessageShow) && - - } - {this.state.isNoticeMessageShow && -
-

{gettext('Are you sure you want to delete the share link?')}

-

{gettext('If the share link is deleted, no one will be able to access it any more.')}

- {' '} - -
- } -
- ); - } -} - -LinkDetails.propTypes = { - sharedLinkInfo: PropTypes.object.isRequired, - permissionOptions: PropTypes.array.isRequired, - defaultExpireDays: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number - ]), - showLinkDetails: PropTypes.func.isRequired, - updateLink: PropTypes.func.isRequired, - deleteLink: PropTypes.func.isRequired, - closeShareDialog: PropTypes.func.isRequired -}; - -ShareLinkPanel.propTypes = propTypes; - -export default ShareLinkPanel; diff --git a/frontend/src/components/share-link-panel/index.js b/frontend/src/components/share-link-panel/index.js new file mode 100644 index 0000000000..dbe5610c54 --- /dev/null +++ b/frontend/src/components/share-link-panel/index.js @@ -0,0 +1,225 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { isPro, gettext, shareLinkExpireDaysMin, shareLinkExpireDaysMax, shareLinkExpireDaysDefault } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; +import ShareLink from '../../models/share-link'; +import toaster from '../toast'; +import Loading from '../loading'; +import LinkDetails from './link-details'; +import LinkItem from './link-item'; +import LinkCreation from './link-creation'; + +const propTypes = { + itemPath: PropTypes.string.isRequired, + repoID: PropTypes.string.isRequired, + closeShareDialog: PropTypes.func.isRequired, + userPerm: PropTypes.string, + itemType: PropTypes.string +}; + +class ShareLinkPanel extends React.Component { + + constructor(props) { + super(props); + + this.isExpireDaysNoLimit = (shareLinkExpireDaysMin === 0 && shareLinkExpireDaysMax === 0 && shareLinkExpireDaysDefault == 0); + this.defaultExpireDays = this.isExpireDaysNoLimit ? '' : shareLinkExpireDaysDefault; + + this.state = { + isLoading: true, + mode: 'listLinks', + sharedLinkInfo: null, + shareLinks: [], + permissionOptions: [], + currentPermission: '' + }; + } + + componentDidMount() { + let path = this.props.itemPath; + let repoID = this.props.repoID; + seafileAPI.getShareLink(repoID, path).then((res) => { + this.setState({ + isLoading: false, + shareLinks: res.data.map(item => new ShareLink(item)) + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + + if (isPro) { + const { itemType, userPerm } = this.props; + if (itemType == 'library') { + let permissionOptions = Utils.getShareLinkPermissionList(itemType, userPerm, path); + this.setState({ + permissionOptions: permissionOptions, + currentPermission: permissionOptions[0], + }); + } else { + let getDirentInfoAPI; + if (this.props.itemType === 'file') { + getDirentInfoAPI = seafileAPI.getFileInfo(repoID, path); + } else if (this.props.itemType === 'dir') { + getDirentInfoAPI = seafileAPI.getDirInfo(repoID, path); + } + getDirentInfoAPI.then((res) => { + let canEdit = res.data.can_edit; + let permission = res.data.permission; + let permissionOptions = Utils.getShareLinkPermissionList(this.props.itemType, permission, path, canEdit); + this.setState({ + permissionOptions: permissionOptions, + currentPermission: permissionOptions[0], + }); + }).catch(error => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + } + } + + showLinkDetails = (link) => { + this.setState({ + sharedLinkInfo: link, + mode: link ? 'displayLinkDetails' : '' + }); + } + + updateLink = (link) => { + const { shareLinks } = this.state; + this.setState({ + sharedLinkInfo: link, + shareLinks: shareLinks.map(item => item.token == link.token ? link : item) + }); + } + + deleteLink = () => { + const { sharedLinkInfo, shareLinks } = this.state; + seafileAPI.deleteShareLink(sharedLinkInfo.token).then(() => { + this.setState({ + mode: '', + sharedLinkInfo: null, + shareLinks: shareLinks.filter(item => item.token !== sharedLinkInfo.token) + }); + toaster.success(gettext('The link is deleted.')); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + updateAfterCreation = (newData) => { + const { mode, shareLinks: links } = this.state; + if (mode == 'singleLinkCreation') { + links.unshift(newData); + this.setState({ + mode: 'displayLinkDetails', + sharedLinkInfo: newData, + shareLinks: links + }); + } else { // 'linksCreation' + this.setState({ + mode: '', + shareLinks: newData.concat(links) + }); + } + } + + setMode = (mode) => { + this.setState({ mode: mode }); + } + + render() { + if (this.state.isLoading) { + return ; + } + + const { repoID, itemPath, userPerm } = this.props; + const { mode, shareLinks, sharedLinkInfo, permissionOptions, currentPermission } = this.state; + + switch (mode) { + case 'displayLinkDetails': + return ( + + ); + case 'singleLinkCreation': + return ( + + ); + case 'linksCreation': + return ( + + ); + default: + return ( + +
+
{gettext('Share Link')}
+
+ + +
+
+ {shareLinks.length > 0 && ( + + + + + + + + + + + { + shareLinks.map((item, index) => { + return ( + + ); + }) + } + +
{gettext('Link')}{gettext('Permission')}{gettext('Expiration')}
+ )} +
+ ); + } + } +} + +ShareLinkPanel.propTypes = propTypes; + +export default ShareLinkPanel; diff --git a/frontend/src/components/share-link-panel/link-creation.js b/frontend/src/components/share-link-panel/link-creation.js new file mode 100644 index 0000000000..7283e86aee --- /dev/null +++ b/frontend/src/components/share-link-panel/link-creation.js @@ -0,0 +1,342 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { Button, Form, FormGroup, Label, Input, InputGroup, InputGroupAddon, Alert } from 'reactstrap'; +import { isPro, gettext, shareLinkExpireDaysMin, shareLinkExpireDaysMax, shareLinkExpireDaysDefault, shareLinkForceUsePassword, shareLinkPasswordMinLength, shareLinkPasswordStrengthLevel } from '../../utils/constants'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; +import ShareLink from '../../models/share-link'; +import toaster from '../toast'; +import SetLinkExpiration from '../set-link-expiration'; + +const propTypes = { + itemPath: PropTypes.string.isRequired, + repoID: PropTypes.string.isRequired, + userPerm: PropTypes.string, + type: PropTypes.string.isRequired, + permissionOptions: PropTypes.array.isRequired, + currentPermission: PropTypes.string.isRequired, + updateAfterCreation: PropTypes.func.isRequired, + setMode: PropTypes.func.isRequired, +}; + +const inputWidth = Utils.isDesktop() ? 250 : 210; + +class LinkCreation extends React.Component { + + constructor(props) { + super(props); + + this.isExpireDaysNoLimit = (shareLinkExpireDaysMin === 0 && shareLinkExpireDaysMax === 0 && shareLinkExpireDaysDefault == 0); + this.defaultExpireDays = this.isExpireDaysNoLimit ? '' : shareLinkExpireDaysDefault; + + this.state = { + linkAmount: '', + isShowPasswordInput: shareLinkForceUsePassword ? true : false, + isPasswordVisible: false, + isExpireChecked: !this.isExpireDaysNoLimit, + expType: 'by-days', + expireDays: this.defaultExpireDays, + expDate: null, + password: '', + passwdnew: '', + errorInfo: '', + currentPermission: props.currentPermission + }; + } + + setExpType = (e) => { + this.setState({ + expType: e.target.value + }); + } + + onExpDateChanged = (value) => { + this.setState({ + expDate: value + }); + } + + onPasswordInputChecked = () => { + this.setState({ + isShowPasswordInput: !this.state.isShowPasswordInput, + password: '', + passwdnew: '', + errorInfo: '' + }); + } + + togglePasswordVisible = () => { + this.setState({ + isPasswordVisible: !this.state.isPasswordVisible + }); + } + + generatePassword = () => { + let val = Utils.generatePassword(shareLinkPasswordMinLength); + this.setState({ + password: val, + passwdnew: val + }); + } + + inputPassword = (e) => { + let passwd = e.target.value.trim(); + this.setState({password: passwd}); + } + + inputPasswordNew = (e) => { + let passwd = e.target.value.trim(); + this.setState({passwdnew: passwd}); + } + + setPermission = (e) => { + this.setState({currentPermission: e.target.value}); + } + + generateShareLink = () => { + let isValid = this.validateParamsInput(); + if (isValid) { + this.setState({errorInfo: ''}); + let { type, itemPath, repoID } = this.props; + let { linkAmount, isShowPasswordInput, password, isExpireChecked, expType, expireDays, expDate } = this.state; + let permissions; + if (isPro) { + const permissionDetails = Utils.getShareLinkPermissionObject(this.state.currentPermission).permissionDetails; + permissions = JSON.stringify(permissionDetails); + } + let expirationTime = ''; + if (isExpireChecked) { + if (expType == 'by-days') { + expirationTime = moment().add(parseInt(expireDays), 'days').format(); + } else { + expirationTime = expDate.format(); + } + } + + let request; + if (type == 'batch') { + const autoGeneratePassword = shareLinkForceUsePassword || isShowPasswordInput; + request = seafileAPI.batchCreateMultiShareLink(repoID, itemPath, linkAmount, autoGeneratePassword, expirationTime, permissions); + } else { + request = seafileAPI.createMultiShareLink(repoID, itemPath, password, expirationTime, permissions); + } + + request.then((res) => { + if (type == 'batch') { + const newLinks = res.data.map(item => new ShareLink(item)); + this.props.updateAfterCreation(newLinks); + } else { + const newLink = new ShareLink(res.data); + this.props.updateAfterCreation(newLink); + } + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + } + + onExpireChecked = (e) => { + this.setState({isExpireChecked: e.target.checked}); + } + + onExpireDaysChanged = (e) => { + let day = e.target.value.trim(); + this.setState({expireDays: day}); + } + + validateParamsInput = () => { + const { type } = this.props; + let { linkAmount, isShowPasswordInput, password, passwdnew, isExpireChecked, expType, expireDays, expDate } = this.state; + + if (type == 'batch') { + if (!Number.isInteger(parseInt(linkAmount)) || parseInt(linkAmount) <= 1) { + this.setState({errorInfo: gettext('Please enter an integer bigger than 1 as number of links.')}); + return false; + } + } + + if (isShowPasswordInput) { + if (password.length === 0) { + this.setState({errorInfo: gettext('Please enter a password.')}); + return false; + } + if (password.length < shareLinkPasswordMinLength) { + this.setState({errorInfo: gettext('The password is too short.')}); + return false; + } + if (password !== passwdnew) { + this.setState({errorInfo: gettext('Passwords don\'t match')}); + return false; + } + if (Utils.getStrengthLevel(password) < shareLinkPasswordStrengthLevel) { + this.setState({errorInfo: gettext('The password is too weak. It should include at least {passwordStrengthLevel} of the following: number, upper letter, lower letter and other symbols.').replace('{passwordStrengthLevel}', shareLinkPasswordStrengthLevel)}); + return false; + } + } + + if (isExpireChecked) { + if (expType == 'by-date') { + if (!expDate) { + this.setState({errorInfo: gettext('Please select an expiration time')}); + return false; + } + return true; + } + + // by days + let reg = /^\d+$/; + if (!expireDays) { + this.setState({errorInfo: gettext('Please enter days')}); + return false; + } + if (!reg.test(expireDays)) { + this.setState({errorInfo: gettext('Please enter a non-negative integer')}); + return false; + } + + expireDays = parseInt(expireDays); + let minDays = shareLinkExpireDaysMin; + let maxDays = shareLinkExpireDaysMax; + + if (minDays !== 0 && maxDays == 0) { + if (expireDays < minDays) { + this.setState({errorInfo: 'Please enter valid days'}); + return false; + } + } + + if (minDays === 0 && maxDays !== 0 ) { + if (expireDays > maxDays) { + this.setState({errorInfo: 'Please enter valid days'}); + return false; + } + } + + if (minDays !== 0 && maxDays !== 0) { + if (expireDays < minDays || expireDays > maxDays) { + this.setState({errorInfo: 'Please enter valid days'}); + return false; + } + } + + this.setState({expireDays: expireDays}); + } + + return true; + } + + onLinkAmountChange = (e) => { + this.setState({ + linkAmount: e.target.value + }); + } + + goBack = () => { + this.props.setMode(''); + } + + render() { + const { userPerm, type, permissionOptions } = this.props; + const { isCustomPermission } = Utils.getUserPermission(userPerm); + + return ( + +
+
+ + {type == 'batch' ? gettext('Generate links in batch') : gettext('Generate Link')}
+
+
+ {type == 'batch' && ( + + + + + )} + + {shareLinkForceUsePassword ? ( + + ) : ( + + )} + {type != 'batch' && this.state.isShowPasswordInput && +
+ + + {gettext('(at least {passwordMinLength} characters and includes {passwordStrengthLevel} of the following: number, upper letter, lower letter and other symbols)').replace('{passwordMinLength}', shareLinkPasswordMinLength).replace('{passwordStrengthLevel}', shareLinkPasswordStrengthLevel)} + + + + + + + + + + + + +
+ } +
+ + + {this.state.isExpireChecked && +
+ +
+ } +
+ {(isPro && !isCustomPermission) && ( + + + {permissionOptions.map((item, index) => { + return ( + + + + ); + })} + + )} + {this.state.errorInfo && {gettext(this.state.errorInfo)}} + +
+
+ ); + } +} + +LinkCreation.propTypes = propTypes; + +export default LinkCreation; diff --git a/frontend/src/components/share-link-panel/link-details.js b/frontend/src/components/share-link-panel/link-details.js new file mode 100644 index 0000000000..d337486c38 --- /dev/null +++ b/frontend/src/components/share-link-panel/link-details.js @@ -0,0 +1,269 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import copy from 'copy-to-clipboard'; +import { Button } from 'reactstrap'; +import { isPro, gettext, shareLinkExpireDaysMin, shareLinkExpireDaysMax, shareLinkExpireDaysDefault, canSendShareLinkEmail } from '../../utils/constants'; +import ShareLinkPermissionEditor from '../../components/select-editor/share-link-permission-editor'; +import { seafileAPI } from '../../utils/seafile-api'; +import { Utils } from '../../utils/utils'; +import ShareLink from '../../models/share-link'; +import toaster from '../toast'; +import SendLink from '../send-link'; +import SharedLink from '../shared-link'; +import SetLinkExpiration from '../set-link-expiration'; + +const propTypes = { + sharedLinkInfo: PropTypes.object.isRequired, + permissionOptions: PropTypes.array.isRequired, + defaultExpireDays: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + showLinkDetails: PropTypes.func.isRequired, + updateLink: PropTypes.func.isRequired, + deleteLink: PropTypes.func.isRequired, + closeShareDialog: PropTypes.func.isRequired +}; + +class LinkDetails extends React.Component { + + constructor(props) { + super(props); + this.state = { + storedPasswordVisible: false, + isEditingExpiration: false, + isExpirationEditIconShow: false, + expType: 'by-days', + expireDays: this.props.defaultExpireDays, + expDate: null, + isOpIconShown: false, + isNoticeMessageShow: false, + isSendLinkShown: false + }; + } + + onCopySharedLink = () => { + const { sharedLinkInfo } = this.props; + copy(sharedLinkInfo.link); + toaster.success(gettext('Share link is copied to the clipboard.')); + this.props.closeShareDialog(); + } + + onCopyDownloadLink = () => { + const { sharedLinkInfo } = this.props; + copy(`${sharedLinkInfo.link}?dl=1`); + toaster.success(gettext('Direct download link is copied to the clipboard.')); + this.props.closeShareDialog(); + } + + toggleStoredPasswordVisible = () => { + this.setState({ + storedPasswordVisible: !this.state.storedPasswordVisible + }); + } + + handleMouseOverExpirationEditIcon = () => { + this.setState({isExpirationEditIconShow: true}); + } + + handleMouseOutExpirationEditIcon = () => { + this.setState({isExpirationEditIconShow: false}); + } + + editingExpirationToggle = () => { + this.setState({isEditingExpiration: !this.state.isEditingExpiration}); + } + + setExpType = (e) => { + this.setState({ + expType: e.target.value + }); + } + + onExpDateChanged = (value) => { + this.setState({ + expDate: value + }); + } + + onExpireDaysChanged = (e) => { + let day = e.target.value.trim(); + this.setState({expireDays: day}); + } + + updateExpiration = () => { + const { sharedLinkInfo } = this.props; + const { expType, expireDays, expDate } = this.state; + let expirationTime = ''; + if (expType == 'by-days') { + expirationTime = moment().add(parseInt(expireDays), 'days').format(); + } else { + expirationTime = expDate.format(); + } + seafileAPI.updateShareLink(sharedLinkInfo.token, '', expirationTime).then((res) => { + this.setState({ + isEditingExpiration: false + }); + this.props.updateLink(new ShareLink(res.data)); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + handleMouseOver = () => { + this.setState({isOpIconShown: true}); + } + + handleMouseOut = () => { + this.setState({isOpIconShown: false}); + } + + changePerm = (permission) => { + const { sharedLinkInfo } = this.props; + const { permissionDetails } = Utils.getShareLinkPermissionObject(permission); + seafileAPI.updateShareLink(sharedLinkInfo.token, JSON.stringify(permissionDetails)).then((res) => { + this.props.updateLink(new ShareLink(res.data)); + }).catch((error) => { + let errMessage = Utils.getErrorMsg(error); + toaster.danger(errMessage); + }); + } + + onNoticeMessageToggle = () => { + this.setState({isNoticeMessageShow: !this.state.isNoticeMessageShow}); + } + + toggleSendLink = () => { + this.setState({ isSendLinkShown: !this.state.isSendLinkShown }); + } + + goBack = () => { + this.props.showLinkDetails(null); + } + + render() { + const { sharedLinkInfo, permissionOptions } = this.props; + const { isOpIconShown } = this.state; + const currentPermission = Utils.getShareLinkPermissionStr(sharedLinkInfo.permissions); + return ( +
+ +
+
{gettext('Link:')}
+
+ +
+ {!sharedLinkInfo.is_dir && sharedLinkInfo.permissions.can_download && ( // just for file + <> +
{gettext('Direct Download Link:')}
+
+ +
+ + )} + {sharedLinkInfo.password && ( + <> +
{gettext('Password:')}
+
+
+ + +
+
+ + )} + {sharedLinkInfo.expire_date && ( + <> +
{gettext('Expiration Date:')}
+ {!this.state.isEditingExpiration && +
+ {moment(sharedLinkInfo.expire_date).format('YYYY-MM-DD HH:mm:ss')} + {this.state.isExpirationEditIconShow && ( + + + )} +
+ } + {this.state.isEditingExpiration && +
+
+ +
+ + +
+
+
+ } + + )} + {(isPro && sharedLinkInfo.permissions) && ( + <> +
{gettext('Permission:')}
+
+ +
+ + )} +
+ {(canSendShareLinkEmail && !this.state.isSendLinkShown && !this.state.isNoticeMessageShow) && + + } + {this.state.isSendLinkShown && + + } + {(!this.state.isSendLinkShown && !this.state.isNoticeMessageShow) && + + } + {this.state.isNoticeMessageShow && +
+

{gettext('Are you sure you want to delete the share link?')}

+

{gettext('If the share link is deleted, no one will be able to access it any more.')}

+ {' '} + +
+ } +
+ ); + } +} + +LinkDetails.propTypes = propTypes; + +export default LinkDetails; diff --git a/frontend/src/components/share-link-panel/link-item.js b/frontend/src/components/share-link-panel/link-item.js new file mode 100644 index 0000000000..1a41fc127e --- /dev/null +++ b/frontend/src/components/share-link-panel/link-item.js @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { isPro, gettext } from '../../utils/constants'; +import ShareLinkPermissionEditor from '../../components/select-editor/share-link-permission-editor'; +import { Utils } from '../../utils/utils'; + +const propTypes = { + item: PropTypes.object.isRequired, + permissionOptions: PropTypes.array, + showLinkDetails : PropTypes.func.isRequired +}; + +class LinkItem extends React.Component { + + constructor(props) { + super(props); + this.state = { + isItemOpVisible: false + }; + } + + onMouseOver = () => { + this.setState({ + isItemOpVisible: true + }); + } + + onMouseOut = () => { + this.setState({ + isItemOpVisible: false + }); + } + + cutLink = (link) => { + let length = link.length; + return link.slice(0, 9) + '...' + link.slice(length-5); + } + + viewDetails = (e) => { + e.preventDefault(); + this.props.showLinkDetails(this.props.item); + } + + render() { + const { isItemOpVisible } = this.state; + const { item, permissionOptions } = this.props; + const { permissions, link, expire_date } = item; + const currentPermission = Utils.getShareLinkPermissionStr(permissions); + return ( + + {this.cutLink(link)} + + {(isPro && permissions) && ( + {}} + /> + )} + + + {expire_date ? moment(expire_date).format('YYYY-MM-DD HH:mm') : '--'} + + + {gettext('Details')} + + + ); + } +} + +LinkItem.propTypes = propTypes; + +export default LinkItem; diff --git a/seahub/api2/endpoints/multi_share_links.py b/seahub/api2/endpoints/multi_share_links.py index bb7178fa09..773eaae8e2 100644 --- a/seahub/api2/endpoints/multi_share_links.py +++ b/seahub/api2/endpoints/multi_share_links.py @@ -202,3 +202,213 @@ class MultiShareLinks(APIView): link_info = get_share_link_info(fs) return Response(link_info) + + +class MultiShareLinksBatch(APIView): + + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, CanGenerateShareLink) + throttle_classes = (UserRateThrottle,) + + def post(self, request): + """ Create multi share link. + Permission checking: + 1. default(NOT guest) user; + """ + + # argument check + repo_id = request.data.get('repo_id', None) + if not repo_id: + error_msg = 'repo_id invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + path = request.data.get('path', None) + if not path: + error_msg = 'path invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + share_link_num = request.data.get('number') + if not share_link_num: + error_msg = 'number invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + try: + share_link_num = int(share_link_num) + except ValueError: + error_msg = 'number invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if share_link_num <= 0: + error_msg = 'number invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + auto_generate_password = request.data.get('auto_generate_password') + if not auto_generate_password: + error_msg = 'auto_generate_password invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + auto_generate_password = auto_generate_password.lower() + if auto_generate_password not in ('true', 'false'): + error_msg = 'auto_generate_password invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + auto_generate_password = auto_generate_password == 'true' + + if config.SHARE_LINK_FORCE_USE_PASSWORD and not auto_generate_password: + error_msg = _('Password is required.') + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + expire_days = request.data.get('expire_days', '') + expiration_time = request.data.get('expiration_time', '') + if expire_days and expiration_time: + error_msg = 'Can not pass expire_days and expiration_time at the same time.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + expire_date = None + if expire_days: + try: + expire_days = int(expire_days) + except ValueError: + error_msg = 'expire_days invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if expire_days <= 0: + error_msg = 'expire_days invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if SHARE_LINK_EXPIRE_DAYS_MIN > 0: + if expire_days < SHARE_LINK_EXPIRE_DAYS_MIN: + error_msg = _('Expire days should be greater or equal to %s') % \ + SHARE_LINK_EXPIRE_DAYS_MIN + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if SHARE_LINK_EXPIRE_DAYS_MAX > 0: + if expire_days > SHARE_LINK_EXPIRE_DAYS_MAX: + error_msg = _('Expire days should be less than or equal to %s') % \ + SHARE_LINK_EXPIRE_DAYS_MAX + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + expire_date = timezone.now() + relativedelta(days=expire_days) + + elif expiration_time: + + try: + expire_date = dateutil.parser.isoparse(expiration_time) + except Exception as e: + logger.error(e) + error_msg = 'expiration_time invalid, should be iso format, for example: 2020-05-17T10:26:22+08:00' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + expire_date = expire_date.astimezone(get_current_timezone()).replace(tzinfo=None) + + if SHARE_LINK_EXPIRE_DAYS_MIN > 0: + expire_date_min_limit = timezone.now() + relativedelta(days=SHARE_LINK_EXPIRE_DAYS_MIN) + expire_date_min_limit = expire_date_min_limit.replace(hour=0).replace(minute=0).replace(second=0) + + if expire_date < expire_date_min_limit: + error_msg = _('Expiration time should be later than %s.') % \ + expire_date_min_limit.strftime("%Y-%m-%d %H:%M:%S") + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if SHARE_LINK_EXPIRE_DAYS_MAX > 0: + expire_date_max_limit = timezone.now() + relativedelta(days=SHARE_LINK_EXPIRE_DAYS_MAX) + expire_date_max_limit = expire_date_max_limit.replace(hour=23).replace(minute=59).replace(second=59) + + if expire_date > expire_date_max_limit: + error_msg = _('Expiration time should be earlier than %s.') % \ + expire_date_max_limit.strftime("%Y-%m-%d %H:%M:%S") + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + else: + if SHARE_LINK_EXPIRE_DAYS_DEFAULT > 0: + expire_date = timezone.now() + relativedelta(days=SHARE_LINK_EXPIRE_DAYS_DEFAULT) + + try: + perm = check_permissions_arg(request) + except Exception: + error_msg = 'permissions invalid.' + 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) + + if path != '/': + dirent = seafile_api.get_dirent_by_path(repo_id, path) + if not dirent: + error_msg = 'Dirent %s not found.' % path + return api_error(status.HTTP_404_NOT_FOUND, error_msg) + + # permission check + if repo.encrypted: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + username = request.user.username + repo_folder_permission = seafile_api.check_permission_by_path(repo_id, path, username) + if parse_repo_perm(repo_folder_permission).can_generate_share_link is False: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + if repo_folder_permission in (PERMISSION_PREVIEW_EDIT, PERMISSION_PREVIEW) \ + and perm != FileShare.PERM_VIEW_ONLY: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + if repo_folder_permission in (PERMISSION_READ) \ + and perm not in (FileShare.PERM_VIEW_DL, FileShare.PERM_VIEW_ONLY): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + # can_upload requires rw repo permission + if perm == FileShare.PERM_VIEW_DL_UPLOAD and \ + repo_folder_permission != PERMISSION_READ_WRITE: + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + + if path != '/': + s_type = 'd' if stat.S_ISDIR(dirent.mode) else 'f' + if s_type == 'f': + file_name = os.path.basename(path.rstrip('/')) + can_edit, error_msg = can_edit_file(file_name, dirent.size, repo) + if not can_edit and perm in (FileShare.PERM_EDIT_DL, FileShare.PERM_EDIT_ONLY): + error_msg = 'Permission denied.' + return api_error(status.HTTP_403_FORBIDDEN, error_msg) + else: + s_type = 'd' + + # create share link + org_id = request.user.org.org_id if is_org_context(request) else None + + def generate_password(): + import random + import string + password_length = 8 + characters = string.ascii_letters + string.digits + string.punctuation + password = ''.join(random.choices(characters, k=password_length)) + return password + + created_share_links = [] + for i in range(share_link_num): + password = generate_password() if auto_generate_password else None + if s_type == 'f': + fs = FileShare.objects.create_file_link(username, repo_id, path, + password, expire_date, + permission=perm, org_id=org_id) + + elif s_type == 'd': + fs = FileShare.objects.create_dir_link(username, repo_id, path, + password, expire_date, + permission=perm, org_id=org_id) + + created_share_links.append(fs) + + result = [] + for fs in created_share_links: + link_info = get_share_link_info(fs) + link_info['repo_folder_permission'] = repo_folder_permission + result.append(link_info) + + return Response(result) diff --git a/seahub/urls.py b/seahub/urls.py index 75ec02afb0..c5e4bee605 100644 --- a/seahub/urls.py +++ b/seahub/urls.py @@ -41,8 +41,10 @@ from seahub.api2.endpoints.search_group import SearchGroup from seahub.api2.endpoints.share_links import ShareLinks, ShareLink, \ ShareLinkOnlineOfficeLock, ShareLinkDirents, ShareLinkSaveFileToRepo, \ ShareLinkUpload, ShareLinkUploadDone, ShareLinkSaveItemsToRepo, \ - ShareLinkRepoTags, ShareLinkRepoTagsTaggedFiles, ShareLinksCleanInvalid -from seahub.api2.endpoints.multi_share_links import MultiShareLinks + ShareLinkRepoTags, ShareLinkRepoTagsTaggedFiles, \ + ShareLinksCleanInvalid +from seahub.api2.endpoints.multi_share_links import MultiShareLinks, \ + MultiShareLinksBatch from seahub.api2.endpoints.shared_folders import SharedFolders from seahub.api2.endpoints.shared_repos import SharedRepos, SharedRepo from seahub.api2.endpoints.upload_links import UploadLinks, UploadLink, \ @@ -346,6 +348,7 @@ urlpatterns = [ ## user::shared-download-links url(r'^api/v2.1/share-links/$', ShareLinks.as_view(), name='api-v2.1-share-links'), url(r'^api/v2.1/multi-share-links/$', MultiShareLinks.as_view(), name='api-v2.1-multi-share-links'), + url(r'^api/v2.1/multi-share-links/batch/$', MultiShareLinksBatch.as_view(), name='api-v2.1-multi-share-links-batch'), url(r'^api/v2.1/share-links/clean-invalid/$', ShareLinksCleanInvalid.as_view(), name='api-v2.1-share-links-clean-invalid'), url(r'^api/v2.1/share-links/(?P[a-f0-9]+)/$', ShareLink.as_view(), name='api-v2.1-share-link'), url(r'^api/v2.1/share-links/(?P[a-f0-9]+)/save-file-to-repo/$', ShareLinkSaveFileToRepo.as_view(), name='api-v2.1-share-link-save-file-to-repo'),