diff --git a/docs/en_US/developer_tools.rst b/docs/en_US/developer_tools.rst index 1ff442cc2..bb67e33a0 100644 --- a/docs/en_US/developer_tools.rst +++ b/docs/en_US/developer_tools.rst @@ -16,3 +16,4 @@ PL/SQL code. editgrid schema_diff erd_tool + psql_tool diff --git a/docs/en_US/images/psql_tool.png b/docs/en_US/images/psql_tool.png new file mode 100644 index 000000000..ef3f4a710 Binary files /dev/null and b/docs/en_US/images/psql_tool.png differ diff --git a/docs/en_US/images/tool_menu.png b/docs/en_US/images/tool_menu.png index 7747b9550..599c6d444 100644 Binary files a/docs/en_US/images/tool_menu.png and b/docs/en_US/images/tool_menu.png differ diff --git a/docs/en_US/images/toolbar.png b/docs/en_US/images/toolbar.png index aac16b967..477a3eb49 100644 Binary files a/docs/en_US/images/toolbar.png and b/docs/en_US/images/toolbar.png differ diff --git a/docs/en_US/menu_bar.rst b/docs/en_US/menu_bar.rst index e2aec159a..6d6a82fa2 100644 --- a/docs/en_US/menu_bar.rst +++ b/docs/en_US/menu_bar.rst @@ -128,6 +128,8 @@ Use the *Tools* menu to access the following options (in alphabetical order): +---------------------------+-------------------------------------------------------------------------------------------------------------------------------------------+ | *Search Objects...* | Click to open the :ref:`Search Objects... ` and start searching any kind of objects in a database. | +---------------------------+-------------------------------------------------------------------------------------------------------------------------------------------+ +| *PSQL Tool* | Click to open the :ref:`PSQL Tool ` and start PSQL in the current database context. | ++---------------------------+-------------------------------------------------------------------------------------------------------------------------------------------+ The Help Menu ************* diff --git a/docs/en_US/psql_tool.rst b/docs/en_US/psql_tool.rst new file mode 100644 index 000000000..946925866 --- /dev/null +++ b/docs/en_US/psql_tool.rst @@ -0,0 +1,19 @@ +.. _psql_tool: + +****************** +`PSQL Tool`:index: +****************** + +PSQL tool allows user to connect to PostgreSQL/EDB Advanced server using psql terminal. + +* Open PSQL Tool from Tools menu or PSQL tool button from browser tree or from context menu. + +* PSQL will connect to the current connected database from browser tree. + +.. image:: images/psql_tool.png + :alt: PSQL tool window + :align: center + +You can open multiple instance of the PSQL tool in individual tabs simultaneously. +To close the PSQL tool, click the *X* in the upper-right hand corner of the tab bar. + diff --git a/docs/en_US/release_notes_5_4.rst b/docs/en_US/release_notes_5_4.rst index 6aab19945..239bbb38d 100644 --- a/docs/en_US/release_notes_5_4.rst +++ b/docs/en_US/release_notes_5_4.rst @@ -9,6 +9,7 @@ This release contains a number of bug fixes and new features since the release o New features ************ +| `Issue #2341 `_ - Added support to launch PSQL for the connected database server. | `Issue #4064 `_ - Added window maximize/restore functionality for properties dialog. Housekeeping diff --git a/docs/en_US/toolbar.rst b/docs/en_US/toolbar.rst index f4c685f24..8a5a8da8a 100644 --- a/docs/en_US/toolbar.rst +++ b/docs/en_US/toolbar.rst @@ -20,4 +20,6 @@ the selected browser node. * Use the :ref:`Filtered Rows ` button to access the Data Filter popup to apply a filter to a set of data for viewing/editing. * Use the :ref:`Search objects ` button to access the search objects - dialog. It helps you search any database object. \ No newline at end of file + dialog. It helps you search any database object. +* Use the :ref:`PSQL Tool ` button to open the PSQL in the current + database context. diff --git a/requirements.txt b/requirements.txt index 08dc344db..a14774736 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,6 @@ # ############################################################################### -cheroot==8.* Flask==1.* Flask-Gravatar==0.* Flask-Login==0.* @@ -37,3 +36,5 @@ sshtunnel==0.* ldap3==2.* Flask-BabelEx==0.* gssapi==1.6.* +flask-socketio>=5.0.1 +eventlet==0.30.2 diff --git a/web/config.py b/web/config.py index 4d1f98698..3b34d69b4 100644 --- a/web/config.py +++ b/web/config.py @@ -156,8 +156,8 @@ X_FRAME_OPTIONS = "SAMEORIGIN" # such as JavaScript, CSS, or pretty much anything that the browser loads. # see https://content-security-policy.com/#source_list for more info # e.g. "default-src https: data: 'unsafe-inline' 'unsafe-eval';" -CONTENT_SECURITY_POLICY = "default-src http: data: blob: 'unsafe-inline' " \ - "'unsafe-eval';" +CONTENT_SECURITY_POLICY = "default-src ws: http: data: blob: 'unsafe-inline'" \ + " 'unsafe-eval';" # STRICT_TRANSPORT_SECURITY_ENABLED when set to True will set the # Strict-Transport-Security header @@ -636,6 +636,21 @@ KRB_AUTO_CREATE_USER = True KERBEROS_CCACHE_DIR = os.path.join(DATA_DIR, 'krbccache') +########################################################################## +# PSQL tool settings +########################################################################## +# This will enable PSQL tool in pgAdmin. So user can execute the commands +# using PSQL terminal in pgAdmin. +ENABLE_PSQL = True + +# ALLOW_PSQL_SHELL_COMMAND = True will disable the execution of os level +# commands using meta command \! from PSQL terminal. +# As PSQL allow user to execute the os level commands from the PSQL terminal +# user can execute any system level command as per the system login user +# privileges. Default this setting is set to False but if it set to True +# User will able to execute the system level commands through PSQL terminal +# in pgAdmin. +ALLOW_PSQL_SHELL_COMMANDS = False ########################################################################## # Local config settings diff --git a/web/package.json b/web/package.json index 76ca44c02..d30f0238c 100644 --- a/web/package.json +++ b/web/package.json @@ -117,13 +117,18 @@ "shim-loader": "^1.0.1", "slickgrid": "git+https://github.com/6pac/SlickGrid.git#2.3.16", "snapsvg-cjs": "^0.0.6", + "socket.io-client": "^4.0.0", "split.js": "^1.5.10", "tablesorter": "^2.31.2", "tempusdominus-bootstrap-4": "^5.1.2", "tempusdominus-core": "^5.0.3", "underscore": "^1.13.1", "webcabin-docker": "git+https://github.com/EnterpriseDB/wcDocker/#89e006611f4d0fc24b0a098fa2041821d093be4f", - "wkx": "^0.5.0" + "wkx": "^0.5.0", + "xterm": "^4.11.0", + "xterm-addon-fit": "^0.5.0", + "xterm-addon-search": "^0.8.0", + "xterm-addon-web-links": "^0.4.0" }, "scripts": { "linter": "yarn eslint --no-eslintrc -c .eslintrc.js --ext .js --ext .jsx .", diff --git a/web/pgAdmin4.py b/web/pgAdmin4.py index d2bd1af6f..dc1924cbd 100644 --- a/web/pgAdmin4.py +++ b/web/pgAdmin4.py @@ -13,7 +13,7 @@ to start a web server.""" import sys -from cheroot.wsgi import Server as CherootServer + if sys.version_info < (3, 4): raise RuntimeError('This application must be run under Python 3.4 ' @@ -37,7 +37,7 @@ else: builtins.SERVER_MODE = None import config -from pgadmin import create_app +from pgadmin import create_app, socketio from pgadmin.utils import u_encode, fs_encoding, file_quote from pgadmin.utils.constants import INTERNAL # Get the config database schema version. We store this in pgadmin.model @@ -97,6 +97,8 @@ if not os.path.isfile(config.SQLITE_PATH): ########################################################################## app = create_app() app.debug = False +app.config['sessions'] = dict() + if config.SERVER_MODE: app.wsgi_app = ReverseProxied(app.wsgi_app) @@ -206,17 +208,16 @@ def main(): else: # Can use cheroot instead of flask dev server when not in debug # 10 is default thread count in CherootServer - num_threads = 10 if config.THREADED_MODE else 1 - prod_server = CherootServer( - (config.DEFAULT_SERVER, config.EFFECTIVE_SERVER_PORT), - wsgi_app=app, - numthreads=num_threads, - server_name=config.APP_NAME) + # num_threads = 10 if config.THREADED_MODE else 1 try: - print("Using production server...") - prod_server.start() + socketio.run( + app, + host=config.DEFAULT_SERVER, + port=config.EFFECTIVE_SERVER_PORT, + ) except KeyboardInterrupt: - prod_server.stop() + print("CLOSE SERVER") + socketio.stop() except IOError: app.logger.error("Error starting the app server: %s", sys.exc_info()) diff --git a/web/pgadmin/__init__.py b/web/pgadmin/__init__.py index a73335371..fad910824 100644 --- a/web/pgadmin/__init__.py +++ b/web/pgadmin/__init__.py @@ -19,6 +19,7 @@ from collections import defaultdict from importlib import import_module from flask import Flask, abort, request, current_app, session, url_for +from flask_socketio import SocketIO from werkzeug.exceptions import HTTPException from flask_babelex import Babel, gettext from flask_babelex import gettext as _ @@ -52,10 +53,15 @@ import mimetypes mimetypes.add_type('application/javascript', '.js') mimetypes.add_type('text/css', '.css') + winreg = None if os.name == 'nt': import winreg +socketio = SocketIO(manage_session=False, async_mode='eventlet', + logger=False, engineio_logger=False, debug=False, + ping_interval=25, ping_timeout=120) + class PgAdmin(Flask): def __init__(self, *args, **kwargs): @@ -811,4 +817,5 @@ def create_app(app_name=None): ########################################################################## # All done! ########################################################################## + socketio.init_app(app) return app diff --git a/web/pgadmin/browser/register_browser_preferences.py b/web/pgadmin/browser/register_browser_preferences.py index 235db0271..d835ca82d 100644 --- a/web/pgadmin/browser/register_browser_preferences.py +++ b/web/pgadmin/browser/register_browser_preferences.py @@ -10,6 +10,7 @@ from flask_babelex import gettext from pgadmin.utils.constants import PREF_LABEL_DISPLAY,\ PREF_LABEL_KEYBOARD_SHORTCUTS, PREF_LABEL_TABS_SETTINGS, \ PREF_LABEL_OPTIONS +from flask_security import current_user import config LOCK_LAYOUT_LEVEL = { @@ -511,10 +512,12 @@ def register_browser_preferences(self): options=[{'label': gettext('Query Tool'), 'value': 'qt'}, {'label': gettext('Debugger'), 'value': 'debugger'}, {'label': gettext('Schema Diff'), 'value': 'schema_diff'}, - {'label': gettext('ERD Tool'), 'value': 'erd_tool'}], - help_str=gettext('Select Query Tool, Debugger, or Schema Diff from ' - 'the drop-down to set open in new browser tab for ' - 'that particular module.'), + {'label': gettext('ERD Tool'), 'value': 'erd_tool'}, + {'label': gettext('PSQL Tool'), 'value': 'psql_tool'}], + help_str=gettext('Select Query Tool, Debugger, Schema Diff, ERD Tool ' + 'or PSQL Tool from the drop-down to set ' + 'open in new browser tab for that particular module.' + ), select2={ 'multiple': True, 'allowClear': False, 'tags': True, 'first_empty': False, @@ -523,3 +526,16 @@ def register_browser_preferences(self): 'placeholder': gettext('Select open new tab...') } ) + + self.psql_tab_title = self.preference.register( + 'tab_settings', 'psql_tab_title_placeholder', + gettext("PSQL tool tab title"), + 'text', '%DATABASE%/%USERNAME%@%SERVER%', + category_label=PREF_LABEL_DISPLAY, + help_str=gettext( + 'Supported placeholders are %DATABASE%, %USERNAME%, and %SERVER%. ' + 'Users can provide any string with or without placeholders of' + ' their choice. The blank title will be revert back to the' + ' default title with placeholders.' + ) + ) diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index f4165c32d..fb7ff12c1 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -34,6 +34,7 @@ from pgadmin.utils.constants import UNAUTH_REQ, MIMETYPE_APP_JS, \ SERVER_CONNECTION_CLOSED from sqlalchemy import or_ from pgadmin.utils.preferences import Preferences +from .... import socketio as sio def has_any(data, keys): @@ -1499,6 +1500,13 @@ class ServerNode(PGChildNodeView): # Release Connection manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid) + # Check if any psql terminal is running for the current disconnecting + # server. If any terminate the psql tool connection. + if 'sid_soid_mapping' in current_app.config and str(sid) in \ + current_app.config['sid_soid_mapping']: + if str(sid) in current_app.config['sid_soid_mapping']: + for i in current_app.config['sid_soid_mapping'][str(sid)]: + sio.emit('disconnect-psql', namespace='/pty', to=i) status = manager.release() diff --git a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js index 01ab89c50..eb20cac5f 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js +++ b/web/pgadmin/browser/server_groups/servers/databases/static/js/database.js @@ -122,6 +122,9 @@ define('pgadmin.node.database', [ is_connected: function(node) { return (node && node.connected == true && node.canDisconn == true); }, + is_psql_enabled: function(node) { + return (node && node.connected == true) && pgAdmin['enable_psql']; + }, is_conn_allow: function(node) { return (node && node.allowConn == true); }, diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.js b/web/pgadmin/browser/server_groups/servers/static/js/server.js index 45dea090e..ffd715f8e 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.js @@ -101,7 +101,8 @@ define('pgadmin.node.server', [ icon: 'fa fa-unlink', enable : 'is_connected',data: { data_disabled: gettext('Database is already disconnected.'), }, - },{ + }, + { name: 'reload_configuration', node: 'server', module: this, applies: ['tools', 'context'], callback: 'reload_configuration', category: 'reload', priority: 6, label: gettext('Reload Configuration'), @@ -728,6 +729,14 @@ define('pgadmin.node.server', [ return false; }, + /* Open psql tool for server*/ + server_psql_tool: function(args) { + var input = args || {}, + t = pgBrowser.tree, + i = input.item || t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined; + pgBrowser.psql.psql_tool(d, i, true); + } }, model: pgAdmin.Browser.Node.Model.extend({ defaults: { diff --git a/web/pgadmin/browser/static/js/collection.js b/web/pgadmin/browser/static/js/collection.js index 3823ef357..e763aa11f 100644 --- a/web/pgadmin/browser/static/js/collection.js +++ b/web/pgadmin/browser/static/js/collection.js @@ -64,6 +64,14 @@ define([ priority: 997, label: gettext('Search Objects...'), icon: 'fa fa-search', }]); + + // show psql tool same as query tool. + pgAdmin.Browser.add_menus([{ + name: 'show_psql_tool', node: this.type, module: this, + applies: ['context'], callback: 'show_psql_tool', + priority: 998, label: gettext('PSQL Tool (Beta)'), + icon: 'fas fa-terminal', + }]); } } }, @@ -498,6 +506,13 @@ define([ pgAdmin.SearchObjects.show_search_objects('', pgAdmin.Browser.tree.selected()); } }, + show_psql_tool: function(args) { + var input = args || {}, + t = pgBrowser.tree, + i = input.item || t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined; + pgBrowser.psql.psql_tool(d, i, true); + }, }); return pgBrowser.Collection; diff --git a/web/pgadmin/browser/static/js/node.js b/web/pgadmin/browser/static/js/node.js index ca3cb1e38..f7c04d0de 100644 --- a/web/pgadmin/browser/static/js/node.js +++ b/web/pgadmin/browser/static/js/node.js @@ -209,6 +209,16 @@ define('pgadmin.browser.node', [ priority: 997, label: gettext('Search Objects...'), icon: 'fa fa-search', enable: enable, }]); + + if(pgAdmin['enable_psql']) { + // show psql tool same as query tool. + pgAdmin.Browser.add_menus([{ + name: 'show_psql_tool', node: this.type, module: this, + applies: ['context'], callback: 'show_psql_tool', + priority: 998, label: gettext('PSQL Tool (Beta)'), + icon: 'fas fa-terminal', + }]); + } } // This will add options of scripts eg:'CREATE Script' @@ -903,6 +913,15 @@ define('pgadmin.browser.node', [ pgAdmin.DataGrid.show_query_tool('', i); }, + // Callback to render psql tool. + show_psql_tool: function(args) { + var input = args || {}, + t = pgBrowser.tree, + i = input.item || t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined; + pgBrowser.psql.psql_tool(d, i, true); + }, + // Logic to change the server background colour // There is no way of applying CSS to parent element so we have to // do it via JS code only diff --git a/web/pgadmin/browser/static/js/toolbar.js b/web/pgadmin/browser/static/js/toolbar.js index 09baaf228..65e29c857 100644 --- a/web/pgadmin/browser/static/js/toolbar.js +++ b/web/pgadmin/browser/static/js/toolbar.js @@ -56,9 +56,23 @@ let _defaultToolBarButtons = [ toggleClass: '', parentClass: 'pg-toolbar-btn btn-primary-icon', enabled: false, - }, + } ]; +if(pgAdmin['enable_psql']) { + _defaultToolBarButtons.push({ + label: gettext('PSQL Tool'), + ariaLabel: gettext('PSQL Tool'), + btnClass: 'fas fa-terminal', + text: '', + toggled: false, + toggleClass: '', + parentClass: 'pg-toolbar-btn btn-primary-icon pg-toolbar-psql', + enabled: false, + }); +} + + // Place holder for non default tool bar buttons. let _otherToolbarButtons = []; @@ -105,6 +119,13 @@ export function initializeToolbar(panel, wcDocker) { pgAdmin.DataGrid.show_filtered_row({mnuid: 4}, pgAdmin.Browser.tree.selected()); else if ('name' in data && data.name === gettext('Search objects')) pgAdmin.SearchObjects.show_search_objects('', pgAdmin.Browser.tree.selected()); + else if ('name' in data && data.name === gettext('PSQL Tool')){ + var input = {}, + t = pgAdmin.Browser.tree, + i = input.item || t.selected(), + d = i && i.length == 1 ? t.itemData(i) : undefined; + pgAdmin.Browser.psql.psql_tool(d, i, true); + } }); } diff --git a/web/pgadmin/browser/static/scss/_browser.scss b/web/pgadmin/browser/static/scss/_browser.scss index 0d1573848..ae72f0e69 100644 --- a/web/pgadmin/browser/static/scss/_browser.scss +++ b/web/pgadmin/browser/static/scss/_browser.scss @@ -53,3 +53,9 @@ samp, border-width: 1px; font-size: 1.15em; } + +.pg-toolbar-psql { + padding-top: 0em; + font-size: inherit; + align-items: center; +} diff --git a/web/pgadmin/browser/templates/browser/js/utils.js b/web/pgadmin/browser/templates/browser/js/utils.js index 8597df483..b0a317a50 100644 --- a/web/pgadmin/browser/templates/browser/js/utils.js +++ b/web/pgadmin/browser/templates/browser/js/utils.js @@ -52,6 +52,10 @@ define('pgadmin.browser.utils', pgAdmin['user_inactivity_timeout'] = {{ current_app.config.get('USER_INACTIVITY_TIMEOUT') }}; pgAdmin['override_user_inactivity_timeout'] = '{{ current_app.config.get('OVERRIDE_USER_INACTIVITY_TIMEOUT') }}' == 'True'; + /* GET PSQL Tool related config */ + pgAdmin['enable_psql'] = '{{ current_app.config.get('ENABLE_PSQL') }}' == 'True'; + pgAdmin['allow_psql_shell_commands'] = '{{ current_app.config.get('ALLOW_PSQL_SHELL_COMMANDS') }}' == 'True'; + // Define list of nodes on which Query tool option doesn't appears var unsupported_nodes = pgAdmin.unsupported_nodes = [ 'server_group', 'server', 'coll-tablespace', 'tablespace', diff --git a/web/pgadmin/browser/utils.py b/web/pgadmin/browser/utils.py index 312b0ee17..5e22f2d56 100644 --- a/web/pgadmin/browser/utils.py +++ b/web/pgadmin/browser/utils.py @@ -62,7 +62,8 @@ def underscore_unescape(text): ">": '>', """: '"', "`": '`', - "'": "'" + "'": "'", + "'": "'" } # always replace & first diff --git a/web/pgadmin/static/bundle/browser.js b/web/pgadmin/static/bundle/browser.js index 14140a2fd..0b3ad81bc 100644 --- a/web/pgadmin/static/bundle/browser.js +++ b/web/pgadmin/static/bundle/browser.js @@ -11,6 +11,7 @@ define('bundled_browser',[ 'pgadmin.browser', 'sources/browser/index', 'top/tools/erd/static/js/index', + 'top/tools/psql/static/js/index', ], function(pgBrowser) { pgBrowser.init(); }); diff --git a/web/pgadmin/static/css/style.css b/web/pgadmin/static/css/style.css index c2a776c8e..5b4a9f2d1 100644 --- a/web/pgadmin/static/css/style.css +++ b/web/pgadmin/static/css/style.css @@ -21,3 +21,5 @@ @import '../vendor/backgrid/backgrid.css'; @import '../vendor/backgrid/backgrid-select-all.css'; + +@import 'node_modules/xterm/css/xterm.css'; diff --git a/web/pgadmin/static/scss/pgadmin.scss b/web/pgadmin/static/scss/pgadmin.scss index 6c38c6f77..2e5bc6d9f 100644 --- a/web/pgadmin/static/scss/pgadmin.scss +++ b/web/pgadmin/static/scss/pgadmin.scss @@ -10,6 +10,11 @@ $theme-colors: ( --color-fg: #{$color-fg}; --color-bg: #{$color-bg}; --border-color: #{$border-color}; + --psql-background: #{$psql-background}; + --psql-foreground: #{$psql-foreground}; + --psql-cursor: #{$psql-cursor}; + --psql-cursorAccent: #{$psql-cursorAccent}; + --psql-selection: #{$psql-selection}; } @import "bootstrap/scss/bootstrap"; diff --git a/web/pgadmin/static/scss/resources/_default.variables.scss b/web/pgadmin/static/scss/resources/_default.variables.scss index ce33424e4..d9fb6653a 100644 --- a/web/pgadmin/static/scss/resources/_default.variables.scss +++ b/web/pgadmin/static/scss/resources/_default.variables.scss @@ -371,3 +371,10 @@ $erd-link-selected-color: $color-fg !default; $erd-bg-grid: url("data:image/svg+xml, %3Csvg width='100%25' viewBox='0 0 45 45' style='background-color:#{url-friendly-colour($erd-canvas-bg)}' height='100%25' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cpattern id='smallGrid' width='15' height='15' patternUnits='userSpaceOnUse'%3E%3Cpath d='M 15 0 L 0 0 0 15' fill='none' stroke='#{url-friendly-colour($erd-canvas-grid)}' stroke-width='0.5'/%3E%3C/pattern%3E%3Cpattern id='grid' width='45' height='45' patternUnits='userSpaceOnUse'%3E%3Crect width='100' height='100' fill='url(%23smallGrid)'/%3E%3Cpath d='M 100 0 L 0 0 0 100' fill='none' stroke='#{url-friendly-colour($erd-canvas-grid)}' stroke-width='1'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='url(%23grid)' /%3E%3C/svg%3E%0A"); $select2-readonly: $color-gray-lighter !default; + +// psql tool variables +$psql-background: $color-bg !default; +$psql-foreground: $color-fg !default; +$psql-cursor: $color-fg !default; +$psql-cursorAccent: $color-fg !default; +$psql-selection: #326690 !default; diff --git a/web/pgadmin/static/scss/resources/dark/_theme.variables.scss b/web/pgadmin/static/scss/resources/dark/_theme.variables.scss index efa36df8c..2fa907b87 100644 --- a/web/pgadmin/static/scss/resources/dark/_theme.variables.scss +++ b/web/pgadmin/static/scss/resources/dark/_theme.variables.scss @@ -136,3 +136,10 @@ $erd-link-color: $color-fg; $erd-link-selected-color: $color-fg; $select2-readonly: $color-bg; + +// psql tool variables +$psql-background: $color-bg; +$psql-foreground: $color-fg; +$psql-cursor: $color-fg; +$psql-cursorAccent: $color-fg; +$psql-selection: #d6effc; diff --git a/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss b/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss index 682f2622b..b4783bce0 100644 --- a/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss +++ b/web/pgadmin/static/scss/resources/high_contrast/_theme.variables.scss @@ -207,3 +207,10 @@ $quick-search-a-text-color: $black !default; $quick-search-info-icon: #8A8A8A !default; $select2-readonly: $color-gray; + +// psql tool variables +$psql-background: $color-bg; +$psql-foreground: $color-fg; +$psql-cursor: $color-fg; +$psql-cursorAccent: $color-fg; +$psql-selection: $color-primary-light; diff --git a/web/pgadmin/tools/datagrid/static/js/datagrid_panel_title.js b/web/pgadmin/tools/datagrid/static/js/datagrid_panel_title.js index 1348cc450..42efc04da 100644 --- a/web/pgadmin/tools/datagrid/static/js/datagrid_panel_title.js +++ b/web/pgadmin/tools/datagrid/static/js/datagrid_panel_title.js @@ -100,6 +100,10 @@ export function generateTitle(title_placeholder, title_data) { title_placeholder = title_placeholder.replace(new RegExp('%ARGS%'), _.unescape(title_data.args)); title_placeholder = title_placeholder.replace(new RegExp('%SCHEMA%'), _.unescape(title_data.schema)); title_placeholder = title_placeholder.replace(new RegExp('%DATABASE%'), _.unescape(title_data.database)); + } else if(title_data.type == 'psql_tool') { + title_placeholder = title_placeholder.replace(new RegExp('%DATABASE%'), _.unescape(title_data.database)); + title_placeholder = title_placeholder.replace(new RegExp('%USERNAME%'), _.unescape(title_data.username)); + title_placeholder = title_placeholder.replace(new RegExp('%SERVER%'), _.unescape(title_data.server)); } return _.escape(title_placeholder); diff --git a/web/pgadmin/tools/psql/__init__.py b/web/pgadmin/tools/psql/__init__.py new file mode 100644 index 000000000..30b5237c7 --- /dev/null +++ b/web/pgadmin/tools/psql/__init__.py @@ -0,0 +1,686 @@ +#!/usr/bin/env python3 +import fcntl +import os +import pty +import re +import select +import struct +import termios +import config +import eventlet.green.subprocess as subprocess + +from config import PG_DEFAULT_DRIVER +from flask import Response, url_for, request +from flask import render_template, copy_current_request_context, \ + current_app as app +from flask_babelex import gettext +from flask_security import login_required, current_user +from pgadmin.browser.utils import underscore_unescape +from pgadmin.utils import PgAdminModule +from pgadmin.utils.constants import MIMETYPE_APP_JS +from pgadmin.utils.driver import get_driver +from ... import socketio as sio +from pgadmin.utils import get_complete_file_path + + +session_input = dict() +session_input_cursor = dict() +session_last_cmd = dict() +pdata = dict() +cdata = dict() + + +class PSQLModule(PgAdminModule): + """ + class PSQLModule(PgAdminModule) + A module class for PSQL derived from PgAdminModule. + """ + + LABEL = gettext("PSQL") + + def get_own_menuitems(self): + return {} + + def get_own_javascripts(self): + return [{ + 'name': 'pgadmin.psql', + 'path': url_for('psql.index') + "psql", + 'when': None + }] + + def get_panels(self): + return [] + + def get_exposed_url_endpoints(self): + """ + Returns: + list: URL endpoints for PSQL module + """ + return [ + 'psql.panel' + ] + + +blueprint = PSQLModule('psql', __name__, static_url_path='/static') + + +@blueprint.route("/psql.js") +@login_required +def script(): + """render the required javascript""" + return Response( + response=render_template("psql/js/psql.js", _=gettext), + status=200, + mimetype=MIMETYPE_APP_JS + ) + + +@blueprint.route('/panel/', + methods=["POST"], + endpoint="panel") +@login_required +def panel(trans_id): + """ + Return panel template for PSQL tools. + :param trans_id: + """ + params = { + 'trans_id': trans_id, + 'title': request.form['title'] + } + if 'sid_soid_mapping' not in app.config: + app.config['sid_soid_mapping'] = dict() + if request.args: + params.update({k: v for k, v in request.args.items()}) + # Set TERM env for xterm. + os.environ['TERM'] = 'xterm' + + return render_template('editor_template.html', + sid=params['sid'], + db=underscore_unescape(params['db']) if params[ + 'db'] else 'postgres', + server_type=params['server_type'], + is_enable=config.ENABLE_PSQL, + title=underscore_unescape(params['title']), + theme=params['theme'] + ) + + +def set_term_size(fd, row, col, xpix=0, ypix=0): + """ + Set the terminal size as per UI xterm size. + :param fd: + :param row: + :param col: + :param xpix: + :param ypix: + """ + term_size = struct.pack('HHHH', row, col, xpix, ypix) + fcntl.ioctl(fd, termios.TIOCSWINSZ, term_size) + + +@sio.on('connect', namespace='/pty') +def connect(): + """ + Connect to the server through socket. + :return: + :rtype: + """ + if config.ENABLE_PSQL: + sio.emit('connected', {'sid': request.sid}, namespace='/pty', + to=request.sid) + + if request.sid in session_last_cmd: + session_last_cmd[request.sid]['is_new_connection'] = False + else: + session_last_cmd[request.sid] = {'cmd': '', 'arrow_up': False, + 'invalid_cmd': False, + 'is_new_connection': False} + else: + sio.emit('conn_not_allow', {'sid': request.sid}, namespace='/pty', + to=request.sid) + + +def create_pty_terminal(connection_data): + # Create the pty terminal process, parent and fd are file descriptors + # for parent and child. + parent, fd = pty.openpty() + p = None + if parent is not None: + # Child process + p = subprocess.Popen(connection_data, + preexec_fn=os.setsid, + stdin=fd, + stdout=fd, + stderr=fd, + universal_newlines=True + ) + + app.config['sessions'][request.sid] = parent + pdata[request.sid] = p + cdata[request.sid] = fd + else: + app.config['sessions'][request.sid] = parent + cdata[request.sid] = fd + set_term_size(fd, 50, 50) + + return p, parent, fd + + +def read_terminal_data(parent, data_ready, max_read_bytes, sid): + """ + Read the terminal output. + :param parent: + :param data_ready: + :param max_read_bytes: + :param sid: + :return: + """ + if parent in data_ready: + # Read the output from parent fd (terminal). + output = os.read(parent, max_read_bytes) + emit_output = True + + if sid in session_last_cmd and session_last_cmd[sid][ + 'arrow_up'] and not session_last_cmd[request.sid][ + 'arrow_left_right']: + session_last_cmd[sid]['cmd'] = output.decode() + session_input_cursor[request.sid] = len( + session_last_cmd[sid]['cmd']) + session_last_cmd[sid]['arrow_up'] = True + + if sid in session_last_cmd and session_last_cmd[sid]['invalid_cmd']: + # If command is invalid then emit error to user. + emit_output = False + sio.emit( + 'pty-output', + { + 'result': gettext( + "ERROR: Shell commands are disabled " + "in psql for security\r\n"), + 'error': True + }, + namespace='/pty', room=sid) + # If command is valid then emit output to user. + if emit_output: + sio.emit('pty-output', + {'result': output.decode(), + 'error': False}, + namespace='/pty', room=sid) + else: + session_last_cmd[request.sid]['invalid_cmd'] = False + + +@sio.on('start_process', namespace='/pty') +def start_process(data): + """ + Start the pty terminal and execute psql command and emit results to user. + :param data: + :return: + """ + @copy_current_request_context + def read_and_forward_pty_output(sid, data): + max_read_bytes = 1024 * 20 + + p, parent, fd = create_pty_terminal(connection_data) + + while p and p.poll() is None: + if request.sid in app.config['sessions']: + # This code is added to make this unit testable. + if "is_test" not in data: + sio.sleep(0.01) + else: + data['count'] += 1 + if data['count'] == 5: + break + + timeout = 0 + # module provides access to platform-specific I/O + # monitoring functions + (data_ready, _, _) = select.select([parent, fd], [], [], + timeout) + + read_terminal_data(parent, data_ready, max_read_bytes, sid) + + # Check user is authenticated and PSQL is enabled in config. + if current_user.is_authenticated and config.ENABLE_PSQL: + connection_data = [] + try: + db = '' + if data['db']: + db = underscore_unescape(data['db']).replace('\\', "\\\\") + + conn, manager = _get_connection(int(data['sid']), data) + psql_utility = manager.utility('sql') + connection_data = get_connection_str(psql_utility, db, + manager) + except Exception as e: + # If any error raised during the start the PSQL emit error to UI. + # request.sid: This sid is socket id. + sio.emit( + 'conn_error', + { + 'error': 'Error while running psql command: {0}'.format(e), + }, namespace='/pty', room=request.sid) + + try: + if str(data['sid']) not in app.config['sid_soid_mapping']: + # request.sid: refer request.sid as socket id. + app.config['sid_soid_mapping'][str(data['sid'])] = list() + app.config['sid_soid_mapping'][str(data['sid'])].append( + request.sid) + else: + app.config['sid_soid_mapping'][str(data['sid'])].append( + request.sid) + + sio.start_background_task(read_and_forward_pty_output, + request.sid, data) + except Exception as e: + sio.emit( + 'conn_error', + { + 'error': 'Error while running psql command: {0}'.format(e), + }, namespace='/pty', room=request.sid) + else: + # Show error if user is not authenticated. + sio.emit('conn_not_allow', {'sid': request.sid}, namespace='/pty', + to=request.sid) + + +def _get_connection(sid, data): + """ + Get the connection object of ERD. + :param sid: + :param did: + :param trans_id: + :return: + """ + manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid) + try: + conn = manager.connection() + # This is added for unit test only, no use in normal execution. + if 'pwd' in data: + kwargs = {'password': data['pwd'], "user": data['user']} + status, msg = conn.connect(**kwargs) + else: + status, msg = conn.connect() + if not status: + app.logger.error(msg) + sio.emit(sio.emit( + 'conn_error', + { + 'error': 'Error while running psql command: {0}' + ''.format('Server connection not present.'), + }, namespace='/pty', room=request.sid)) + raise RuntimeError('Server is not connected.') + + return conn, manager + except Exception as e: + app.logger.error(e) + raise + + +def get_connection_str(psql_utility, db, manager): + """ + Get connection string(through connection dsn) + :param psql_utility: PostgreSQL binary path. + :param db: database name to connect specific db. + :return: connection attribute list for PSQL connection. + """ + conn_attr = get_conn_str(manager, db) + conn_attr_list = list() + conn_attr_list.append(psql_utility) + conn_attr_list.append(conn_attr) + return conn_attr_list + + +def get_conn_str(manager, db): + """ + Get connection attributes for psql connection. + :param manager: + :param db: + :return: + """ + manager.export_password_env('PGPASSWORD') + conn_attr =\ + "host={0} port={1} dbname={2} user={3} sslmode={4} " \ + "sslcompression={5} " \ + "".format( + manager.local_bind_host if manager.use_ssh_tunnel else + manager.host, + manager.local_bind_port if manager.use_ssh_tunnel else + manager.port, + db if db != '' else 'postgres', + manager.user if manager.user else 'postgres', + manager.ssl_mode, + True if manager.sslcompression else False, + ) + + if manager.hostaddr: + conn_attr = " {0} hostaddr={1}".format(conn_attr, manager.hostaddr) + + if manager.passfile: + conn_attr = " {0} passfile={1}".format(conn_attr, + get_complete_file_path( + manager.passfile)) + + if get_complete_file_path(manager.sslcert): + conn_attr = " {0} sslcert={1}".format( + conn_attr, get_complete_file_path(manager.sslcert)) + + if get_complete_file_path(manager.sslkey): + conn_attr = " {0} sslkey={1}".format( + conn_attr, get_complete_file_path(manager.sslkey)) + + if get_complete_file_path(manager.sslrootcert): + conn_attr = " {0} sslrootcert={1}".format( + conn_attr, get_complete_file_path(manager.sslrootcert)) + + if get_complete_file_path(manager.sslcrl): + conn_attr = " {0} sslcrl={1}".format( + conn_attr, get_complete_file_path(manager.sslcrl)) + + if manager.service: + conn_attr = " {0} service={1}".format( + conn_attr, get_complete_file_path(manager.service)) + + return conn_attr + + +def check_last_exe_cmd(data): + """ + Check the is user try to execute last executed command. + :param data: + :return: + """ + # If user get previous executed command from history then set + # current command as previous executed command. + if session_last_cmd[request.sid]['cmd'] and session_last_cmd[request.sid][ + 'arrow_up']: + user_input = str( + session_last_cmd[request.sid]['cmd']).strip() + session_last_cmd[request.sid]['arrow_up'] = False + session_last_cmd[request.sid]['cmd'] = '' + else: + if request.sid not in session_input: + session_input[request.sid] = data['input'] + user_input = str(session_input[request.sid]).strip() + else: + user_input = str(session_input[request.sid]).strip() + + return user_input + + +def invalid_cmd(): + """ + Invalid command + :return: + :rtype: + """ + session_last_cmd[request.sid]['invalid_cmd'] = True + + for i in range(len(session_input[request.sid])): + os.write(app.config['sessions'][request.sid], + '\b \b'.encode()) + + os.write(app.config['sessions'][request.sid], + '\n'.encode()) + session_input[request.sid] = '' + + +def check_valid_cmd(user_input): + """ + Check if user entered a valid cmd and \\! command is preset as a string + only in current executing command. if \\! is present as command don't + allow the execution of command. + :param user_input: + :return: + """ + stop_execution = True + # Check \! is passed as string or not. + double_quote_strs = re.findall('"([^"]*)"', user_input) + if not double_quote_strs: + double_quote_strs = re.findall("'([^']*)'", user_input) + + if double_quote_strs: + for sub_str in double_quote_strs: + if re.search("\\\!", sub_str): + stop_execution = False + # break + + if stop_execution: + session_last_cmd[request.sid]['invalid_cmd'] = True + # Remove already added command from terminal. + for i in range(len(user_input)): + os.write(app.config['sessions'][request.sid], + '\b \b'.encode()) + # Add Enter event to execute the command. + os.write(app.config['sessions'][request.sid], + '\n'.encode()) + else: + session_last_cmd[request.sid]['invalid_cmd'] = False + os.write(app.config['sessions'][request.sid], + '\n'.encode()) + + +def enter_key_press(data): + """ + Handel the Enter key press event. + :param data: + """ + user_input = check_last_exe_cmd(data) + session_input_cursor[request.sid] = 0 + + # If ALLOW_PSQL_SHELL_COMMANDS is False then user can't execute + # \! meta command to run shell commands through PSQL terminal. + # Check before executing the user entered command does not + # contains \! in input. + is_new_connection = session_last_cmd[request.sid][ + 'is_new_connection'] + if user_input.startswith('\\!') and re.match("^\\\!$", user_input) and len( + user_input) == 2 and not config.ALLOW_PSQL_SHELL_COMMANDS \ + and not is_new_connection: + invalid_cmd() + elif re.search("\\\!", user_input) and \ + not config.ALLOW_PSQL_SHELL_COMMANDS and\ + not session_last_cmd[request.sid]['is_new_connection']: + check_valid_cmd(user_input) + elif user_input == '\q' or user_input == 'q\\q': + # If user enter \q to terminate the PSQL, emit the msg to + # notify user connection is terminated. + sio.emit('pty-output', + { + 'result': gettext( + 'Connection terminated, To create new ' + 'connection please open another psql' + ' tool.'), + 'error': True}, + namespace='/pty', room=request.sid) + os.write(app.config['sessions'][request.sid], + '\n'.encode()) + + else: + os.write(app.config['sessions'][request.sid], + data['input'].encode()) + session_input[request.sid] = '' + session_last_cmd[request.sid]['is_new_connection'] = False + + +def backspace_key_press(): + """ + Handel the backspace key press event. + :return: + :rtype: + """ + session_last_cmd[request.sid]['arrow_left_right'] = True + + if session_last_cmd[request.sid]['cmd']: + session_input[request.sid] = \ + session_last_cmd[request.sid]['cmd'] + + user_input = list(session_input[request.sid]) + + if session_input_cursor[request.sid] == 1: + index = 0 + session_input_cursor[request.sid] -= 1 + else: + if session_input_cursor[request.sid] > 0: + index = (session_input_cursor[request.sid]) - 1 + session_input_cursor[request.sid] -= 1 + else: + index = session_input_cursor[request.sid] + session_input_cursor[request.sid] = 0 + + if len(user_input): + del user_input[index] + session_input[request.sid] = "".join(user_input) + + if len(session_input[request.sid]) == 0: + session_input_cursor[request.sid] = 0 + session_last_cmd[request.sid]['cmd'] = '' + + +def set_user_input(data): + """ + Check and set current input as user input in session_input. + :param data: + """ + if session_last_cmd[request.sid]['cmd'] and \ + session_input[request.sid] == '': + session_input[request.sid] = \ + session_last_cmd[request.sid]['cmd'] + session_input_cursor[request.sid] = len( + session_input[request.sid]) + else: + session_last_cmd[request.sid]['arrow_up'] = False + session_last_cmd[request.sid]['cmd'] = '' + user_input = list(session_input[request.sid]) + user_input.insert(session_input_cursor[request.sid], + data['input']) + session_input[request.sid] = ''.join(user_input) + session_input_cursor[request.sid] += 1 + session_last_cmd[request.sid]['arrow_left_right'] = False + + +def other_key_press(data): + """ + Handel the other key press from psql tool. + :param data: + :type data: + :return: + :rtype: + """ + if data['key_name'] == 'ArrowLeft': + session_last_cmd[request.sid]['arrow_left_right'] = True + if session_input_cursor[request.sid] > 0: + session_input_cursor[request.sid] -= 1 + + elif data['key_name'] == 'ArrowRight': + session_last_cmd[request.sid]['arrow_left_right'] = True + if session_input_cursor[request.sid] < len( + session_input[request.sid]): + session_input_cursor[request.sid] += 1 + + elif data['key_name'] == 'ArrowUp': + session_last_cmd[request.sid]['arrow_up'] = True + session_last_cmd[request.sid]['arrow_left_right'] = False + session_input[request.sid] = session_last_cmd[request.sid][ + 'cmd'] + session_input_cursor[request.sid] = len( + session_last_cmd[request.sid]['cmd']) + + elif request.sid in session_input and \ + data['key_name'] == 'Backspace' and \ + (len(session_input[request.sid]) or + len(session_last_cmd[request.sid])): + backspace_key_press() + elif request.sid in session_input: + set_user_input(data) + else: + session_input_cursor[request.sid] = 0 + session_input[request.sid] = data['input'] + session_input_cursor[request.sid] += 1 + + # Write user input to terminal parent fd. + os.write(app.config['sessions'][request.sid], + data['input'].encode()) + + +@sio.on('socket_input', namespace='/pty') +def socket_input(data): + """ + This get the user input through socket. + :param data: User input from socket. + """ + try: + # request.sid: refer request.sid as socket id. + # Check PSQL enabled setting from config. + enable_psql = True if config.ENABLE_PSQL else False + + if request.sid in app.config['sessions']: + if data['key_name'] == 'Enter' and enable_psql: + enter_key_press(data) + else: + other_key_press(data) + except Exception as e: + # Delete socket id from sessions. + # request.sid: refer request.sid as socket id. + sio.emit('pty-output', + { + 'result': gettext('Invalid session.\r\n'), + 'error': True + }, + namespace='/pty', room=request.sid) + del app.config['sessions'][request.sid] + + +@sio.on('resize', namespace='/pty') +def resize(data): + """ + Resize the pty terminal as per the UI terminal. + :param data: UI terminal rows and cols data + """ + # request.sid: refer request.sid as socket id. + if request.sid in app.config['sessions']: + set_term_size(app.config['sessions'][request.sid], data['rows'], + data['cols']) + + +@sio.on('disconnect', namespace='/pty') +def disconnect(): + """ + Disconnect the socket and terminate the process + """ + # request.sid: refer request.sid as socket id. + if request.sid in pdata: + # On disconnect socket manually exit the psql terminal and close the + # parend and child fd then kill the subprocess. + disconnect_socket() + + +@sio.on('server-disconnect', namespace='/pty') +def server_disconnect(data): + """ + Disconnect the socket and terminate the process after user disconnect + the server. we can't use disconnect event name as it is reserved for socket + internal use. + """ + # request.sid: refer request.sid as socket id. + if request.sid in pdata and request.sid in app.config['sid_soid_mapping'][ + data['sid']]: + # On disconnect socket manually exit the psql terminal and close the + # parend and child fd then kill the subprocess. + app.config['sid_soid_mapping'][data['sid']] = [soid for soid in + app.config[ + 'sid_soid_mapping'][ + data['sid']] if + soid != request.sid] + disconnect_socket() + + +def disconnect_socket(): + os.write(app.config['sessions'][request.sid], '\q\n'.encode()) + sio.sleep(1) + os.close(app.config['sessions'][request.sid]) + os.close(cdata[request.sid]) + del app.config['sessions'][request.sid] diff --git a/web/pgadmin/tools/psql/static/js/index.js b/web/pgadmin/tools/psql/static/js/index.js new file mode 100644 index 000000000..4d88ba04f --- /dev/null +++ b/web/pgadmin/tools/psql/static/js/index.js @@ -0,0 +1,23 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import gettext from 'sources/gettext'; +import url_for from 'sources/url_for'; +import $ from 'jquery'; +import _ from 'underscore'; +import pgAdmin from 'sources/pgadmin'; +import pgBrowser from 'top/browser/static/js/browser'; +import * as csrfToken from 'sources/csrf'; +import {initialize} from './psql_module'; + +let pgBrowserOut = initialize(gettext, url_for, $, _, pgAdmin, csrfToken, pgBrowser); + +module.exports = { + pgBrowser: pgBrowserOut, +}; diff --git a/web/pgadmin/tools/psql/static/js/psql_module.js b/web/pgadmin/tools/psql/static/js/psql_module.js new file mode 100644 index 000000000..9ff02aa54 --- /dev/null +++ b/web/pgadmin/tools/psql/static/js/psql_module.js @@ -0,0 +1,429 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2021, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// +import { Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import { WebLinksAddon } from 'xterm-addon-web-links'; +import { SearchAddon } from 'xterm-addon-search'; +import { io } from 'socketio'; +import Alertify from 'pgadmin.alertifyjs'; +import {enable} from 'pgadmin.browser.toolbar'; +import clipboard from 'sources/selection/clipboard'; +import 'wcdocker'; +import {getRandomInt} from 'sources/utils'; + +import {getTreeNodeHierarchyFromIdentifier} from 'sources/tree/pgadmin_tree_node'; +import {generateTitle} from 'tools/datagrid/static/js/datagrid_panel_title'; + + +export function setPanelTitle(psqlToolPanel, panelTitle) { + psqlToolPanel.title(''+panelTitle+''); +} + +var wcDocker = window.wcDocker; + +export function initialize(gettext, url_for, $, _, pgAdmin, csrfToken, Browser) { + var pgBrowser = Browser; + var terminal = Terminal; + var parentData = null; + /* Return back, this has been called more than once */ + if (pgBrowser.psql) + return pgBrowser.psql; + + + // Create an Object Restore of pgBrowser class + pgBrowser.psql = { + init: function() { + this.initialized = true; + csrfToken.setPGCSRFToken(pgAdmin.csrf_token_header, pgAdmin.csrf_token); + // Define the nodes on which the menus to be appear + var menus = [{ + name: 'psql', + module: this, + applies: ['tools'], + callback: 'psql_tool', + priority: 1, + label: gettext('PSQL Tool (Beta)'), + enable: this.psqlToolEnabled, + }]; + + this.enable_psql_tool = pgAdmin['enable_psql']; + if(pgAdmin['enable_psql']) { + pgBrowser.add_menus(menus); + } + + // Creating a new pgBrowser frame to show the data. + var psqlFrameType = new pgBrowser.Frame({ + name: 'frm_psqltool', + showTitle: true, + isCloseable: true, + isPrivate: true, + url: 'about:blank', + }); + + var self = this; + /* Cache may take time to load for the first time + * Keep trying till available + */ + let cacheIntervalId = setInterval(function() { + if(pgBrowser.preference_version() > 0) { + self.preferences = pgBrowser.get_preferences_for_module('psql'); + clearInterval(cacheIntervalId); + } + },0); + + pgBrowser.onPreferencesChange('psql', function() { + self.preferences = pgBrowser.get_preferences_for_module('psql'); + }); + + // Load the newly created frame + psqlFrameType.load(pgBrowser.docker); + return this; + }, + /* Enable/disable PSQL tool menu in tools based + * on node selected. if selected node is present + * in unsupported_nodes, menu will be disabled + * otherwise enabled. + */ + psqlToolEnabled: function(obj) { + + var isEnabled = (() => { + if (!_.isUndefined(obj) && !_.isNull(obj) && pgAdmin['enable_psql']) { + if (_.indexOf(pgAdmin.unsupported_nodes, obj._type) == -1) { + if (obj._type == 'database' && obj.allowConn) { + return true; + } else if (obj._type != 'database') { + return true; + } else { + return false; + } + } else { + return false; + } + } else { + return false; + } + })(); + + enable(gettext('PSQL Tool'), isEnabled); + return isEnabled; + }, + retrieveAncestorOfTypeServer: function(item) { + let serverInformation = null; + // let aciTreeItem = item || pgBrowser.treeMenu.selected(); + let treeNode = pgBrowser.treeMenu.findNodeByDomElement(item); + + if (treeNode) { + let nodeData; + let databaseNode = treeNode.ancestorNode( + (node) => { + nodeData = node.getData(); + return (nodeData._type === 'database'); + } + ); + let isServerNode = (node) => { + nodeData = node.getData(); + return nodeData._type === 'server'; + }; + + if (databaseNode !== null) { + if (nodeData._label.indexOf('=') >= 0) { + this.alertify.alert( + gettext(this.errorAlertTitle), + gettext( + 'Databases with = symbols in the name cannot be backed up or restored using this utility.' + ) + ); + } else { + if (databaseNode.anyParent(isServerNode)) + serverInformation = nodeData; + } + } else { + if (treeNode.anyFamilyMember(isServerNode)) + serverInformation = nodeData; + } + } + + if (serverInformation === null) { + this.alertify.alert( + gettext(this.errorAlertTitle), + gettext('Please select server or child node from the browser tree.') + ); + } + return serverInformation; + }, + psql_tool: function(data, aciTreeIdentifier, gen=false) { + const module = 'paths'; + let preference_name = 'pg_bin_dir'; + let msg = gettext('Please configure the PostgreSQL Binary Path in the Preferences dialog.'); + const serverInformation = this.retrieveAncestorOfTypeServer(aciTreeIdentifier); + + if ((serverInformation.type && serverInformation.type === 'ppas') || + serverInformation.server_type === 'ppas') { + preference_name = 'ppas_bin_dir'; + msg = gettext('Please configure the EDB Advanced Server Binary Path in the Preferences dialog.'); + } + const preference = pgBrowser.get_preference(module, preference_name); + if (preference) { + if (!preference.value) { + Alertify.alert(gettext('Configuration required'), msg); + return false; + } + } else { + Alertify.alert( + gettext(this.errorAlertTitle), + gettext('Failed to load preference %s of module %s', preference_name, module) + ); + return false; + } + const node = pgBrowser.treeMenu.findNodeByDomElement(aciTreeIdentifier); + if (node === undefined || !node.getData()) { + Alertify.alert( + gettext('PSQL Error'), + gettext('No object selected.') + ); + return; + } + + parentData = getTreeNodeHierarchyFromIdentifier.call( + pgBrowser, + aciTreeIdentifier + ); + + if(_.isUndefined(parentData.server)) { + Alertify.alert( + gettext('PSQL Error'), + gettext('Please select a server/database object.') + ); + return; + } + + const transId = getRandomInt(1, 9999999); + + var panelTitle = ''; + // Set psql tab title as per prefrences setting. + var title_data = { + 'database': parentData.database ? parentData.database.label : 'postgres' , + 'username': parentData.server.user_name, + 'server': parentData.server.label, + 'type': 'psql_tool', + }; + var tab_title_placeholder = pgBrowser.get_preferences_for_module('browser').psql_tab_title_placeholder; + panelTitle = generateTitle(tab_title_placeholder, title_data); + + const [panelUrl, panelCloseUrl] = this.getPanelUrls(transId, panelTitle, parentData, gen); + + let psqlToolForm = ` +
+ + +
+ + `; + var open_new_tab = pgBrowser.get_preferences_for_module('browser').new_browser_tab_open; + if (open_new_tab && open_new_tab.includes('psql_tool')) { + var newWin = window.open('', '_blank'); + newWin.document.write(psqlToolForm); + newWin.document.title = panelTitle; + } else { + /* On successfully initialization find the properties panel, + * create new panel and add it to the dashboard panel. + */ + var propertiesPanel = pgBrowser.docker.findPanels('properties'); + var psqlToolPanel = pgBrowser.docker.addPanel('frm_psqltool', wcDocker.DOCK.STACKED, propertiesPanel[0]); + + // Set panel title and icon + setPanelTitle(psqlToolPanel, panelTitle); + psqlToolPanel.icon('fas fa-terminal psql-tab-style'); + psqlToolPanel.focus(); + + var openPSQLToolURL = function(j) { + // add spinner element + let $spinner_el = + $(`
+
+
+
+
+
+
`).appendTo($(j).data('embeddedFrame').$container); + + let init_poller_id = setInterval(function() { + var frameInitialized = $(j).data('frameInitialized'); + if (frameInitialized) { + clearInterval(init_poller_id); + var frame = $(j).data('embeddedFrame'); + if (frame) { + frame.onLoaded(()=>{ + $spinner_el.remove(); + }); + frame.openHTML(psqlToolForm); + } + } + }, 100); + }; + + openPSQLToolURL(psqlToolPanel); + + } + + }, + getPanelUrls: function(transId, panelTitle, parentData) { + let openUrl = url_for('psql.panel', { + trans_id: transId, + }); + const misc_preferences = pgBrowser.get_preferences_for_module('misc'); + var theme = misc_preferences.theme; + + openUrl += `?sgid=${parentData.server_group._id}` + +`&sid=${parentData.server._id}` + +`&server_type=${parentData.server.server_type}` + + `&theme=${theme}`; + + if(parentData.database && parentData.database._id) { + let db_label = parentData.database._label.replace('\\', '\\\\'); + openUrl += `&db=${db_label}`; + } else { + openUrl += `&db=${''}`; + } + + let closeUrl = url_for('psql.close', { + trans_id: transId, + }); + return [openUrl, closeUrl]; + }, + psql_terminal: function() { + // theme colors + var term = new terminal({ + cursorBlink: true, + macOptionIsMeta: true, + scrollback: 5000, + }); + + return term; + }, + psql_Addon: function(term) { + const fitAddon = this.psql_fit_screen(); + term.loadAddon(fitAddon); + + const webLinksAddon = this.psql_web_link(); + term.loadAddon(webLinksAddon); + + const searchAddon = this.psql_search(); + term.loadAddon(searchAddon); + + fitAddon.fit(); + term.resize(15, 50); + fitAddon.fit(); + return fitAddon; + }, + psql_fit_screen: function() { + return new FitAddon(); + }, + psql_web_link: function() { + return new WebLinksAddon(); + }, + psql_search: function() { + return new SearchAddon(); + }, + psql_socket: function() { + return io('/pty', {pingTimeout: 120000, pingInterval: 25000}); + }, + set_theme: function(term) { + var theme = { + background: getComputedStyle(document.documentElement).getPropertyValue('--psql-background'), + foreground: getComputedStyle(document.documentElement).getPropertyValue('--psql-foreground'), + cursor: getComputedStyle(document.documentElement).getPropertyValue('--psql-cursor'), + cursorAccent: getComputedStyle(document.documentElement).getPropertyValue('--psql-cursorAccent'), + selection: getComputedStyle(document.documentElement).getPropertyValue('--psql-selection'), + }; + term.setOption('theme', theme); + }, + psql_socket_io: function(socket, is_enable, sid, db, server_type, fitAddon, term) { + // Listen all the socket events emit from server. + socket.on('pty-output', function(data){ + if(data.error) { + term.write('\r\n'); + } + term.write(data.result); + if(data.error) { + term.write('\r\n'); + } + }); + // Connect socket + socket.on('connect', () => { + if(is_enable == 'True'){ + socket.emit('start_process', {'sid': sid, 'db': db, 'stype': server_type }); + } + fitAddon.fit(); + socket.emit('resize', {'cols': term.cols, 'rows': term.rows}); + }); + + socket.on('conn_error', (response) => { + term.write(response.error); + fitAddon.fit(); + socket.emit('resize', {'cols': term.cols, 'rows': term.rows}); + }); + + socket.on('conn_not_allow', () => { + term.write('PSQL connection not allowed'); + fitAddon.fit(); + socket.emit('resize', {'cols': term.cols, 'rows': term.rows}); + }); + + socket.on('disconnect-psql', () => { + socket.emit('server-disconnect', {'sid': sid}); + term.write('\r\nServer disconnected, Connection terminated, To create new connection please open another psql tool.'); + }); + }, + psql_terminal_io: function(term, socket) { + // Listen key press event from terminal and emit socket event. + let selected_text = ''; + term.attachCustomKeyEventHandler(e => { + e.stopPropagation(); + if(e.type=='keydown' && e.metaKey &&(e.key == 'v' || e.key == 'V')) { + if(selected_text != '') { + if (selected_text.length > 0) { + socket.emit('socket_input', {'input': selected_text, 'key_name': e.code}); + selected_text = ''; + } + } else { + navigator.clipboard.readText().then( clipText => { + selected_text = clipText; + if (selected_text.length > 0) { + socket.emit('socket_input', {'input': selected_text, 'key_name': e.code}); + selected_text = ''; + } + }); + } + }else if(e.type=='keydown' && e.metaKey && (e.key == 'c' || e.key == 'C')) { + if (term.hasSelection()) { + selected_text = term.getSelection(); + } else { + selected_text = clipboard.readText(); + } + } + return true; + }); + + term.onKey(function (ev) { + if (pgAdmin['allow_psql_shell_commands']) { + socket.emit('socket_input', {'input': ev.key, 'key_name': ev.domEvent.code}); + } else { + socket.emit('socket_input', {'input': ev.key, 'key_name': ev.domEvent.code}); + } + }); + } + }; + + return pgBrowser.psql; +} + diff --git a/web/pgadmin/tools/psql/templates/editor_template.html b/web/pgadmin/tools/psql/templates/editor_template.html new file mode 100644 index 000000000..43b346b2f --- /dev/null +++ b/web/pgadmin/tools/psql/templates/editor_template.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}{{title}}{% endblock %} + +{% block css_link %} + +{% endblock %} +{% block body %} + +
+{% endblock %} + + +{% block init_script %} +require( + ['sources/generated/psql_tool'], + function(pgBrowser) { + const term = self.pgAdmin.Browser.psql.psql_terminal(); + + const fitAddon = self.pgAdmin.Browser.psql.psql_Addon(term); + + self.pgAdmin.Browser.psql.set_theme(term); + + term.open(document.getElementById('psql-terminal')); + + const socket = self.pgAdmin.Browser.psql.psql_socket(); + self.pgAdmin.Browser.psql.psql_socket_io(socket, '{{is_enable}}', '{{sid}}', '{{db}}', '{{server_type}}', fitAddon, term); + self.pgAdmin.Browser.psql.psql_terminal_io(term, socket); + + + function fitToscreen(){ + fitAddon.fit() + socket.emit("resize", {"cols": term.cols, "rows": term.rows}) + } + + function debounce(func, wait_ms) { + let timeout + return function(...args) { + const context = this + clearTimeout(timeout) + timeout = setTimeout(() => func.apply(context, args), wait_ms) + } + } + + const wait_ms = 50;; + window.onresize = debounce(fitToscreen, wait_ms) + }); +{% endblock %} + + diff --git a/web/pgadmin/tools/psql/tests/__init__.py b/web/pgadmin/tools/psql/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/web/pgadmin/tools/psql/tests/psql_test_data.json b/web/pgadmin/tools/psql/tests/psql_test_data.json new file mode 100644 index 000000000..4148c75b6 --- /dev/null +++ b/web/pgadmin/tools/psql/tests/psql_test_data.json @@ -0,0 +1,184 @@ +{ + "psql_user_input": [ + { + "name": "Enter Select 1;", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Enter Backspace", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_backspace": true, + "mock_data": { + + }, + "expected_data": { + } + },{ + "name": "Enter Backspace", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_backspace": true, + "move_cursor_up": true, + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Enter ArrowUp", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_arrowUp": true, + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Enter ArrowUp", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_arrowUp": true, + "is_history": true, + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Enter ArrowLeft", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_arrowLeft": true, + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Enter ArrowRight", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_arrowRight": true, + "mock_data": { + + }, + "expected_data": { + } + },{ + "name": "Read previous executed command", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select 1;", + "is_arrowRight": true, + "move_cursor_right": true, + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Meta command \\! not allowed", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "\\!", + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Meta command \\! with other cmd not allowed", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "\\! ls", + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Valid commands", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "select \"\\!\"", + "mock_data": { + + }, + "expected_data": { + } + },{ + "name": "First command as enter", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "", + "mock_data": { + + }, + "expected_data": { + } + }, + { + "name": "Exist psql terminal by using \\q", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "\\q", + "mock_data": { + + }, + "expected_data": { + } + } + ], + "resize_terminal": [ + { + "name": "Resize psql terminal as per UI.", + "is_positive_test": true, + "mocking_required": false, + "input_data": { + "cols": 141, + "rows": 39 + }, + "mock_data": { + + }, + "expected_data": { + } + } + ], + "backend_task": [ + { + "name": "Backend Task", + "is_positive_test": true, + "mocking_required": false, + "input_cmd": "Select 1;", + "is_backend_task": true, + "mock_data": { + "is_test": true + }, + "expected_data": { + } + } + ] +} diff --git a/web/pgadmin/tools/psql/tests/test_backend_task.py b/web/pgadmin/tools/psql/tests/test_backend_task.py new file mode 100644 index 000000000..a4f14ee4a --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_backend_task.py @@ -0,0 +1,87 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgadmin.utils import server_utils as server_utils +from pgAdmin4 import app +from . import utils as psql_utils +from .... import socketio + + +class PSQLBackend(BaseTestGenerator): + scenarios = utils.generate_scenarios('backend_task', + psql_utils.test_cases) + + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + database_info = parent_node_dict["database"][-1] + self.did = database_info["db_id"] + self.sid = parent_node_dict["server"][-1]["server_id"] + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + self.server_con = server_utils.connect_server(self, self.sid) + + def runTest(self): + # Fetch flask client to access current user and other cookies. + flask_client = app.test_client() + flask_client.get('/') + self.test_client = socketio.test_client(app, namespace='/pty', + flask_test_client=flask_client) + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + + data = { + 'sid': self.sid, + 'db': 'postgres', + 'pwd': self.server['db_password'], + 'user': self.server['username'], + 'is_test': True, + 'count': 0 + } + + self.test_client.emit('start_process', data, namespace='/pty') + self.test_client.get_received('/pty') + + for p in self.server['db_password']: + input_data = { + 'input': p, + 'key_name': 'Key{0}'.format(p) + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + + input_data = { + 'input': '\\n', + 'key_name': 'Enter' + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + + for ip in self.input_cmd: + input_data = { + 'input': ip, + 'key_name': 'Key{0}'.format(ip) + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + + input_data = { + 'input': '\\n', + 'key_name': 'Enter' + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + self.test_client.disconnect(namespace='/pty') + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_panel.py b/web/pgadmin/tools/psql/tests/test_panel.py new file mode 100644 index 000000000..6f93c8c27 --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_panel.py @@ -0,0 +1,36 @@ +import uuid +import random +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data + + +class PSQLPanel(BaseTestGenerator): + + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + self.sid = parent_node_dict["server"][-1]["server_id"] + self.did = utils.create_database(self.server, self.db_name) + self.sgid = config_data["server_group"] + self.theme = 'standard' + + def runTest(self): + trans_id = random.randint(1, 9999999) + url = '/psql/panel/{trans_id}?sgid={sgid}&sid={sid}&server_type=pg' \ + '&db={db_name}&theme={theme}'.\ + format(trans_id=trans_id, sgid=self.sgid, sid=self.sid, + db_name=self.db_name, theme=self.theme) + + response = self.tester.post( + url, data={"title": "panel_title"}, + content_type="application/x-www-form-urlencoded") + self.assertEqual(response.status_code, 200) + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_psql_disabled.py b/web/pgadmin/tools/psql/tests/test_psql_disabled.py new file mode 100644 index 000000000..7dc524982 --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_psql_disabled.py @@ -0,0 +1,34 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgAdmin4 import app +from .... import socketio + + +class PSQLSocketDisabled(BaseTestGenerator): + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + self.sid = parent_node_dict["server"][-1]["server_id"] + self.did = utils.create_database(self.server, self.db_name) + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = False + + def runTest(self): + self.test_client = socketio.test_client(app, namespace='/pty') + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'conn_not_allow' + self.test_client.disconnect(namespace='/pty') + self.assertFalse(self.test_client.is_connected('/pty')) + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_psql_input.py b/web/pgadmin/tools/psql/tests/test_psql_input.py new file mode 100644 index 000000000..6f6eabe7c --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_psql_input.py @@ -0,0 +1,148 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgadmin.utils import server_utils as server_utils +from pgAdmin4 import app +from . import utils as psql_utils +from .... import socketio + + +class PSQLInput(BaseTestGenerator): + scenarios = utils.generate_scenarios('psql_user_input', + psql_utils.test_cases) + + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + database_info = parent_node_dict["database"][-1] + self.did = database_info["db_id"] + self.sid = parent_node_dict["server"][-1]["server_id"] + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + self.server_con = server_utils.connect_server(self, self.sid) + + def runTest(self): + # Fetch flask client to access current user and other cookies. + flask_client = app.test_client() + flask_client.get('/') + self.test_client = socketio.test_client(app, namespace='/pty', + flask_test_client=flask_client) + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + + data = { + 'sid': self.sid, + 'db': 'postgres', + 'pwd': self.server['db_password'], + 'user': self.server['username'] + } + + self.test_client.emit('start_process', data, namespace='/pty') + self.test_client.get_received('/pty') + + input_data = { + 'input': '\\n', + 'key_name': 'Enter' + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + + for ip in self.input_cmd: + input_data = { + 'input': ip, + 'key_name': 'Key{0}'.format(ip) + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + + if hasattr(self, 'is_backspace') and self.is_backspace: + if hasattr(self, 'move_cursor_up') and self.move_cursor_up: + input_data = { + 'input': '', + 'key_name': 'ArrowUp' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + + for ip in self.input_cmd: + input_data = { + 'input': ip, + 'key_name': 'Backspace' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + self.test_client.get_received('/pty') + + if hasattr(self, 'is_arrowUp') and self.is_arrowUp: + if hasattr(self, 'is_history') and self.is_history: + for ip in self.input_cmd: + input_data = { + 'input': ip, + 'key_name': 'Key{0}'.format(ip) + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + self.test_client.get_received('/pty') + + input_data = { + 'input': '', + 'key_name': 'ArrowUp' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + self.test_client.get_received('/pty') + + if hasattr(self, 'is_arrowLeft') and self.is_arrowLeft: + for ip in self.input_cmd: + input_data = { + 'input': ip, + 'key_name': 'ArrowLeft' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + self.test_client.get_received('/pty') + + if hasattr(self, 'is_arrowRight') and self.is_arrowRight: + for ip in self.input_cmd: + input_data = { + 'input': ip, + 'key_name': 'ArrowRight' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + self.test_client.get_received('/pty') + + if hasattr(self, 'move_cursor_right') and self.is_arrowRight: + for i in range(2): + input_data = { + 'input': '', + 'key_name': 'ArrowLeft' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + input_data = { + 'input': '', + 'key_name': 'ArrowRight' + } + self.test_client.emit('socket_input', input_data, + namespace='/pty') + + input_data = { + 'input': '\\n', + 'key_name': 'Enter' + } + self.test_client.emit('socket_input', input_data, namespace='/pty') + self.test_client.get_received('/pty') + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_resize_terminal.py b/web/pgadmin/tools/psql/tests/test_resize_terminal.py new file mode 100644 index 000000000..d305c2d0c --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_resize_terminal.py @@ -0,0 +1,58 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgadmin.utils import server_utils as server_utils +from pgAdmin4 import app +from . import utils as psql_utils +from .... import socketio + + +class PSQLResizeTerminal(BaseTestGenerator): + scenarios = utils.generate_scenarios('resize_terminal', + psql_utils.test_cases) + + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + database_info = parent_node_dict["database"][-1] + self.did = database_info["db_id"] + self.sid = parent_node_dict["server"][-1]["server_id"] + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + self.server_con = server_utils.connect_server(self, self.sid) + + def runTest(self): + # Fetch flask client to access current user and other cookies. + flask_client = app.test_client() + flask_client.get('/') + self.test_client = socketio.test_client(app, namespace='/pty', + flask_test_client=flask_client) + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + + data = { + 'sid': self.sid, + 'db': 'postgres', + 'pwd': self.server['db_password'], + 'user': self.server['username'] + } + + self.test_client.emit('start_process', data, namespace='/pty') + self.test_client.get_received('/pty') + + self.test_client.emit('resize', self.input_data, namespace='/pty') + + self.test_client.disconnect(namespace='/pty') + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_socket_connect.py b/web/pgadmin/tools/psql/tests/test_socket_connect.py new file mode 100644 index 000000000..3791525af --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_socket_connect.py @@ -0,0 +1,35 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgAdmin4 import app +from .... import socketio + + +class PSQLSocketConnect(BaseTestGenerator): + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + self.sid = parent_node_dict["server"][-1]["server_id"] + self.did = utils.create_database(self.server, self.db_name) + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + + def runTest(self): + self.test_client = socketio.test_client(app, namespace='/pty') + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + self.test_client.disconnect(namespace='/pty') + self.assertFalse(self.test_client.is_connected('/pty')) + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_socket_disconnect.py b/web/pgadmin/tools/psql/tests/test_socket_disconnect.py new file mode 100644 index 000000000..0619eb30e --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_socket_disconnect.py @@ -0,0 +1,52 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgAdmin4 import app +from .... import socketio + + +class PSQLSocketDisconnect(BaseTestGenerator): + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + self.sid = parent_node_dict["server"][-1]["server_id"] + self.did = utils.create_database(self.server, self.db_name) + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + + def runTest(self): + # Fetch flask client to access current user and other cookies. + flask_test_client = app.test_client() + flask_test_client.get('/') + + self.test_client = socketio.test_client( + app, + flask_test_client=flask_test_client, + namespace='/pty') + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + + data = { + 'sid': self.sid, + 'db': 'postgres', + 'pwd': self.server['db_password'], + 'user': self.server['username'] + } + + self.test_client.emit('start_process', data, namespace='/pty') + self.test_client.get_received('/pty') + + self.test_client.disconnect(namespace='/pty') + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_start_process.py b/web/pgadmin/tools/psql/tests/test_start_process.py new file mode 100644 index 000000000..1245de3f1 --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_start_process.py @@ -0,0 +1,57 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgadmin.utils import server_utils as server_utils +from pgAdmin4 import app +from .... import socketio + + +class PSQLStartProcess(BaseTestGenerator): + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + database_info = parent_node_dict["database"][-1] + self.did = database_info["db_id"] + self.sid = parent_node_dict["server"][-1]["server_id"] + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + + self.server_con = server_utils.connect_server(self, self.sid) + + def runTest(self): + # Fetch flask client to access current user and other cookies. + flask_client = app.test_client() + flask_client.get('/') + self.test_client = socketio.test_client(app, namespace='/pty', + flask_test_client=flask_client) + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + + import random + trans_id = random.randint(1, 9999999) + + data = { + 'sid': self.sid, + 'db': 'postgres', + 'pwd': self.server['db_password'], + 'user': self.server['username'] + } + + self.test_client.emit('start_process', data, namespace='/pty') + self.test_client.get_received('/pty') + + self.test_client.disconnect(namespace='/pty') + self.assertFalse(self.test_client.is_connected('/pty')) + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/test_start_process_fail.py b/web/pgadmin/tools/psql/tests/test_start_process_fail.py new file mode 100644 index 000000000..0acc7be27 --- /dev/null +++ b/web/pgadmin/tools/psql/tests/test_start_process_fail.py @@ -0,0 +1,48 @@ +import uuid +import config +from pgadmin.utils.route import BaseTestGenerator +from regression.python_test_utils import test_utils as utils +from regression import parent_node_dict +from regression.test_setup import config_data +from pgAdmin4 import app +from .... import socketio + + +class PSQLStartProcessFail(BaseTestGenerator): + def setUp(self): + self.db_name = "psqltestdb_{0}".format(str(uuid.uuid4())[1:8]) + self.sid = parent_node_dict["server"][-1]["server_id"] + self.did = utils.create_database(self.server, self.db_name) + self.sgid = config_data["server_group"] + config.ENABLE_PSQL = True + + def runTest(self): + self.test_client = socketio.test_client(app, namespace='/pty') + self.assertTrue(self.test_client.is_connected('/pty')) + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'connected' + assert received[0]['args'][0]['sid'] != '' + + data = { + 'sid': self.sid, + 'db': 'postgres', + 'pwd': self.server['db_password'], + 'user': self.server['username'] + } + config.ENABLE_PSQL = False + self.test_client.emit('start_process', data, namespace='/pty') + received = self.test_client.get_received('/pty') + + assert received[0]['name'] == 'conn_not_allow' + + self.test_client.disconnect(namespace='/pty') + self.assertFalse(self.test_client.is_connected('/pty')) + + def tearDown(self): + connection = utils.get_db_connection(self.server['db'], + self.server['username'], + self.server['db_password'], + self.server['host'], + self.server['port']) + utils.drop_database(connection, self.db_name) diff --git a/web/pgadmin/tools/psql/tests/utils.py b/web/pgadmin/tools/psql/tests/utils.py new file mode 100644 index 000000000..85bd5373d --- /dev/null +++ b/web/pgadmin/tools/psql/tests/utils.py @@ -0,0 +1,6 @@ +import os +import json + +CURRENT_PATH = os.path.dirname(os.path.realpath(__file__)) +with open(CURRENT_PATH + "/psql_test_data.json") as data_file: + test_cases = json.load(data_file) diff --git a/web/pgadmin/tools/sqleditor/static/scss/_sqleditor.scss b/web/pgadmin/tools/sqleditor/static/scss/_sqleditor.scss index 0bb408021..a58eb7e9e 100644 --- a/web/pgadmin/tools/sqleditor/static/scss/_sqleditor.scss +++ b/web/pgadmin/tools/sqleditor/static/scss/_sqleditor.scss @@ -374,3 +374,20 @@ div.strikeout:after { /* Setting it to hardcoded white as the SVG generated is having white bg * Need to check what can be done. */ + +/* Css for psql */ +.psql_terminal .terminal { + padding-top: 1%; + padding-left: 0.5%; + height: 100%; +} + +.psql-icon-style { + font-size: inherit; + padding-left: 0em; +} + +.psql-tab-style { + font-size: small; + padding-left: 0em; +} diff --git a/web/pgadmin/utils/csrf.py b/web/pgadmin/utils/csrf.py index 23abfffaf..71ae82ea1 100644 --- a/web/pgadmin/utils/csrf.py +++ b/web/pgadmin/utils/csrf.py @@ -38,6 +38,7 @@ class _PGCSRFProtect(CSRFProtect): 'pgadmin.tools.schema_diff.ddl_compare', 'pgadmin.authenticate.login', 'pgadmin.tools.erd.panel', + 'pgadmin.tools.psql.panel', ] for exempt in exempt_views: diff --git a/web/pgadmin/utils/preferences.py b/web/pgadmin/utils/preferences.py index 15ceeb1af..e9efe479b 100644 --- a/web/pgadmin/utils/preferences.py +++ b/web/pgadmin/utils/preferences.py @@ -72,6 +72,7 @@ class _Preference(object): self.select2 = kwargs.get('select2', None) self.fields = kwargs.get('fields', None) self.allow_blanks = kwargs.get('allow_blanks', None) + self.disabled = kwargs.get('disabled', False) # Look into the configuration table to find out the id of the specific # preference. @@ -252,6 +253,7 @@ class _Preference(object): 'select2': self.select2, 'value': self.get(), 'fields': self.fields, + 'disabled': self.disabled, } return res @@ -414,6 +416,7 @@ class Preferences(object): :param fields: field schema (if preference has more than one field to take input from user e.g. keyboardshortcut preference) :param allow_blanks: Flag specify whether to allow blank value. + :param disabled: Flag specify whether to disable the setting or not. """ min_val = kwargs.get('min_val', None) max_val = kwargs.get('max_val', None) @@ -423,6 +426,7 @@ class Preferences(object): select2 = kwargs.get('select2', None) fields = kwargs.get('fields', None) allow_blanks = kwargs.get('allow_blanks', None) + disabled = kwargs.get('disabled', False) cat = self.__category(category, category_label) if name in cat['preferences']: @@ -439,7 +443,8 @@ class Preferences(object): (cat['preferences'])[name] = res = _Preference( cat['id'], name, label, _type, default, help_str=help_str, min_val=min_val, max_val=max_val, options=options, - select2=select2, fields=fields, allow_blanks=allow_blanks + select2=select2, fields=fields, allow_blanks=allow_blanks, + disabled=disabled ) return res diff --git a/web/webpack.config.js b/web/webpack.config.js index 779f2faa4..f631178b1 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -355,6 +355,7 @@ module.exports = [{ debugger_direct: './pgadmin/tools/debugger/static/js/direct.js', schema_diff: './pgadmin/tools/schema_diff/static/js/schema_diff_hook.js', erd_tool: './pgadmin/tools/erd/static/js/erd_tool_hook.js', + psql_tool: './pgadmin/tools/psql/static/js/index.js', file_utils: './pgadmin/misc/file_manager/static/js/utility.js', 'pgadmin.style': pgadminCssStyles, pgadmin: pgadminScssStyles, @@ -493,7 +494,7 @@ module.exports = [{ ], }, }, - }, { + },{ test: require.resolve('./node_modules/acitree/js/jquery.aciTree.min'), use: { loader: 'imports-loader', @@ -532,6 +533,7 @@ module.exports = [{ 'pure|pgadmin.tools.storage_manager', 'pure|pgadmin.tools.search_objects', 'pure|pgadmin.tools.erd_module', + 'pure|pgadmin.tools.psql_module', ], }, }, diff --git a/web/webpack.shim.js b/web/webpack.shim.js index 074b25806..e0b28091c 100644 --- a/web/webpack.shim.js +++ b/web/webpack.shim.js @@ -159,6 +159,15 @@ var webpackShimConfig = { 'jquery.acisortable': path.join(__dirname, './node_modules/acitree/js/jquery.aciSortable.min'), 'jquery.acifragment': path.join(__dirname, './node_modules/acitree/js/jquery.aciFragment.min'), + //xterm + 'xterm': path.join(__dirname, './node_modules/xterm/lib/xterm.js'), + 'xterm-addon-fit': path.join(__dirname, './node_modules/xterm-addon-fit/lib/xterm-addon-fit.js'), + 'xterm-addon-web-links': path.join(__dirname, './node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js'), + 'xterm-addon-search': path.join(__dirname, './node_modules/xterm-addon-search/lib/xterm-addon-search.js'), + + //socket + 'socketio': path.join(__dirname, './node_modules/socket.io-client/dist/socket.io.js'), + // Backbone and Backgrid 'backbone': path.join(__dirname, './node_modules/backbone/backbone'), 'backbone.undo': path.join(__dirname, './node_modules/backbone-undo/Backbone.Undo'), @@ -288,6 +297,8 @@ var webpackShimConfig = { 'pgadmin.tools.storage_manager': path.join(__dirname, './pgadmin/tools/storage_manager/static/js/storage_manager'), 'pgadmin.tools.erd_module': path.join(__dirname, './pgadmin/tools/erd/static/js/erd_module'), 'pgadmin.tools.erd': path.join(__dirname, './pgadmin/tools/erd/static/js'), + 'pgadmin.tools.psql_module': path.join(__dirname, './pgadmin/tools/psql/static/js/psql_module'), + 'pgadmin.tools.psql': path.join(__dirname, './pgadmin/tools/psql/static/js'), 'pgadmin.search_objects': path.join(__dirname, './pgadmin/tools/search_objects/static/js'), 'pgadmin.tools.user_management': path.join(__dirname, './pgadmin/tools/user_management/static/js/user_management'), 'pgadmin.user_management.current_user': '/user_management/current_user', diff --git a/web/webpack.test.config.js b/web/webpack.test.config.js index e0b6fd69f..3cc079f92 100644 --- a/web/webpack.test.config.js +++ b/web/webpack.test.config.js @@ -177,6 +177,7 @@ module.exports = { 'pgadmin.browser.preferences': path.join(__dirname, './pgadmin/browser/static/js/preferences'), 'pgadmin.browser.activity': path.join(__dirname, './pgadmin/browser/static/js/activity'), 'pgadmin.tools.erd': path.join(__dirname, './pgadmin/tools/erd/static/js'), + 'pgadmin.tools.psql': path.join(__dirname, './pgadmin/tools/psql/static/js'), 'bundled_codemirror': path.join(__dirname, './pgadmin/static/bundle/codemirror'), 'tools': path.join(__dirname, './pgadmin/tools/'), 'pgadmin.user_management.current_user': regressionDir + '/javascript/fake_current_user', diff --git a/web/yarn.lock b/web/yarn.lock index e517bf0fe..e73777f69 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -2031,6 +2031,11 @@ backgrid@~0.3.7: backbone "1.1.2 || 1.2.3 || ~1.3.2" underscore "^1.8.0" +backo2@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" + integrity sha1-MasayLEpNjRj41s+u2n038+6eUc= + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -3638,7 +3643,22 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: dependencies: once "^1.4.0" -engine.io-parser@~4.0.0: +engine.io-client@~5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-5.1.1.tgz#f5c3aaaef1bdc9443aac6ffde48b3b2fb2dc56fc" + integrity sha512-jPFpw2HLL0lhZ2KY0BpZhIJdleQcUO9W1xkIpo0h3d6s+5D6+EV/xgQw9qWOmymszv2WXef/6KUUehyxEKomlQ== + dependencies: + base64-arraybuffer "0.1.4" + component-emitter "~1.3.0" + debug "~4.3.1" + engine.io-parser "~4.0.1" + has-cors "1.1.0" + parseqs "0.0.6" + parseuri "0.0.6" + ws "~7.4.2" + yeast "0.1.2" + +engine.io-parser@~4.0.0, engine.io-parser@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-4.0.2.tgz#e41d0b3fb66f7bf4a3671d2038a154024edb501e" integrity sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg== @@ -4643,6 +4663,11 @@ has-bigints@^1.0.1: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA== +has-cors@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39" + integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk= + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" @@ -6734,6 +6759,16 @@ parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parseqs@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5" + integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w== + +parseuri@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a" + integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow== + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -7985,7 +8020,20 @@ socket.io-adapter@~2.1.0: resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.1.0.tgz#edc5dc36602f2985918d631c1399215e97a1b527" integrity sha512-+vDov/aTsLjViYTwS9fPy5pEtTkrbEKsw2M+oVSoFGw6OD1IpvlV1VPhUzNbofCQ8oyMbdYJqDtGdmHQK6TdPg== -socket.io-parser@~4.0.3: +socket.io-client@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.1.2.tgz#95ad7113318ea01fba0860237b96d71e1b1fd2eb" + integrity sha512-RDpWJP4DQT1XeexmeDyDkm0vrFc0+bUsHDKiVGaNISJvJonhQQOMqV9Vwfg0ZpPJ27LCdan7iqTI92FRSOkFWQ== + dependencies: + "@types/component-emitter" "^1.2.10" + backo2 "~1.0.2" + component-emitter "~1.3.0" + debug "~4.3.1" + engine.io-client "~5.1.1" + parseuri "0.0.6" + socket.io-parser "~4.0.4" + +socket.io-parser@~4.0.3, socket.io-parser@~4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0" integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g== @@ -9218,6 +9266,26 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +xterm-addon-fit@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xterm-addon-fit/-/xterm-addon-fit-0.5.0.tgz#2d51b983b786a97dcd6cde805e700c7f913bc596" + integrity sha512-DsS9fqhXHacEmsPxBJZvfj2la30Iz9xk+UKjhQgnYNkrUIN5CYLbw7WEfz117c7+S86S/tpHPfvNxJsF5/G8wQ== + +xterm-addon-search@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/xterm-addon-search/-/xterm-addon-search-0.8.0.tgz#e33eab918df7eac7e7baf95dd2b3d14133754881" + integrity sha512-MPJGPVPpHRUw9cLIuqQbrVepmENMOybVUSxIALz5h1ryyQBrVqVujq2hL5aroX5/dZJoHx9lGHQTVLQ07SKgKA== + +xterm-addon-web-links@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/xterm-addon-web-links/-/xterm-addon-web-links-0.4.0.tgz#265cbf8221b9b315d0a748e1323bee331cd5da03" + integrity sha512-xv8GeiINmx0zENO9hf5k+5bnkaE8mRzF+OBAr9WeFq2eLaQSudioQSiT34M1ofKbzcdjSsKiZm19Rw3i4eXamg== + +xterm@^4.11.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/xterm/-/xterm-4.12.0.tgz#db09b425b4dcae5b96f8cbbaaa93b3bc60997ca9" + integrity sha512-K5mF/p3txUV18mjiZFlElagoHFpqXrm5OYHeoymeXSu8GG/nMaOO/+NRcNCwfdjzAbdQ5VLF32hEHiWWKKm0bw== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -9273,6 +9341,11 @@ yauzl@^2.4.2: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" +yeast@0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" + integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk= + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"