diff --git a/app/package.json b/app/package.json index f79b1fe7e..7a732767f 100644 --- a/app/package.json +++ b/app/package.json @@ -121,6 +121,7 @@ }, "optionalDependencies": { "better-sqlite3": "9.6.0", - "msnodesqlv8": "^4.2.1" + "msnodesqlv8": "^4.2.1", + "oracledb": "^6.6.0" } } diff --git a/app/src/electron.js b/app/src/electron.js index a60c07830..9f51f771a 100644 --- a/app/src/electron.js +++ b/app/src/electron.js @@ -365,11 +365,13 @@ function createWindow() { console.log('Error saving config-root:', err.message); } }); + + // mainWindow.webContents.toggleDevTools(); + mainWindow.loadURL(startUrl); if (os.platform() == 'linux') { mainWindow.setIcon(path.resolve(__dirname, '../icon.png')); } - // mainWindow.webContents.toggleDevTools(); mainWindow.on('maximize', () => { mainWindow.webContents.send('setIsMaximized', true); diff --git a/app/yarn.lock b/app/yarn.lock index 63e86571c..acc10fc82 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1944,6 +1944,11 @@ open@^7.4.2: is-docker "^2.0.0" is-wsl "^2.1.1" +oracledb@^6.6.0: + version "6.6.0" + resolved "https://registry.yarnpkg.com/oracledb/-/oracledb-6.6.0.tgz#bb40adbe81a84a1e544c48af9f120c61f030e936" + integrity sha512-T3dx+o3j+tVN53wQyr4yGTmoPHLy+a2V8yb1T2PmWrsj3ZlSt2Yu1BgV2yTDqnmBZYpRi/I3yJXRCOHHD7PiyA== + os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" diff --git a/fillNativeModules.js b/fillNativeModules.js index 07d7c1cf9..23024893d 100644 --- a/fillNativeModules.js +++ b/fillNativeModules.js @@ -3,9 +3,10 @@ const fs = require('fs'); let fillContent = ''; if (process.platform == 'win32') { - fillContent += `content.msnodesqlv8 = () => require('msnodesqlv8');`; + fillContent += `content.msnodesqlv8 = () => require('msnodesqlv8');\n`; } -fillContent += `content['better-sqlite3'] = () => require('better-sqlite3');`; +fillContent += `content['better-sqlite3'] = () => require('better-sqlite3');\n`; +fillContent += `content['oracledb'] = () => require('oracledb');\n`; const getContent = empty => ` // this file is generated automatically by script fillNativeModules.js, do not edit it manually diff --git a/package.json b/package.json index f1f4321d5..e1607a6b5 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "start:api:singledb": "yarn workspace dbgate-api start:singledb | pino-pretty", "start:api:auth": "yarn workspace dbgate-api start:auth | pino-pretty", "start:api:dblogin": "yarn workspace dbgate-api start:dblogin | pino-pretty", + "start:api:storage": "yarn workspace dbgate-api start:storage | pino-pretty", + "sync:pro": "cd sync && yarn start", "start:web": "yarn workspace dbgate-web dev", "start:sqltree": "yarn workspace dbgate-sqltree start", "start:tools": "yarn workspace dbgate-tools start", @@ -35,6 +37,7 @@ "build:web:docker": "yarn workspace dbgate-web build", "build:plugins:frontend": "workspaces-run --only=\"dbgate-plugin-*\" -- yarn build:frontend", "build:plugins:frontend:watch": "workspaces-run --parallel --only=\"dbgate-plugin-*\" -- yarn build:frontend:watch", + "storage-json": "dbmodel model-to-json storage-db packages/api/src/storageModel.js --commonjs", "plugins:copydist": "workspaces-run --only=\"dbgate-plugin-*\" -- yarn copydist", "build:app:local": "yarn plugins:copydist && cd app && yarn build:local", "start:app:local": "cd app && yarn start:local", @@ -48,8 +51,8 @@ "resetPackagedPlugins": "node resetPackagedPlugins", "prettier": "prettier --write packages/api/src && prettier --write packages/datalib/src && prettier --write packages/filterparser/src && prettier --write packages/sqltree/src && prettier --write packages/tools/src && prettier --write packages/types && prettier --write packages/web/src && prettier --write app/src", "copy:docker:build": "copyfiles packages/api/dist/* docker -f && copyfiles packages/web/public/* docker -u 2 && copyfiles \"packages/web/public/**/*\" docker -u 2 && copyfiles \"plugins/dist/**/*\" docker/plugins -u 2", - "install:sqlite:docker": "cd docker && yarn init --yes && yarn add better-sqlite3 && cd ..", - "prepare:docker": "yarn plugins:copydist && yarn build:web:docker && yarn build:api && yarn copy:docker:build && yarn install:sqlite:docker", + "install:drivers:docker": "cd docker && yarn init --yes && yarn add better-sqlite3 && yarn add oracledb && cd ..", + "prepare:docker": "yarn plugins:copydist && yarn build:web:docker && yarn build:api && yarn copy:docker:build && yarn install:drivers:docker", "start": "concurrently --kill-others-on-fail \"yarn start:api\" \"yarn start:web\"", "lib": "concurrently --kill-others-on-fail \"yarn start:sqltree\" \"yarn start:filterparser\" \"yarn start:datalib\" \"yarn start:tools\" \"yarn build:plugins:frontend:watch\"", "ts:api": "yarn workspace dbgate-api ts", diff --git a/packages/api/package.json b/packages/api/package.json index e5e11b46c..f9f11f2f1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -66,6 +66,7 @@ "start:auth": "env-cmd -f env/auth/.env node src/index.js --listen-api", "start:dblogin": "env-cmd -f env/dblogin/.env node src/index.js --listen-api", "start:filedb": "env-cmd node src/index.js /home/jena/test/chinook/Chinook.db --listen-api", + "start:storage": "env-cmd -f env/storage/.env node src/index.js --listen-api", "start:singleconn": "env-cmd node src/index.js --server localhost --user root --port 3307 --engine mysql@dbgate-plugin-mysql --password test --listen-api", "ts": "tsc", "build": "webpack" @@ -83,6 +84,7 @@ }, "optionalDependencies": { "better-sqlite3": "9.6.0", - "msnodesqlv8": "^4.2.1" + "msnodesqlv8": "^4.2.1", + "oracledb": "^6.6.0" } } diff --git a/packages/api/src/auth/authCommon.js b/packages/api/src/auth/authCommon.js new file mode 100644 index 000000000..824b9baa8 --- /dev/null +++ b/packages/api/src/auth/authCommon.js @@ -0,0 +1,16 @@ +const crypto = require('crypto'); + +const tokenSecret = crypto.randomUUID(); + +function getTokenLifetime() { + return process.env.TOKEN_LIFETIME || '1d'; +} + +function getTokenSecret() { + return tokenSecret; +} + +module.exports = { + getTokenLifetime, + getTokenSecret, +}; diff --git a/packages/api/src/auth/authProvider.js b/packages/api/src/auth/authProvider.js new file mode 100644 index 000000000..4f8852d73 --- /dev/null +++ b/packages/api/src/auth/authProvider.js @@ -0,0 +1,251 @@ +const { getTokenSecret, getTokenLifetime } = require('./authCommon'); +const _ = require('lodash'); +const axios = require('axios'); +const { getLogger } = require('dbgate-tools'); + +const AD = require('activedirectory2').promiseWrapper; +const jwt = require('jsonwebtoken'); + +const logger = getLogger('authProvider'); + +class AuthProviderBase { + async login(login, password, options = undefined) { + return {}; + } + + shouldAuthorizeApi() { + return false; + } + + oauthToken(params) { + return {}; + } + + getCurrentLogin(req) { + const login = req?.user?.login ?? req?.auth?.user ?? null; + return login; + } + + isUserLoggedIn(req) { + return !!req?.user || !!req?.auth; + } + + getCurrentPermissions(req) { + const login = this.getCurrentLogin(req); + const permissions = process.env[`LOGIN_PERMISSIONS_${login}`]; + return permissions || process.env.PERMISSIONS; + } + + isLoginForm() { + return false; + } + + getAdditionalConfigProps() { + return {}; + } + + getLoginPageConnections() { + return null; + } + + getSingleConnectionId(req) { + return null; + } +} + +class OAuthProvider extends AuthProviderBase { + shouldAuthorizeApi() { + return true; + } + + async oauthToken(params) { + const { redirectUri, code } = params; + + const scopeParam = process.env.OAUTH_SCOPE ? `&scope=${process.env.OAUTH_SCOPE}` : ''; + const resp = await axios.default.post( + `${process.env.OAUTH_TOKEN}`, + `grant_type=authorization_code&code=${encodeURIComponent(code)}&redirect_uri=${encodeURIComponent( + redirectUri + )}&client_id=${process.env.OAUTH_CLIENT_ID}&client_secret=${process.env.OAUTH_CLIENT_SECRET}${scopeParam}` + ); + + const { access_token, refresh_token } = resp.data; + + const payload = jwt.decode(access_token); + + logger.info({ payload }, 'User payload returned from OAUTH'); + + const login = + process.env.OAUTH_LOGIN_FIELD && payload && payload[process.env.OAUTH_LOGIN_FIELD] + ? payload[process.env.OAUTH_LOGIN_FIELD] + : 'oauth'; + + if ( + process.env.OAUTH_ALLOWED_LOGINS && + !process.env.OAUTH_ALLOWED_LOGINS.split(',').find(x => x.toLowerCase().trim() == login.toLowerCase().trim()) + ) { + return { error: `Username ${login} not allowed to log in` }; + } + + const groups = + process.env.OAUTH_GROUP_FIELD && payload && payload[process.env.OAUTH_GROUP_FIELD] + ? payload[process.env.OAUTH_GROUP_FIELD] + : []; + + const allowedGroups = process.env.OAUTH_ALLOWED_GROUPS + ? process.env.OAUTH_ALLOWED_GROUPS.split(',').map(group => group.toLowerCase().trim()) + : []; + + if (process.env.OAUTH_ALLOWED_GROUPS && !groups.some(group => allowedGroups.includes(group.toLowerCase().trim()))) { + return { error: `Username ${login} does not belong to an allowed group` }; + } + + if (access_token) { + return { + accessToken: jwt.sign({ login }, getTokenSecret(), { expiresIn: getTokenLifetime() }), + }; + } + + return { error: 'Token not found' }; + } + + getAdditionalConfigProps() { + return { + oauth: process.env.OAUTH_AUTH, + oauthClient: process.env.OAUTH_CLIENT_ID, + oauthScope: process.env.OAUTH_SCOPE, + oauthLogout: process.env.OAUTH_LOGOUT, + }; + } +} + +class ADProvider extends AuthProviderBase { + async login(login, password) { + const adConfig = { + url: process.env.AD_URL, + baseDN: process.env.AD_BASEDN, + username: process.env.AD_USERNAME, + password: process.env.AD_PASSWORD, + }; + const ad = new AD(adConfig); + try { + const res = await ad.authenticate(login, password); + if (!res) { + return { error: 'Login failed' }; + } + if ( + process.env.AD_ALLOWED_LOGINS && + !process.env.AD_ALLOWED_LOGINS.split(',').find(x => x.toLowerCase().trim() == login.toLowerCase().trim()) + ) { + return { error: `Username ${login} not allowed to log in` }; + } + return { + accessToken: jwt.sign({ login }, getTokenSecret(), { expiresIn: getTokenLifetime() }), + }; + } catch (e) { + return { error: 'Login failed' }; + } + } + + shouldAuthorizeApi() { + return !process.env.BASIC_AUTH; + } + + isLoginForm() { + return !process.env.BASIC_AUTH; + } +} + +class LoginsProvider extends AuthProviderBase { + async login(login, password) { + if (password == process.env[`LOGIN_PASSWORD_${login}`]) { + return { + accessToken: jwt.sign({ login }, getTokenSecret(), { expiresIn: getTokenLifetime() }), + }; + } + return { error: 'Invalid credentials' }; + } + + shouldAuthorizeApi() { + return !process.env.BASIC_AUTH; + } + + isLoginForm() { + return !process.env.BASIC_AUTH; + } +} + +class DenyAllProvider extends AuthProviderBase { + shouldAuthorizeApi() { + return true; + } + + async login(login, password) { + return { error: 'Login not allowed' }; + } +} + +function hasEnvLogins() { + if (process.env.LOGIN && process.env.PASSWORD) { + return true; + } + for (const key in process.env) { + if (key.startsWith('LOGIN_PASSWORD_')) { + return true; + } + } + return false; +} + +function detectEnvAuthProvider() { + if (process.env.AUTH_PROVIDER) { + return process.env.AUTH_PROVIDER; + } + + if (process.env.STORAGE_DATABASE) { + return 'denyall'; + } + if (process.env.OAUTH_AUTH) { + return 'oauth'; + } + if (process.env.AD_URL) { + return 'ad'; + } + if (hasEnvLogins()) { + return 'logins'; + } + return 'none'; +} + +function createEnvAuthProvider() { + const authProvider = detectEnvAuthProvider(); + switch (authProvider) { + case 'oauth': + return new OAuthProvider(); + case 'ad': + return new ADProvider(); + case 'logins': + return new LoginsProvider(); + case 'denyall': + return new DenyAllProvider(); + default: + return new AuthProviderBase(); + } +} + +let authProvider = createEnvAuthProvider(); + +function getAuthProvider() { + return authProvider; +} + +function setAuthProvider(value) { + authProvider = value; +} + +module.exports = { + AuthProviderBase, + detectEnvAuthProvider, + getAuthProvider, + setAuthProvider, +}; diff --git a/packages/api/src/controllers/auth.js b/packages/api/src/controllers/auth.js index bf5b84a1f..e52a32ffe 100644 --- a/packages/api/src/controllers/auth.js +++ b/packages/api/src/controllers/auth.js @@ -1,24 +1,15 @@ const axios = require('axios'); const jwt = require('jsonwebtoken'); const getExpressPath = require('../utility/getExpressPath'); -const { getLogins } = require('../utility/hasPermission'); const { getLogger } = require('dbgate-tools'); const AD = require('activedirectory2').promiseWrapper; const crypto = require('crypto'); +const { getTokenSecret, getTokenLifetime } = require('../auth/authCommon'); +const { getAuthProvider } = require('../auth/authProvider'); +const storage = require('./storage'); const logger = getLogger('auth'); -const tokenSecret = crypto.randomUUID(); - -function shouldAuthorizeApi() { - const logins = getLogins(); - return !!process.env.OAUTH_AUTH || !!process.env.AD_URL || (!!logins && !process.env.BASIC_AUTH); -} - -function getTokenLifetime() { - return process.env.TOKEN_LIFETIME || '1d'; -} - function unauthorizedResponse(req, res, text) { // if (req.path == getExpressPath('/config/get-settings')) { // return res.json({}); @@ -30,9 +21,23 @@ function unauthorizedResponse(req, res, text) { } function authMiddleware(req, res, next) { - const SKIP_AUTH_PATHS = ['/config/get', '/auth/oauth-token', '/auth/login', '/stream']; + const SKIP_AUTH_PATHS = [ + '/config/get', + '/config/get-settings', + '/auth/oauth-token', + '/auth/login', + '/stream', + 'storage/get-connections-for-login-page', + '/connections/dblogin', + '/connections/dblogin-auth', + '/connections/dblogin-auth-token', + ]; - if (!shouldAuthorizeApi()) { + // console.log('********************* getAuthProvider()', getAuthProvider()); + + const isAdminPage = req.headers['x-is-admin-page'] == 'true'; + + if (!isAdminPage && !getAuthProvider().shouldAuthorizeApi()) { return next(); } let skipAuth = !!SKIP_AUTH_PATHS.find(x => req.path == getExpressPath(x)); @@ -46,7 +51,7 @@ function authMiddleware(req, res, next) { } const token = authHeader.split(' ')[1]; try { - const decoded = jwt.verify(token, tokenSecret); + const decoded = jwt.verify(token, getTokenSecret()); req.user = decoded; return next(); } catch (err) { @@ -63,106 +68,34 @@ function authMiddleware(req, res, next) { module.exports = { oauthToken_meta: true, async oauthToken(params) { - const { redirectUri, code } = params; - - const scopeParam = process.env.OAUTH_SCOPE ? `&scope=${process.env.OAUTH_SCOPE}` : ''; - const resp = await axios.default.post( - `${process.env.OAUTH_TOKEN}`, - `grant_type=authorization_code&code=${encodeURIComponent(code)}&redirect_uri=${encodeURIComponent( - redirectUri - )}&client_id=${process.env.OAUTH_CLIENT_ID}&client_secret=${process.env.OAUTH_CLIENT_SECRET}${scopeParam}` - ); - - const { access_token, refresh_token } = resp.data; - - const payload = jwt.decode(access_token); - - logger.info({ payload }, 'User payload returned from OAUTH'); - - const login = - process.env.OAUTH_LOGIN_FIELD && payload && payload[process.env.OAUTH_LOGIN_FIELD] - ? payload[process.env.OAUTH_LOGIN_FIELD] - : 'oauth'; - - if ( - process.env.OAUTH_ALLOWED_LOGINS && - !process.env.OAUTH_ALLOWED_LOGINS.split(',').find(x => x.toLowerCase().trim() == login.toLowerCase().trim()) - ) { - return { error: `Username ${login} not allowed to log in` }; - } - - const groups = - process.env.OAUTH_GROUP_FIELD && payload && payload[process.env.OAUTH_GROUP_FIELD] - ? payload[process.env.OAUTH_GROUP_FIELD] - : []; - - const allowedGroups = - process.env.OAUTH_ALLOWED_GROUPS - ? process.env.OAUTH_ALLOWED_GROUPS.split(',').map(group => group.toLowerCase().trim()) - : []; - - if ( - process.env.OAUTH_ALLOWED_GROUPS && - !groups.some(group => allowedGroups.includes(group.toLowerCase().trim())) - ) { - return { error: `Username ${login} does not belong to an allowed group` }; - } - - if (access_token) { - return { - accessToken: jwt.sign({ login }, tokenSecret, { expiresIn: getTokenLifetime() }), - }; - } - - return { error: 'Token not found' }; + return getAuthProvider().oauthToken(params); }, login_meta: true, async login(params) { - const { login, password } = params; + const { login, password, isAdminPage } = params; - if (process.env.AD_URL) { - const adConfig = { - url: process.env.AD_URL, - baseDN: process.env.AD_BASEDN, - username: process.env.AD_USERNAME, - password: process.env.AD_PASSOWRD, - }; - const ad = new AD(adConfig); - try { - const res = await ad.authenticate(login, password); - if (!res) { - return { error: 'Login failed' }; - } - if ( - process.env.AD_ALLOWED_LOGINS && - !process.env.AD_ALLOWED_LOGINS.split(',').find(x => x.toLowerCase().trim() == login.toLowerCase().trim()) - ) { - return { error: `Username ${login} not allowed to log in` }; - } + if (isAdminPage) { + if (process.env.ADMIN_PASSWORD && process.env.ADMIN_PASSWORD == password) { return { - accessToken: jwt.sign({ login }, tokenSecret, { expiresIn: getTokenLifetime() }), - }; - } catch (err) { - logger.error({ err }, 'Failed active directory authentization'); - return { - error: err.message, + accessToken: jwt.sign( + { + login: 'superadmin', + permissions: await storage.loadSuperadminPermissions(), + roleId: -3, + }, + getTokenSecret(), + { + expiresIn: getTokenLifetime(), + } + ), }; } + + return { error: 'Login failed' }; } - const logins = getLogins(); - if (!logins) { - return { error: 'Logins not configured' }; - } - const foundLogin = logins.find(x => x.login == login); - if (foundLogin && foundLogin.password && foundLogin.password == password) { - return { - accessToken: jwt.sign({ login }, tokenSecret, { expiresIn: getTokenLifetime() }), - }; - } - return { error: 'Invalid credentials' }; + return getAuthProvider().login(login, password); }, authMiddleware, - shouldAuthorizeApi, }; diff --git a/packages/api/src/controllers/config.js b/packages/api/src/controllers/config.js index c19c31f83..f551a5153 100644 --- a/packages/api/src/controllers/config.js +++ b/packages/api/src/controllers/config.js @@ -3,7 +3,7 @@ const os = require('os'); const path = require('path'); const axios = require('axios'); const { datadir, getLogsFilePath } = require('../utility/directories'); -const { hasPermission, getLogins } = require('../utility/hasPermission'); +const { hasPermission } = require('../utility/hasPermission'); const socket = require('../utility/socket'); const _ = require('lodash'); const AsyncLock = require('async-lock'); @@ -11,6 +11,7 @@ const AsyncLock = require('async-lock'); const currentVersion = require('../currentVersion'); const platformInfo = require('../utility/platformInfo'); const connections = require('../controllers/connections'); +const { getAuthProvider } = require('../auth/authProvider'); const lock = new AsyncLock(); @@ -27,27 +28,37 @@ module.exports = { get_meta: true, async get(_params, req) { - const logins = getLogins(); - const loginName = - req && req.user && req.user.login ? req.user.login : req && req.auth && req.auth.user ? req.auth.user : null; - const login = logins && loginName ? logins.find(x => x.login == loginName) : null; - const permissions = login ? login.permissions : process.env.PERMISSIONS; + const authProvider = getAuthProvider(); + const login = authProvider.getCurrentLogin(req); + const permissions = authProvider.getCurrentPermissions(req); + const isLoginForm = authProvider.isLoginForm(); + const additionalConfigProps = authProvider.getAdditionalConfigProps(); + const isUserLoggedIn = authProvider.isUserLoggedIn(req); + + const singleConid = authProvider.getSingleConnectionId(req); + + const singleConnection = singleConid + ? await connections.getCore({ conid: singleConid }) + : connections.singleConnection; return { runAsPortal: !!connections.portalConnections, singleDbConnection: connections.singleDbConnection, - singleConnection: connections.singleConnection, + singleConnection: singleConnection, + isUserLoggedIn, // hideAppEditor: !!process.env.HIDE_APP_EDITOR, allowShellConnection: platformInfo.allowShellConnection, allowShellScripting: platformInfo.allowShellScripting, isDocker: platformInfo.isDocker, + isElectron: platformInfo.isElectron, + isLicenseValid: platformInfo.isLicenseValid, + licenseError: platformInfo.licenseError, permissions, login, - oauth: process.env.OAUTH_AUTH, - oauthClient: process.env.OAUTH_CLIENT_ID, - oauthScope: process.env.OAUTH_SCOPE, - oauthLogout: process.env.OAUTH_LOGOUT, - isLoginForm: !!process.env.AD_URL || (!!logins && !process.env.BASIC_AUTH), + ...additionalConfigProps, + isLoginForm, + isAdminLoginForm: !!(process.env.STORAGE_DATABASE && process.env.ADMIN_PASSWORD && !process.env.BASIC_AUTH), + storageDatabase: process.env.STORAGE_DATABASE, logsFilePath: getLogsFilePath(), connectionsFilePath: path.join(datadir(), 'connections.jsonl'), ...currentVersion, diff --git a/packages/api/src/controllers/connections.js b/packages/api/src/controllers/connections.js index 2cda2e876..193d5fd5b 100644 --- a/packages/api/src/controllers/connections.js +++ b/packages/api/src/controllers/connections.js @@ -16,6 +16,8 @@ const { safeJsonParse, getLogger } = require('dbgate-tools'); const platformInfo = require('../utility/platformInfo'); const { connectionHasPermission, testConnectionPermission } = require('../utility/hasPermission'); const pipeForkLogs = require('../utility/pipeForkLogs'); +const requireEngineDriver = require('../utility/requireEngineDriver'); +const { getAuthProvider } = require('../auth/authProvider'); const logger = getLogger('connections'); @@ -199,6 +201,12 @@ module.exports = { list_meta: true, async list(_params, req) { + const storage = require('./storage'); + + const storageConnections = await storage.connections(req); + if (storageConnections) { + return storageConnections; + } if (portalConnections) { if (platformInfo.allowShellConnection) return portalConnections; return portalConnections.map(maskConnection).filter(x => connectionHasPermission(x, req)); @@ -236,14 +244,16 @@ module.exports = { }, saveVolatile_meta: true, - async saveVolatile({ conid, user, password, test }) { + async saveVolatile({ conid, user = undefined, password = undefined, accessToken = undefined, test = false }) { const old = await this.getCore({ conid }); const res = { ...old, _id: crypto.randomUUID(), password, + accessToken, passwordMode: undefined, unsaved: true, + useRedirectDbLogin: false, }; if (old.passwordMode == 'askUser') { res.user = user; @@ -336,6 +346,14 @@ module.exports = { if (volatile) { return volatile; } + + const storage = require('./storage'); + + const storageConnection = await storage.getConnection({ conid }); + if (storageConnection) { + return storageConnection; + } + if (portalConnections) { const res = portalConnections.find(x => x._id == conid) || null; return mask && !platformInfo.allowShellConnection ? maskConnection(res) : res; @@ -365,4 +383,64 @@ module.exports = { }); return res; }, + + dblogin_meta: { + raw: true, + method: 'get', + }, + async dblogin(req, res) { + const { conid, state, redirectUri } = req.query; + const connection = await this.getCore({ conid }); + const driver = requireEngineDriver(connection); + const authUrl = await driver.getRedirectAuthUrl(connection, { redirectUri, state }); + res.redirect(authUrl); + }, + + dbloginToken_meta: true, + async dbloginToken({ code, conid, strmid, redirectUri }) { + try { + const connection = await this.getCore({ conid }); + const driver = requireEngineDriver(connection); + const accessToken = await driver.getAuthTokenFromCode(connection, { code, redirectUri }); + const volatile = await this.saveVolatile({ conid, accessToken }); + // console.log('******************************** WE HAVE ACCESS TOKEN', accessToken); + socket.emit('got-volatile-token', { strmid, savedConId: conid, volatileConId: volatile._id }); + return { success: true }; + } catch (err) { + logger.error({ err }, 'Error getting DB token'); + return { error: err.message }; + } + }, + + dbloginAuthToken_meta: true, + async dbloginAuthToken({ code, conid, redirectUri }) { + try { + const connection = await this.getCore({ conid }); + const driver = requireEngineDriver(connection); + const accessToken = await driver.getAuthTokenFromCode(connection, { code, redirectUri }); + const volatile = await this.saveVolatile({ conid, accessToken }); + const authProvider = getAuthProvider(); + const resp = await authProvider.login(null, null, { conid: volatile._id }); + return resp; + } catch (err) { + logger.error({ err }, 'Error getting DB token'); + return { error: err.message }; + } + }, + + dbloginAuth_meta: true, + async dbloginAuth({ conid, user, password }) { + if (user || password) { + const saveResp = await this.saveVolatile({ conid, user, password, test: true }); + if (saveResp.msgtype == 'connected') { + const loginResp = await getAuthProvider().login(user, password, { conid: saveResp._id }); + return loginResp; + } + return saveResp; + } + + // user and password is stored in connection, volatile connection is not needed + const loginResp = await getAuthProvider().login(null, null, { conid }); + return loginResp; + }, }; diff --git a/packages/api/src/controllers/databaseConnections.js b/packages/api/src/controllers/databaseConnections.js index 65f9fbd28..47321775f 100644 --- a/packages/api/src/controllers/databaseConnections.js +++ b/packages/api/src/controllers/databaseConnections.js @@ -89,6 +89,9 @@ module.exports = { if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') { throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode }); } + if (connection.useRedirectDbLogin) { + throw new MissingCredentialsError({ conid, redirectToDbLogin: true }); + } const subprocess = fork( global['API_PACKAGE'] || process.argv[1], [ diff --git a/packages/api/src/controllers/serverConnections.js b/packages/api/src/controllers/serverConnections.js index a35bb3330..945ce305f 100644 --- a/packages/api/src/controllers/serverConnections.js +++ b/packages/api/src/controllers/serverConnections.js @@ -56,7 +56,10 @@ module.exports = { if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') { throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode }); } - const subprocess = fork( + if (connection.useRedirectDbLogin) { + throw new MissingCredentialsError({ conid, redirectToDbLogin: true }); + } + const subprocess = fork( global['API_PACKAGE'] || process.argv[1], [ '--is-forked-api', diff --git a/packages/api/src/controllers/storage.js b/packages/api/src/controllers/storage.js new file mode 100644 index 000000000..871b7ad2a --- /dev/null +++ b/packages/api/src/controllers/storage.js @@ -0,0 +1,20 @@ +module.exports = { + connections_meta: true, + async connections(req) { + return null; + }, + + getConnection_meta: true, + async getConnection({ conid }) { + return null; + }, + + async loadSuperadminPermissions() { + return []; + }, + + getConnectionsForLoginPage_meta: true, + async getConnectionsForLoginPage() { + return null; + }, +}; diff --git a/packages/api/src/index.js b/packages/api/src/index.js index dd2b0113d..e75101beb 100644 --- a/packages/api/src/index.js +++ b/packages/api/src/index.js @@ -98,6 +98,7 @@ if (processArgs.listenApi) { const shell = require('./shell/index'); const dbgateTools = require('dbgate-tools'); +const currentVersion = require('./currentVersion'); global['DBGATE_TOOLS'] = dbgateTools; @@ -116,6 +117,7 @@ module.exports = { ...shell, getLogger, configureLogger, + currentVersion, // loadLogsContent, getMainModule: () => require('./main'), }; diff --git a/packages/api/src/main.js b/packages/api/src/main.js index 1164ada6d..731d882ae 100644 --- a/packages/api/src/main.js +++ b/packages/api/src/main.js @@ -18,6 +18,7 @@ const sessions = require('./controllers/sessions'); const runners = require('./controllers/runners'); const jsldata = require('./controllers/jsldata'); const config = require('./controllers/config'); +const storage = require('./controllers/storage'); const archive = require('./controllers/archive'); const apps = require('./controllers/apps'); const auth = require('./controllers/auth'); @@ -31,9 +32,9 @@ const onFinished = require('on-finished'); const { rundir } = require('./utility/directories'); const platformInfo = require('./utility/platformInfo'); const getExpressPath = require('./utility/getExpressPath'); -const { getLogins } = require('./utility/hasPermission'); const _ = require('lodash'); const { getLogger } = require('dbgate-tools'); +const { getAuthProvider } = require('./auth/authProvider'); const logger = getLogger('main'); @@ -44,11 +45,23 @@ function start() { const server = http.createServer(app); - const logins = getLogins(); - if (logins && process.env.BASIC_AUTH) { + if (process.env.BASIC_AUTH) { + async function authorizer(username, password, cb) { + try { + const resp = await getAuthProvider().login(username, password); + if (resp.accessToken) { + cb(null, true); + } else { + cb(null, false); + } + } catch (err) { + cb(err, false); + } + } app.use( basicAuth({ - users: _.fromPairs(logins.filter(x => x.password).map(x => [x.login, x.password])), + authorizer, + authorizeAsync: true, challenge: true, realm: 'DbGate Web App', }) @@ -72,9 +85,7 @@ function start() { }); } - if (auth.shouldAuthorizeApi()) { - app.use(auth.authMiddleware); - } + app.use(auth.authMiddleware); app.get(getExpressPath('/stream'), async function (req, res) { const strmid = req.query.strmid; @@ -162,6 +173,7 @@ function useAllControllers(app, electron) { useController(app, electron, '/runners', runners); useController(app, electron, '/jsldata', jsldata); useController(app, electron, '/config', config); + useController(app, electron, '/storage', storage); useController(app, electron, '/archive', archive); useController(app, electron, '/uploads', uploads); useController(app, electron, '/plugins', plugins); diff --git a/packages/api/src/proc/sshForwardProcess.js b/packages/api/src/proc/sshForwardProcess.js index 8f1461a9d..d679dea9e 100644 --- a/packages/api/src/proc/sshForwardProcess.js +++ b/packages/api/src/proc/sshForwardProcess.js @@ -15,10 +15,12 @@ async function getSshConnection(connection) { agentForward: connection.sshMode == 'agent', passphrase: connection.sshMode == 'keyFile' ? connection.sshKeyfilePassword : undefined, username: connection.sshLogin, - password: connection.sshMode == 'userPassword' ? connection.sshPassword : undefined, + password: (connection.sshMode || 'userPassword') == 'userPassword' ? connection.sshPassword : undefined, agentSocket: connection.sshMode == 'agent' ? platformInfo.sshAuthSock : undefined, privateKey: - connection.sshMode == 'keyFile' && connection.sshKeyfile ? await fs.readFile(connection.sshKeyfile) : undefined, + connection.sshMode == 'keyFile' && (connection.sshKeyfile || platformInfo?.defaultKeyfile) + ? await fs.readFile(connection.sshKeyfile || platformInfo?.defaultKeyfile) + : undefined, skipAutoPrivateKey: true, noReadline: true, }; diff --git a/packages/api/src/shell/dbModelToJson.js b/packages/api/src/shell/dbModelToJson.js new file mode 100644 index 000000000..4b24d637c --- /dev/null +++ b/packages/api/src/shell/dbModelToJson.js @@ -0,0 +1,16 @@ +const importDbModel = require('../utility/importDbModel'); +const fs = require('fs'); + +async function dbModelToJson({ modelFolder, outputFile, commonjs }) { + const dbInfo = await importDbModel(modelFolder); + + const json = JSON.stringify(dbInfo, null, 2); + if (commonjs) { + fs.writeFileSync(outputFile, `module.exports = ${json};`); + return; + } else { + fs.writeFileSync(outputFile, json); + } +} + +module.exports = dbModelToJson; diff --git a/packages/api/src/shell/index.js b/packages/api/src/shell/index.js index 2293a26e8..253bb053a 100644 --- a/packages/api/src/shell/index.js +++ b/packages/api/src/shell/index.js @@ -27,6 +27,8 @@ const loadDatabase = require('./loadDatabase'); const generateModelSql = require('./generateModelSql'); const modifyJsonLinesReader = require('./modifyJsonLinesReader'); const dataDuplicator = require('./dataDuplicator'); +const dbModelToJson = require('./dbModelToJson'); +const jsonToDbModel = require('./jsonToDbModel'); const dbgateApi = { queryReader, @@ -57,6 +59,8 @@ const dbgateApi = { generateModelSql, modifyJsonLinesReader, dataDuplicator, + dbModelToJson, + jsonToDbModel, }; requirePlugin.initializeDbgateApi(dbgateApi); diff --git a/packages/api/src/shell/jsonToDbModel.js b/packages/api/src/shell/jsonToDbModel.js new file mode 100644 index 000000000..dfa4bdc46 --- /dev/null +++ b/packages/api/src/shell/jsonToDbModel.js @@ -0,0 +1,9 @@ +const exportDbModel = require('../utility/exportDbModel'); +const fs = require('fs'); + +async function jsonToDbModel({ modelFile, outputDir }) { + const dbInfo = JSON.parse(fs.readFileSync(modelFile, 'utf-8')); + await exportDbModel(dbInfo, outputDir); +} + +module.exports = jsonToDbModel; diff --git a/packages/api/src/shell/requirePlugin.js b/packages/api/src/shell/requirePlugin.js index 7e1c46769..6db3714d5 100644 --- a/packages/api/src/shell/requirePlugin.js +++ b/packages/api/src/shell/requirePlugin.js @@ -11,6 +11,7 @@ const loadedPlugins = {}; const dbgateEnv = { dbgateApi: null, nativeModules, + platformInfo, }; function requirePlugin(packageName, requiredPlugin = null) { if (!packageName) throw new Error('Missing packageName in plugin'); diff --git a/packages/api/src/utility/checkLicense.js b/packages/api/src/utility/checkLicense.js new file mode 100644 index 000000000..78e37202c --- /dev/null +++ b/packages/api/src/utility/checkLicense.js @@ -0,0 +1,7 @@ +function checkLicense() { + return null; +} + +module.exports = { + checkLicense, +}; diff --git a/packages/api/src/utility/hasPermission.js b/packages/api/src/utility/hasPermission.js index d8c7d15bc..1d6b63abf 100644 --- a/packages/api/src/utility/hasPermission.js +++ b/packages/api/src/utility/hasPermission.js @@ -1,72 +1,81 @@ const { compilePermissions, testPermission } = require('dbgate-tools'); const _ = require('lodash'); +const { getAuthProvider } = require('../auth/authProvider'); -const userPermissions = {}; +const cachedPermissions = {}; function hasPermission(tested, req) { if (!req) { // request object not available, allow all return true; } - const { user } = (req && req.auth) || {}; - const { login } = (process.env.OAUTH_PERMISSIONS && req && req.user) || {}; - const key = user || login || ''; - const logins = getLogins(); - if (!userPermissions[key]) { - if (logins) { - const login = logins.find(x => x.login == user); - userPermissions[key] = compilePermissions(login ? login.permissions : null); - } else { - userPermissions[key] = compilePermissions(process.env.PERMISSIONS); - } + const permissions = getAuthProvider().getCurrentPermissions(req); + + if (!cachedPermissions[permissions]) { + cachedPermissions[permissions] = compilePermissions(permissions); } - return testPermission(tested, userPermissions[key]); + + return testPermission(tested, cachedPermissions[permissions]); + + // const { user } = (req && req.auth) || {}; + // const { login } = (process.env.OAUTH_PERMISSIONS && req && req.user) || {}; + // const key = user || login || ''; + // const logins = getLogins(); + + // if (!userPermissions[key]) { + // if (logins) { + // const login = logins.find(x => x.login == user); + // userPermissions[key] = compilePermissions(login ? login.permissions : null); + // } else { + // userPermissions[key] = compilePermissions(process.env.PERMISSIONS); + // } + // } + // return testPermission(tested, userPermissions[key]); } -let loginsCache = null; -let loginsLoaded = false; +// let loginsCache = null; +// let loginsLoaded = false; -function getLogins() { - if (loginsLoaded) { - return loginsCache; - } +// function getLogins() { +// if (loginsLoaded) { +// return loginsCache; +// } - const res = []; - if (process.env.LOGIN && process.env.PASSWORD) { - res.push({ - login: process.env.LOGIN, - password: process.env.PASSWORD, - permissions: process.env.PERMISSIONS, - }); - } - if (process.env.LOGINS) { - const logins = _.compact(process.env.LOGINS.split(',').map(x => x.trim())); - for (const login of logins) { - const password = process.env[`LOGIN_PASSWORD_${login}`]; - const permissions = process.env[`LOGIN_PERMISSIONS_${login}`]; - if (password) { - res.push({ - login, - password, - permissions, - }); - } - } - } - else if (process.env.OAUTH_PERMISSIONS) { - const login_permission_keys = Object.keys(process.env).filter((key) => _.startsWith(key, 'LOGIN_PERMISSIONS_')) - for (const permissions_key of login_permission_keys) { - const login = permissions_key.replace('LOGIN_PERMISSIONS_', ''); - const permissions = process.env[permissions_key]; - userPermissions[login] = compilePermissions(permissions); - } - } +// const res = []; +// if (process.env.LOGIN && process.env.PASSWORD) { +// res.push({ +// login: process.env.LOGIN, +// password: process.env.PASSWORD, +// permissions: process.env.PERMISSIONS, +// }); +// } +// if (process.env.LOGINS) { +// const logins = _.compact(process.env.LOGINS.split(',').map(x => x.trim())); +// for (const login of logins) { +// const password = process.env[`LOGIN_PASSWORD_${login}`]; +// const permissions = process.env[`LOGIN_PERMISSIONS_${login}`]; +// if (password) { +// res.push({ +// login, +// password, +// permissions, +// }); +// } +// } +// } else if (process.env.OAUTH_PERMISSIONS) { +// const login_permission_keys = Object.keys(process.env).filter(key => _.startsWith(key, 'LOGIN_PERMISSIONS_')); +// for (const permissions_key of login_permission_keys) { +// const login = permissions_key.replace('LOGIN_PERMISSIONS_', ''); +// const permissions = process.env[permissions_key]; +// userPermissions[login] = compilePermissions(permissions); +// } +// } - loginsCache = res.length > 0 ? res : null; - loginsLoaded = true; - return loginsCache; -} +// loginsCache = res.length > 0 ? res : null; +// loginsLoaded = true; +// return loginsCache; +// } function connectionHasPermission(connection, req) { if (!connection) { @@ -87,7 +96,6 @@ function testConnectionPermission(connection, req) { module.exports = { hasPermission, - getLogins, connectionHasPermission, testConnectionPermission, }; diff --git a/packages/api/src/utility/platformInfo.js b/packages/api/src/utility/platformInfo.js index d6af1323a..f11cbf86d 100644 --- a/packages/api/src/utility/platformInfo.js +++ b/packages/api/src/utility/platformInfo.js @@ -3,6 +3,7 @@ const os = require('os'); const path = require('path'); const processArgs = require('./processArgs'); const isElectron = require('is-electron'); +const { checkLicense } = require('./checkLicense'); const platform = process.env.OS_OVERRIDE ? process.env.OS_OVERRIDE : process.platform; const isWindows = platform === 'win32'; @@ -12,6 +13,8 @@ const isDocker = fs.existsSync('/home/dbgate-docker/public'); const isDevMode = process.env.DEVMODE == '1'; const isNpmDist = !!global['IS_NPM_DIST']; const isForkedApi = processArgs.isForkedApi; +const licenseError = checkLicense(); +const isLicenseValid = licenseError == null; // function moduleAvailable(name) { // try { @@ -30,6 +33,8 @@ const platformInfo = { isElectronBundle: isElectron() && !isDevMode, isForkedApi, isElectron: isElectron(), + isLicenseValid, + licenseError, isDevMode, isNpmDist, isSnap: process.env.ELECTRON_SNAP == 'true', diff --git a/packages/api/src/utility/socket.js b/packages/api/src/utility/socket.js index 00c6300bc..f81da41df 100644 --- a/packages/api/src/utility/socket.js +++ b/packages/api/src/utility/socket.js @@ -31,6 +31,9 @@ module.exports = { electronSender.send(message, data == null ? null : data); } for (const strmid in sseResponses) { + if (data?.strmid && data?.strmid != strmid) { + continue; + } let skipThisStream = false; if (sseResponses[strmid].filter) { for (const key in sseResponses[strmid].filter) { @@ -47,7 +50,7 @@ module.exports = { } sseResponses[strmid].response?.write( - `event: ${message}\ndata: ${stableStringify(data == null ? null : data)}\n\n` + `event: ${message}\ndata: ${stableStringify(data == null ? null : _.omit(data, ['strmid']))}\n\n` ); } }, diff --git a/packages/api/src/utility/useController.js b/packages/api/src/utility/useController.js index 3ff9afc82..12432fe18 100644 --- a/packages/api/src/utility/useController.js +++ b/packages/api/src/utility/useController.js @@ -67,7 +67,7 @@ module.exports = function useController(app, electron, route, controller) { } if (raw) { - router[method](routeAction, controller[key]); + router[method](routeAction, (req, res) => controller[key](req, res)); } else { router[method](routeAction, async (req, res) => { // if (controller._init && !controller._init_called) { diff --git a/packages/api/webpack.config.js b/packages/api/webpack.config.js index 29c9a4d06..4f80022cb 100644 --- a/packages/api/webpack.config.js +++ b/packages/api/webpack.config.js @@ -47,6 +47,8 @@ var config = { ], externals: { 'better-sqlite3': 'commonjs better-sqlite3', + 'oracledb': 'commonjs oracledb', + 'msnodesqlv8': 'commonjs msnodesqlv8', }, }; diff --git a/packages/datalib/src/ChangeSet.ts b/packages/datalib/src/ChangeSet.ts index 28396351a..0d9947beb 100644 --- a/packages/datalib/src/ChangeSet.ts +++ b/packages/datalib/src/ChangeSet.ts @@ -20,6 +20,7 @@ export interface ChangeSetItem { document?: any; condition?: { [column: string]: string }; fields?: { [column: string]: string }; + insertIfNotExistsFields?: { [column: string]: string }; } export interface ChangeSetItemFields { @@ -229,13 +230,23 @@ export function batchUpdateChangeSet( return changeSet; } -function extractFields(item: ChangeSetItem, allowNulls = true): UpdateField[] { - return _.keys(item.fields) - .filter(targetColumn => allowNulls || item.fields[targetColumn] != null) +function extractFields(item: ChangeSetItem, allowNulls = true, allowedDocumentColumns: string[] = []): UpdateField[] { + const allFields = { + ...item.fields, + }; + + for (const docField in item.document || {}) { + if (allowedDocumentColumns.includes(docField)) { + allFields[docField] = item.document[docField]; + } + } + + return _.keys(allFields) + .filter(targetColumn => allowNulls || allFields[targetColumn] != null) .map(targetColumn => ({ targetColumn, exprType: 'value', - value: item.fields[targetColumn], + value: allFields[targetColumn], })); } @@ -243,17 +254,19 @@ function changeSetInsertToSql( item: ChangeSetItem, dbinfo: DatabaseInfo = null ): [AllowIdentityInsert, Insert, AllowIdentityInsert] { - const fields = extractFields(item, false); + const table = dbinfo?.tables?.find(x => x.schemaName == item.schemaName && x.pureName == item.pureName); + const fields = extractFields( + item, + false, + table?.columns?.map(x => x.columnName) + ); if (fields.length == 0) return null; let autoInc = false; - if (dbinfo) { - const table = dbinfo.tables.find(x => x.schemaName == item.schemaName && x.pureName == item.pureName); - if (table) { - const autoIncCol = table.columns.find(x => x.autoIncrement); - // console.log('autoIncCol', autoIncCol); - if (autoIncCol && fields.find(x => x.targetColumn == autoIncCol.columnName)) { - autoInc = true; - } + if (table) { + const autoIncCol = table.columns.find(x => x.autoIncrement); + // console.log('autoIncCol', autoIncCol); + if (autoIncCol && fields.find(x => x.targetColumn == autoIncCol.columnName)) { + autoInc = true; } } const targetTable = { @@ -272,6 +285,9 @@ function changeSetInsertToSql( targetTable, commandType: 'insert', fields, + insertWhereNotExistsCondition: item.insertIfNotExistsFields + ? compileSimpleChangeSetCondition(item.insertIfNotExistsFields) + : null, }, autoInc ? { @@ -320,7 +336,39 @@ export function extractChangeSetCondition(item: ChangeSetItem, alias?: string): }; } -function changeSetUpdateToSql(item: ChangeSetItem): Update { +function compileSimpleChangeSetCondition(fields: { [column: string]: string }): Condition { + function getColumnCondition(columnName: string): Condition { + const value = fields[columnName]; + const expr: Expression = { + exprType: 'column', + columnName, + }; + if (value == null) { + return { + conditionType: 'isNull', + expr, + }; + } else { + return { + conditionType: 'binary', + operator: '=', + left: expr, + right: { + exprType: 'value', + value, + }, + }; + } + } + return { + conditionType: 'and', + conditions: _.keys(fields).map(columnName => getColumnCondition(columnName)), + }; +} + +function changeSetUpdateToSql(item: ChangeSetItem, dbinfo: DatabaseInfo = null): Update { + const table = dbinfo?.tables?.find(x => x.schemaName == item.schemaName && x.pureName == item.pureName); + return { from: { name: { @@ -329,7 +377,11 @@ function changeSetUpdateToSql(item: ChangeSetItem): Update { }, }, commandType: 'update', - fields: extractFields(item), + fields: extractFields( + item, + true, + table?.columns?.map(x => x.columnName) + ), where: extractChangeSetCondition(item), }; } @@ -351,7 +403,7 @@ export function changeSetToSql(changeSet: ChangeSet, dbinfo: DatabaseInfo): Comm return _.compact( _.flatten([ ...(changeSet.inserts.map(item => changeSetInsertToSql(item, dbinfo)) as any), - ...changeSet.updates.map(changeSetUpdateToSql), + ...changeSet.updates.map(item => changeSetUpdateToSql(item, dbinfo)), ...changeSet.deletes.map(changeSetDeleteToSql), ]) ); @@ -446,7 +498,12 @@ export function changeSetInsertNewRow(changeSet: ChangeSet, name?: NamedObjectIn }; } -export function changeSetInsertDocuments(changeSet: ChangeSet, documents: any[], name?: NamedObjectInfo): ChangeSet { +export function changeSetInsertDocuments( + changeSet: ChangeSet, + documents: any[], + name?: NamedObjectInfo, + insertIfNotExistsFieldNames?: string[] +): ChangeSet { const insertedRows = getChangeSetInsertedRows(changeSet, name); return { ...changeSet, @@ -456,6 +513,7 @@ export function changeSetInsertDocuments(changeSet: ChangeSet, documents: any[], ...name, insertedRowIndex: insertedRows.length + index, fields: doc, + insertIfNotExistsFields: insertIfNotExistsFieldNames ? _.pick(doc, insertIfNotExistsFieldNames) : null, })), ], }; diff --git a/packages/datalib/src/CustomGridDisplay.ts b/packages/datalib/src/CustomGridDisplay.ts new file mode 100644 index 000000000..ef5e96d75 --- /dev/null +++ b/packages/datalib/src/CustomGridDisplay.ts @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import { filterName, isTableColumnUnique } from 'dbgate-tools'; +import { GridDisplay, ChangeCacheFunc, DisplayColumn, DisplayedColumnInfo, ChangeConfigFunc } from './GridDisplay'; +import type { + TableInfo, + EngineDriver, + ViewInfo, + ColumnInfo, + NamedObjectInfo, + DatabaseInfo, + ForeignKeyInfo, +} from 'dbgate-types'; +import { GridConfig, GridCache, createGridCache } from './GridConfig'; +import { Expression, Select, treeToSql, dumpSqlSelect, ColumnRefExpression, Condition } from 'dbgate-sqltree'; + +export interface CustomGridColumn { + columnName: string; + columnLabel: string; + isPrimaryKey?: boolean; +} + +export class CustomGridDisplay extends GridDisplay { + customColumns: CustomGridColumn[]; + + constructor( + public tableName: NamedObjectInfo, + columns: CustomGridColumn[], + driver: EngineDriver, + config: GridConfig, + setConfig: ChangeConfigFunc, + cache: GridCache, + setCache: ChangeCacheFunc, + dbinfo: DatabaseInfo, + serverVersion, + isReadOnly = false, + public additionalcondition: Condition = null + ) { + super(config, setConfig, cache, setCache, driver, dbinfo, serverVersion); + + this.customColumns = columns; + + this.columns = columns.map(col => ({ + columnName: col.columnName, + headerText: col.columnLabel, + uniqueName: col.columnName, + uniquePath: [col.columnName], + isPrimaryKey: col.isPrimaryKey, + isForeignKeyUnique: false, + schemaName: tableName.schemaName, + pureName: tableName.pureName, + })); + + this.changeSetKeyFields = columns.filter(x => x.isPrimaryKey).map(x => x.columnName); + this.baseTable = { + ...tableName, + columns: this.columns.map(x => ({ ...tableName, columnName: x.columnName, dataType: 'string' })), + foreignKeys: [], + }; + + this.filterable = true; + this.sortable = true; + this.groupable = false; + this.editable = !isReadOnly; + this.supportsReload = true; + } + + createSelect(options = {}) { + const select = this.createSelectBase( + this.tableName, + [], + // @ts-ignore + // this.columns.map(col => ({ + // columnName: col.columnName, + // })), + options, + this.customColumns.find(x => x.isPrimaryKey)?.columnName + ); + select.selectAll = true; + if (this.additionalcondition) { + if (select.where) { + select.where = { + conditionType: 'and', + conditions: [select.where, this.additionalcondition], + }; + } else { + select.where = this.additionalcondition; + } + } + return select; + } +} diff --git a/packages/datalib/src/GridDisplay.ts b/packages/datalib/src/GridDisplay.ts index 9a6f34e53..84f031af6 100644 --- a/packages/datalib/src/GridDisplay.ts +++ b/packages/datalib/src/GridDisplay.ts @@ -556,9 +556,9 @@ export abstract class GridDisplay { }; } - createSelectBase(name: NamedObjectInfo, columns: ColumnInfo[], options) { + createSelectBase(name: NamedObjectInfo, columns: ColumnInfo[], options, defaultOrderColumnName?: string) { if (!columns) return null; - const orderColumnName = columns[0].columnName; + const orderColumnName = defaultOrderColumnName ?? columns[0]?.columnName; const select: Select = { commandType: 'select', from: { @@ -734,6 +734,7 @@ export abstract class GridDisplay { alias: 'count', }, ]; + select.selectAll = false; } return select; // const sql = treeToSql(this.driver, select, dumpSqlSelect); diff --git a/packages/datalib/src/index.ts b/packages/datalib/src/index.ts index ca6111036..85f4e40f8 100644 --- a/packages/datalib/src/index.ts +++ b/packages/datalib/src/index.ts @@ -21,3 +21,4 @@ export * from './perspectiveTools'; export * from './DataDuplicator'; export * from './FreeTableGridDisplay'; export * from './FreeTableModel'; +export * from './CustomGridDisplay'; diff --git a/packages/dbmodel/bin/dbmodel.js b/packages/dbmodel/bin/dbmodel.js index 8c666380c..34855902c 100755 --- a/packages/dbmodel/bin/dbmodel.js +++ b/packages/dbmodel/bin/dbmodel.js @@ -26,6 +26,8 @@ async function runAndExit(promise) { } } +program.version(dbgateApi.currentVersion.version); + program .option('-s, --server ', 'server host') .option('-u, --user ', 'user name') @@ -36,7 +38,8 @@ program '--load-data-condition ', 'regex, which table data will be loaded and stored in model (in load command)' ) - .requiredOption('-e, --engine ', 'engine name, eg. mysql@dbgate-plugin-mysql'); + .option('-e, --engine ', 'engine name, eg. mysql@dbgate-plugin-mysql') + .option('--commonjs', 'Creates CommonJS module'); program .command('deploy ') @@ -115,4 +118,30 @@ program ); }); +program + .command('json-to-model ') + .description('Converts JSON file to model') + .action((jsonFile, modelFolder) => { + runAndExit( + dbgateApi.jsonToDbModel({ + modelFile: jsonFile, + outputDir: modelFolder, + }) + ); + }); + +program + .command('model-to-json ') + .description('Converts model to JSON file') + .action((modelFolder, jsonFile) => { + const { commonjs } = program.opts(); + runAndExit( + dbgateApi.dbModelToJson({ + modelFolder, + outputFile: jsonFile, + commonjs, + }) + ); + }); + program.parse(process.argv); diff --git a/packages/sqltree/src/dumpSqlCommand.ts b/packages/sqltree/src/dumpSqlCommand.ts index 9a7ea6fe5..b16f3c34d 100644 --- a/packages/sqltree/src/dumpSqlCommand.ts +++ b/packages/sqltree/src/dumpSqlCommand.ts @@ -15,7 +15,7 @@ export function dumpSqlSelect(dmp: SqlDumper, cmd: Select) { if (cmd.selectAll) { dmp.put('* '); } - if (cmd.columns) { + if (cmd.columns && cmd.columns.length > 0) { if (cmd.selectAll) dmp.put('&n,'); dmp.put('&>&n'); dmp.putCollection(',&n', cmd.columns, fld => { @@ -92,13 +92,28 @@ export function dumpSqlDelete(dmp: SqlDumper, cmd: Delete) { } export function dumpSqlInsert(dmp: SqlDumper, cmd: Insert) { - dmp.put( - '^insert ^into %f (%,i) ^values (', - cmd.targetTable, - cmd.fields.map(x => x.targetColumn) - ); - dmp.putCollection(',', cmd.fields, x => dumpSqlExpression(dmp, x)); - dmp.put(')'); + if (cmd.insertWhereNotExistsCondition) { + dmp.put( + '^insert ^into %f (%,i) ^select ', + cmd.targetTable, + cmd.fields.map(x => x.targetColumn) + ); + dmp.putCollection(',', cmd.fields, x => dumpSqlExpression(dmp, x)); + if (dmp.dialect.requireFromDual) { + dmp.put(' ^from ^dual '); + } + dmp.put(' ^where ^not ^exists (^select * ^from %f ^where ', cmd.targetTable); + dumpSqlCondition(dmp, cmd.insertWhereNotExistsCondition); + dmp.put(')'); + } else { + dmp.put( + '^insert ^into %f (%,i) ^values (', + cmd.targetTable, + cmd.fields.map(x => x.targetColumn) + ); + dmp.putCollection(',', cmd.fields, x => dumpSqlExpression(dmp, x)); + dmp.put(')'); + } } export function dumpSqlCommand(dmp: SqlDumper, cmd: Command) { diff --git a/packages/sqltree/src/dumpSqlCondition.ts b/packages/sqltree/src/dumpSqlCondition.ts index a95fa86f4..96798e4b4 100644 --- a/packages/sqltree/src/dumpSqlCondition.ts +++ b/packages/sqltree/src/dumpSqlCondition.ts @@ -72,6 +72,10 @@ export function dumpSqlCondition(dmp: SqlDumper, condition: Condition) { dumpSqlExpression(dmp, condition.expr); dmp.put(' ^in (%,v)', condition.values); break; + case 'notIn': + dumpSqlExpression(dmp, condition.expr); + dmp.put(' ^not ^in (%,v)', condition.values); + break; case 'rawTemplate': let was = false; for (const item of condition.templateSql.split('$$')) { diff --git a/packages/sqltree/src/types.ts b/packages/sqltree/src/types.ts index c3ce9af29..5a8390e11 100644 --- a/packages/sqltree/src/types.ts +++ b/packages/sqltree/src/types.ts @@ -38,6 +38,7 @@ export interface Insert { commandType: 'insert'; fields: UpdateField[]; targetTable: NamedObjectInfo; + insertWhereNotExistsCondition?: Condition; } export interface AllowIdentityInsert { @@ -105,6 +106,12 @@ export interface InCondition { values: any[]; } +export interface NotInCondition { + conditionType: 'notIn'; + expr: Expression; + values: any[]; +} + export interface RawTemplateCondition { conditionType: 'rawTemplate'; templateSql: string; @@ -126,6 +133,7 @@ export type Condition = | NotExistsCondition | BetweenCondition | InCondition + | NotInCondition | RawTemplateCondition | AnyColumnPassEvalOnlyCondition; diff --git a/packages/web/src/utility/getConnectionLabel.ts b/packages/tools/src/getConnectionLabel.ts similarity index 90% rename from packages/web/src/utility/getConnectionLabel.ts rename to packages/tools/src/getConnectionLabel.ts index 2350ecc84..061b77969 100644 --- a/packages/web/src/utility/getConnectionLabel.ts +++ b/packages/tools/src/getConnectionLabel.ts @@ -31,7 +31,7 @@ function getConnectionLabelCore(connection, { allowExplicitDatabase = true } = { return ''; } -export default function getConnectionLabel(connection, { allowExplicitDatabase = true, showUnsaved = false } = {}) { +export function getConnectionLabel(connection, { allowExplicitDatabase = true, showUnsaved = false } = {}) { const res = getConnectionLabelCore(connection, { allowExplicitDatabase }); if (res && showUnsaved && connection?.unsaved) { diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index f5e614e2b..69e972cac 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -20,3 +20,4 @@ export * from './computeDiffRows'; export * from './preloadedRowsTools'; export * from './ScriptWriter'; export * from './getLogger'; +export * from './getConnectionLabel'; diff --git a/packages/tools/src/testPermission.ts b/packages/tools/src/testPermission.ts index 2fc50bea8..5727d726f 100644 --- a/packages/tools/src/testPermission.ts +++ b/packages/tools/src/testPermission.ts @@ -73,3 +73,44 @@ export function testPermission(tested: string, permissions: CompiledPermissions) return allow; } + +export function testSubPermission( + tested: string, + permissions: string[], + allowSamePermission = true +): true | false | null { + let result = null; + for (const permWithSign of permissions) { + const perm = permWithSign.startsWith('~') ? permWithSign.substring(1) : permWithSign; + const deny = permWithSign.startsWith('~'); + + if (perm.endsWith('*')) { + const prefix = perm.substring(0, perm.length - 1); + if (tested.startsWith(prefix)) { + result = !deny; + } + } else { + if (allowSamePermission && tested == perm) { + result = !deny; + } + } + } + return result; +} + +export function getPredefinedPermissions(predefinedRoleName: string) { + switch (predefinedRoleName) { + case 'superadmin': + return ['*', '~widgets/*', 'widgets/admin', 'widgets/database', '~all-connections']; + case 'logged-user': + return ['*', '~widgets/admin', '~admin/*', '~internal-storage', '~all-connections']; + case 'anonymous-user': + return ['*', '~widgets/admin', '~admin/*', '~internal-storage', '~all-connections']; + default: + return null; + } +} + +export function sortPermissionsFromTheSameLevel(permissions: string[]) { + return [...permissions.filter(x => x.startsWith('~')), ...permissions.filter(x => !x.startsWith('~'))]; +} diff --git a/packages/tools/src/yamlModelConv.ts b/packages/tools/src/yamlModelConv.ts index bf0c57982..3cda81c5b 100644 --- a/packages/tools/src/yamlModelConv.ts +++ b/packages/tools/src/yamlModelConv.ts @@ -11,6 +11,8 @@ export interface ColumnInfoYaml { length?: number; autoIncrement?: boolean; references?: string; + refDeleteAction?: string; + refUpdateAction?: string; primaryKey?: boolean; default?: string; } @@ -104,6 +106,8 @@ function convertForeignKeyFromYaml( constraintType: 'foreignKey', pureName: table.name, refTableName: col.references, + deleteAction: col.refDeleteAction, + updateAction: col.refUpdateAction, columns: [ { columnName: col.name, @@ -134,11 +138,15 @@ export function tableInfoFromYaml(table: TableInfoYaml, allTables: TableInfoYaml return res; } -export function databaseInfoFromYamlModel(files: DatabaseModelFile[]): DatabaseInfo { +export function databaseInfoFromYamlModel(filesOrDbInfo: DatabaseModelFile[] | DatabaseInfo): DatabaseInfo { + if (!Array.isArray(filesOrDbInfo)) { + return filesOrDbInfo; + } + const model = DatabaseAnalyser.createEmptyStructure(); const tablesYaml = []; - for (const file of files) { + for (const file of filesOrDbInfo) { if (file.name.endsWith('.table.yaml') || file.name.endsWith('.sql')) { if (file.name.endsWith('.table.yaml')) { tablesYaml.push(file.json); diff --git a/packages/types/dialect.d.ts b/packages/types/dialect.d.ts index 47642df6e..0db6a110f 100644 --- a/packages/types/dialect.d.ts +++ b/packages/types/dialect.d.ts @@ -34,6 +34,7 @@ export interface SqlDialect { dropCheck?: boolean; dropReferencesWhenDropTable?: boolean; + requireFromDual?: boolean; predefinedDataTypes: string[]; diff --git a/packages/types/engines.d.ts b/packages/types/engines.d.ts index 2b09547a1..e8bfc8155 100644 --- a/packages/types/engines.d.ts +++ b/packages/types/engines.d.ts @@ -90,7 +90,13 @@ export interface EngineDriver { profilerChartMeasures?: { label: string; field: string }[]; isElectronOnly?: boolean; supportedCreateDatabase?: boolean; - showConnectionField?: (field: string, values: any) => boolean; + showConnectionField?: ( + field: string, + values: any, + { + config: {}, + } + ) => boolean; showConnectionTab?: (tab: 'ssl' | 'sshTunnel', values: any) => boolean; beforeConnectionSave?: (values: any) => any; databaseUrlPlaceholder?: string; @@ -143,6 +149,8 @@ export interface EngineDriver { summaryCommand(pool, command, row): Promise; startProfiler(pool, options): Promise; stopProfiler(pool, profiler): Promise; + getRedirectAuthUrl(connection, options): Promise; + getAuthTokenFromCode(connection, options): Promise; analyserClass?: any; dumperClass?: any; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index c8fd1ad07..0c0c8c08b 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -14,13 +14,15 @@ // import { shouldWaitForElectronInitialize } from './utility/getElectron'; import { subscribeConnectionPingers } from './utility/connectionsPinger'; import { subscribePermissionCompiler } from './utility/hasPermission'; - import { apiCall } from './utility/api'; + import { apiCall, installNewVolatileConnectionListener } from './utility/api'; import { getConfig, getSettings, getUsedApps } from './utility/metadataLoaders'; import AppTitleProvider from './utility/AppTitleProvider.svelte'; import getElectron from './utility/getElectron'; import AppStartInfo from './widgets/AppStartInfo.svelte'; import SettingsListener from './utility/SettingsListener.svelte'; - import { handleAuthOnStartup, handleOauthCallback } from './clientAuth'; + import { handleAuthOnStartup } from './clientAuth'; + + export let isAdminPage = false; let loadedApi = false; let loadedPlugins = false; @@ -35,19 +37,22 @@ // console.log('************** LOADING API'); const config = await getConfig(); - await handleAuthOnStartup(config); + await handleAuthOnStartup(config, isAdminPage); const connections = await apiCall('connections/list'); const settings = await getSettings(); const apps = await getUsedApps(); - loadedApi = settings && connections && config && apps; + const loadedApiValue = !!(settings && connections && config && apps); - if (loadedApi) { + if (loadedApiValue) { subscribeApiDependendStores(); subscribeConnectionPingers(); subscribePermissionCompiler(); + installNewVolatileConnectionListener(); } + loadedApi = loadedApiValue; + if (!loadedApi) { console.log('API not initialized correctly, trying again in 1s'); setTimeout(loadApi, 1000); diff --git a/packages/web/src/ErrorPage.svelte b/packages/web/src/ErrorPage.svelte new file mode 100644 index 000000000..e04f5b034 --- /dev/null +++ b/packages/web/src/ErrorPage.svelte @@ -0,0 +1,95 @@ + + +
+
DbGate
+
+ +
+
Configuration error
+ {#if $config?.isLicenseValid == false} + + {:else if error} + + {:else} + +
+ internalRedirectTo('/')}>Back to app +
+ {/if} +
+
+
+ + diff --git a/packages/web/src/LoginPage.svelte b/packages/web/src/LoginPage.svelte index 9def0998e..e76bd2419 100644 --- a/packages/web/src/LoginPage.svelte +++ b/packages/web/src/LoginPage.svelte @@ -1,16 +1,46 @@ @@ -22,31 +52,129 @@
Log In
- - - + + {#if !isAdminPage && availableConnections} + ({ value: conn.conid, label: conn.label }))} + /> + {/if} + + {#if selectedConnection} + {#if selectedConnection.passwordMode == 'askUser'} + + {/if} + {#if selectedConnection.passwordMode == 'askUser' || selectedConnection.passwordMode == 'askPassword'} + + {/if} + {:else} + {#if !isAdminPage} + + {/if} + + {/if} + + {#if isAdminPage && $config && !$config.isAdminLoginForm} + + {/if} + + {#if isTesting} +
+ Testing connection +
+ {/if} + + {#if !isTesting && sqlConnectResult && sqlConnectResult.msgtype == 'error'} +
+ Connect failed: + {sqlConnectResult.error} +
+ {/if}
- { - enableApi(); - const resp = await apiCall('auth/login', e.detail); - if (resp.error) { - internalRedirectTo(`/?page=not-logged&error=${encodeURIComponent(resp.error)}`); - return; - } - const { accessToken } = resp; - if (accessToken) { - localStorage.setItem('accessToken', accessToken); - internalRedirectTo('/'); - return; - } - internalRedirectTo(`/?page=not-logged`); - }} - /> + {#if selectedConnection?.useRedirectDbLogin} + { + const state = `dbg-dblogin:${strmid}:${selectedConnection?.conid}`; + sessionStorage.setItem('dbloginAuthState', state); + // openWebLink( + // `connections/dblogin?conid=${selectedConnection?.conid}&state=${encodeURIComponent(state)}&redirectUri=${ + // location.origin + location.pathname + // }` + // ); + internalRedirectTo( + `/connections/dblogin?conid=${selectedConnection?.conid}&state=${encodeURIComponent(state)}&redirectUri=${ + location.origin + location.pathname + }` + ); + }} + /> + {:else if selectedConnection} + { + if (selectedConnection.passwordMode == 'askUser' || selectedConnection.passwordMode == 'askPassword') { + enableApi(); + isTesting = true; + testIdRef.update(x => x + 1); + const testid = testIdRef.get(); + const resp = await apiCall('connections/dblogin-auth', { + conid: selectedConnection.conid, + user: $values['login'], + password: $values['password'], + }); + if (testIdRef.get() != testid) return; + isTesting = false; + if (resp.accessToken) { + localStorage.setItem('accessToken', resp.accessToken); + internalRedirectTo('/'); + } else { + sqlConnectResult = resp; + } + } else { + enableApi(); + const resp = await apiCall('connections/dblogin-auth', { + conid: selectedConnection.conid, + }); + localStorage.setItem('accessToken', resp.accessToken); + internalRedirectTo('/'); + } + }} + /> + {:else} + { + enableApi(); + const resp = await apiCall('auth/login', { + isAdminPage, + ...e.detail, + }); + if (resp.error) { + internalRedirectTo( + `/?page=not-logged&error=${encodeURIComponent(resp.error)}&is-admin=${isAdminPage ? 'true' : ''}` + ); + return; + } + const { accessToken } = resp; + if (accessToken) { + localStorage.setItem(isAdminPage ? 'adminAccessToken' : 'accessToken', accessToken); + if (isAdminPage) { + internalRedirectTo('/?page=admin'); + } else { + internalRedirectTo('/'); + } + return; + } + internalRedirectTo(`/?page=not-logged`); + }} + /> + {/if}
-
+
diff --git a/packages/web/src/NotLoggedPage.svelte b/packages/web/src/NotLoggedPage.svelte index 0dad204f0..0dcf8bfe4 100644 --- a/packages/web/src/NotLoggedPage.svelte +++ b/packages/web/src/NotLoggedPage.svelte @@ -1,7 +1,7 @@ diff --git a/packages/web/src/appobj/ConnectionAppObject.svelte b/packages/web/src/appobj/ConnectionAppObject.svelte index 100211744..b5d977565 100644 --- a/packages/web/src/appobj/ConnectionAppObject.svelte +++ b/packages/web/src/appobj/ConnectionAppObject.svelte @@ -98,7 +98,6 @@ import openNewTab from '../utility/openNewTab'; import { getDatabaseMenuItems } from './DatabaseAppObject.svelte'; import getElectron from '../utility/getElectron'; - import getConnectionLabel from '../utility/getConnectionLabel'; import { getDatabaseList, useUsedApps } from '../utility/metadataLoaders'; import { getLocalStorage } from '../utility/storageCache'; import { apiCall, removeVolatileMapping } from '../utility/api'; @@ -106,6 +105,7 @@ import { closeMultipleTabs } from '../tabpanel/TabsPanel.svelte'; import AboutModal from '../modals/AboutModal.svelte'; import { tick } from 'svelte'; + import { getConnectionLabel } from 'dbgate-tools'; export let data; export let passProps; diff --git a/packages/web/src/appobj/DatabaseAppObject.svelte b/packages/web/src/appobj/DatabaseAppObject.svelte index 252274674..21c1ce657 100644 --- a/packages/web/src/appobj/DatabaseAppObject.svelte +++ b/packages/web/src/appobj/DatabaseAppObject.svelte @@ -340,7 +340,6 @@
- + Browse
diff --git a/packages/web/src/forms/FormSelectFieldRaw.svelte b/packages/web/src/forms/FormSelectFieldRaw.svelte index d67d76605..b2c6ac814 100644 --- a/packages/web/src/forms/FormSelectFieldRaw.svelte +++ b/packages/web/src/forms/FormSelectFieldRaw.svelte @@ -10,6 +10,7 @@ export let options; export let isClearable = false; export let selectFieldComponent = SelectField; + export let defaultSelectValue; const { values, setFieldValue } = getFormContext(); @@ -17,7 +18,7 @@ { setFieldValue(name, e.detail); diff --git a/packages/web/src/icons/FontIcon.svelte b/packages/web/src/icons/FontIcon.svelte index bec34ccdd..6bd0312d4 100644 --- a/packages/web/src/icons/FontIcon.svelte +++ b/packages/web/src/icons/FontIcon.svelte @@ -24,6 +24,10 @@ 'icon sql-generator': 'mdi mdi-cog-transfer', 'icon keyboard': 'mdi mdi-keyboard-settings', 'icon settings': 'mdi mdi-cog', + 'icon users': 'mdi mdi-account-multiple', + 'icon role': 'mdi mdi-account-group', + 'icon admin': 'mdi mdi-security', + 'icon auth': 'mdi mdi-account-key', 'icon version': 'mdi mdi-ticket-confirmation', 'icon pin': 'mdi mdi-pin', 'icon arrange': 'mdi mdi-arrange-send-to-back', @@ -185,6 +189,10 @@ 'img app-query': 'mdi mdi-view-comfy color-icon-magenta', 'img connection': 'mdi mdi-connection color-icon-blue', 'img profiler': 'mdi mdi-gauge color-icon-blue', + 'img users': 'mdi mdi-account-multiple color-icon-blue', + 'img role': 'mdi mdi-account-group color-icon-blue', + 'img admin': 'mdi mdi-security color-icon-blue', + 'img auth': 'mdi mdi-account-key color-icon-blue', 'img add': 'mdi mdi-plus-circle color-icon-green', 'img minus': 'mdi mdi-minus-circle color-icon-red', diff --git a/packages/web/src/impexp/FormConnectionSelect.svelte b/packages/web/src/impexp/FormConnectionSelect.svelte index 0f71c9925..7d7a1ee56 100644 --- a/packages/web/src/impexp/FormConnectionSelect.svelte +++ b/packages/web/src/impexp/FormConnectionSelect.svelte @@ -1,8 +1,8 @@ @@ -53,11 +57,11 @@ ]} /> -{#if driver?.showConnectionField('databaseFile', $values)} +{#if driver?.showConnectionField('databaseFile', $values, showConnectionFieldArgs)} {/if} -{#if driver?.showConnectionField('useDatabaseUrl', $values)} +{#if driver?.showConnectionField('useDatabaseUrl', $values, showConnectionFieldArgs)}
{/if} -{#if driver?.showConnectionField('databaseUrl', $values)} +{#if driver?.showConnectionField('databaseUrl', $values, showConnectionFieldArgs)} {/if} -{#if $authTypes && driver?.showConnectionField('authType', $values)} - ({ - value: auth.name, - label: auth.title, - }))} - /> +{#if $authTypes && driver?.showConnectionField('authType', $values, showConnectionFieldArgs)} + {#key $authTypes} + ({ + value: auth.name, + label: auth.title, + }))} + /> + {/key} {/if} -{#if driver?.showConnectionField('server', $values)} +{#if driver?.showConnectionField('clientLibraryPath', $values, showConnectionFieldArgs)} + +{/if} + +{#if driver?.showConnectionField('server', $values, showConnectionFieldArgs)}
- {#if driver?.showConnectionField('port', $values)} + {#if driver?.showConnectionField('port', $values, showConnectionFieldArgs)}
{/if} -{#if driver?.showConnectionField('socketPath', $values)} +{#if driver?.showConnectionField('socketPath', $values, showConnectionFieldArgs)} {/if} -{#if driver?.showConnectionField('treeKeySeparator', $values)} +{#if driver?.showConnectionField('treeKeySeparator', $values, showConnectionFieldArgs)} {/if} -{#if driver?.showConnectionField('windowsDomain', $values)} +{#if driver?.showConnectionField('windowsDomain', $values, showConnectionFieldArgs)} {/if} -{#if driver?.showConnectionField('isReadOnly', $values)} +{#if driver?.showConnectionField('isReadOnly', $values, showConnectionFieldArgs)} {/if} -{#if driver?.showConnectionField('trustServerCertificate', $values)} +{#if driver?.showConnectionField('trustServerCertificate', $values, showConnectionFieldArgs)} {/if} -{#if driver?.showConnectionField('defaultDatabase', $values)} +{#if driver?.showConnectionField('defaultDatabase', $values, showConnectionFieldArgs)} {/if} -{#if defaultDatabase && driver?.showConnectionField('singleDatabase', $values)} +{#if defaultDatabase && driver?.showConnectionField('singleDatabase', $values, showConnectionFieldArgs)} {/if} diff --git a/packages/web/src/settings/ConnectionSshTunnelFields.svelte b/packages/web/src/settings/ConnectionSshTunnelFields.svelte index bde6ef679..5e1ddd46b 100644 --- a/packages/web/src/settings/ConnectionSshTunnelFields.svelte +++ b/packages/web/src/settings/ConnectionSshTunnelFields.svelte @@ -19,11 +19,11 @@ $: useSshTunnel = $values.useSshTunnel; $: platformInfo = usePlatformInfo(); - $: { - if (!$values.sshMode) setFieldValue('sshMode', 'userPassword'); - // if (!$values.sshPort) setFieldValue('sshPort', '22'); - if (!$values.sshKeyfile && $platformInfo) setFieldValue('sshKeyfile', $platformInfo.defaultKeyfile); - } + // $: { + // if (!$values.sshMode) setFieldValue('sshMode', 'userPassword'); + // // if (!$values.sshPort) setFieldValue('sshPort', '22'); + // if (!$values.sshKeyfile && $platformInfo) setFieldValue('sshKeyfile', $platformInfo.defaultKeyfile); + // } $: isConnected = $openedConnections.includes($values._id) || $openedSingleDatabaseConnections.includes($values._id); @@ -55,6 +55,7 @@ label="SSH Authentication" name="sshMode" isNative + defaultSelectValue="userPassword" disabled={isConnected || !useSshTunnel} options={[ { value: 'userPassword', label: 'Username & password' }, @@ -63,11 +64,11 @@ ]} /> -{#if $values.sshMode != 'userPassword'} +{#if ($values.sshMode || 'userPassword') != 'userPassword'} {/if} -{#if $values.sshMode == 'userPassword'} +{#if ($values.sshMode || 'userPassword') == 'userPassword'}
diff --git a/packages/web/src/stores.ts b/packages/web/src/stores.ts index 583d86174..202f23989 100644 --- a/packages/web/src/stores.ts +++ b/packages/web/src/stores.ts @@ -7,6 +7,7 @@ import { getSettings, useConfig, useSettings } from './utility/metadataLoaders'; import _ from 'lodash'; import { safeJsonParse } from 'dbgate-tools'; import { apiCall } from './utility/api'; +import { getOpenedTabsStorageName, isAdminPage } from './utility/pageDefs'; export interface TabDefinition { title: string; @@ -72,7 +73,10 @@ function subscribeCssVariable(store, transform, cssVariable) { store.subscribe(value => document.documentElement.style.setProperty(cssVariable, transform(value))); } -export const selectedWidget = writableWithStorage('database', 'selectedWidget'); +export const selectedWidget = writableWithStorage( + isAdminPage() ? 'admin' : 'database', + isAdminPage() ? 'selectedAdminWidget' : 'selectedWidget' +); export const lockedDatabaseMode = writableWithStorage(false, 'lockedDatabaseMode'); export const visibleWidgetSideBar = writableWithStorage(true, 'visibleWidgetSideBar'); export const visibleSelectedWidget = derived( @@ -86,7 +90,7 @@ export const temporaryOpenedConnections = writable([]); export const openedSingleDatabaseConnections = writable([]); export const expandedConnections = writable([]); export const currentDatabase = writable(null); -export const openedTabs = writableWithForage([], 'openedTabs', x => [...(x || [])]); +export const openedTabs = writableWithForage([], getOpenedTabsStorageName(), x => [...(x || [])]); export const copyRowsFormat = writableWithStorage('textWithoutHeaders', 'copyRowsFormat'); export const extensions = writable(null); export const visibleCommandPalette = writable(null); diff --git a/packages/web/src/tabpanel/TabsPanel.svelte b/packages/web/src/tabpanel/TabsPanel.svelte index 0bd36b16b..d859fd2eb 100644 --- a/packages/web/src/tabpanel/TabsPanel.svelte +++ b/packages/web/src/tabpanel/TabsPanel.svelte @@ -158,6 +158,7 @@ function getTabDbName(tab, connectionList) { if (tab.tabComponent == 'ConnectionTab') return 'Connections'; + if (tab.tabComponent?.startsWith('Admin')) return 'Administration'; if (tab.props && tab.props.conid && tab.props.database) return tab.props.database; if (tab.props && tab.props.conid) { const connection = connectionList?.find(x => x._id == tab.props.conid); @@ -174,6 +175,7 @@ if (key.startsWith('archive://')) return 'icon archive'; if (key.startsWith('server://')) return 'icon server'; if (key.startsWith('connections.')) return 'icon connection'; + if (key.startsWith('admin.')) return 'icon admin'; } return 'icon file'; } @@ -285,7 +287,6 @@ import tabs from '../tabs'; import { setSelectedTab } from '../utility/common'; import contextMenu from '../utility/contextMenu'; - import getConnectionLabel from '../utility/getConnectionLabel'; import { isElectronAvailable } from '../utility/getElectron'; import { getConnectionInfo, useConnectionList } from '../utility/metadataLoaders'; import { duplicateTab, getTabDbKey, sortTabs, groupTabs } from '../utility/openNewTab'; @@ -293,6 +294,7 @@ import TabCloseButton from '../elements/TabCloseButton.svelte'; import CloseTabModal from '../modals/CloseTabModal.svelte'; import SwitchDatabaseModal from '../modals/SwitchDatabaseModal.svelte'; + import { getConnectionLabel } from 'dbgate-tools'; export let multiTabIndex; export let shownTab; diff --git a/packages/web/src/tabs/ConnectionTab.svelte b/packages/web/src/tabs/ConnectionTab.svelte index d17e3fe48..9d627e112 100644 --- a/packages/web/src/tabs/ConnectionTab.svelte +++ b/packages/web/src/tabs/ConnectionTab.svelte @@ -28,30 +28,37 @@ import { apiCall } from '../utility/api'; import { showSnackbarError, showSnackbarSuccess } from '../utility/snackbar'; import { changeTab } from '../utility/common'; - import getConnectionLabel from '../utility/getConnectionLabel'; + import { getConnectionLabel } from 'dbgate-tools'; import { onMount } from 'svelte'; import { disconnectServerConnection, openConnection } from '../appobj/ConnectionAppObject.svelte'; import { disconnectDatabaseConnection } from '../appobj/DatabaseAppObject.svelte'; + import { useConfig } from '../utility/metadataLoaders'; export let connection; export let tabid; export let conid; + export let connectionStore = undefined; + + export let onlyTestButton; let isTesting; let sqlConnectResult; - const values = writable( - connection || { - server: getCurrentConfig().isDocker ? 'dockerhost' : 'localhost', - engine: '', - } - ); + const values = + connectionStore || + writable( + connection || { + server: getCurrentConfig().isDocker ? 'dockerhost' : 'localhost', + engine: '', + } + ); // $: console.log('ConnectionTab.$values', $values); // $: console.log('ConnectionTab.driver', driver); $: engine = $values.engine; $: driver = $extensions.drivers.find(x => x.engine == engine); + $: config = useConfig(); const testIdRef = createRef(0); @@ -86,7 +93,7 @@ 'socketPath', 'serviceName', ]; - const visibleProps = allProps.filter(x => driver?.showConnectionField(x, $values)); + const visibleProps = allProps.filter(x => driver?.showConnectionField(x, $values, { config: $config })); const omitProps = _.difference(allProps, visibleProps); if (!$values.defaultDatabase) omitProps.push('singleDatabase'); @@ -174,6 +181,11 @@ } }); + export function changeConnectionBeforeSave(connection) { + if (driver?.beforeConnectionSave) return driver.beforeConnectionSave(connection); + return connection; + } + $: isConnected = $openedConnections.includes($values._id) || $openedSingleDatabaseConnections.includes($values._id); // $: console.log('CONN VALUES', $values); @@ -204,7 +216,13 @@ {#if driver}
- {#if isConnected} + {#if onlyTestButton} + {#if isTesting} + + {:else} + + {/if} + {:else if isConnected} {:else} diff --git a/packages/web/src/tabs/index-pro.js b/packages/web/src/tabs/index-pro.js new file mode 100644 index 000000000..7c645e42f --- /dev/null +++ b/packages/web/src/tabs/index-pro.js @@ -0,0 +1 @@ +export default {}; \ No newline at end of file diff --git a/packages/web/src/tabs/index.js b/packages/web/src/tabs/index.js index b828fdb7e..2049232a7 100644 --- a/packages/web/src/tabs/index.js +++ b/packages/web/src/tabs/index.js @@ -30,6 +30,8 @@ import * as ProfilerTab from './ProfilerTab.svelte'; import * as DataDuplicatorTab from './DataDuplicatorTab.svelte'; import * as ImportExportTab from './ImportExportTab.svelte'; +import protabs from './index-pro'; + export default { TableDataTab, CollectionDataTab, @@ -62,4 +64,5 @@ export default { ProfilerTab, DataDuplicatorTab, ImportExportTab, + ...protabs, }; diff --git a/packages/web/src/utility/ErrorHandler.svelte b/packages/web/src/utility/ErrorHandler.svelte index 18e65cf0e..87b808b78 100644 --- a/packages/web/src/utility/ErrorHandler.svelte +++ b/packages/web/src/utility/ErrorHandler.svelte @@ -3,6 +3,7 @@ import _ from 'lodash'; import { TabDefinition } from '../stores'; import getElectron from './getElectron'; + import { getOpenedTabsStorageName } from './pageDefs'; let counter = 0; $: counterCopy = counter; @@ -26,15 +27,15 @@ ) ) { try { - let openedTabs = (await localforage.getItem('openedTabs')) || []; + let openedTabs = (await localforage.getItem(getOpenedTabsStorageName())) || []; if (!_.isArray(openedTabs)) openedTabs = []; openedTabs = openedTabs .map(tab => (tab.closedTime ? tab : { ...tab, closedTime: new Date().getTime() })) .map(tab => ({ ...tab, selected: false })); - await localforage.setItem('openedTabs', openedTabs); + await localforage.setItem(getOpenedTabsStorageName(), openedTabs); await localStorage.setItem('selectedWidget', 'history'); } catch (err) { - localforage.removeItem('openedTabs'); + localforage.removeItem(getOpenedTabsStorageName()); } // try { // await localforage.clear(); diff --git a/packages/web/src/utility/InnerActivator.svelte b/packages/web/src/utility/InnerActivator.svelte new file mode 100644 index 000000000..924f4b51d --- /dev/null +++ b/packages/web/src/utility/InnerActivator.svelte @@ -0,0 +1,8 @@ + diff --git a/packages/web/src/utility/OpenTabsOnStartup.svelte b/packages/web/src/utility/OpenTabsOnStartup.svelte index ae18fe1ad..71009c5f4 100644 --- a/packages/web/src/utility/OpenTabsOnStartup.svelte +++ b/packages/web/src/utility/OpenTabsOnStartup.svelte @@ -4,7 +4,7 @@ import runCommand from '../commands/runCommand'; import ErrorMessageModal from '../modals/ErrorMessageModal.svelte'; import { showModal } from '../modals/modalTools'; - import { openedTabs } from '../stores'; + import { commandsCustomized, openedTabs } from '../stores'; import { getConfig, getConnectionList, useFavorites } from './metadataLoaders'; import openNewTab from './openNewTab'; @@ -49,7 +49,11 @@ } } - if (!$openedTabs.find(x => x.closedTime == null) && !(await getConnectionList()).find(x => !x.unsaved)) { + if ( + !$openedTabs.find(x => x.closedTime == null) && + !(await getConnectionList()).find(x => !x.unsaved) && + $commandsCustomized['new.connection']?.enabled + ) { openNewTab({ title: 'New Connection', icon: 'img connection', diff --git a/packages/web/src/utility/api.ts b/packages/web/src/utility/api.ts index 9e46e0296..875c44c4c 100644 --- a/packages/web/src/utility/api.ts +++ b/packages/web/src/utility/api.ts @@ -4,22 +4,37 @@ import { writable } from 'svelte/store'; import getElectron from './getElectron'; // import socket from './socket'; import { showSnackbarError } from '../utility/snackbar'; -import { isOauthCallback, redirectToLogin } from '../clientAuth'; +import { isOauthCallback, redirectToAdminLogin, redirectToLogin } from '../clientAuth'; import { showModal } from '../modals/modalTools'; import DatabaseLoginModal, { isDatabaseLoginVisible } from '../modals/DatabaseLoginModal.svelte'; import _ from 'lodash'; import uuidv1 from 'uuid/v1'; +import { openWebLink } from './exportFileTools'; +import { callServerPing } from './connectionsPinger'; +import { batchDispatchCacheTriggers, dispatchCacheChange } from './cache'; export const strmid = uuidv1(); let eventSource; -let apiLogging = false; +let apiLogging = true; // let cacheCleanerRegistered; let apiDisabled = false; const disabledOnOauth = isOauthCallback(); -const volatileConnectionMap = {}; -const volatileConnectionMapInv = {}; +export const volatileConnectionMapStore = writable({}); +export const volatileConnectionMapInvStore = writable({}); + +let volatileConnectionMapValue = {}; +volatileConnectionMapStore.subscribe(value => { + volatileConnectionMapValue = value; +}); +export const getVolatileConnectionMap = () => volatileConnectionMapValue; + +let volatileConnectionMapInvValue = {}; +volatileConnectionMapInvStore.subscribe(value => { + volatileConnectionMapInvValue = value; +}); +export const getVolatileConnectionInvMap = () => volatileConnectionMapInvValue; export function disableApi() { apiDisabled = true; @@ -30,23 +45,29 @@ export function enableApi() { } export function setVolatileConnectionRemapping(existingConnectionId, volatileConnectionId) { - volatileConnectionMap[existingConnectionId] = volatileConnectionId; - volatileConnectionMapInv[volatileConnectionId] = existingConnectionId; + volatileConnectionMapStore.update(x => ({ + ...x, + [existingConnectionId]: volatileConnectionId, + })); + volatileConnectionMapInvStore.update(x => ({ + ...x, + [volatileConnectionId]: existingConnectionId, + })); } export function getVolatileRemapping(conid) { - return volatileConnectionMap[conid] || conid; + return volatileConnectionMapValue[conid] || conid; } export function getVolatileRemappingInv(conid) { - return volatileConnectionMapInv[conid] || conid; + return volatileConnectionMapInvValue[conid] || conid; } export function removeVolatileMapping(conid) { - const mapped = volatileConnectionMap[conid]; + const mapped = volatileConnectionMapValue[conid]; if (mapped) { - delete volatileConnectionMap[conid]; - delete volatileConnectionMapInv[mapped]; + volatileConnectionMapStore.update(x => _.omit(x, conid)); + volatileConnectionMapInvStore.update(x => _.omit(x, mapped)); } } @@ -63,7 +84,15 @@ function processApiResponse(route, args, resp) { // } if (resp?.missingCredentials) { - if (!isDatabaseLoginVisible()) { + if (resp.detail.redirectToDbLogin) { + const state = `dbg-dblogin:${strmid}:${resp.detail.conid}`; + localStorage.setItem('dbloginState', state); + openWebLink( + `connections/dblogin?conid=${resp.detail.conid}&state=${encodeURIComponent(state)}&redirectUri=${ + location.origin + location.pathname + }` + ); + } else if (!isDatabaseLoginVisible()) { showModal(DatabaseLoginModal, resp.detail); } return null; @@ -83,16 +112,16 @@ function processApiResponse(route, args, resp) { export function transformApiArgs(args) { return _.mapValues(args, (v, k) => { - if (k == 'conid' && v && volatileConnectionMap[v]) return volatileConnectionMap[v]; - if (k == 'conidArray' && _.isArray(v)) return v.map(x => volatileConnectionMap[x] || x); + if (k == 'conid' && v && volatileConnectionMapValue[v]) return volatileConnectionMapValue[v]; + if (k == 'conidArray' && _.isArray(v)) return v.map(x => volatileConnectionMapValue[x] || x); return v; }); } export function transformApiArgsInv(args) { return _.mapValues(args, (v, k) => { - if (k == 'conid' && v && volatileConnectionMapInv[v]) return volatileConnectionMapInv[v]; - if (k == 'conidArray' && _.isArray(v)) return v.map(x => volatileConnectionMapInv[x] || x); + if (k == 'conid' && v && volatileConnectionMapInvValue[v]) return volatileConnectionMapInvValue[v]; + if (k == 'conidArray' && _.isArray(v)) return v.map(x => volatileConnectionMapInvValue[x] || x); return v; }); } @@ -132,9 +161,13 @@ export async function apiCall(route: string, args: {} = undefined) { disableApi(); console.log('Disabling API', route); - if (params.get('page') != 'login' && params.get('page') != 'not-logged') { + if (params.get('page') != 'login' && params.get('page') != 'admin-login' && params.get('page') != 'not-logged') { // unauthorized - redirectToLogin(); + if (params.get('page') == 'admin') { + redirectToAdminLogin(); + } else { + redirectToLogin(); + } } return; } @@ -205,6 +238,19 @@ export function useApiCall(route, args, defaultValue) { return result; } +export function getVolatileConnections() { + return Object.values(volatileConnectionMapValue); +} + +export function installNewVolatileConnectionListener() { + apiOn('got-volatile-token', async ({ savedConId, volatileConId }) => { + setVolatileConnectionRemapping(savedConId, volatileConId); + await callServerPing(); + dispatchCacheChange({ key: `server-status-changed` }); + batchDispatchCacheTriggers(x => x.conid == savedConId); + }); +} + function enableApiLog() { apiLogging = true; console.log('API loggin enabled'); diff --git a/packages/web/src/utility/common.ts b/packages/web/src/utility/common.ts index e5c083cb2..4afd1801c 100644 --- a/packages/web/src/utility/common.ts +++ b/packages/web/src/utility/common.ts @@ -1,6 +1,5 @@ import { getOpenedTabs, openedTabs } from '../stores'; import _ from 'lodash'; -import getElectron from './getElectron'; export class LoadingToken { isCanceled = false; diff --git a/packages/web/src/utility/connectionsPinger.js b/packages/web/src/utility/connectionsPinger.js index 259f19070..c921082c1 100644 --- a/packages/web/src/utility/connectionsPinger.js +++ b/packages/web/src/utility/connectionsPinger.js @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { openedConnections, currentDatabase, openedConnectionsWithTemporary } from '../stores'; -import { apiCall, strmid } from './api'; -import { getConnectionList } from './metadataLoaders'; +import { currentDatabase, openedConnectionsWithTemporary, getCurrentConfig, getOpenedConnections } from '../stores'; +import { apiCall, getVolatileConnections, strmid } from './api'; +import hasPermission from '../utility/hasPermission'; // const doServerPing = async value => { // const connectionList = getConnectionList(); @@ -10,7 +10,21 @@ import { getConnectionList } from './metadataLoaders'; // }; const doServerPing = value => { - apiCall('server-connections/ping', { conidArray: value, strmid }); + const config = getCurrentConfig(); + + const conidArray = [...value]; + if (config.storageDatabase && hasPermission('internal-storage')) { + conidArray.push('__storage'); + } + conidArray.push(...getVolatileConnections()); + if (config.singleConnection) { + conidArray.push(config.singleConnection._id); + } + + apiCall('server-connections/ping', { + conidArray, + strmid, + }); }; const doDatabasePing = value => { @@ -38,3 +52,8 @@ export function subscribeConnectionPingers() { currentDatabaseHandle = window.setInterval(() => doDatabasePing(value), 20 * 1000); }); } + +export function callServerPing() { + const connections = getOpenedConnections(); + doServerPing(connections); +} diff --git a/packages/web/src/utility/hasPermission.ts b/packages/web/src/utility/hasPermission.ts index 8f546afa2..64f162671 100644 --- a/packages/web/src/utility/hasPermission.ts +++ b/packages/web/src/utility/hasPermission.ts @@ -4,13 +4,17 @@ import { useConfig } from './metadataLoaders'; let compiled = null; export default function hasPermission(tested) { + // console.log('TESTING PERM', tested, compiled, testPermission(tested, compiled)); return testPermission(tested, compiled); } export function subscribePermissionCompiler() { + // console.log('subscribePermissionCompiler', compiled); + useConfig().subscribe(value => { if (!value) return; const { permissions } = value; compiled = compilePermissions(permissions); + // console.log('COMPILED PERMS', compiled); }); } diff --git a/packages/web/src/utility/localStorageGarbageCollector.js b/packages/web/src/utility/localStorageGarbageCollector.js index 86af35a3c..bee6f51b3 100644 --- a/packages/web/src/utility/localStorageGarbageCollector.js +++ b/packages/web/src/utility/localStorageGarbageCollector.js @@ -1,14 +1,15 @@ import moment from 'moment'; import localforage from 'localforage'; +import { getOpenedTabsStorageName } from './pageDefs'; export default async function localStorageGarbageCollector() { - const openedTabsJson = await localforage.getItem('openedTabs'); + const openedTabsJson = await localforage.getItem(getOpenedTabsStorageName()); let openedTabs = openedTabsJson ?? []; const closeLimit = moment().add(-7, 'day').valueOf(); openedTabs = openedTabs.filter(x => !x.closedTime || x.closedTime > closeLimit); - await localforage.setItem('openedTabs', openedTabs); + await localforage.setItem(getOpenedTabsStorageName(), openedTabs); const toRemove = []; for (const key in localStorage) { diff --git a/packages/web/src/utility/openElectronFile.ts b/packages/web/src/utility/openElectronFile.ts index 6d498a48a..a7f1e4fef 100644 --- a/packages/web/src/utility/openElectronFile.ts +++ b/packages/web/src/utility/openElectronFile.ts @@ -5,7 +5,7 @@ import ImportExportModal from '../modals/ImportExportModal.svelte'; import getElectron from './getElectron'; import { currentDatabase, extensions, getCurrentDatabase } from '../stores'; import { getUploadListener } from './uploadFiles'; -import getConnectionLabel, { getDatabaseFileLabel } from './getConnectionLabel'; +import {getConnectionLabel, getDatabaseFileLabel } from 'dbgate-tools'; import { apiCall } from './api'; import openNewTab from './openNewTab'; import { openJsonDocument } from '../tabs/JsonTab.svelte'; diff --git a/packages/web/src/utility/openNewTab.ts b/packages/web/src/utility/openNewTab.ts index fbb8a642d..687002dce 100644 --- a/packages/web/src/utility/openNewTab.ts +++ b/packages/web/src/utility/openNewTab.ts @@ -152,6 +152,9 @@ export function getTabDbKey(tab) { if (tab.tabComponent == 'ConnectionTab') { return 'connections.'; } + if (tab.tabComponent?.startsWith('Admin')) { + return 'admin.'; + } if (tab.props && tab.props.conid && tab.props.database) { return `database://${tab.props.database}-${tab.props.conid}`; } diff --git a/packages/web/src/utility/pageDefs.ts b/packages/web/src/utility/pageDefs.ts new file mode 100644 index 000000000..10495d214 --- /dev/null +++ b/packages/web/src/utility/pageDefs.ts @@ -0,0 +1,15 @@ +let isAdminPageCache; + +export function isAdminPage() { + if (isAdminPageCache == null) { + const params = new URLSearchParams(location.search); + const urlPage = params.get('page'); + + isAdminPageCache = urlPage == 'admin'; + } + return isAdminPageCache; +} + +export function getOpenedTabsStorageName() { + return isAdminPage() ? 'adminOpenedTabs' : 'openedTabs'; +} diff --git a/packages/web/src/utility/resolveApi.ts b/packages/web/src/utility/resolveApi.ts index 23333b0b3..40329867b 100644 --- a/packages/web/src/utility/resolveApi.ts +++ b/packages/web/src/utility/resolveApi.ts @@ -1,4 +1,5 @@ import getElectron from './getElectron'; +import { isAdminPage } from './pageDefs'; let apiUrl = null; try { @@ -16,9 +17,12 @@ export function resolveApiHeaders() { const electron = getElectron(); const res = {}; - const accessToken = localStorage.getItem('accessToken'); + const accessToken = localStorage.getItem(isAdminPage() ? 'adminAccessToken' : 'accessToken'); if (accessToken) { res['Authorization'] = `Bearer ${accessToken}`; } + if (isAdminPage()) { + res['x-is-admin-page'] = 'true'; + } return res; } diff --git a/packages/web/src/widgets/AdminMenuWidget.svelte b/packages/web/src/widgets/AdminMenuWidget.svelte new file mode 100644 index 000000000..8ad0da5dd --- /dev/null +++ b/packages/web/src/widgets/AdminMenuWidget.svelte @@ -0,0 +1 @@ +Sorry, administration is not available \ No newline at end of file diff --git a/packages/web/src/widgets/ConnectionList.svelte b/packages/web/src/widgets/ConnectionList.svelte index ff0073fea..03160390b 100644 --- a/packages/web/src/widgets/ConnectionList.svelte +++ b/packages/web/src/widgets/ConnectionList.svelte @@ -18,11 +18,11 @@ collapsedConnectionGroupNames, } from '../stores'; import runCommand from '../commands/runCommand'; - import getConnectionLabel from '../utility/getConnectionLabel'; + import { getConnectionLabel } from 'dbgate-tools'; import { useConnectionColorFactory } from '../utility/useConnectionColor'; import FontIcon from '../icons/FontIcon.svelte'; import CloseSearchButton from '../buttons/CloseSearchButton.svelte'; - import { apiCall, getVolatileRemapping } from '../utility/api'; + import { apiCall, volatileConnectionMapStore } from '../utility/api'; import LargeButton from '../buttons/LargeButton.svelte'; import { plusExpandIcon, chevronExpandIcon } from '../icons/expandIcons'; import { safeJsonParse } from 'dbgate-tools'; @@ -37,7 +37,10 @@ $: connectionsWithStatus = $connections && $serverStatus - ? $connections.map(conn => ({ ...conn, status: $serverStatus[getVolatileRemapping(conn._id)] })) + ? $connections.map(conn => ({ + ...conn, + status: $serverStatus[$volatileConnectionMapStore[conn._id] || conn._id], + })) : $connections; $: connectionsWithStatusFiltered = connectionsWithStatus?.filter( diff --git a/packages/web/src/widgets/StatusBar.svelte b/packages/web/src/widgets/StatusBar.svelte index bc824dbb8..338854879 100644 --- a/packages/web/src/widgets/StatusBar.svelte +++ b/packages/web/src/widgets/StatusBar.svelte @@ -14,7 +14,7 @@ selectedWidget, visibleCommandPalette, } from '../stores'; - import getConnectionLabel from '../utility/getConnectionLabel'; + import { getConnectionLabel } from 'dbgate-tools'; import { useConnectionList, useDatabaseServerVersion, useDatabaseStatus } from '../utility/metadataLoaders'; import { findCommand } from '../commands/runCommand'; import { useConnectionColor } from '../utility/useConnectionColor'; diff --git a/packages/web/src/widgets/WidgetContainer.svelte b/packages/web/src/widgets/WidgetContainer.svelte index 2d41c11dc..096c920c8 100644 --- a/packages/web/src/widgets/WidgetContainer.svelte +++ b/packages/web/src/widgets/WidgetContainer.svelte @@ -7,6 +7,7 @@ import CellDataWidget from './CellDataWidget.svelte'; import HistoryWidget from './HistoryWidget.svelte'; import AppWidget from './AppWidget.svelte'; + import AdminMenuWidget from './AdminMenuWidget.svelte';
{/if} - {#each widgets.filter(x => hasPermission(`widgets/${x.name}`)) as item} + {#each widgets.filter(x => x && hasPermission(`widgets/${x.name}`)) as item}
+
diff --git a/plugins/dbgate-plugin-mongo/src/backend/Analyser.js b/plugins/dbgate-plugin-mongo/src/backend/Analyser.js index dcc8e6669..8799f6c22 100644 --- a/plugins/dbgate-plugin-mongo/src/backend/Analyser.js +++ b/plugins/dbgate-plugin-mongo/src/backend/Analyser.js @@ -33,7 +33,7 @@ class Analyser extends DatabaseAnalyser { collections: [ ...collections.map((x, index) => ({ pureName: x.name, - tableRowCount: stats[index].count, + tableRowCount: stats[index]?.count, })), ...views.map((x, index) => ({ pureName: x.name, diff --git a/plugins/dbgate-plugin-mssql/package.json b/plugins/dbgate-plugin-mssql/package.json index 51d1d9efd..47925b83f 100644 --- a/plugins/dbgate-plugin-mssql/package.json +++ b/plugins/dbgate-plugin-mssql/package.json @@ -31,12 +31,13 @@ "plugout": "dbgate-plugout dbgate-plugin-mssql" }, "devDependencies": { + "async-lock": "^1.2.6", + "@azure/msal-node": "^2.12.0", "dbgate-plugin-tools": "^1.0.7", "dbgate-query-splitter": "^4.10.1", - "webpack": "^5.91.0", - "webpack-cli": "^5.1.4", "dbgate-tools": "^5.0.0-alpha.1", "tedious": "^18.2.0", - "async-lock": "^1.2.6" + "webpack": "^5.91.0", + "webpack-cli": "^5.1.4" } -} \ No newline at end of file +} diff --git a/plugins/dbgate-plugin-mssql/src/backend/azureAuth.js b/plugins/dbgate-plugin-mssql/src/backend/azureAuth.js new file mode 100644 index 000000000..5ebca70d1 --- /dev/null +++ b/plugins/dbgate-plugin-mssql/src/backend/azureAuth.js @@ -0,0 +1,22 @@ +function getAzureAuthTypes(platformInfo) { + return null; +} + +async function azureGetRedirectAuthUrl(connection) { + return null; +} + +async function azureGetAuthTokenFromCode(connection, code) { + return null; +} + +function getAzureAuthOptions(connection) { + return null; +} + +module.exports = { + getAzureAuthTypes, + azureGetRedirectAuthUrl, + azureGetAuthTokenFromCode, + getAzureAuthOptions, +}; diff --git a/plugins/dbgate-plugin-mssql/src/backend/driver.js b/plugins/dbgate-plugin-mssql/src/backend/driver.js index 4168015c5..1e688254e 100644 --- a/plugins/dbgate-plugin-mssql/src/backend/driver.js +++ b/plugins/dbgate-plugin-mssql/src/backend/driver.js @@ -8,8 +8,11 @@ const AsyncLock = require('async-lock'); const nativeDriver = require('./nativeDriver'); const lock = new AsyncLock(); const { tediousConnect, tediousQueryCore, tediousReadQuery, tediousStream } = require('./tediousDriver'); +const { getAzureAuthTypes, azureGetRedirectAuthUrl, azureGetAuthTokenFromCode } = require('./azureAuth'); const { nativeConnect, nativeQueryCore, nativeReadQuery, nativeStream } = nativeDriver; + let requireMsnodesqlv8; +let platformInfo; const versionQuery = ` SELECT @@ -52,7 +55,14 @@ const driver = { analyserClass: MsSqlAnalyser, getAuthTypes() { - return requireMsnodesqlv8 ? windowsAuthTypes : null; + const res = []; + if (requireMsnodesqlv8) res.push(...windowsAuthTypes); + const azureAuthTypes = getAzureAuthTypes(platformInfo); + if (azureAuthTypes) res.push(...azureAuthTypes); + if (res.length > 0) { + return _.uniqBy(res, 'name'); + } + return null; }, async connect(conn) { @@ -115,12 +125,19 @@ const driver = { const { rows } = await this.query(pool, 'SELECT name FROM sys.databases order by name'); return rows; }, + getRedirectAuthUrl(connection, options) { + return azureGetRedirectAuthUrl(connection, options); + }, + getAuthTokenFromCode(connection, options) { + return azureGetAuthTokenFromCode(connection, options); + }, }; driver.initialize = dbgateEnv => { if (dbgateEnv.nativeModules && dbgateEnv.nativeModules.msnodesqlv8) { requireMsnodesqlv8 = dbgateEnv.nativeModules.msnodesqlv8; } + platformInfo = dbgateEnv.platformInfo; nativeDriver.initialize(dbgateEnv); }; diff --git a/plugins/dbgate-plugin-mssql/src/backend/tediousDriver.js b/plugins/dbgate-plugin-mssql/src/backend/tediousDriver.js index d1efd36df..91bf1fda5 100644 --- a/plugins/dbgate-plugin-mssql/src/backend/tediousDriver.js +++ b/plugins/dbgate-plugin-mssql/src/backend/tediousDriver.js @@ -2,6 +2,7 @@ const _ = require('lodash'); const stream = require('stream'); const tedious = require('tedious'); const makeUniqueColumnNames = require('./makeUniqueColumnNames'); +const { getAzureAuthOptions } = require('./azureAuth'); function extractTediousColumns(columns, addDriverNativeColumn = false) { const res = columns.map(col => { @@ -22,10 +23,11 @@ function extractTediousColumns(columns, addDriverNativeColumn = false) { return res; } -async function tediousConnect({ server, port, user, password, database, ssl, trustServerCertificate, windowsDomain }) { +async function tediousConnect(storedConnection) { + const { server, port, user, password, database, ssl, trustServerCertificate, windowsDomain, authType } = storedConnection; return new Promise((resolve, reject) => { const connectionOptions = { - encrypt: !!ssl, + encrypt: !!ssl || authType == 'msentra', cryptoCredentialsDetails: ssl ? _.pick(ssl, ['ca', 'cert', 'key']) : undefined, trustServerCertificate: ssl ? (!ssl.ca && !ssl.cert && !ssl.key ? true : ssl.rejectUnauthorized) : undefined, enableArithAbort: true, @@ -40,18 +42,21 @@ async function tediousConnect({ server, port, user, password, database, ssl, tru connectionOptions.database = database; } + const authentication = + authType == 'msentra' + ? getAzureAuthOptions(storedConnection) + : { + type: windowsDomain ? 'ntlm' : 'default', + options: { + userName: user, + password: password, + ...(windowsDomain ? { domain: windowsDomain } : {}), + }, + }; + const connection = new tedious.Connection({ server, - - authentication: { - type: windowsDomain ? 'ntlm' : 'default', - options: { - userName: user, - password: password, - ...(windowsDomain ? { domain: windowsDomain } : {}), - }, - }, - + authentication, options: connectionOptions, }); connection.on('connect', function (err) { diff --git a/plugins/dbgate-plugin-mssql/src/frontend/driver.js b/plugins/dbgate-plugin-mssql/src/frontend/driver.js index 84a7ddba1..fe0ab05c6 100644 --- a/plugins/dbgate-plugin-mssql/src/frontend/driver.js +++ b/plugins/dbgate-plugin-mssql/src/frontend/driver.js @@ -130,7 +130,7 @@ const driver = { field ) || (field == 'trustServerCertificate' && values.authType != 'sql' && values.authType != 'sspi') || - (field == 'windowsDomain' && values.authType != 'sql' && values.authType != 'sspi'), + (field == 'windowsDomain' && values.authType != 'sql' && values.authType != 'sspi' && values.authType != 'msentra'), // (field == 'useDatabaseUrl' && values.authType != 'sql' && values.authType != 'sspi') getQuerySplitterOptions: usage => usage == 'editor' @@ -154,6 +154,13 @@ const driver = { }, ]; }, + + beforeConnectionSave: connection => { + return { + ...connection, + useRedirectDbLogin: connection.authType == 'msentra' ? 1 : 0, + }; + }, }; module.exports = driver; diff --git a/plugins/dbgate-plugin-oracle/package.json b/plugins/dbgate-plugin-oracle/package.json index c53bdb571..85c837f35 100644 --- a/plugins/dbgate-plugin-oracle/package.json +++ b/plugins/dbgate-plugin-oracle/package.json @@ -36,8 +36,5 @@ "lodash": "^4.17.21", "webpack": "^5.91.0", "webpack-cli": "^5.1.4" - }, - "dependencies": { - "oracledb": "^6.5.1" } } diff --git a/plugins/dbgate-plugin-oracle/src/backend/drivers.js b/plugins/dbgate-plugin-oracle/src/backend/driver.js similarity index 88% rename from plugins/dbgate-plugin-oracle/src/backend/drivers.js rename to plugins/dbgate-plugin-oracle/src/backend/driver.js index 8ff5cb243..8d50b394e 100644 --- a/plugins/dbgate-plugin-oracle/src/backend/drivers.js +++ b/plugins/dbgate-plugin-oracle/src/backend/driver.js @@ -1,12 +1,22 @@ const _ = require('lodash'); const stream = require('stream'); -const driverBases = require('../frontend/drivers'); +const driverBase = require('../frontend/driver'); const Analyser = require('./Analyser'); -//--const pg = require('pg'); -const oracledb = require('oracledb'); const { createBulkInsertStreamBase, makeUniqueColumnNames } = require('dbgate-tools'); const createOracleBulkInsertStream = require('./createOracleBulkInsertStream'); +const { platform } = require('os'); + +let requireOracledb; +let platformInfo; + +let oracledbValue; +function getOracledb() { + if (!oracledbValue) { + oracledbValue = requireOracledb(); + } + return oracledbValue; +} /* pg.types.setTypeParser(1082, 'text', val => val); // date @@ -33,8 +43,10 @@ function zipDataRow(rowArray, columns) { return obj; } +let oracleClientInitialized = false; + /** @type {import('dbgate-types').EngineDriver} */ -const drivers = driverBases.map(driverBase => ({ +const driver = { ...driverBase, analyserClass: Analyser, @@ -51,8 +63,14 @@ const drivers = driverBases.map(driverBase => ({ ssl, isReadOnly, authType, + clientLibraryPath, socketPath, }) { + const oracledb = getOracledb(); + if (authType == 'thick' && !oracleClientInitialized) { + oracledb.initOracleClient({ libDir: clientLibraryPath || process.env.ORACLE_INSTANT_CLIENT }); + oracleClientInitialized = true; + } client = await oracledb.getConnection({ user, password, @@ -315,17 +333,26 @@ const drivers = driverBases.map(driverBase => ({ }, getAuthTypes() { - return [ - { - title: 'Host and port', - name: 'hostPort', - }, - { - title: 'Socket', - name: 'socket', - }, - ]; + if (platformInfo?.isElectron || process.env.ORACLE_INSTANT_CLIENT) { + return [ + { + title: 'Thin mode (default) - direct connection to Oracle database', + name: 'thin', + }, + { + title: 'Thick mode - connection via Oracle instant client', + name: 'thick', + }, + ]; + } }, -})); +}; -module.exports = drivers; +driver.initialize = dbgateEnv => { + if (dbgateEnv.nativeModules && dbgateEnv.nativeModules['oracledb']) { + requireOracledb = dbgateEnv.nativeModules['oracledb']; + } + platformInfo = dbgateEnv.platformInfo; +}; + +module.exports = driver; diff --git a/plugins/dbgate-plugin-oracle/src/backend/index.js b/plugins/dbgate-plugin-oracle/src/backend/index.js index de004729b..5f45d5a3e 100644 --- a/plugins/dbgate-plugin-oracle/src/backend/index.js +++ b/plugins/dbgate-plugin-oracle/src/backend/index.js @@ -1,6 +1,9 @@ -const drivers = require('./drivers'); +const driver = require('./driver'); module.exports = { packageName: 'dbgate-plugin-oracle', - drivers, + drivers: [driver], + initialize(dbgateEnv) { + driver.initialize(dbgateEnv); + }, }; diff --git a/plugins/dbgate-plugin-oracle/src/frontend/drivers.js b/plugins/dbgate-plugin-oracle/src/frontend/driver.js similarity index 91% rename from plugins/dbgate-plugin-oracle/src/frontend/drivers.js rename to plugins/dbgate-plugin-oracle/src/frontend/driver.js index 69608365a..ebd9bfe84 100644 --- a/plugins/dbgate-plugin-oracle/src/frontend/drivers.js +++ b/plugins/dbgate-plugin-oracle/src/frontend/driver.js @@ -35,6 +35,7 @@ const dialect = { dropCheck: true, dropReferencesWhenDropTable: true, + requireFromDual: true, predefinedDataTypes: [ 'VARCHAR2', @@ -81,10 +82,15 @@ const dialect = { }, }; -const oracleDriverBase = { - ...driverBase, - dumperClass: Dumper, +/** @type {import('dbgate-types').EngineDriver} */ +const oracleDriver = { + engine: 'oracle@dbgate-plugin-oracle', + title: 'OracleDB', + defaultPort: 1521, + authTypeLabel: 'Driver mode', + defaultAuthTypeName: 'thin', dialect, + dumperClass: Dumper, // showConnectionField: (field, values) => // ['server', 'port', 'user', 'password', 'defaultDatabase', 'singleDatabase'].includes(field), getQuerySplitterOptions: () => oracleSplitterOptions, @@ -92,8 +98,11 @@ const oracleDriverBase = { databaseUrlPlaceholder: 'e.g. localhost:1521/orcl', - showConnectionField: (field, values) => { + showConnectionField: (field, values, { config }) => { if (field == 'useDatabaseUrl') return true; + if (field == 'authType') return true; + if (field == 'clientLibraryPath') return config?.isElectron && values.authType == 'thick'; + if (values.useDatabaseUrl) { return ['databaseUrl', 'user', 'password'].includes(field); } @@ -125,18 +134,6 @@ $$ LANGUAGE plpgsql;`, }, ]; }, -}; - -/** @type {import('dbgate-types').EngineDriver} */ -const oracleDriver = { - ...oracleDriverBase, - engine: 'oracle@dbgate-plugin-oracle', - title: 'OracleDB', - defaultPort: 1521, - dialect: { - ...dialect, - materializedViews: true, - }, dialectByVersion(version) { if (version) { @@ -155,4 +152,4 @@ const oracleDriver = { showConnectionTab: field => field == 'sshTunnel', }; -module.exports = [oracleDriver]; +module.exports = oracleDriver; diff --git a/plugins/dbgate-plugin-oracle/src/frontend/index.js b/plugins/dbgate-plugin-oracle/src/frontend/index.js index ca201649c..0659d3a5e 100644 --- a/plugins/dbgate-plugin-oracle/src/frontend/index.js +++ b/plugins/dbgate-plugin-oracle/src/frontend/index.js @@ -1,6 +1,6 @@ -import drivers from './drivers'; +import driver from './driver'; export default { packageName: 'dbgate-plugin-oracle', - drivers, + drivers: [driver], }; diff --git a/yarn.lock b/yarn.lock index 7c89521b2..9ff5025b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -164,6 +164,20 @@ resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.10.0.tgz#215449726717b53d549953db77562cad6cb8421c" integrity sha512-Zk6DPDz7e1wPgLoLgAp0349Yay9RvcjPM5We/ehuenDNsz/t9QEFI7tRoHpp/e47I4p20XE3FiDlhKwAo3utDA== +"@azure/msal-common@14.14.0": + version "14.14.0" + resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.14.0.tgz#31a015070d5864ebcf9ebb988fcbc5c5536f22d1" + integrity sha512-OxcOk9H1/1fktHh6//VCORgSNJc2dCQObTm6JNmL824Z6iZSO6eFo/Bttxe0hETn9B+cr7gDouTQtsRq3YPuSQ== + +"@azure/msal-node@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.12.0.tgz#57ee6b6011a320046d72dc0828fec46278f2ab2c" + integrity sha512-jmk5Im5KujRA2AcyCb0awA3buV8niSrwXZs+NBJWIvxOz76RvNlusGIqi43A0h45BPUy93Qb+CPdpJn82NFTIg== + dependencies: + "@azure/msal-common" "14.14.0" + jsonwebtoken "^9.0.0" + uuid "^8.3.0" + "@azure/msal-node@^2.5.1": version "2.8.0" resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.8.0.tgz#ef6e4a76bcd0851f7b1240d94b00fe1f9a52d559" @@ -7978,10 +7992,10 @@ optionator@^0.8.1, optionator@^0.8.3: resolved "https://registry.yarnpkg.com/opts/-/opts-2.0.2.tgz#a17e189fbbfee171da559edd8a42423bc5993ce1" integrity sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg== -oracledb@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/oracledb/-/oracledb-6.5.1.tgz#814d985035acdb1a6470b1152af0ca3767569ede" - integrity sha512-JzoSGei1wnvmqgKnAZK1W650mzHTZXx+7hClV4mwsbY/ZjUtrpnojNJMYJ2jkOhj7XG5oJPfXc4GqDKaNzkxqg== +oracledb@^6.6.0: + version "6.6.0" + resolved "https://registry.yarnpkg.com/oracledb/-/oracledb-6.6.0.tgz#bb40adbe81a84a1e544c48af9f120c61f030e936" + integrity sha512-T3dx+o3j+tVN53wQyr4yGTmoPHLy+a2V8yb1T2PmWrsj3ZlSt2Yu1BgV2yTDqnmBZYpRi/I3yJXRCOHHD7PiyA== os-tmpdir@~1.0.2: version "1.0.2"