diff --git a/packages/client-react/.env.js b/packages/client-react/.env.js index 05f448a29..fb28ff93a 100644 --- a/packages/client-react/.env.js +++ b/packages/client-react/.env.js @@ -3,7 +3,7 @@ module.exports = { HOST: process.env.HOST ? process.env.HOST : 'localhost', PORT: process.env.PORT ? process.env.PORT : 3000, - SERVER_URL: process.env.SERVER_URL ? process.env.SERVER_URL : 'http://localhost:8080/myfilemanager', + FILE_SERVER_URL: process.env.FILE_SERVER_URL ? process.env.FILE_SERVER_URL : 'http://localhost:3020',// 'http://localhost:8080/myfilemanager', CLIENT_ID: process.env.CLIENT_ID, API_KEY: process.env.API_KEY diff --git a/packages/client-react/config/webpack.config.js b/packages/client-react/config/webpack.config.js index 69904b269..700a1b963 100644 --- a/packages/client-react/config/webpack.config.js +++ b/packages/client-react/config/webpack.config.js @@ -163,7 +163,8 @@ module.exports = { } }], include: [ - path.resolve(__dirname, '../src') + path.resolve(__dirname, '../src'), + path.resolve(__dirname, '../www') ] } ] diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 537d5b276..0b29df2d0 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -41,7 +41,7 @@ "babel-cli": "6.26.0", "babel-core": "6.26.0", "babel-eslint": "8.2.3", - "babel-loader": "6.4.1", + "babel-loader": "7.1.5", "babel-plugin-lodash": "3.2.11", "babel-plugin-transform-decorators-legacy": "1.3.4", "babel-plugin-transform-runtime": "6.23.0", @@ -86,10 +86,13 @@ "source-map-loader": "0.1.6", "style-loader": "0.13.2", "url-loader": "0.5.8", - "webpack": "2.2.1", + "webpack": "2.7.0", "webpack-bundle-analyzer": "2.8.2", "webpack-dev-middleware": "1.10.1", - "write-file-webpack-plugin": "3.4.2" + "write-file-webpack-plugin": "3.4.2", + "http-proxy-middleware": "2.0.3", + "react-ace": "10.1.0" + }, "dependencies": { "@opuscapita/react-svg": "2.0.1", diff --git a/packages/client-react/src/client/components/Dialog/Dialog.react.js b/packages/client-react/src/client/components/Dialog/Dialog.react.js index cc13912bd..8dcda3126 100644 --- a/packages/client-react/src/client/components/Dialog/Dialog.react.js +++ b/packages/client-react/src/client/components/Dialog/Dialog.react.js @@ -4,11 +4,13 @@ import './Dialog.less'; const propTypes = { autofocus: PropTypes.bool, - onHide: PropTypes.func + onHide: PropTypes.func, + className: PropTypes.string }; const defaultProps = { autofocus: false, - onHide: () => {} + onHide: () => {}, + className: "oc-fm--dialog" }; export default @@ -26,7 +28,7 @@ class Dialog extends Component { return (
(autofocus && ref && ref.focus())} - className="oc-fm--dialog" + className={this.props.className} onKeyDown={this.handleKeyDown} onClick={e => e.stopPropagation()} onMouseDown={e => e.stopPropagation()} diff --git a/packages/client-react/src/client/components/EditDialog/EditDialog.DOCUMENTATION.md b/packages/client-react/src/client/components/EditDialog/EditDialog.DOCUMENTATION.md new file mode 100644 index 000000000..8e550a209 --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/EditDialog.DOCUMENTATION.md @@ -0,0 +1,27 @@ +### Synopsis + +EditDialog is +*Write here a short introduction and/or overview that explains **what** component is.* + +### Props Reference + +| Name | Type | Description | +| ------------------------------ | :---------------------- | ----------------------------------------------------------- | +| demoProp | string | Write a description of the property | + +### Code Example + +``` +
+ +
+``` + +### Component Name + +EditDialog + +### License + +Apache License Version 2.0 + diff --git a/packages/client-react/src/client/components/EditDialog/EditDialog.SCOPE.react.js b/packages/client-react/src/client/components/EditDialog/EditDialog.SCOPE.react.js new file mode 100644 index 000000000..1341caf12 --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/EditDialog.SCOPE.react.js @@ -0,0 +1,24 @@ +/* + What is a SCOPE file. See documentation here: + https://github.com/OpusCapita/react-showroom-client/blob/master/docs/scope-component.md +*/ + +import React, { Component } from 'react'; +import { showroomScopeDecorator } from '@opuscapita/react-showroom-client'; + +@showroomScopeDecorator +export default +class EditDialogScope extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + render() { + return ( +
+ {this._renderChildren()} +
+ ); + } +} diff --git a/packages/client-react/src/client/components/EditDialog/EditDialog.less b/packages/client-react/src/client/components/EditDialog/EditDialog.less new file mode 100644 index 000000000..290ed4d64 --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/EditDialog.less @@ -0,0 +1,61 @@ +.oc-fm-edit-dialog { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + padding: 12px; + width: 92%; + height: 92%; +// width: calc(100% - 10px); +// height: calc(100% - 10px); + border-radius: 4px; + background: #fff; + position: relative; + box-shadow: rgba(0, 0, 0, 0.25) 0px 2px 16px, rgba(0, 0, 0, 0.15) 0px 1px 4px; + + &:focus { + outline:none; + } +} + +.oc-edit--dialog__content { + width: 100%; + max-width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + + &:focus { + outline: none; + } +} + +.oc-edit--dialog__close-icon { + width: 24px; + height: 24px; + position: absolute; + top: 2px; + right: 2px; + &:hover { + background-color: #e0e0e0; + } +} + +@font-face { + font-family : 'Droid Sans Mono'; + src : url(https://themes.googleusercontent.com/static/fonts/droidsansmono/v5/ns-m2xQYezAtqh7ai59hJTwtzT4qNq-faudv5qbO9-U.eot); + src : + local('Droid Sans Mono'), + local('DroidSansMono'), + url(/font/DroidSansMono.eot) format('embedded-opentype'), + url(https://themes.googleusercontent.com/static/fonts/droidsansmono/v5/ns-m2xQYezAtqh7ai59hJTwtzT4qNq-faudv5qbO9-U.eot?#iefix) format('embedded-opentype'), + url(https://themes.googleusercontent.com/static/fonts/droidsansmono/v5/ns-m2xQYezAtqh7ai59hJTwtzT4qNq-faudv5qbO9-U.eot) format('embedded-opentype'), + url(/font/DroidSansMono.woff2) format('woff2'), + url(/font/DroidSansMono.woff) format('woff'), + url(https://themes.googleusercontent.com/static/fonts/droidsansmono/v5/ns-m2xQYezAtqh7ai59hJUYuTAAIFFn5GTWtryCmBQ4.woff) format('woff'), + local('Consolas'); + font-style : normal; + font-weight : 400; +} diff --git a/packages/client-react/src/client/components/EditDialog/EditDialog.react.js b/packages/client-react/src/client/components/EditDialog/EditDialog.react.js new file mode 100644 index 000000000..ce4e3c0ca --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/EditDialog.react.js @@ -0,0 +1,254 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import './EditDialog.less'; +import Dialog from '../Dialog'; +import FileSaveConfirmDialog from '../FileSaveConfirmDialog'; +import AceEditor from "react-ace"; +import Svg from '@opuscapita/react-svg/lib/SVG'; +import icons from './icons-svg'; +import {getModeForPath, modes, modesByName} from "ace-builds/src-noconflict/ext-modelist"; + +// const languages = [ +// "javascript", +// "java", +// "python", +// "xml", +// "markdown", +// "json", +// "html", +// "typescript", +// "css", +// "text", +// "plain_text" +// ]; + +// languages.map(lang => { +// try { +// require(`ace-builds/src-noconflict/mode-${lang}`); +// require(`ace-builds/src-noconflict/snippets/${lang}`); +// } catch(ignore) {} +// }); + +// modes.map(mode => { +// try { +// require(`ace-builds/src-noconflict/mode-${mode.name}`); +// require(`ace-builds/src-noconflict/snippets/${mode.name}`); +// } catch(ignore) {} +// }); + +import 'ace-builds/src-noconflict/mode-javascript'; +import 'ace-builds/src-noconflict/snippets/javascript'; + +import 'ace-builds/src-noconflict/mode-java'; +import 'ace-builds/src-noconflict/snippets/java'; + +import 'ace-builds/src-noconflict/mode-python'; +import 'ace-builds/src-noconflict/snippets/python'; + +import 'ace-builds/src-noconflict/mode-xml'; +import 'ace-builds/src-noconflict/snippets/xml'; + +import 'ace-builds/src-noconflict/mode-json'; +import "ace-builds/src-noconflict/snippets/json"; + +import 'ace-builds/src-noconflict/mode-html'; +import 'ace-builds/src-noconflict/snippets/html'; + +import 'ace-builds/src-noconflict/mode-typescript'; +import 'ace-builds/src-noconflict/snippets/typescript'; + +import 'ace-builds/src-noconflict/mode-css'; +import 'ace-builds/src-noconflict/snippets/css'; + +import 'ace-builds/src-noconflict/mode-text'; +import 'ace-builds/src-noconflict/snippets/text'; + +import 'ace-builds/src-noconflict/mode-plain_text'; +import 'ace-builds/src-noconflict/snippets/plain_text'; + +import 'ace-builds/src-noconflict/mode-makefile'; +import 'ace-builds/src-noconflict/snippets/makefile'; + +import 'ace-builds/src-noconflict/mode-markdown'; +import 'ace-builds/src-noconflict/snippets/markdown'; + +import 'ace-builds/src-noconflict/mode-lua'; +import 'ace-builds/src-noconflict/snippets/lua'; + +import 'ace-builds/src-noconflict/mode-jsx' +import 'ace-builds/src-noconflict/snippets/jsx'; + + +//import "ace-builds/src-noconflict/theme-monokai"; +import 'ace-builds/src-noconflict/theme-dracula'; + +//import 'ace-builds/src-noconflict/ext-beautify'; +import 'ace-builds/src-noconflict/ext-code_lens'; +// import 'ace-builds/src-noconflict/ext-elastic_tabstops_lite'; +// import 'ace-builds/src-noconflict/ext-emmet'; +import 'ace-builds/src-noconflict/ext-error_marker'; +import 'ace-builds/src-noconflict/ext-hardwrap'; +import 'ace-builds/src-noconflict/ext-keybinding_menu'; +import 'ace-builds/src-noconflict/ext-language_tools'; +import 'ace-builds/src-noconflict/ext-linking'; +//import 'ace-builds/src-noconflict/ext-modelist'; +import 'ace-builds/src-noconflict/ext-options'; +import 'ace-builds/src-noconflict/ext-prompt'; +import 'ace-builds/src-noconflict/ext-rtl'; +import 'ace-builds/src-noconflict/ext-searchbox'; +import 'ace-builds/src-noconflict/ext-settings_menu'; +//import 'ace-builds/src-noconflict/ext-spellcheck'; +//import 'ace-builds/src-noconflict/ext-split'; +//import 'ace-builds/src-noconflict/ext-static_highlight'; +//import 'ace-builds/src-noconflict/ext-statusbar'; +import 'ace-builds/src-noconflict/ext-textarea'; +//import 'ace-builds/src-noconflict/ext-themelist'; +import 'ace-builds/src-noconflict/ext-whitespace'; +// import 'ace-builds/src-noconflict/keybinding-emacs'; +// import 'ace-builds/src-noconflict/keybinding-sublime'; +// import 'ace-builds/src-noconflict/keybinding-vim'; +// import 'ace-builds/src-noconflict/keybinding-vscode'; + + +const propTypes = { + readOnly: PropTypes.bool, + headerText: PropTypes.string, + fileName: PropTypes.string, + onChange: PropTypes.func, + onHide: PropTypes.func, + onSubmit: PropTypes.func, + onValidate: PropTypes.func, + getFileContent: PropTypes.func, +}; +const defaultProps = { + readOnly: false, + headerText: '', + fileName: '', + onChange: () => {}, + onHide: () => {}, + onSubmit: () => {}, + onValidate: () => {}, + getFileContent: () => {}, +}; + +export default +class EditDialog extends Component { + constructor(props) { + super(props); + this.state = { + showSaveConfirmDialog: false, + editorText: "", + editorMode: "text" + }; + this.newText = null; + this.saveConfirmDialog = React.createElement(FileSaveConfirmDialog, { ...FileSaveConfirmDialog.defaultProps, + onSubmit: this.handleSubmit, + onHide: this.handleHideSaveConfirmDialog, + onIgnore: this.handleSkipSaveAndClose, + }); + } + + componentDidMount() { + this._isMounted = true + this.initEditorText(); + } + + componentWillUnmount() { + this._isMounted = false + } + + initEditorText = async (e) => { + let value = await this.props.getFileContent(); + + let mode = getModeForPath(this.props.fileName).name; + try { //prevent exception on client, if not imported mode is used. + await import (`ace-builds/src-noconflict/mode-${mode}`); + } catch(error) { + mode = 'text'; + } + + this.setState({ editorText: value, editorMode: mode }); + } + + handleChange = async (value, event) => { + this.newText = value; + if (this._isMounted) { + + } + } + + handleSubmit = async () => { + if (this.newText) { + const validationError = await this.props.onSubmit(this.newText); + + // if (validationError && this._isMounted) { + // this.setState({ validationError }); + // } + this.handleSkipSaveAndClose(); + } + } + + handleClose = async () => { + if (this._isMounted && this.newText) { + this.setState({ showSaveConfirmDialog: true, editorText: this.newText}); + } else { + this.handleSkipSaveAndClose(); + } + } + + handleSkipSaveAndClose = async () => { + this.props.onHide(); + this.newText = null; + } + + handleHideSaveConfirmDialog = async () => { + if (this._isMounted) + this.setState({ showSaveConfirmDialog: false }); + } + + render() { + const { onHide, headerText } = this.props; + const { showSaveConfirmDialog } = this.state; + + + return ( + +
+ {showSaveConfirmDialog ? (
{this.saveConfirmDialog}
) : null} + + + +
+ {headerText} +
+ + +
+
+ ); + } +} + +EditDialog.propTypes = propTypes; +EditDialog.defaultProps = defaultProps; diff --git a/packages/client-react/src/client/components/EditDialog/EditDialog.spec.js b/packages/client-react/src/client/components/EditDialog/EditDialog.spec.js new file mode 100644 index 000000000..30b01316e --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/EditDialog.spec.js @@ -0,0 +1,21 @@ +import React from 'react'; // eslint-disable-line +import { expect } from 'chai'; // eslint-disable-line +import { shallow } from 'enzyme'; // eslint-disable-line +import EditDialog from '.'; // eslint-disable-line + +describe('', () => { + /* Recommended test-cases + + it('should have default props', () => { + let component = ; + expect(component.props.testProp).to.equal('Give me back my label!'); + expect(component.props.onClick).to.be.a('function'); + }); + it('should have the right class name', () => { + let wrapper = shallow(); + expect(wrapper).to.have.className('set-name-dialog'); + expect(wrapper).to.have.className('test-class-name'); + }); + + */ +}); diff --git a/packages/client-react/src/client/components/EditDialog/icons-svg.js b/packages/client-react/src/client/components/EditDialog/icons-svg.js new file mode 100644 index 000000000..1dc75b397 --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/icons-svg.js @@ -0,0 +1,3 @@ +export default { + close: ``, +}; diff --git a/packages/client-react/src/client/components/EditDialog/index.js b/packages/client-react/src/client/components/EditDialog/index.js new file mode 100644 index 000000000..4b1a9b544 --- /dev/null +++ b/packages/client-react/src/client/components/EditDialog/index.js @@ -0,0 +1 @@ +export default require('./EditDialog.react').default; diff --git a/packages/client-react/src/client/components/FileNavigator/FileNavigator.less b/packages/client-react/src/client/components/FileNavigator/FileNavigator.less index d74bb32dd..ce61bc889 100644 --- a/packages/client-react/src/client/components/FileNavigator/FileNavigator.less +++ b/packages/client-react/src/client/components/FileNavigator/FileNavigator.less @@ -18,13 +18,14 @@ } .oc-fm--file-navigator__location-bar { - border-top: 1px solid rgba(0,0,0,.08); + //border-top: 1px solid rgba(0,0,0,.08); + border-bottom: 1px solid #f5f5f5; position: relative; z-index: 10; } .oc-fm--file-navigator__notifications { - width: 280px; + width: 310px; position: absolute; right: 24px; bottom: 12px; diff --git a/packages/client-react/src/client/components/FileNavigator/FileNavigator.react.js b/packages/client-react/src/client/components/FileNavigator/FileNavigator.react.js index 11aa30c85..db05616d4 100644 --- a/packages/client-react/src/client/components/FileNavigator/FileNavigator.react.js +++ b/packages/client-react/src/client/components/FileNavigator/FileNavigator.react.js @@ -104,6 +104,11 @@ class FileNavigator extends Component { const initializedCapabilities = capabilities(apiOptions, capabilitiesProps); this.setState({ initializedCapabilities }); } + + if (this.props.signInRenderer !== nextProps.signInRenderer) { + this.setState( () => ({ apiSignedIn: false })); + this.monitorApiAvailability(); + } } componentWillUnmount() { @@ -165,14 +170,16 @@ class FileNavigator extends Component { }; monitorApiAvailability = () => { - const { api } = this.props; + const { api, apiOptions, signInRenderer } = this.props; - this.apiAvailabilityTimeout = setTimeout(() => { - if (api.hasSignedIn()) { + this.apiAvailabilityTimeout = setTimeout( async () => { + let response = await api.hasSignedIn(apiOptions); + if (response) { this.setStateAsync({ apiInitialized: true, apiSignedIn: true }); this.handleApiReady(); } else { - this.monitorApiAvailability(); + if (signInRenderer === null) + this.monitorApiAvailability(); } }, MONITOR_API_AVAILABILITY_TIMEOUT); }; @@ -300,7 +307,7 @@ class FileNavigator extends Component { }; handleResourceItemDoubleClick = async ({ event, number, rowData }) => { - const { loadingView } = this.state; + const { loadingView, initializedCapabilities } = this.state; const { id } = rowData; if (loadingView) { @@ -312,6 +319,14 @@ class FileNavigator extends Component { this.navigateToDir(id); } + const isFile = rowData.type === 'file'; + if (isFile) { + const { apiOptions } = this.props; + const fileOpenCapability = find(initializedCapabilities, (o) => (o.id === 'edit' || o.id === 'view') && o.shouldBeAvailable(apiOptions)); + if (fileOpenCapability) + fileOpenCapability.handler(); + } + this.focusView(); this.props.onResourceItemDoubleClick({ event, number, rowData }); @@ -485,6 +500,12 @@ class FileNavigator extends Component { locale={apiOptions.locale} />
+
+ +
-
- -
+ + +``` + +### Component Name + +FileSaveConfirmDialog + +### License + +Apache License Version 2.0 + diff --git a/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.SCOPE.react.js b/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.SCOPE.react.js new file mode 100644 index 000000000..a8c93a321 --- /dev/null +++ b/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.SCOPE.react.js @@ -0,0 +1,24 @@ +/* + What is a SCOPE file. See documentation here: + https://github.com/OpusCapita/react-showroom-client/blob/master/docs/scope-component.md +*/ + +import React, { Component } from 'react'; +import { showroomScopeDecorator } from '@opuscapita/react-showroom-client'; + +@showroomScopeDecorator +export default +class FileSaveConfirmDialogScope extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + render() { + return ( +
+ {this._renderChildren()} +
+ ); + } +} diff --git a/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.react.js b/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.react.js new file mode 100644 index 000000000..4fa44c5fd --- /dev/null +++ b/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.react.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Dialog from '../Dialog'; + +const propTypes = { + headerText: PropTypes.string, + messageText: PropTypes.string, + cancelButtonText: PropTypes.string, + submitButtonText: PropTypes.string, + onHide: PropTypes.func, + onSubmit: PropTypes.func +}; +const defaultProps = { + headerText: 'Save modifed file?', + messageText: null, + cancelButtonText: 'Cancel', + submitButtonText: 'Yes', + onHide: () => {}, + onSubmit: () => {}, + onIgnore: () => {} +}; + +export default +class FileSaveConfirmDialog extends Component { + constructor(props) { + super(props); + } + + componentDidMount() { + if (this.ref) { + this.ref.focus(); + } + } + + handleKeyDown = async (e) => { + if (e.which === 13) { // Enter key + this.handleSubmit(); + } + }; + + handleCancel = async () => { + this.props.onHide(); + }; + + handleSubmit = async () => { + this.props.onSubmit(); + }; + + handleIgnore = async () => { + this.props.onIgnore(); + }; + + render() { + const { onHide, headerText, messageText, submitButtonText, cancelButtonText } = this.props; + + return ( + +
(this.ref = ref)} + className="oc-fm--dialog__content" onKeyDown={this.handleKeyDown} + > +
+ {headerText} +
+ + {messageText && ( +
{messageText}
+ )} + +
+ + + +
+
+
+ ); + } +} + +FileSaveConfirmDialog.propTypes = propTypes; +FileSaveConfirmDialog.defaultProps = defaultProps; diff --git a/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.spec.js b/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.spec.js new file mode 100644 index 000000000..40e22797e --- /dev/null +++ b/packages/client-react/src/client/components/FileSaveConfirmDialog/FileSaveConfirmDialog.spec.js @@ -0,0 +1,21 @@ +import React from 'react'; // eslint-disable-line +import { expect } from 'chai'; // eslint-disable-line +import { shallow } from 'enzyme'; // eslint-disable-line +import FileSaveConfirmDialog from '.'; // eslint-disable-line + +describe('', () => { + /* Recommended test-cases + + it('should have default props', () => { + let component = ; + expect(component.props.testProp).to.equal('Give me back my label!'); + expect(component.props.onClick).to.be.a('function'); + }); + it('should have the right class name', () => { + let wrapper = shallow(); + expect(wrapper).to.have.className('set-name-dialog'); + expect(wrapper).to.have.className('test-class-name'); + }); + + */ +}); diff --git a/packages/client-react/src/client/components/FileSaveConfirmDialog/index.js b/packages/client-react/src/client/components/FileSaveConfirmDialog/index.js new file mode 100644 index 000000000..aea27fb31 --- /dev/null +++ b/packages/client-react/src/client/components/FileSaveConfirmDialog/index.js @@ -0,0 +1 @@ +export default require('./FileSaveConfirmDialog.react').default; diff --git a/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.DOCUMENTATION.md b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.DOCUMENTATION.md new file mode 100644 index 000000000..852d71355 --- /dev/null +++ b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.DOCUMENTATION.md @@ -0,0 +1,125 @@ +### Synopsis + +MultiUserAccessFileManager is +*Write here a short introduction and/or overview that explains **what** component is.* + | + +### Connectors + +Connector is a bridge between server API and `@opuscapita/react-filemanager`. + +> NOTE: Filemanager Connector API and related props are not documented while API isn't stabilized. + +**Available connectors:** + +[connector-node-v1 source](https://github.com/OpusCapita/filemanager/tree/master/packages/connector-node-v1) +[connector-google-drive-v2 source](https://github.com/OpusCapita/filemanager/tree/master/packages/connector-google-drive-v2) + +You can write you own custom connectors (documentation on how to do it will appear later). + +### Types + +#### Resource + +`resource` is a current directory resource definition. + +`resource` object schema can be various. It depends on **connector** implementation. + +Resource example for **connector-node-v1**: + +``` +{ + "capabilities": { + "canListChildren": true, + "canAddChildren": true, + "canRemoveChildren": true, + "canDelete": false, + "canRename": false, + "canCopy": false, + "canEdit": false, + "canDownload": false + }, + "createdTime": 1515854279676, + "id": "Lw", + "modifiedTime": 1515854279660, + "name": "Customization area", + "type": "dir", + "parentId": null +} +``` + +#### Resource children + +`resourceChildren` is an array of `resource`s. + +In **FileNavigator** its a files and folders list of current `resource`. + +#### Resource location + +`resourceLocation` is an array of `resources`s. + +Its an array of current `resource` ancestors (parents). + +For **Massive Attack** folder in **Customization area => Music => Massive Attack** folders hierarchy `resourceLocation` can have such stucture: + +``` +[ + { + "capabilities": { ... }, + "createdTime": 1515858963105, + "id": "Lw", + "modifiedTime": 1515858963105, + "name": "Customization area", + "type": "dir", + "parentId": null + }, + { + "capabilities": { ... }, + "createdTime": 1515858970729, + "id": "L011c2lj", + "modifiedTime": 1515858970729, + "name": "Music", + "type": "dir", + "parentId": "Lw" + }, + { + "capabilities": { ... }, + "createdTime": 1515858970729, + "id": "L011c2ljL01hc3NpdmUgQXR0YWNr", + "modifiedTime": 1515858970729, + "name": "Massive Attack", + "type": "dir", + "parentId": "L011c2lj" + } +] +``` + +#### Selection + +`selection` is an array of selected resource `id`s. + +`selection` example: + +``` +["L0ltYWdlcw","L01pc2M","L011c2lj","L1NvdW5k","L1ZpZGVv"] +``` + +### Code Example + +``` +
+ {/*NODE_JS_EXAMPLE*/} + +
+ +
+
+``` + +### Component Name + +MultiUserAccessFileManager + +### License + +Apache License Version 2.0 diff --git a/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.SCOPE.react.js b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.SCOPE.react.js new file mode 100644 index 000000000..f4cc1f4b8 --- /dev/null +++ b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.SCOPE.react.js @@ -0,0 +1,61 @@ +/* + What is a SCOPE file. See documentation here: + https://github.com/OpusCapita/react-showroom-client/blob/master/docs/scope-component.md +*/ + +import React, { Component } from 'react'; +import { showroomScopeDecorator } from '@opuscapita/react-showroom-client'; +import connectorNodeV1 from '@opuscapita/react-filemanager-connector-node-v1'; + +window.connectors = { + nodeV1: connectorNodeV1 +}; + +@showroomScopeDecorator +export default +class MultiUserAccessFileManagerScope extends Component { + constructor(props) { + super(props); + + this.state = { + nodejsInitPath: '/', + nodejsInitId: '' + }; + } + + componentDidMount() { + this.handleNodejsInitPathChange(''); + } + + handleNodejsLocationChange = (resourceLocation) => { + const resourceLocationString = '/' + resourceLocation.slice(1, resourceLocation.length).map(o => o.name).join('/'); + this.setState({ + nodejsInitPath: resourceLocationString, + nodejsInitId: resourceLocation[resourceLocation.length - 1].id + }); + } + + handleNodejsInitPathChange = async (path) => { + this.setState({ + nodejsInitPath: path || '/' + }); + + const apiOptions = { + apiRoot: `${window.env.SERVER_URL}` + }; + + const nodejsInitId = await window.connectors.nodeV1.api.getIdForPath(apiOptions, path || '/'); + + if (nodejsInitId) { + this.setState({ nodejsInitId }); + } + } + + render() { + return ( +
+ {this._renderChildren()} +
+ ); + } +} diff --git a/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.less b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.less new file mode 100644 index 000000000..3e0df303e --- /dev/null +++ b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.less @@ -0,0 +1,26 @@ +.oc-fm--file-manager { + height: 100%; + display: flex; + flex-direction: column; + border: 1px solid rgba(0,0,0,.08); + overflow: hidden; +} + +.oc-fm--file-manager svg { + // Fix for blurry SVG icons in Firefox and IE + transform: scale(1); +} + +.oc-fm--file-manager__navigators { + flex: 1; + display: flex; +} + +.oc-fm--file-manager__navigator { + flex: 1; + border-right: 1px solid rgba(0,0,0,.08); + + &:last-child { + border-right: none; + } +} diff --git a/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.react.js b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.react.js new file mode 100644 index 000000000..518269bad --- /dev/null +++ b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.react.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component, Children } from 'react'; +import './MultiUserAccessFileManager.less'; +import FileNavigator from '../FileNavigator' +import SigninDialog from '../SigninDialog' +import connectorNodeV1 from '@opuscapita/react-filemanager-connector-node-v1'; + +const api = connectorNodeV1.api; + +const apiOptions = { + ...connectorNodeV1.apiOptions, + apiRoot: `${window.env.SERVER_URL}`, + locale: 'en' +} + +const propTypes = { + className: PropTypes.string +}; +const defaultProps = {}; + +export default +class MultiUserAccessFileManager extends Component { + + constructor(props) { + super(props); + this.state = { showsignin: true }; + this.signinDialog = React.createElement(SigninDialog, { ...SigninDialog.defaultProps, + onSubmit: this.onSignin }); + } + + onSignin = async (username, password) => { +// console.log('Username:' + username + ' Password:' + password); + let response = await api.signIn(apiOptions, username, password); + if (response) { + this.setState( () => ({showsignin: false})); + } else { + return 'Invalid username or password.' + } + } + + onSignout = async () => { + await api.signOut(apiOptions); + this.setState( () => ({showsignin: true})); + } + + render() { + const { children, className, ...restProps } = this.props; + const { showsignin } = this.state; + + return ( +
+
+
+ ([ + ...(connectorNodeV1.capabilities(apiOptions, actions)), + ({ + id: 'signout-button', + icon: { + svg: '' + }, + label: 'Signout', + shouldBeAvailable: () => true, + availableInContexts: ['toolbar'], + handler: this.onSignout + }) + ])} + signInRenderer={ showsignin ? () => this.signinDialog : null} + listViewLayout={connectorNodeV1.listViewLayout} + viewLayoutOptions={connectorNodeV1.viewLayoutOptions} + /> +
+
+
+ ); + } +} + +MultiUserAccessFileManager.propTypes = propTypes; +MultiUserAccessFileManager.defaultProps = defaultProps; diff --git a/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.spec.js b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.spec.js new file mode 100644 index 000000000..97cae2272 --- /dev/null +++ b/packages/client-react/src/client/components/MultiUserAccessFileManager/MultiUserAccessFileManager.spec.js @@ -0,0 +1,21 @@ +import React from 'react'; // eslint-disable-line +import { expect } from 'chai'; // eslint-disable-line +import { shallow } from 'enzyme'; // eslint-disable-line +import MultiUserAccessFileManager from '.'; // eslint-disable-line + +describe('', () => { + /* Recommended test-cases + + it('should have default props', () => { + let component = ; + expect(component.props.testProp).to.equal('Give me back my label!'); + expect(component.props.onClick).to.be.a('function'); + }); + it('should have the right class name', () => { + let wrapper = shallow(); + expect(wrapper).to.have.className('file-manager'); + expect(wrapper).to.have.className('test-class-name'); + }); + + */ +}); diff --git a/packages/client-react/src/client/components/MultiUserAccessFileManager/index.js b/packages/client-react/src/client/components/MultiUserAccessFileManager/index.js new file mode 100644 index 000000000..80b04109d --- /dev/null +++ b/packages/client-react/src/client/components/MultiUserAccessFileManager/index.js @@ -0,0 +1 @@ +export default require('./MultiUserAccessFileManager.react').default; diff --git a/packages/client-react/src/client/components/SigninDialog/SigninDialog.DOCUMENTATION.md b/packages/client-react/src/client/components/SigninDialog/SigninDialog.DOCUMENTATION.md new file mode 100644 index 000000000..eff893b07 --- /dev/null +++ b/packages/client-react/src/client/components/SigninDialog/SigninDialog.DOCUMENTATION.md @@ -0,0 +1,27 @@ +### Synopsis + +SigninDialog is +*Write here a short introduction and/or overview that explains **what** component is.* + +### Props Reference + +| Name | Type | Description | +| ------------------------------ | :---------------------- | ----------------------------------------------------------- | +| demoProp | string | Write a description of the property | + +### Code Example + +``` +
+ +
+``` + +### Component Name + +SigninDialog + +### License + +Apache License Version 2.0 + diff --git a/packages/client-react/src/client/components/SigninDialog/SigninDialog.SCOPE.react.js b/packages/client-react/src/client/components/SigninDialog/SigninDialog.SCOPE.react.js new file mode 100644 index 000000000..5d7e0941a --- /dev/null +++ b/packages/client-react/src/client/components/SigninDialog/SigninDialog.SCOPE.react.js @@ -0,0 +1,24 @@ +/* + What is a SCOPE file. See documentation here: + https://github.com/OpusCapita/react-showroom-client/blob/master/docs/scope-component.md +*/ + +import React, { Component } from 'react'; +import { showroomScopeDecorator } from '@opuscapita/react-showroom-client'; + +@showroomScopeDecorator +export default +class SigninDialogScope extends Component { + constructor(props) { + super(props); + this.state = {}; + } + + render() { + return ( +
+ {this._renderChildren()} +
+ ); + } +} diff --git a/packages/client-react/src/client/components/SigninDialog/SigninDialog.less b/packages/client-react/src/client/components/SigninDialog/SigninDialog.less new file mode 100644 index 000000000..eaa9700b9 --- /dev/null +++ b/packages/client-react/src/client/components/SigninDialog/SigninDialog.less @@ -0,0 +1,2 @@ +.set-name-dialog { +} diff --git a/packages/client-react/src/client/components/SigninDialog/SigninDialog.react.js b/packages/client-react/src/client/components/SigninDialog/SigninDialog.react.js new file mode 100644 index 000000000..1b6cb48d3 --- /dev/null +++ b/packages/client-react/src/client/components/SigninDialog/SigninDialog.react.js @@ -0,0 +1,174 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import './SigninDialog.less'; +import Dialog from '../Dialog'; + +const propTypes = { + cancelButtonText: PropTypes.string, + headerText: PropTypes.string, + messageText: PropTypes.string, + usernameLabelText: PropTypes.string, + passwordLabelText: PropTypes.string, + initalUsernameValue: PropTypes.string, + onChange: PropTypes.func, + onHide: PropTypes.func, + onSubmit: PropTypes.func, + onValidate: PropTypes.func, + submitButtonText: PropTypes.string +}; +const defaultProps = { + cancelButtonText: 'Clear', + headerText: 'Sign in', + messageText: '', + usernameLabelText: 'Username:', + passwordLabelText: 'Password:', + initalUsernameValue: '', + initalPasswordValue: '', + onChange: () => {}, + onHide: () => {}, + onSubmit: () => {}, + onValidate: () => {}, + submitButtonText: 'Sign in' +}; + +export default +class SigninDialog extends Component { + constructor(props) { + super(props); + this.state = { + username: props.initalUsernameValue, + password: props.initalPasswordValue, + validationError: null, + valid: false + }; + } + + componentDidMount() { + this._isMounted = true + } + + componentWillUnmount() { + this._isMounted = false + } + + handleChange = async (e) => { + this.setState({ [e.target.name]: e.target.value }); + const validationError = await this.props.onValidate(e.target.value); + if (this._isMounted) { + this.setState({ validationError, valid: !validationError }); + } + } + + handleKeyDown = async (e) => { + if (e.which === 13) { // Enter key + if (!this.state.validationError && this.state.username) { + this.handleSubmit(this.state.value); + } + } + } + + handleSubmitButtonClick = async (e) => { + if (!this.state.validationError && this.state.username) { + this.handleSubmit(this.state.username); + } + } + + handleSubmit = async () => { + const validationError = await this.props.onSubmit(this.state.username, this.state.password); + + if (validationError && this._isMounted) { + this.setState({ validationError }); + } + } + + handleFocus = (e) => { + // Move caret to the end + const tmpValue = e.target.value; + e.target.value = ''; // eslint-disable-line no-param-reassign + e.target.value = tmpValue; // eslint-disable-line no-param-reassign + } + + render() { + const { onHide, headerText, usernameLabelText, passwordLabelText, messageText, submitButtonText, cancelButtonText } = this.props; + const { username, password, validationError, valid } = this.state; + + const showValidationErrorElement = typeof validationError === 'string' && validationError; + const validationErrorElement = ( +
+ {validationError ||  } +
+ ); + + return ( + +
+
+ {headerText} +
+ + {messageText && ( +
{messageText}
+ )} + + {usernameLabelText && ( +
{usernameLabelText}
+ )} + + + + {passwordLabelText && ( +
{passwordLabelText}
+ )} + + + {validationErrorElement} + +
+ + +
+
+
+ ); + } +} + +SigninDialog.propTypes = propTypes; +SigninDialog.defaultProps = defaultProps; diff --git a/packages/client-react/src/client/components/SigninDialog/SigninDialog.spec.js b/packages/client-react/src/client/components/SigninDialog/SigninDialog.spec.js new file mode 100644 index 000000000..4eae541fc --- /dev/null +++ b/packages/client-react/src/client/components/SigninDialog/SigninDialog.spec.js @@ -0,0 +1,21 @@ +import React from 'react'; // eslint-disable-line +import { expect } from 'chai'; // eslint-disable-line +import { shallow } from 'enzyme'; // eslint-disable-line +import SigninDialog from '.'; // eslint-disable-line + +describe('', () => { + /* Recommended test-cases + + it('should have default props', () => { + let component = ; + expect(component.props.testProp).to.equal('Give me back my label!'); + expect(component.props.onClick).to.be.a('function'); + }); + it('should have the right class name', () => { + let wrapper = shallow(); + expect(wrapper).to.have.className('set-name-dialog'); + expect(wrapper).to.have.className('test-class-name'); + }); + + */ +}); diff --git a/packages/client-react/src/client/components/SigninDialog/index.js b/packages/client-react/src/client/components/SigninDialog/index.js new file mode 100644 index 000000000..2a0e16b55 --- /dev/null +++ b/packages/client-react/src/client/components/SigninDialog/index.js @@ -0,0 +1 @@ +export default require('./SigninDialog.react').default; diff --git a/packages/client-react/src/client/components/shared-components.js b/packages/client-react/src/client/components/shared-components.js index b537200ff..fc6c220c6 100644 --- a/packages/client-react/src/client/components/shared-components.js +++ b/packages/client-react/src/client/components/shared-components.js @@ -9,6 +9,7 @@ import Notification from './Notification'; import NotificationProgressItem from './NotificationProgressItem'; import ProgressIcon from './ProgressIcon'; import SetNameDialog from './SetNameDialog'; +import EditDialog from './EditDialog'; import { Column } from 'react-virtualized'; export default { @@ -23,5 +24,6 @@ export default { Notification, NotificationProgressItem, ProgressIcon, - SetNameDialog + SetNameDialog, + EditDialog }; diff --git a/packages/client-react/src/client/index.js b/packages/client-react/src/client/index.js index 0a6a533d7..c53cb6174 100644 --- a/packages/client-react/src/client/index.js +++ b/packages/client-react/src/client/index.js @@ -3,6 +3,7 @@ module.exports = { FileNavigator: require('./components/FileNavigator').default, FileManager: require('./components/FileManager').default, + MultiUserAccessFileManager: require('./components/MultiUserAccessFileManager').default, HeaderCell: require('./components/HeaderCell').default, LoadingCell: require('./components/LoadingCell').default, NameCell: require('./components/NameCell').default, diff --git a/packages/client-react/www/index-page-fm.js b/packages/client-react/www/index-page-fm.js new file mode 100644 index 000000000..ea545da0b --- /dev/null +++ b/packages/client-react/www/index-page-fm.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { MultiUserAccessFileManager } from '@opuscapita/react-filemanager'; + +ReactDOM.render((), document.getElementById('main')); diff --git a/packages/client-react/www/index.js b/packages/client-react/www/index.js index 90f55b5df..fb24cdcf4 100644 --- a/packages/client-react/www/index.js +++ b/packages/client-react/www/index.js @@ -2,14 +2,18 @@ const compression = require('compression'); const express = require('express'); +const { createProxyMiddleware } = require('http-proxy-middleware'); const fs = require('fs'); const path = require('path'); const host = require('../.env').HOST; const port = require('../.env').PORT; +const fileserverurl = require('../.env').FILE_SERVER_URL; const webpack = require('webpack'); const compiler = webpack(require('../config/webpack.config')); const env = require('../.env'); +env.SERVER_URL = `http://${host}:${port}`; + const app = express(); let serverOptions = { @@ -39,9 +43,11 @@ app.get('/', function(req, res) { res.sendFile(path.normalize(__dirname + '/index.html')); }); +app.use('/**', createProxyMiddleware({ target: fileserverurl, followRedirects: true, changeOrigin: false})); + app.listen(port, (err) => { if (err) { console.log(err); } - console.log(`The server is running at http://${host}:${port}/`); + console.log(`The client react server is running at http://${host}:${port}/\nfilemanager server is expecting at ${fileserverurl}`); }); diff --git a/packages/connector-node-v1/src/api.js b/packages/connector-node-v1/src/api.js index 77bd0ec27..cef2f95e4 100644 --- a/packages/connector-node-v1/src/api.js +++ b/packages/connector-node-v1/src/api.js @@ -6,8 +6,15 @@ import { normalizeResource } from './utils/common'; * * @returns {boolean} */ -function hasSignedIn() { - return true; +async function hasSignedIn(options) { + const route = `${options.apiRoot}/authentication/hassignedin`; + try { + const response = await request.get(route); + const {username} = response.body; + return {username: username}; + } catch (err) { + return false; + } } /** @@ -18,7 +25,7 @@ function hasSignedIn() { function init() { return { apiInitialized: true, - apiSignedIn: true + apiSignedIn: false }; } @@ -93,11 +100,12 @@ async function getParentIdForResource(options, resource) { return resource.parentId; } -async function uploadFileToId({ apiOptions, parentId, file, onProgress }) { +async function uploadFileToId({ apiOptions, parentId, file, onProgress, overwrite }) { const route = `${apiOptions.apiRoot}/files`; return request.post(route). field('type', 'file'). field('parentId', parentId). + field('overwrite', overwrite === true ). attach('files', file.file, file.name). on('progress', event => { onProgress(event.percent); @@ -150,6 +158,26 @@ async function removeResources(options, selectedResources) { return Promise.all(selectedResources.map(resource => removeResource(options, resource))) } +async function signIn(options, username, password) { + const route = `${options.apiRoot}/authentication/signin`; + try { + const response = await request.post(route).send({username: btoa(username), password: btoa(password)}); + return {username: response.body.username}; + } catch (err) { + return false; + } +} + +async function signOut(options) { + const route = `${options.apiRoot}/authentication/signout`; + try { + await request.get(route); + return true; + } catch (err) { + return false; + } +} + export default { init, hasSignedIn, @@ -164,5 +192,7 @@ export default { downloadResources, renameResource, removeResources, - uploadFileToId + uploadFileToId, + signIn, + signOut }; diff --git a/packages/connector-node-v1/src/capabilities/edit.js b/packages/connector-node-v1/src/capabilities/edit.js new file mode 100644 index 000000000..7db98e7e4 --- /dev/null +++ b/packages/connector-node-v1/src/capabilities/edit.js @@ -0,0 +1,99 @@ +import api from '../api'; +import sanitizeFilename from 'sanitize-filename'; +import onFailError from '../utils/onFailError'; +import icons from '../icons-svg'; +import getMess from '../translations'; + +const label = 'edit'; + +function handler(apiOptions, actions) { + const { + showDialog, + hideDialog, + navigateToDir, + updateNotifications, + getSelectedResources, + getResource, + getNotifications + } = actions; + + const getMessage = getMess.bind(null, apiOptions.locale); + const localeLabel = getMessage(label); + + const selectedResources = getSelectedResources(); + const filesizelimit = 100; + if (selectedResources[0].size / 1024 > filesizelimit) { + onFailError({ + getNotifications, + label: localeLabel, + notificationId: label, + updateNotifications, + message: getMessage('editFileLimit', {filesizelimit}) + }); + return; + } + + const rawDialogElement = { + elementType: 'EditDialog', + elementProps: { + readOnly: false, + onHide: hideDialog, + onSubmit: async (text) => { + const onProgress = (progress) => {}; + const selectedResources = getSelectedResources(); +// alert('' + JSON.stringify(atob(selectedResources[0].id))); + try { + hideDialog(); + const resource = getResource(); + var data = new Blob([text]); + const file = { + name: selectedResources[0].name, + file: data + }; + const result = await api.uploadFileToId({ apiOptions, parentId: resource.id, file, onProgress, overwrite: true }); +// alert('111' + result.body[0].id); + navigateToDir(resource.id, selectedResources[0].id, false); + return null; + } catch (err) { + hideDialog(); + onFailError({ + getNotifications, + label: localeLabel, + notificationId: label, + updateNotifications + }); + console.log(err); + return null + } + }, + getFileContent: async () => { + const onProgress = (progress) => {}; + const resources = getSelectedResources(); + const content = await api.downloadResources({ resources, apiOptions, onProgress}); + return content.text(); + }, + headerText: getMessage('file') + ": " + getSelectedResources()[0].name, + fileName: getSelectedResources()[0].name + } + }; + showDialog(rawDialogElement); +} + +export default (apiOptions, actions) => { + const localeLabel = getMess(apiOptions.locale, label); + const { getSelectedResources } = actions; + return { + id: label, + icon: { svg: icons.edit }, + label: localeLabel, + shouldBeAvailable: (apiOptions) => { + const selectedResources = getSelectedResources(); + return ( + selectedResources.length === 1 && + selectedResources.every(r => r.capabilities.canEdit) + ); + }, + availableInContexts: ['row', 'toolbar'], + handler: () => handler(apiOptions, actions) + }; +} diff --git a/packages/connector-node-v1/src/capabilities/index.js b/packages/connector-node-v1/src/capabilities/index.js index 6bfe91fe7..02729eeda 100644 --- a/packages/connector-node-v1/src/capabilities/index.js +++ b/packages/connector-node-v1/src/capabilities/index.js @@ -4,9 +4,13 @@ import download from './download'; import upload from './upload'; import rename from './rename'; import sort from './sort'; +import edit from './edit'; +import view from './view'; const capabilities = [ createFolder, + edit, + view, rename, download, upload, diff --git a/packages/connector-node-v1/src/capabilities/view.js b/packages/connector-node-v1/src/capabilities/view.js new file mode 100644 index 000000000..165bdc3d8 --- /dev/null +++ b/packages/connector-node-v1/src/capabilities/view.js @@ -0,0 +1,71 @@ +import api from '../api'; +import sanitizeFilename from 'sanitize-filename'; +import onFailError from '../utils/onFailError'; +import icons from '../icons-svg'; +import getMess from '../translations'; + +const label = 'view'; + +function handler(apiOptions, actions) { + const { + showDialog, + hideDialog, + navigateToDir, + updateNotifications, + getSelectedResources, + getResource, + getNotifications + } = actions; + + const getMessage = getMess.bind(null, apiOptions.locale); + const localeLabel = getMessage(label); + + const selectedResources = getSelectedResources(); + const filesizelimit = 100; + if (selectedResources[0].size / 1024 > filesizelimit) { + onFailError({ + getNotifications, + label: localeLabel, + notificationId: label, + updateNotifications, + message: getMessage('editFileLimit', {filesizelimit}) + }); + return; + } + + const rawDialogElement = { + elementType: 'EditDialog', + elementProps: { + readOnly: true, + onHide: hideDialog, + getFileContent: async () => { + const onProgress = (progress) => {}; + const resources = getSelectedResources(); + const content = await api.downloadResources({ resources, apiOptions, onProgress}); + return content.text(); + }, + headerText: getMessage('file') + ": " + getSelectedResources()[0].name, + fileName: getSelectedResources()[0].name + } + }; + showDialog(rawDialogElement); +} + +export default (apiOptions, actions) => { + const localeLabel = getMess(apiOptions.locale, label); + const { getSelectedResources } = actions; + return { + id: label, + icon: { svg: icons.view }, + label: localeLabel, + shouldBeAvailable: (apiOptions) => { + const selectedResources = getSelectedResources(); + return ( + selectedResources.length === 1 && + selectedResources.every(r => r.capabilities.canDownload && !r.capabilities.canEdit) + ); + }, + availableInContexts: ['row', 'toolbar'], + handler: () => handler(apiOptions, actions) + }; +} diff --git a/packages/connector-node-v1/src/icons-svg.js b/packages/connector-node-v1/src/icons-svg.js index f50b5b18e..ea96af163 100644 --- a/packages/connector-node-v1/src/icons-svg.js +++ b/packages/connector-node-v1/src/icons-svg.js @@ -10,12 +10,16 @@ export default { delete: ``, rename: ``, folder: ``, + brokenLink: ``, + encodingNameError: ``, volumeUp: ``, image: ``, ondemandVideo: ``, archive: ``, book: ``, - insertDriveFile: ``, + insertDriveFile: ``, + edit: ``, + view: ``, warning: `` }; /* eslint-enable */ diff --git a/packages/connector-node-v1/src/icons.js b/packages/connector-node-v1/src/icons.js index 9a190373e..958b36f7a 100644 --- a/packages/connector-node-v1/src/icons.js +++ b/packages/connector-node-v1/src/icons.js @@ -1,6 +1,8 @@ import icons from './icons-svg'; const dirIcon = icons.folder; +const brokenLinkIcon = icons.brokenLink; +const encodingNameError = icons.encodingNameError; const soundFileIcon = icons.volumeUp; const pictureFileIcon = icons.image; const videoFileIcon = icons.ondemandVideo; @@ -23,6 +25,10 @@ function matchFileExtensions(filename, extensions) { export function getIcon(resource) { if (resource.type === 'dir') { return { svg: dirIcon, fill: defaultFillColor }; + } else if (resource.type === 'brokenlink') { + return { svg: brokenLinkIcon, fill: defaultFillColor }; + } else if (resource.type === 'encodingnameerror') { + return { svg: encodingNameError, fill: defaultFillColor }; } else if (matchFileExtensions(resource.name, soundFilesExtensions)) { return { svg: soundFileIcon, fill: `#e53935` }; } else if (matchFileExtensions(resource.name, pictureFilesExtensions)) { diff --git a/packages/connector-node-v1/src/translations.js b/packages/connector-node-v1/src/translations.js index c0d24e349..83f38d9f2 100644 --- a/packages/connector-node-v1/src/translations.js +++ b/packages/connector-node-v1/src/translations.js @@ -29,7 +29,11 @@ const translations = { fileSize: 'File size', lastModified: 'Last modified', reallyRemove: '{files} will be deleted. Do you really want to proceed?', - unableReadDir: 'Unable to read a directory.' + unableReadDir: 'Unable to read a directory.', + edit: 'Edit', + view: 'View', + file: 'File', + editFileLimit: 'File cannot be loaded, max. size {filesizelimit}KB.' }, fr: { uploading: 'Ajout d\'un document en cours', diff --git a/packages/server-nodejs/api-tests/api-tests.spec.js b/packages/server-nodejs/api-tests/api-tests.spec.js index acb128e28..027db3114 100644 --- a/packages/server-nodejs/api-tests/api-tests.spec.js +++ b/packages/server-nodejs/api-tests/api-tests.spec.js @@ -52,10 +52,28 @@ function createIncorrectId(dirId, addName) { return base64url(`${id2path(dirId)}/${addName}`); } +var cookie = '' +describe('Authentication signin', () => { + it('Correct authentication', done => { + request.post(`${baseUrl}/authentication/signin`). + set('Content-Type', 'application/json'). + send({username: btoa("service"),password: btoa("secret")}). + then(res => { + expect(res.status).to.equal(200); + cookie = res.headers['set-cookie'] ? res.headers['set-cookie'][0] : ''; + done(); + }). + catch(err => { + done(err); + }); + }).timeout(1000); +}); + describe('Get resources metadata', () => { it('Get rootId', (done) => { request. get(`${baseUrl}/files`). + set('Cookie', cookie). then(res => { expect(res.status).to.equal(200); @@ -81,6 +99,7 @@ describe('Get resources metadata', () => { it('Get root children', (done) => { request. get(`${baseUrl}/files/${rootId}/children`). + set('Cookie', cookie). query({ action: 'edit', city: 'London' }). // query string then(res => { let jsonData = res.body; @@ -107,6 +126,7 @@ describe('Get resources metadata', () => { it('Get root children with query params', (done) => { request. get(`${baseUrl}/files/${rootId}/children`). + set('Cookie', cookie). query({ orderBy: 'name', orderDirection: 'ASC' }). // query string then(res => { let jsonData = res.body; @@ -135,6 +155,7 @@ describe('Get resources metadata', () => { it('Get root children with incorrect orderBy', (done) => { request. get(`${baseUrl}/files/${rootId}/children`). + set('Cookie', cookie). query({ orderBy: 'nameOne' }). catch(err => { if (err && err.response && err.response.request.res) { @@ -152,6 +173,7 @@ describe('Get resources metadata', () => { it('Get root children with incorrect orderDirection', (done) => { request. get(`${baseUrl}/files/${rootId}/children`). + set('Cookie', cookie). query({ orderDirection: 'DSC' }). catch(err => { if (err && err.response && err.response.request.res) { @@ -169,6 +191,7 @@ describe('Get resources metadata', () => { it('Get workChildDir children', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -194,6 +217,7 @@ describe('Get resources metadata', () => { it('Get children with incorrect id', (done) => { request. get(`${baseUrl}/files/${createIncorrectId(workChildDirId, 'incorrect_dir_name')}/children`). + set('Cookie', cookie). catch(err => { if (err && err.response && err.response.request.res) { expect(err.response.request.res.statusCode).to.equal(410); @@ -210,6 +234,7 @@ describe('Get resources metadata', () => { it('Get workChildDir metadata', (done) => { request. get(`${baseUrl}/files/${workChildDirId}`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -228,6 +253,7 @@ describe('Get resources metadata', () => { it('Get workFile metadata', (done) => { request. get(`${baseUrl}/files/${workFileId}`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -250,6 +276,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring }). @@ -275,6 +302,7 @@ describe('Search for files/dirs', () => { it('Search with invalid id', done => { request. get(`${baseUrl}/files/${createIncorrectId(workChildDirId, 'incorrect_dir_name')}/search`). + set('Cookie', cookie). query({ itemNameSubstring: 'name' }). @@ -292,6 +320,7 @@ describe('Search for files/dirs', () => { it('Search in root directory for files with letter "c"', done => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: 'c' }). @@ -309,6 +338,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring }). @@ -335,6 +365,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemNameCaseSensitive: false @@ -363,6 +394,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemNameCaseSensitive: true @@ -387,6 +419,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemType: 'dir' @@ -415,6 +448,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemType: 'file' @@ -437,6 +471,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemType: 'file', @@ -466,6 +501,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemType: 'file', @@ -489,6 +525,7 @@ describe('Search for files/dirs', () => { request. get(`${baseUrl}/files/${rootId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: nameSubstring, itemType: 'file', @@ -516,6 +553,7 @@ describe('Search for files/dirs', () => { it('Case-insensitive content search by default', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/search`). + set('Cookie', cookie). query({ fileContentSubstring: 'log' }). @@ -539,6 +577,7 @@ describe('Search for files/dirs', () => { it('Case-insensitive content search', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/search`). + set('Cookie', cookie). query({ fileContentSubstring: 'log', fileContentCaseSensitive: 'false' @@ -563,6 +602,7 @@ describe('Search for files/dirs', () => { it('Case-insensitive content + filename search', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/search`). + set('Cookie', cookie). query({ itemNameSubstring: 'hello', itemNameCaseSensitive: 'true', @@ -584,6 +624,7 @@ describe('Search for files/dirs', () => { it('Case-sensitive content search', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/search`). + set('Cookie', cookie). query({ fileContentSubstring: 'log', fileContentCaseSensitive: 'true' @@ -603,6 +644,7 @@ describe('Search for files/dirs', () => { it('Invalid content search for a dirs', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/search`). + set('Cookie', cookie). query({ fileContentSubstring: 'log', fileContentCaseSensitive: 'true', @@ -622,6 +664,7 @@ describe('Search for files/dirs', () => { it('Invalid content search for a dirs/files', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/search`). + set('Cookie', cookie). query({ fileContentSubstring: 'log', fileContentCaseSensitive: 'true', @@ -648,6 +691,7 @@ describe('Rename resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ name: newName }). then(res => { let jsonData = res.body; @@ -672,6 +716,7 @@ describe('Rename resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ name: newName }). catch(err => { if (err && err.response && err.response.request.res) { @@ -693,6 +738,7 @@ describe('Rename resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ name: newName }). catch(err => { if (err && err.response && err.response.request.res) { @@ -713,6 +759,7 @@ describe('Rename resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ name: workChildDirName }). then(res => { let jsonData = res.body; @@ -737,6 +784,7 @@ describe('Rename resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ name: newName }). then(res => { let jsonData = res.body; @@ -760,6 +808,7 @@ describe('Rename resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ name: workFileName }). then(res => { let jsonData = res.body; @@ -780,6 +829,7 @@ describe('Rename resources', () => { it('Check root dir', (done) => { request. get(`${baseUrl}/files/${rootId}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -796,6 +846,7 @@ describe('Rename resources', () => { it('Check workChildDir', (done) => { request. get(`${baseUrl}/files/${workChildDirId}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -822,6 +873,7 @@ describe('Create dirs', () => { type: 'dir' }; request(method, route). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -861,6 +913,7 @@ describe('Create dirs', () => { type: 'dir' }; request(method, route). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -900,6 +953,7 @@ describe('Create dirs', () => { type: 'dir' }; request(method, route). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -939,6 +993,7 @@ describe('Create dirs', () => { type: 'dir' }; request(method, route). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -977,6 +1032,7 @@ describe('Create dirs', () => { type: 'dir' }; request(method, route). + set('Cookie', cookie). send(params). catch(err => { if (err && err.response && err.response.request.res) { @@ -999,6 +1055,7 @@ describe('Create dirs', () => { type: 'dir' }; request(method, route). + set('Cookie', cookie). send(params). catch(err => { if (err && err.response && err.response.request.res) { @@ -1021,6 +1078,7 @@ describe('Create dirs', () => { name: 'new dir 1', }; request(method, route). + set('Cookie', cookie). send(params). catch(err => { if (err && err.response && err.response.request.res) { @@ -1042,6 +1100,7 @@ describe('Create dirs', () => { let route = `${baseUrl}/files`; request.post(route). + set('Cookie', cookie). field('type', 'dir'). field('name', 'new dir 1'). field('parentId', newGrandchildId3). @@ -1062,6 +1121,7 @@ describe('Create dirs', () => { it('Check newDir', done => { request. get(`${baseUrl}/files/${newDirId}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; newDirSize = jsonData.items.length; @@ -1090,6 +1150,7 @@ describe('Copy resouces', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ parents: [newGrandchildId1, workChildDirId] }). then(res => { let jsonData = res.body; @@ -1123,6 +1184,7 @@ describe('Copy resouces', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ parents: [newGrandchildId1, workChildDirId] }). catch(err => { if (err && err.response && err.response.request.res) { @@ -1143,6 +1205,7 @@ describe('Copy resouces', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ parents: [createIncorrectId(workChildDirId, 'incorrect_dir_name'), workChildDirId] }). catch(err => { if (err && err.response && err.response.request.res) { @@ -1163,6 +1226,7 @@ describe('Copy resouces', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send({ parents: [newGrandchildId1, createIncorrectId(workChildDirId, 'incorrect_dir_name')] }). catch(err => { if (err && err.response && err.response.request.res) { @@ -1188,6 +1252,7 @@ describe('Copy resouces', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -1227,6 +1292,7 @@ describe('Copy resouces', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -1257,6 +1323,7 @@ describe('Copy resouces', () => { it('Check workChildDir', done => { request. get(`${baseUrl}/files/${workChildDirId}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1273,6 +1340,7 @@ describe('Copy resouces', () => { it('Check newGrandchildId1', done => { request. get(`${baseUrl}/files/${newGrandchildId1}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1289,6 +1357,7 @@ describe('Copy resouces', () => { it('Check newGrandchildId2', done => { request. get(`${baseUrl}/files/${newGrandchildId2}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1313,6 +1382,7 @@ describe('Move resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send(params). catch(err => { if (err && err.response && err.response.request.res) { @@ -1336,6 +1406,7 @@ describe('Move resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send(params). catch(err => { if (err && err.response && err.response.request.res) { @@ -1359,6 +1430,7 @@ describe('Move resources', () => { request(method, route). type('application/json'). + set('Cookie', cookie). send(params). then(res => { let jsonData = res.body; @@ -1391,6 +1463,7 @@ describe('Move resources', () => { it('Check newGrandchildId1', done => { request. get(`${baseUrl}/files/${newGrandchildId1}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1407,6 +1480,7 @@ describe('Move resources', () => { it('Check newGrandchildId2', done => { request. get(`${baseUrl}/files/${newGrandchildId2}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1423,6 +1497,7 @@ describe('Move resources', () => { it('Check newGrandchildId3', done => { request. get(`${baseUrl}/files/${newGrandchildId3}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1440,7 +1515,7 @@ describe('Move resources', () => { describe('Download', () => { it('Download file', done => { const downloadUrl = `${baseUrl}/download?items=${copiedFileId3}`; - request.get(downloadUrl). + request.get(downloadUrl).set('Cookie', cookie). responseType('blob'). then(res => { expect(res.status).to.equal(200); @@ -1455,7 +1530,7 @@ describe('Download', () => { it('Download folder', done => { const downloadUrl = `${baseUrl}/download?items=${newGrandchildId3}`; - request.get(downloadUrl). + request.get(downloadUrl).set('Cookie', cookie). responseType('blob'). then(res => { expect(res.status).to.equal(200); @@ -1470,7 +1545,7 @@ describe('Download', () => { it('Download file with incorrect id', done => { const downloadUrl = `${baseUrl}/download?items=${createIncorrectId(newGrandchildId3, 'incorrect-dir')}`; - request.get(downloadUrl). + request.get(downloadUrl).set('Cookie', cookie). responseType('blob'). catch(err => { if (err && err.response && err.response.request.res) { @@ -1493,7 +1568,7 @@ describe('Upload file', () => { let file = fs.readFileSync(`${workDirPath}/${fileName}`); let route = `${baseUrl}/files`; - request.post(route). + request.post(route).set('Cookie', cookie). field('type', 'file'). field('parentId', createIncorrectId(newGrandchildId3, 'incorrect_dir_name')). attach('files', file, fileName). @@ -1517,7 +1592,8 @@ describe('Upload file', () => { let file = fs.readFileSync(`${workDirPath}/${fileName}`); let route = `${baseUrl}/files`; - request.post(route). + request.post(route).set('Cookie', cookie). + set('Cookie', cookie). field('type', 'file'). field('parentId', newGrandchildId3). attach('files', file, fileName). @@ -1552,7 +1628,7 @@ describe('Remove resources', () => { it('Remove file', done => { let route = `${baseUrl}/files/${copiedFileId3}`; let method = 'DELETE'; - request(method, route). + request(method, route).set('Cookie', cookie). then(res => { expect(res.status).to.equal(200); @@ -1566,6 +1642,7 @@ describe('Remove resources', () => { it('Check newGrandchildId3', done => { request. get(`${baseUrl}/files/${newGrandchildId3}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1583,6 +1660,7 @@ describe('Remove resources', () => { let route = `${baseUrl}/files/${createIncorrectId(newDirId, 'incorrect-dir')}`; let method = 'DELETE'; request(method, route). + set('Cookie', cookie). then(res => { expect(res.status).to.equal(200); @@ -1597,6 +1675,7 @@ describe('Remove resources', () => { let route = `${baseUrl}/files/${rootId}`; let method = 'DELETE'; request(method, route). + set('Cookie', cookie). catch(err => { if (err && err.response && err.response.request.res) { expect(err.response.request.res.statusCode).to.equal(400); @@ -1614,6 +1693,7 @@ describe('Remove resources', () => { let route = `${baseUrl}/files/${newGrandchildId3}`; let method = 'DELETE'; request(method, route). + set('Cookie', cookie). then(res => { expect(res.status).to.equal(200); @@ -1627,6 +1707,7 @@ describe('Remove resources', () => { it('Check newDir', done => { request. get(`${baseUrl}/files/${newDirId}/children`). + set('Cookie', cookie). then(res => { let jsonData = res.body; @@ -1644,6 +1725,7 @@ describe('Remove resources', () => { let route = `${baseUrl}/files/${newDirId}`; let method = 'DELETE'; request(method, route). + set('Cookie', cookie). then(res => { expect(res.status).to.equal(200); @@ -1654,3 +1736,90 @@ describe('Remove resources', () => { }); }); }); + +describe('Authentication tests', () => { + it('Wrong authentication request', done => { + request.get(`${baseUrl}/authentication/wrongcmd`). + set('Cookie', cookie). + catch(err => { + if (err && err.response && err.response.request && err.response.request.res) { + expect(err.response.request.res.statusCode).to.equal(404); + done(); + } else { + done(err); + } + }); + }).timeout(1000); + + it('Check expired session', done => { + request.get(`${baseUrl}/authentication/signout`). + set('Cookie', cookie). + then(res => { + expect(res.status).to.equal(200); + request.get(`${baseUrl}/authentication/hassignedin`). + then(res => { + expect(res.status).to.equal(200); + done(); + }). + catch(err => { + if (err && err.response && err.response.request && err.response.request.res) { + expect(err.response.request.res.statusCode).to.equal(419); + done(); + } else { + done(err); + } + }); + + }). + catch(err => { + done(err); + }); + }).timeout(1000); + + it('Wrong authentication', done => { + request.post(`${baseUrl}/authentication/signin`). + set('Content-Type', 'application/json'). + send('{"username":"wrong_user","password":"incorrect_pass"}'). + then(res => { + expect(res.status).to.equal(200); + done(); + }). + catch(err => { + if (err && err.response && err.response.request && err.response.request.res) { + expect(err.response.request.res.statusCode).to.equal(419); + done(); + } else { + done(err); + } + }); + }).timeout(1000); + + + it('Correct authentication', done => { + request.post(`${baseUrl}/authentication/signin`). + set('Content-Type', 'application/json'). + send({username: btoa("service"),password: btoa("secret")}). + then(res => { + expect(res.status).to.equal(200); + + request.get(`${baseUrl}/authentication/hassignedin`). + then(res => { + expect(res.status).to.equal(200); + + done(); + }). + catch(err => { + if (err && err.response && err.response.request && err.response.request.res) { + expect(err.response.request.res.statusCode).to.equal(419); + done(); + } else { + done(err); + } + }); + + }). + catch(err => { + done(err); + }); + }).timeout(1000); +}); diff --git a/packages/server-nodejs/config/server-config-users.js b/packages/server-nodejs/config/server-config-users.js new file mode 100644 index 000000000..cd3598c8c --- /dev/null +++ b/packages/server-nodejs/config/server-config-users.js @@ -0,0 +1,12 @@ +'use strict'; + +const path = require('path'); + +module.exports = { + fsRoot: path.resolve('./test-files'), + rootName: 'User storage', + readOnly: false, + users: [{username: 'service', password: 'secret', readOnly: false}, {username: 'operator', password: 'secret', readOnly: true}], + port: process.env.PORT || '3020', + host: process.env.HOST || 'localhost' +}; diff --git a/packages/server-nodejs/constants.js b/packages/server-nodejs/constants.js index 62d3dff54..48fa7ff09 100644 --- a/packages/server-nodejs/constants.js +++ b/packages/server-nodejs/constants.js @@ -1,5 +1,7 @@ 'use strict'; module.exports = { TYPE_FILE: 'file', - TYPE_DIR: 'dir' + TYPE_DIR: 'dir', + TYPE_BROKEN_LINK: 'brokenlink', + TYPE_ENCODING_NAME_ERROR: 'encodingnameerror' }; diff --git a/packages/server-nodejs/middleware.js b/packages/server-nodejs/middleware.js index 0c8c8986f..ef72a9beb 100644 --- a/packages/server-nodejs/middleware.js +++ b/packages/server-nodejs/middleware.js @@ -6,6 +6,7 @@ const logger = require('./logger'); module.exports = config => router({ fsRoot: config.fsRoot, rootName: config.rootName, + users: config.users, readOnly: config.readOnly, logger: config.logger || logger }); diff --git a/packages/server-nodejs/package.json b/packages/server-nodejs/package.json index 1cdf52ce8..0e03f3a03 100644 --- a/packages/server-nodejs/package.json +++ b/packages/server-nodejs/package.json @@ -13,6 +13,11 @@ "pretest-restapi": "npm run prepare-demo", "test-restapi": "pm2 delete 'fmServer'; pm2 start start.js --name 'fmServer' && mocha --require config/test/mocha-setup.js --recursive api-tests/*.spec.js --reporter mocha-multi-reporters --reporter-options configFile=./config/test/reporters.json", "posttest-restapi": "rimraf test-files", + + "pretest-restapi-users": "npm run prepare-demo", + "test-restapi-users": "pm2 delete 'fmServer'; SERVER_CONFIG=./config/server-config-users pm2 start start.js --name 'fmServer' && mocha --require config/test/mocha-setup.js api-tests/*.spec.js --reporter mocha-multi-reporters --reporter-options configFile=./config/test/reporters.json", + "posttest-restapi-users": "rimraf test-files", + "build": "node -e \"console.log('Building \"$npm_package_name\" version \"$npm_package_version\"! ')\"" }, "publishConfig": { @@ -27,6 +32,7 @@ "body-parser": "1.18.3", "express": "4.16.3", "express-easy-zip": "1.1.4", + "express-session": "1.16.1", "fs-extra": "7.0.0", "helmet": "3.13.0", "isbinaryfile": "3.0.3", diff --git a/packages/server-nodejs/router/authentication.js b/packages/server-nodejs/router/authentication.js new file mode 100644 index 000000000..2f1a1b04f --- /dev/null +++ b/packages/server-nodejs/router/authentication.js @@ -0,0 +1,66 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs-extra'); +const getClientIp = require('../utils/get-client-ip'); + +module.exports = ({ + config, + req, + res, + handleError +}) => { + let subreq = req.path.replace('/authentication/', ''); + subreq = subreq.replace('/', ''); + + config.logger.info(`Authentication request "${subreq}" requested by ${getClientIp(req)}`); + + switch(subreq) { + case 'signin': + let { username, password } = req.body; + username = Buffer.from(username,'base64').toString(); + password = Buffer.from(password,'base64').toString(); +// console.log(`username:${username} pasword:${password}`); + const user = config.users ? config.users.find( user => (user.username === username) && (user.password === password)) : false; + if ( user ) { +// console.log(`200 username:${username} password:${password}`); + req.session.user = {username: username, readOnly: user.readOnly}; + res.json({username: user.username}); + res.status(200).end(); + } else { + if (config.users) { +// console.log(`419 username:${username} password:${password}`); + res.status(419).end(); + } else { + res.json({username: ''}); + res.status(200).end(); + } + } + break; + + case 'signout': + req.session.destroy(); + res.status(200).end(); + break; + + case 'hassignedin': + if (config.users) { + if ( req.session.user ) { + res.json({username: req.session.user.username}); + res.status(200).end(); + } else { + res.status(419).end(); + } + } else { + res.json({username: ''}); + res.status(200).end(); + } + break; + + default: + return handleError(Object.assign( + new Error(`Resource not found`), + { httpCode: 404 } + )); + } +}; diff --git a/packages/server-nodejs/router/download.js b/packages/server-nodejs/router/download.js index cccf909d3..dc86bca07 100644 --- a/packages/server-nodejs/router/download.js +++ b/packages/server-nodejs/router/download.js @@ -24,6 +24,13 @@ module.exports = ({ config, req, res, handleError }) => { const preview = req.query.preview === 'true'; let reqPaths; + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + try { reqPaths = ids.map(id => id2path(id)); } catch (err) { diff --git a/packages/server-nodejs/router/index.js b/packages/server-nodejs/router/index.js index 78da80dfd..4116810a6 100644 --- a/packages/server-nodejs/router/index.js +++ b/packages/server-nodejs/router/index.js @@ -4,8 +4,11 @@ const helmet = require('helmet'); const zip = require('express-easy-zip'); // 'node-archiver', 'zipstream' or 'easyzip' may be used instead. const bodyParser = require('body-parser'); const express = require('express'); +const session = require('express-session'); const path = require('path'); +const sessionAge = 24 * (60 * 60 * 1000); //24h + const { id2path, handleError @@ -14,6 +17,8 @@ const { module.exports = config => { const router = express.Router(); + router.use(session({secret: 'top secret', resave: false, saveUninitialized: false, cookie: { maxAge: sessionAge }})); + router.use(function(req, res, next) { res.header('Access-Control-Allow-Methods', 'GET,POST,HEAD,OPTIONS,PUT,PATCH,DELETE'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); @@ -69,6 +74,10 @@ module.exports = config => { router.route('/download'). get(connect('./download')); + router.route('/authentication/*'). + get(connect('./authentication', _ => ({}))). + post(connect('./authentication', _ => ({}))); + router.use((err, req, res, next) => handleError({ config, req, res })(err)); return router; }; diff --git a/packages/server-nodejs/router/lib.js b/packages/server-nodejs/router/lib.js index ea3249a62..49b0ce5d6 100644 --- a/packages/server-nodejs/router/lib.js +++ b/packages/server-nodejs/router/lib.js @@ -17,7 +17,9 @@ const { const { TYPE_FILE, - TYPE_DIR + TYPE_DIR, + TYPE_BROKEN_LINK, + TYPE_ENCODING_NAME_ERROR } = require('../constants'); const @@ -145,6 +147,7 @@ const checkName = name => { */ const getResource = async ({ config, + session, path: userPath, // path relative to config.fsRoot, path.sep for user root. Optional. parent: userParent, // path relative to config.fsRoot, null for user root. Optional. basename: userBasename, // null for user root. Optional. @@ -171,9 +174,10 @@ const getResource = async ({ let parent; ([stats, parent] = await Promise.all([ // eslint-disable-line no-param-reassign,prefer-const - stats || fs.stat(path.join(config.fsRoot, userPath)), + stats || fs.stat(path.join(config.fsRoot, userPath)).catch(() => {return fs.lstat(path.join(config.fsRoot, userPath)).catch(() => {return false;})}), userParent && getResource({ config, + session, path: userParent }) ])); @@ -184,22 +188,26 @@ const getResource = async ({ createdTime: stats.birthtime, modifiedTime: stats.mtime, capabilities: { - canDelete: !!userParent && !config.readOnly, - canRename: !!userParent && !config.readOnly, - canCopy: !!userParent && !config.readOnly, - canEdit: stats.isFile() && !config.readOnly, // Only files can be edited - canDownload: stats.isFile() // Only files can be downloaded + canDelete: !!userParent && !isReadOnly(config, session), + canRename: !!userParent && !isReadOnly(config, session), + canCopy: !!userParent && !isReadOnly(config, session), + canEdit: stats && stats.isFile() && !isReadOnly(config, session), // Only files can be edited + canDownload: stats && stats.isFile() // Only files can be downloaded } }; - if (stats.isDirectory()) { + if (stats === false) { + resource.type = TYPE_ENCODING_NAME_ERROR; + } else if (stats.isDirectory()) { resource.type = TYPE_DIR; resource.capabilities.canListChildren = true; - resource.capabilities.canAddChildren = !config.readOnly; - resource.capabilities.canRemoveChildren = !config.readOnly; + resource.capabilities.canAddChildren = !isReadOnly(config, session); + resource.capabilities.canRemoveChildren = !isReadOnly(config, session); } else if (stats.isFile()) { resource.type = TYPE_FILE; resource.size = stats.size; + } else if (stats.isSymbolicLink()) { + resource.type = TYPE_BROKEN_LINK; } else { throw new Error(UNKNOWN_RESOURCE_TYPE_ERROR); } @@ -254,6 +262,9 @@ const isBinaryFile = async filePath => { return _isBinaryFile(filePath); } +const isReadOnly = (config, session) => { + return config.users ? (session.user ? session.user.readOnly : config.readOnly) : config.readOnly; +} module.exports = { UNKNOWN_RESOURCE_TYPE_ERROR, @@ -264,5 +275,6 @@ module.exports = { getResource, handleError, isBinaryFile, - fsCaseSensitive + fsCaseSensitive, + isReadOnly } diff --git a/packages/server-nodejs/router/listChildren.js b/packages/server-nodejs/router/listChildren.js index a9d610dc1..46f2abdb4 100644 --- a/packages/server-nodejs/router/listChildren.js +++ b/packages/server-nodejs/router/listChildren.js @@ -19,6 +19,13 @@ module.exports = ({ }) => { let sorter; + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + try { sorter = getSorter({ caseSensitive: false, @@ -31,13 +38,14 @@ module.exports = ({ const absPath = path.join(config.fsRoot, userPath); config.logger.info(`Children for ${absPath} requested by ${getClientIp(req)}`); - + return fs.readdir(absPath). then(basenames => Promise.all( basenames. map( basename => getResource({ config, + session: req.session, parent: userPath, basename }). diff --git a/packages/server-nodejs/router/remove.js b/packages/server-nodejs/router/remove.js index 2eb7986c5..b74a1087e 100644 --- a/packages/server-nodejs/router/remove.js +++ b/packages/server-nodejs/router/remove.js @@ -4,6 +4,10 @@ const path = require('path'); const fs = require('fs-extra'); const getClientIp = require('../utils/get-client-ip'); +const { + isReadOnly +} = require('./lib'); + module.exports = ({ config, req, @@ -11,7 +15,14 @@ module.exports = ({ handleError, path: userPath }) => { - if (config.readOnly) { + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + + if (isReadOnly(config, req.session)) { return handleError(Object.assign( new Error(`File Manager is in read-only mode`), { httpCode: 403 } diff --git a/packages/server-nodejs/router/renameCopyMove.js b/packages/server-nodejs/router/renameCopyMove.js index 34d361fb7..fc5f11f45 100644 --- a/packages/server-nodejs/router/renameCopyMove.js +++ b/packages/server-nodejs/router/renameCopyMove.js @@ -8,7 +8,8 @@ const getClientIp = require('../utils/get-client-ip'); const { checkName, id2path, - getResource + getResource, + isReadOnly } = require('./lib'); const MAX_RETRIES = 3; @@ -21,7 +22,14 @@ module.exports = ({ handleError, path: relativeItemPath }) => { - if (config.readOnly) { + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + + if (isReadOnly(config, req.session)) { return handleError(Object.assign( new Error(`File Manager is in read-only mode`), { httpCode: 403 } @@ -143,6 +151,7 @@ module.exports = ({ }(MAX_RETRIES)). then(_ => getResource({ config, + session: req.session, parent: targetRelativePath, basename })). diff --git a/packages/server-nodejs/router/search.js b/packages/server-nodejs/router/search.js index 6f5ec6092..51eb73792 100644 --- a/packages/server-nodejs/router/search.js +++ b/packages/server-nodejs/router/search.js @@ -66,6 +66,13 @@ module.exports = async ({ handleError, path: userPath }) => { + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + if (req.query.cacheId) { const searchStream = gCache[req.query.cacheId]; @@ -194,7 +201,7 @@ module.exports = async ({ config.logger.info(`Search inside ${absPath} requested by ${getClientIp(req)}`); try { - const searchStream = getSearchStream(absPath, config, { + const searchStream = getSearchStream(absPath, config, req.session, { itemNameSubstring, itemNameCaseSensitive, itemType, diff --git a/packages/server-nodejs/router/statResource.js b/packages/server-nodejs/router/statResource.js index 673d59459..7c425720b 100644 --- a/packages/server-nodejs/router/statResource.js +++ b/packages/server-nodejs/router/statResource.js @@ -12,10 +12,18 @@ module.exports = ({ handleError, path: userPath }) => { + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + config.logger.info(`Stat for ${path.join(config.fsRoot, userPath)} requested by ${getClientIp(req)}`); getResource({ config, + session: req.session, path: userPath }). then(resource => res.json(resource)). diff --git a/packages/server-nodejs/router/streams/fsItem2ResourceStream.js b/packages/server-nodejs/router/streams/fsItem2ResourceStream.js index 20de5ef44..a51104b34 100644 --- a/packages/server-nodejs/router/streams/fsItem2ResourceStream.js +++ b/packages/server-nodejs/router/streams/fsItem2ResourceStream.js @@ -5,7 +5,7 @@ const { relative, sep } = require('path'); const { getResource } = require('../lib'); module.exports = class extends Transform { - constructor(config) { + constructor(config, session) { super({ objectMode: true, highWaterMark: 16 // Max number of objects the buffer might contain. @@ -13,6 +13,7 @@ module.exports = class extends Transform { this.isDestroyed = false; // Whether the stream has been destroyed by `destroy()` call. this.config = config; + this.session = session; } /** @@ -26,6 +27,7 @@ module.exports = class extends Transform { // untill the buffer is drained. const resource = await getResource({ config: this.config, + session: this.session, stats: item.stats, path: sep + relative(this.config.fsRoot, item.path) // `path.relative()` never starts/ends with `path.sep`. }); diff --git a/packages/server-nodejs/router/streams/searchContentStream.js b/packages/server-nodejs/router/streams/searchContentStream.js index 0552fd6e7..88eaeaecc 100644 --- a/packages/server-nodejs/router/streams/searchContentStream.js +++ b/packages/server-nodejs/router/streams/searchContentStream.js @@ -53,7 +53,7 @@ module.exports = class extends Transform { * or the file's extracted text if it is binary and the module knows how to extract text * from this kind of binary files. */ - async getReadStreamBuilder(filePath) { + getReadStreamBuilder(filePath) { return new Promise(async (resolve, reject) => { // eslint-disable-line consistent-return if (extname(filePath).slice(1).toLowerCase() === 'pdf') { const pdfParser = new PdfParser(undefined, 1); @@ -66,7 +66,7 @@ module.exports = class extends Transform { try { if (await isBinaryFile(filePath)) { - return resolve(); + return resolve(null); } } catch (err) { // Ignore the error and try to read the file as UTF8-encoded. @@ -109,77 +109,74 @@ module.exports = class extends Transform { * The stream will handle items in a sequence, i.e.`_transform()` will never be invoked again with the next file, * until the previous invocation completes by executing `done()` callback. */ - async _transform(file, encoding, done) { // eslint-disable-line consistent-return - let readStreamBuilder; - - try { - readStreamBuilder = await this.getReadStreamBuilder(file.path); - } catch (err) { - // Log error and get next file for content analysis. - this.config.logger.error( - `Error searching file "${file.path}": ${err}` + '\n' + (err.stack && err.stack.split('\n')) - ); - - return done(null); - } - - if (!readStreamBuilder || this.isDestroyed) { - return done(null); - } - - let chunkPrefix = ''; - - this.readStream = readStreamBuilder(). - /* - * "If a file is read till its end, the 'end' event occurs followed by 'close'. And if a file is not entirely - * read - for instance, because of an error or upon calling the `destroy()` method - there will be no 'end' - * because the file hasn't been ended. But the 'close' event is always ensured upon a file closure." - * https://www.reddit.com/user/soshace/comments/91kq9a/20_nodejs_lessons_data_streams_in_nodejs/ - */ - on('close', _ => done(null)). - on('end', _ => done(null)). - on('error', err => { - // Use `this.emit('error',err,file)` instead of `done(err,file)` since the later causes 'data' event to occur. - this.emit('error', err, file); - - // XXX: contrary to the above quote, neither 'close' nor 'end' event is never emitted in case of 'error' event - // => call `done()` here as well as in 'close' event listener. - done(null); - }). - on('readable', _ => { - let chunk; - - while ((chunk = this.readStream.read()) !== null) { // eslint-disable-line no-cond-assign - chunk = chunkPrefix + (this.options.caseSensitive ? - chunk : - chunk.toLowerCase() - ); - - if (chunk.includes(this.str)) { - done(file); - break; + _transform(file, encoding, done) { // eslint-disable-line consistent-return + let readStreamBuilder; + + readStreamBuilder = this.getReadStreamBuilder(file.path); + + readStreamBuilder.then(createReadStream => { + if (!createReadStream || this.isDestroyed) { + return done(null); + } else { + let chunkPrefix = ''; + + this.readStream = createReadStream(); + + this.readStream. + /* + * "If a file is read till its end, the 'end' event occurs followed by 'close'. And if a file is not entirely + * read - for instance, because of an error or upon calling the `destroy()` method - there will be no 'end' + * because the file hasn't been ended. But the 'close' event is always ensured upon a file closure." + * https://www.reddit.com/user/soshace/comments/91kq9a/20_nodejs_lessons_data_streams_in_nodejs/ + */ + on('close', _ => done(null)). + on('end', _ => done(null)). + on('error', err => { + // Use `this.emit('error',err,file)` instead of `done(err,file)` since the later causes 'data' event to occur. + this.emit('error', err, file); + + // XXX: contrary to the above quote, neither 'close' nor 'end' event is never emitted in case of 'error' event + // => call `done()` here as well as in 'close' event listener. + done(null); + }). + on('readable', _ => { + let chunk; + while ((chunk = this.readStream.read()) !== null) { // eslint-disable-line no-cond-assign + chunk = chunkPrefix + (this.options.caseSensitive ? + chunk : + chunk.toLowerCase() + ); + + if (chunk.includes(this.str)) { + done(file); + break; + } + + chunkPrefix = chunk.slice(1 - this.str.length); + } + }); + + done = ((done, invoked) => satisfactoryFile => { // eslint-disable-line no-param-reassign + if (invoked) { return; } + + if (!this.isDestroyed) { + if (satisfactoryFile) { + this.push(satisfactoryFile); + } + + invoked = true; // eslint-disable-line no-param-reassign + this.readStream.removeAllListeners(); + this.readStream.destroy(); // Implicitly emits 'close' event. + this.readStream = undefined; } - chunkPrefix = chunk.slice(1 - this.str.length); - } - }); - - done = ((done, invoked) => satisfactoryFile => { // eslint-disable-line no-param-reassign - if (invoked) { return; } - - if (!this.isDestroyed) { - if (satisfactoryFile) { - this.push(satisfactoryFile); - } - - invoked = true; // eslint-disable-line no-param-reassign - this.readStream.removeAllListeners(); - this.readStream.destroy(); // Implicitly emits 'close' event. - this.readStream = undefined; + done(null); + })(done); } - - done(null); - })(done); + }).catch( err => { + this.config.logger.error(`Error searching file "${file.path}": ${err}` + '\n' + (err.stack && err.stack.split('\n'))); + return done(null); + }) } _destroy(err, done) { diff --git a/packages/server-nodejs/router/streams/searchStream.js b/packages/server-nodejs/router/streams/searchStream.js index dd2b90bda..267480529 100644 --- a/packages/server-nodejs/router/streams/searchStream.js +++ b/packages/server-nodejs/router/streams/searchStream.js @@ -36,7 +36,7 @@ const { * The param is ignored if `options.fileContentSubstring` is an empty string. * @returns {ReadableStream} */ -module.exports = (rootPath, config, { +module.exports = (rootPath, config, session, { itemNameSubstring, itemNameCaseSensitive, itemType, @@ -68,7 +68,7 @@ module.exports = (rootPath, config, { })); } - streams.push(new FsItem2ResourceStream(config)); + streams.push(new FsItem2ResourceStream(config, session)); streams.slice(1).forEach((targetStream, i) => { const sourceStream = streams[i]; diff --git a/packages/server-nodejs/router/uploadOrCreate.js b/packages/server-nodejs/router/uploadOrCreate.js index 9f25f6e29..5830507d0 100644 --- a/packages/server-nodejs/router/uploadOrCreate.js +++ b/packages/server-nodejs/router/uploadOrCreate.js @@ -9,7 +9,8 @@ const getClientIp = require('../utils/get-client-ip'); const { checkName, id2path, - getResource + getResource, + isReadOnly } = require('./lib'); const { @@ -54,7 +55,7 @@ const upload = multer({ catch(cb); }, filename(req, file, cb) { - const { parentId } = req.body; + const { parentId, overwrite } = req.body; try { checkName(file.originalname); @@ -76,29 +77,40 @@ const upload = multer({ )); } - return fs.readdir(parentPath). - then(basenames => { - let basename = file.originalname; - - if (basenames.includes(basename)) { - const { name, ext } = path.parse(basename); - let suffix = 1; - - do { - basename = `${name} (${suffix++})${ext}`; - } while (basenames.includes(basename)); - } - - cb(null, basename); - }). - catch(cb); + if (overwrite === 'true') { + return cb(null, file.originalname); + } else { + return fs.readdir(parentPath). + then(basenames => { + let basename = file.originalname; + + if (basenames.includes(basename)) { + const { name, ext } = path.parse(basename); + let suffix = 1; + + do { + basename = `${name} (${suffix++})${ext}`; + } while (basenames.includes(basename)); + } + + cb(null, basename); + }). + catch(cb); + } } }) }). array('files'); module.exports = ({ config, req, res, handleError }) => { - if (config.readOnly) { + if (config.users && req.session.user === undefined) { + return handleError(Object.assign( + new Error(`Session expired.`), + { httpCode: 419 } + )); + } + + if (isReadOnly(config, req.session)) { return handleError(Object.assign( new Error(`File Manager is in read-only mode`), { httpCode: 403 } @@ -142,6 +154,7 @@ module.exports = ({ config, req, res, handleError }) => { then(_ => fs.ensureDir(dirPath)). then(_ => getResource({ config, + session: req.session, parent: reqParentPath, basename: name })). @@ -160,6 +173,7 @@ module.exports = ({ config, req, res, handleError }) => { return Promise.all( req.files.map(({ filename }) => getResource({ config, + session: req.session, parent: reqParentPath, basename: filename })) diff --git a/packages/server-nodejs/server.js b/packages/server-nodejs/server.js index 040d653ca..03e2a2b44 100644 --- a/packages/server-nodejs/server.js +++ b/packages/server-nodejs/server.js @@ -5,7 +5,9 @@ const logger = require('./logger'); const app = express(); -function run(config = require('./config/server-config')) { +const server_config = process.env.SERVER_CONFIG ? process.env.SERVER_CONFIG : './config/server-config' + +function run(config = require(server_config)) { const host = config.host; const port = config.port;