diff --git a/packages/api/.env b/packages/api/.env index dc654e025..1a2d10b07 100644 --- a/packages/api/.env +++ b/packages/api/.env @@ -1,5 +1,7 @@ DEVMODE=1 SHELL_SCRIPTING=1 +# LOCAL_DBGATE_CLOUD=1 +# LOCAL_DBGATE_IDENTITY=1 # CLOUD_UPGRADE_FILE=c:\test\upg\upgrade.zip diff --git a/packages/api/src/controllers/auth.js b/packages/api/src/controllers/auth.js index 15600f156..4f614f065 100644 --- a/packages/api/src/controllers/auth.js +++ b/packages/api/src/controllers/auth.js @@ -13,6 +13,8 @@ const { } = require('../auth/authProvider'); const storage = require('./storage'); const { decryptPasswordString } = require('../utility/crypting'); +const { createDbGateIdentitySession, startCloudTokenChecking } = require('../utility/cloudIntf'); +const socket = require('../utility/socket'); const logger = getLogger('auth'); @@ -135,5 +137,14 @@ module.exports = { return getAuthProviderById(amoid).redirect(params); }, + createCloudLoginSession_meta: true, + async createCloudLoginSession({ client }) { + const res = await createDbGateIdentitySession(client); + startCloudTokenChecking(res.sid, tokenHolder => { + socket.emit('got-cloud-token', tokenHolder); + }); + return res; + }, + authMiddleware, }; diff --git a/packages/api/src/controllers/cloud.js b/packages/api/src/controllers/cloud.js new file mode 100644 index 000000000..c2cc1af2d --- /dev/null +++ b/packages/api/src/controllers/cloud.js @@ -0,0 +1,250 @@ +const { + getPublicCloudFiles, + getPublicFileData, + refreshPublicFiles, + callCloudApiGet, + callCloudApiPost, + getCloudFolderEncryptor, + getCloudContent, + putCloudContent, + removeCloudCachedConnection, +} = require('../utility/cloudIntf'); +const connections = require('./connections'); +const socket = require('../utility/socket'); +const { recryptConnection, getInternalEncryptor, encryptConnection } = require('../utility/crypting'); +const { getConnectionLabel, getLogger, extractErrorLogData } = require('dbgate-tools'); +const logger = getLogger('cloud'); +const _ = require('lodash'); +const fs = require('fs-extra'); + +module.exports = { + publicFiles_meta: true, + async publicFiles() { + const res = await getPublicCloudFiles(); + return res; + }, + + publicFileData_meta: true, + async publicFileData({ path }) { + const res = getPublicFileData(path); + return res; + }, + + refreshPublicFiles_meta: true, + async refreshPublicFiles({ isRefresh }) { + await refreshPublicFiles(isRefresh); + return { + status: 'ok', + }; + }, + + contentList_meta: true, + async contentList() { + try { + const resp = await callCloudApiGet('content-list'); + return resp; + } catch (err) { + logger.error(extractErrorLogData(err), 'Error getting cloud content list'); + + return []; + } + }, + + getContent_meta: true, + async getContent({ folid, cntid }) { + const resp = await getCloudContent(folid, cntid); + return resp; + }, + + putContent_meta: true, + async putContent({ folid, cntid, content, name, type }) { + const resp = await putCloudContent(folid, cntid, content, name, type); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + createFolder_meta: true, + async createFolder({ name }) { + const resp = await callCloudApiPost(`folders/create`, { name }); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + grantFolder_meta: true, + async grantFolder({ inviteLink }) { + const m = inviteLink.match(/^dbgate\:\/\/folder\/v1\/([a-zA-Z0-9]+)\?mode=(read|write|admin)$/); + if (!m) { + throw new Error('Invalid invite link format'); + } + const invite = m[1]; + const mode = m[2]; + + const resp = await callCloudApiPost(`folders/grant/${mode}`, { invite }); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + renameFolder_meta: true, + async renameFolder({ folid, name }) { + const resp = await callCloudApiPost(`folders/rename`, { folid, name }); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + deleteFolder_meta: true, + async deleteFolder({ folid }) { + const resp = await callCloudApiPost(`folders/delete`, { folid }); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + getInviteToken_meta: true, + async getInviteToken({ folid, role }) { + const resp = await callCloudApiGet(`invite-token/${folid}/${role}`); + return resp; + }, + + refreshContent_meta: true, + async refreshContent() { + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return { + status: 'ok', + }; + }, + + copyConnectionCloud_meta: true, + async copyConnectionCloud({ conid, folid }) { + const conn = await connections.getCore({ conid }); + const folderEncryptor = await getCloudFolderEncryptor(folid); + const recryptedConn = recryptConnection(conn, getInternalEncryptor(), folderEncryptor); + const connToSend = _.omit(recryptedConn, ['_id']); + const resp = await putCloudContent( + folid, + undefined, + JSON.stringify(connToSend), + getConnectionLabel(conn), + 'connection' + ); + return resp; + }, + + saveConnection_meta: true, + async saveConnection({ folid, connection }) { + let cntid = undefined; + if (connection._id) { + const m = connection._id.match(/^cloud\:\/\/(.+)\/(.+)$/); + if (!m) { + throw new Error('Invalid cloud connection ID format'); + } + folid = m[1]; + cntid = m[2]; + } + + if (!folid) { + throw new Error('Missing cloud folder ID'); + } + + const folderEncryptor = await getCloudFolderEncryptor(folid); + const recryptedConn = encryptConnection(connection, folderEncryptor); + const resp = await putCloudContent( + folid, + cntid, + JSON.stringify(recryptedConn), + getConnectionLabel(recryptedConn), + 'connection' + ); + + if (resp.apiErrorMessage) { + return resp; + } + + removeCloudCachedConnection(folid, resp.cntid); + cntid = resp.cntid; + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return { + ...recryptedConn, + _id: `cloud://${folid}/${cntid}`, + }; + }, + + duplicateConnection_meta: true, + async duplicateConnection({ conid }) { + const m = conid.match(/^cloud\:\/\/(.+)\/(.+)$/); + if (!m) { + throw new Error('Invalid cloud connection ID format'); + } + const folid = m[1]; + const cntid = m[2]; + const respGet = await getCloudContent(folid, cntid); + const conn = JSON.parse(respGet.content); + const conn2 = { + ...conn, + displayName: getConnectionLabel(conn) + ' - copy', + }; + const respPut = await putCloudContent(folid, undefined, JSON.stringify(conn2), conn2.displayName, 'connection'); + return respPut; + }, + + deleteConnection_meta: true, + async deleteConnection({ conid }) { + const m = conid.match(/^cloud\:\/\/(.+)\/(.+)$/); + if (!m) { + throw new Error('Invalid cloud connection ID format'); + } + const folid = m[1]; + const cntid = m[2]; + const resp = await callCloudApiPost(`content/delete/${folid}/${cntid}`); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + deleteContent_meta: true, + async deleteContent({ folid, cntid }) { + const resp = await callCloudApiPost(`content/delete/${folid}/${cntid}`); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + renameContent_meta: true, + async renameContent({ folid, cntid, name }) { + const resp = await callCloudApiPost(`content/rename/${folid}/${cntid}`, { name }); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + saveFile_meta: true, + async saveFile({ folid, cntid, fileName, data, contentFolder, format }) { + const resp = await putCloudContent(folid, cntid, data, fileName, 'file', contentFolder, format); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + copyFile_meta: true, + async copyFile({ folid, cntid, name }) { + const resp = await callCloudApiPost(`content/duplicate/${folid}/${cntid}`, { name }); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; + }, + + exportFile_meta: true, + async exportFile({ folid, cntid, filePath }, req) { + const { content } = await getCloudContent(folid, cntid); + if (!content) { + throw new Error('File not found'); + } + await fs.writeFile(filePath, content); + return true; + }, +}; diff --git a/packages/api/src/controllers/connections.js b/packages/api/src/controllers/connections.js index 13a452a64..f5243f0d0 100644 --- a/packages/api/src/controllers/connections.js +++ b/packages/api/src/controllers/connections.js @@ -239,6 +239,19 @@ module.exports = { return (await this.datastore.find()).filter(x => connectionHasPermission(x, req)); }, + async getUsedEngines() { + const storage = require('./storage'); + + const storageEngines = await storage.getUsedEngines(); + if (storageEngines) { + return storageEngines; + } + if (portalConnections) { + return _.uniq(_.compact(portalConnections.map(x => x.engine))); + } + return _.uniq((await this.datastore.find()).map(x => x.engine)); + }, + test_meta: true, test({ connection, requestDbList = false }) { const subprocess = fork( @@ -410,6 +423,13 @@ module.exports = { return volatile; } + const cloudMatch = conid.match(/^cloud\:\/\/(.+)\/(.+)$/); + if (cloudMatch) { + const { loadCachedCloudConnection } = require('../utility/cloudIntf'); + const conn = await loadCachedCloudConnection(cloudMatch[1], cloudMatch[2]); + return conn; + } + const storage = require('./storage'); const storageConnection = await storage.getConnection({ conid }); diff --git a/packages/api/src/controllers/databaseConnections.js b/packages/api/src/controllers/databaseConnections.js index 9bfe37013..4f50b1085 100644 --- a/packages/api/src/controllers/databaseConnections.js +++ b/packages/api/src/controllers/databaseConnections.js @@ -148,6 +148,9 @@ module.exports = { const existing = this.opened.find(x => x.conid == conid && x.database == database); if (existing) return existing; const connection = await connections.getCore({ conid }); + if (!connection) { + throw new Error(`databaseConnections: Connection with conid="${conid}" not found`); + } if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') { throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode }); } diff --git a/packages/api/src/controllers/serverConnections.js b/packages/api/src/controllers/serverConnections.js index d9a065bd9..68e89e477 100644 --- a/packages/api/src/controllers/serverConnections.js +++ b/packages/api/src/controllers/serverConnections.js @@ -52,7 +52,7 @@ module.exports = { if (existing) return existing; const connection = await connections.getCore({ conid }); if (!connection) { - throw new Error(`Connection with conid="${conid}" not found`); + throw new Error(`serverConnections: Connection with conid="${conid}" not found`); } if (connection.singleDatabase) { return null; diff --git a/packages/api/src/controllers/storage.js b/packages/api/src/controllers/storage.js index 6d498f869..f7066eb22 100644 --- a/packages/api/src/controllers/storage.js +++ b/packages/api/src/controllers/storage.js @@ -32,4 +32,8 @@ module.exports = { }, startRefreshLicense() {}, + + async getUsedEngines() { + return null; + }, }; diff --git a/packages/api/src/main.js b/packages/api/src/main.js index ac0c33ef5..571593bd1 100644 --- a/packages/api/src/main.js +++ b/packages/api/src/main.js @@ -27,6 +27,7 @@ const plugins = require('./controllers/plugins'); const files = require('./controllers/files'); const scheduler = require('./controllers/scheduler'); const queryHistory = require('./controllers/queryHistory'); +const cloud = require('./controllers/cloud'); const onFinished = require('on-finished'); const processArgs = require('./utility/processArgs'); @@ -39,6 +40,7 @@ const { getDefaultAuthProvider } = require('./auth/authProvider'); const startCloudUpgradeTimer = require('./utility/cloudUpgrade'); const { isProApp } = require('./utility/checkLicense'); const { getHealthStatus, getHealthStatusSprinx } = require('./utility/healthStatus'); +const { startCloudFiles } = require('./utility/cloudIntf'); const logger = getLogger('main'); @@ -200,6 +202,8 @@ function start() { if (process.env.CLOUD_UPGRADE_FILE) { startCloudUpgradeTimer(); } + + startCloudFiles(); } function useAllControllers(app, electron) { @@ -220,6 +224,7 @@ function useAllControllers(app, electron) { useController(app, electron, '/query-history', queryHistory); useController(app, electron, '/apps', apps); useController(app, electron, '/auth', auth); + useController(app, electron, '/cloud', cloud); } function setElectronSender(electronSender) { diff --git a/packages/api/src/proc/connectProcess.js b/packages/api/src/proc/connectProcess.js index 6d375c596..58d2ac454 100644 --- a/packages/api/src/proc/connectProcess.js +++ b/packages/api/src/proc/connectProcess.js @@ -28,14 +28,7 @@ function start() { let version = { version: 'Unknown', }; - try { - version = await driver.getVersion(dbhan); - } catch (err) { - logger.error(extractErrorLogData(err), 'Error getting DB server version'); - version = { - version: 'Unknown', - }; - } + version = await driver.getVersion(dbhan); let databases = undefined; if (requestDbList) { databases = await driver.listDatabases(dbhan); diff --git a/packages/api/src/utility/authProxy.js b/packages/api/src/utility/authProxy.js index 0d998fec2..58984a664 100644 --- a/packages/api/src/utility/authProxy.js +++ b/packages/api/src/utility/authProxy.js @@ -36,6 +36,10 @@ async function callRefactorSqlQueryApi(query, task, structure, dialect) { return null; } +function getLicenseHttpHeaders() { + return {}; +} + module.exports = { isAuthProxySupported, authProxyGetRedirectUrl, @@ -47,4 +51,5 @@ module.exports = { callTextToSqlApi, callCompleteOnCursorApi, callRefactorSqlQueryApi, + getLicenseHttpHeaders, }; diff --git a/packages/api/src/utility/cloudIntf.js b/packages/api/src/utility/cloudIntf.js new file mode 100644 index 000000000..329a55fc0 --- /dev/null +++ b/packages/api/src/utility/cloudIntf.js @@ -0,0 +1,380 @@ +const axios = require('axios'); +const fs = require('fs-extra'); +const _ = require('lodash'); +const path = require('path'); +const { getLicenseHttpHeaders } = require('./authProxy'); +const { getLogger, extractErrorLogData, jsonLinesParse } = require('dbgate-tools'); +const { datadir } = require('./directories'); +const platformInfo = require('./platformInfo'); +const connections = require('../controllers/connections'); +const { isProApp } = require('./checkLicense'); +const socket = require('./socket'); +const config = require('../controllers/config'); +const simpleEncryptor = require('simple-encryptor'); +const currentVersion = require('../currentVersion'); +const { getPublicIpInfo } = require('./hardwareFingerprint'); + +const logger = getLogger('cloudIntf'); + +let cloudFiles = null; + +const DBGATE_IDENTITY_URL = process.env.LOCAL_DBGATE_IDENTITY + ? 'http://localhost:3103' + : process.env.DEVWEB || process.env.DEVMODE + ? 'https://identity.dbgate.udolni.net' + : 'https://identity.dbgate.io'; + +const DBGATE_CLOUD_URL = process.env.LOCAL_DBGATE_CLOUD + ? 'http://localhost:3110' + : process.env.DEVWEB || process.env.DEVMODE + ? 'https://cloud.dbgate.udolni.net' + : 'https://cloud.dbgate.io'; + +async function createDbGateIdentitySession(client) { + const resp = await axios.default.post( + `${DBGATE_IDENTITY_URL}/api/create-session`, + { + client, + }, + { + headers: { + ...getLicenseHttpHeaders(), + 'Content-Type': 'application/json', + }, + } + ); + return { + sid: resp.data.sid, + url: `${DBGATE_IDENTITY_URL}/api/signin/${resp.data.sid}`, + }; +} + +function startCloudTokenChecking(sid, callback) { + const started = Date.now(); + const interval = setInterval(async () => { + if (Date.now() - started > 60 * 1000) { + clearInterval(interval); + return; + } + + try { + // console.log(`Checking cloud token for session: ${DBGATE_IDENTITY_URL}/api/get-token/${sid}`); + const resp = await axios.default.get(`${DBGATE_IDENTITY_URL}/api/get-token/${sid}`, { + headers: { + ...getLicenseHttpHeaders(), + }, + }); + // console.log('CHECK RESP:', resp.data); + + if (resp.data.email) { + clearInterval(interval); + callback(resp.data); + } + } catch (err) { + logger.error(extractErrorLogData(err), 'Error checking cloud token'); + } + }, 500); +} + +async function loadCloudFiles() { + try { + const fileContent = await fs.readFile(path.join(datadir(), 'cloud-files.jsonl'), 'utf-8'); + const parsedJson = jsonLinesParse(fileContent); + cloudFiles = _.sortBy(parsedJson, x => `${x.folder}/${x.title}`); + } catch (err) { + cloudFiles = []; + } +} + +async function collectCloudFilesSearchTags() { + const res = []; + if (platformInfo.isElectron) { + res.push('app'); + } else { + res.push('web'); + } + if (platformInfo.isWindows) { + res.push('windows'); + } + if (platformInfo.isMac) { + res.push('mac'); + } + if (platformInfo.isLinux) { + res.push('linux'); + } + if (platformInfo.isAwsUbuntuLayout) { + res.push('aws'); + } + if (platformInfo.isAzureUbuntuLayout) { + res.push('azure'); + } + if (platformInfo.isSnap) { + res.push('snap'); + } + if (platformInfo.isDocker) { + res.push('docker'); + } + if (platformInfo.isNpmDist) { + res.push('npm'); + } + const engines = await connections.getUsedEngines(); + const engineTags = engines.map(engine => engine.split('@')[0]); + res.push(...engineTags); + + // team-premium and trials will return the same cloud files as premium - no need to check + res.push(isProApp() ? 'premium' : 'community'); + + return res; +} + +async function getCloudSigninHolder() { + const settingsValue = await config.getSettings(); + const holder = settingsValue['cloudSigninTokenHolder']; + return holder; +} + +async function getCloudSigninHeaders(holder = null) { + if (!holder) { + holder = await getCloudSigninHolder(); + } + if (holder) { + return { + 'x-cloud-login': holder.token, + }; + } + return null; +} + +async function updateCloudFiles(isRefresh) { + let lastCloudFilesTags; + try { + lastCloudFilesTags = await fs.readFile(path.join(datadir(), 'cloud-files-tags.txt'), 'utf-8'); + } catch (err) { + lastCloudFilesTags = ''; + } + + const ipInfo = await getPublicIpInfo(); + + const tags = (await collectCloudFilesSearchTags()).join(','); + let lastCheckedTm = 0; + if (tags == lastCloudFilesTags && cloudFiles.length > 0) { + lastCheckedTm = _.max(cloudFiles.map(x => parseInt(x.modifiedTm))); + } + + logger.info({ tags, lastCheckedTm }, 'Downloading cloud files'); + + const resp = await axios.default.get( + `${DBGATE_CLOUD_URL}/public-cloud-updates?lastCheckedTm=${lastCheckedTm}&tags=${tags}&isRefresh=${ + isRefresh ? 1 : 0 + }&country=${ipInfo?.country || ''}`, + { + headers: { + ...getLicenseHttpHeaders(), + ...(await getCloudSigninHeaders()), + 'x-app-version': currentVersion.version, + }, + } + ); + + logger.info(`Downloaded ${resp.data.length} cloud files`); + + const filesByPath = lastCheckedTm == 0 ? {} : _.keyBy(cloudFiles, 'path'); + for (const file of resp.data) { + if (file.isDeleted) { + delete filesByPath[file.path]; + } else { + filesByPath[file.path] = file; + } + } + + cloudFiles = Object.values(filesByPath); + + await fs.writeFile(path.join(datadir(), 'cloud-files.jsonl'), cloudFiles.map(x => JSON.stringify(x)).join('\n')); + await fs.writeFile(path.join(datadir(), 'cloud-files-tags.txt'), tags); + + socket.emitChanged(`public-cloud-changed`); +} + +async function startCloudFiles() { + loadCloudFiles(); +} + +async function getPublicCloudFiles() { + if (!loadCloudFiles) { + await loadCloudFiles(); + } + return cloudFiles; +} + +async function getPublicFileData(path) { + const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/public/${path}`, { + headers: { + ...getLicenseHttpHeaders(), + }, + }); + return resp.data; +} + +async function refreshPublicFiles(isRefresh) { + if (!cloudFiles) { + await loadCloudFiles(); + } + try { + await updateCloudFiles(isRefresh); + } catch (err) { + logger.error(extractErrorLogData(err), 'Error updating cloud files'); + } +} + +async function callCloudApiGet(endpoint, signinHolder = null, additionalHeaders = {}) { + if (!signinHolder) { + signinHolder = await getCloudSigninHolder(); + } + if (!signinHolder) { + return null; + } + const signinHeaders = await getCloudSigninHeaders(signinHolder); + + const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/${endpoint}`, { + headers: { + ...getLicenseHttpHeaders(), + ...signinHeaders, + ...additionalHeaders, + }, + validateStatus: status => status < 500, + }); + const { errorMessage } = resp.data; + if (errorMessage) { + return { apiErrorMessage: errorMessage }; + } + return resp.data; +} + +async function callCloudApiPost(endpoint, body, signinHolder = null) { + if (!signinHolder) { + signinHolder = await getCloudSigninHolder(); + } + if (!signinHolder) { + return null; + } + const signinHeaders = await getCloudSigninHeaders(signinHolder); + + const resp = await axios.default.post(`${DBGATE_CLOUD_URL}/${endpoint}`, body, { + headers: { + ...getLicenseHttpHeaders(), + ...signinHeaders, + }, + validateStatus: status => status < 500, + }); + const { errorMessage, isLicenseLimit, limitedLicenseLimits } = resp.data; + if (errorMessage) { + return { + apiErrorMessage: errorMessage, + apiErrorIsLicenseLimit: isLicenseLimit, + apiErrorLimitedLicenseLimits: limitedLicenseLimits, + }; + } + return resp.data; +} + +async function getCloudFolderEncryptor(folid) { + const { encryptionKey } = await callCloudApiGet(`folder-key/${folid}`); + if (!encryptionKey) { + throw new Error('No encryption key for folder: ' + folid); + } + return simpleEncryptor.createEncryptor(encryptionKey); +} + +async function getCloudContent(folid, cntid) { + const signinHolder = await getCloudSigninHolder(); + if (!signinHolder) { + throw new Error('No signed in'); + } + + const encryptor = simpleEncryptor.createEncryptor(signinHolder.encryptionKey); + + const { content, name, type, contentFolder, contentType, apiErrorMessage } = await callCloudApiGet( + `content/${folid}/${cntid}`, + signinHolder, + { + 'x-kehid': signinHolder.kehid, + } + ); + + if (apiErrorMessage) { + return { apiErrorMessage }; + } + + return { + content: encryptor.decrypt(content), + name, + type, + contentFolder, + contentType, + }; +} + +/** + * + * @returns Promise<{ cntid: string } | { apiErrorMessage: string }> + */ +async function putCloudContent(folid, cntid, content, name, type, contentFolder = null, contentType = null) { + const signinHolder = await getCloudSigninHolder(); + if (!signinHolder) { + throw new Error('No signed in'); + } + + const encryptor = simpleEncryptor.createEncryptor(signinHolder.encryptionKey); + + const resp = await callCloudApiPost( + `put-content`, + { + folid, + cntid, + name, + type, + kehid: signinHolder.kehid, + content: encryptor.encrypt(content), + contentFolder, + contentType, + }, + signinHolder + ); + socket.emitChanged('cloud-content-changed'); + socket.emit('cloud-content-updated'); + return resp; +} + +const cloudConnectionCache = {}; +async function loadCachedCloudConnection(folid, cntid) { + const cacheKey = `${folid}|${cntid}`; + if (!cloudConnectionCache[cacheKey]) { + const { content } = await getCloudContent(folid, cntid); + cloudConnectionCache[cacheKey] = { + ...JSON.parse(content), + _id: `cloud://${folid}/${cntid}`, + }; + } + return cloudConnectionCache[cacheKey]; +} + +function removeCloudCachedConnection(folid, cntid) { + const cacheKey = `${folid}|${cntid}`; + delete cloudConnectionCache[cacheKey]; +} + +module.exports = { + createDbGateIdentitySession, + startCloudTokenChecking, + startCloudFiles, + getPublicCloudFiles, + getPublicFileData, + refreshPublicFiles, + callCloudApiGet, + callCloudApiPost, + getCloudFolderEncryptor, + getCloudContent, + loadCachedCloudConnection, + putCloudContent, + removeCloudCachedConnection, +}; diff --git a/packages/api/src/utility/crypting.js b/packages/api/src/utility/crypting.js index 171b75904..4d3e9070a 100644 --- a/packages/api/src/utility/crypting.js +++ b/packages/api/src/utility/crypting.js @@ -81,11 +81,11 @@ function decryptPasswordString(password) { return password; } -function encryptObjectPasswordField(obj, field) { +function encryptObjectPasswordField(obj, field, encryptor = null) { if (obj && obj[field] && !obj[field].startsWith('crypt:')) { return { ...obj, - [field]: 'crypt:' + getInternalEncryptor().encrypt(obj[field]), + [field]: 'crypt:' + (encryptor || getInternalEncryptor()).encrypt(obj[field]), }; } return obj; @@ -101,11 +101,11 @@ function decryptObjectPasswordField(obj, field) { return obj; } -function encryptConnection(connection) { +function encryptConnection(connection, encryptor = null) { if (connection.passwordMode != 'saveRaw') { - connection = encryptObjectPasswordField(connection, 'password'); - connection = encryptObjectPasswordField(connection, 'sshPassword'); - connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword'); + connection = encryptObjectPasswordField(connection, 'password', encryptor); + connection = encryptObjectPasswordField(connection, 'sshPassword', encryptor); + connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword', encryptor); } return connection; } diff --git a/packages/api/src/utility/hardwareFingerprint.js b/packages/api/src/utility/hardwareFingerprint.js index 1be04fbb2..c99d86967 100644 --- a/packages/api/src/utility/hardwareFingerprint.js +++ b/packages/api/src/utility/hardwareFingerprint.js @@ -87,4 +87,5 @@ module.exports = { getHardwareFingerprint, getHardwareFingerprintHash, getPublicHardwareFingerprint, + getPublicIpInfo, }; diff --git a/packages/web/public/dimensions.css b/packages/web/public/dimensions.css index 9da6b06b3..b947e21f6 100644 --- a/packages/web/public/dimensions.css +++ b/packages/web/public/dimensions.css @@ -1,5 +1,5 @@ :root { - --dim-widget-icon-size: 60px; + --dim-widget-icon-size: 50px; --dim-statusbar-height: 22px; --dim-left-panel-width: 300px; --dim-tabs-height: 33px; diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 487880df7..124be6c23 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -14,7 +14,12 @@ // import { shouldWaitForElectronInitialize } from './utility/getElectron'; import { subscribeConnectionPingers } from './utility/connectionsPinger'; import { subscribePermissionCompiler } from './utility/hasPermission'; - import { apiCall, installNewVolatileConnectionListener } from './utility/api'; + import { + apiCall, + installNewCloudTokenListener, + installNewVolatileConnectionListener, + refreshPublicCloudFiles, + } from './utility/api'; import { getConfig, getSettings, getUsedApps } from './utility/metadataLoaders'; import AppTitleProvider from './utility/AppTitleProvider.svelte'; import getElectron from './utility/getElectron'; @@ -23,6 +28,7 @@ import { handleAuthOnStartup } from './clientAuth'; import { initializeAppUpdates } from './utility/appUpdate'; import { _t } from './translations'; + import { installCloudListeners } from './utility/cloudListeners'; export let isAdminPage = false; @@ -51,9 +57,13 @@ subscribeConnectionPingers(); subscribePermissionCompiler(); installNewVolatileConnectionListener(); + installNewCloudTokenListener(); initializeAppUpdates(); + installCloudListeners(); } + refreshPublicCloudFiles(); + loadedApi = loadedApiValue; if (!loadedApi) { diff --git a/packages/web/src/appobj/AppObjectGroup.svelte b/packages/web/src/appobj/AppObjectGroup.svelte index 3cd425706..cdcc4b78c 100644 --- a/packages/web/src/appobj/AppObjectGroup.svelte +++ b/packages/web/src/appobj/AppObjectGroup.svelte @@ -12,6 +12,7 @@ export let groupFunc; export let items; export let groupIconFunc = plusExpandIcon; + export let mapGroupTitle = undefined; export let module; export let checkedObjectsStore = null; export let disableContextMenu = false; @@ -63,7 +64,7 @@ - {group} + {mapGroupTitle ? mapGroupTitle(group) : group} {items && `(${countText})`} diff --git a/packages/web/src/appobj/AppObjectList.svelte b/packages/web/src/appobj/AppObjectList.svelte index cbf6d1c9e..ad6c2395b 100644 --- a/packages/web/src/appobj/AppObjectList.svelte +++ b/packages/web/src/appobj/AppObjectList.svelte @@ -26,6 +26,7 @@ export let groupIconFunc = plusExpandIcon; export let groupFunc = undefined; + export let mapGroupTitle = undefined; export let onDropOnGroup = undefined; export let emptyGroupNames = []; export let isExpandedBySearch = false; @@ -127,6 +128,7 @@ {subItemsComponent} {checkedObjectsStore} {groupFunc} + {mapGroupTitle} {disableContextMenu} {filter} {passProps} diff --git a/packages/web/src/appobj/CloudContentAppObject.svelte b/packages/web/src/appobj/CloudContentAppObject.svelte new file mode 100644 index 000000000..819949e6e --- /dev/null +++ b/packages/web/src/appobj/CloudContentAppObject.svelte @@ -0,0 +1,139 @@ + + + + +{#if data.conid && $cloudConnectionsStore[data.conid]} + +{:else if data.type == 'file'} + +{:else} + +{/if} + + diff --git a/packages/web/src/appobj/ConnectionAppObject.svelte b/packages/web/src/appobj/ConnectionAppObject.svelte index 43a9a4663..7ed4d83ff 100644 --- a/packages/web/src/appobj/ConnectionAppObject.svelte +++ b/packages/web/src/appobj/ConnectionAppObject.svelte @@ -108,6 +108,7 @@ import _ from 'lodash'; import AppObjectCore from './AppObjectCore.svelte'; import { + cloudSigninTokenHolder, currentDatabase, DEFAULT_CONNECTION_SEARCH_SETTINGS, expandedConnections, @@ -160,7 +161,7 @@ const handleOpenConnectionTab = () => { openNewTab({ title: getConnectionLabel(data), - icon: 'img connection', + icon: data._id.startsWith('cloud://') ? 'img cloud-connection' : 'img connection', tabComponent: 'ConnectionTab', props: { conid: data._id, @@ -261,11 +262,15 @@ }); }; const handleDuplicate = () => { - apiCall('connections/save', { - ...data, - _id: undefined, - displayName: `${getConnectionLabel(data)} - copy`, - }); + if (data._id.startsWith('cloud://')) { + apiCall('cloud/duplicate-connection', { conid: data._id }); + } else { + apiCall('connections/save', { + ...data, + _id: undefined, + displayName: `${getConnectionLabel(data)} - copy`, + }); + } }; const handleCreateDatabase = () => { showModal(InputTextModal, { @@ -332,6 +337,19 @@ text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }), onClick: handleDuplicate, }, + !$openedConnections.includes(data._id) && + $cloudSigninTokenHolder && + passProps?.cloudContentList?.length > 0 && { + text: _t('connection.copyToCloudFolder', { defaultMessage: 'Copy to cloud folder' }), + submenu: passProps?.cloudContentList + ?.filter(x => x.role == 'write' || x.role == 'admin') + ?.map(fld => ({ + text: fld.name, + onClick: () => { + apiCall('cloud/copy-connection-cloud', { conid: data._id, folid: fld.folid }); + }, + })), + }, ], { divider: true }, !data.singleDatabase && [ @@ -416,7 +434,7 @@ {...$$restProps} {data} title={getConnectionLabel(data, { showUnsaved: true })} - icon={data.singleDatabase ? 'img database' : 'img server'} + icon={data._id.startsWith('cloud://') ? 'img cloud-connection' : data.singleDatabase ? 'img database' : 'img server'} isBold={data.singleDatabase ? $currentDatabase?.connection?._id == data._id && $currentDatabase?.name == data.defaultDatabase : $currentDatabase?.connection?._id == data._id} diff --git a/packages/web/src/appobj/PublicCloudFileAppObject.svelte b/packages/web/src/appobj/PublicCloudFileAppObject.svelte new file mode 100644 index 000000000..1dd291891 --- /dev/null +++ b/packages/web/src/appobj/PublicCloudFileAppObject.svelte @@ -0,0 +1,52 @@ + + + + + + {#if data.description} +
+ {data.description} +
+ {/if} +
+ + diff --git a/packages/web/src/appobj/SavedFileAppObject.svelte b/packages/web/src/appobj/SavedFileAppObject.svelte index da72c8ae8..2cd5e70bc 100644 --- a/packages/web/src/appobj/SavedFileAppObject.svelte +++ b/packages/web/src/appobj/SavedFileAppObject.svelte @@ -206,7 +206,14 @@ showModal(ConfirmModal, { message: `Really delete file ${data.file}?`, onConfirm: () => { - apiCall('files/delete', data); + if (data.folid && data.cntid) { + apiCall('cloud/delete-content', { + folid: data.folid, + cntid: data.cntid, + }); + } else { + apiCall('files/delete', data); + } }, }); }; @@ -217,7 +224,15 @@ label: 'New file name', header: 'Rename file', onConfirm: newFile => { - apiCall('files/rename', { ...data, newFile }); + if (data.folid && data.cntid) { + apiCall('cloud/rename-content', { + folid: data.folid, + cntid: data.cntid, + name: newFile, + }); + } else { + apiCall('files/rename', { ...data, newFile }); + } }, }); }; @@ -226,9 +241,17 @@ showModal(InputTextModal, { value: data.file, label: 'New file name', - header: 'Rename file', + header: 'Copy file', onConfirm: newFile => { - apiCall('files/copy', { ...data, newFile }); + if (data.folid && data.cntid) { + apiCall('cloud/copy-file', { + folid: data.folid, + cntid: data.cntid, + name: newFile, + }); + } else { + apiCall('files/copy', { ...data, newFile }); + } }, }); }; @@ -236,21 +259,38 @@ const handleDownload = () => { saveFileToDisk( async filePath => { - await apiCall('files/export-file', { - folder, - file: data.file, - filePath, - }); + if (data.folid && data.cntid) { + await apiCall('cloud/export-file', { + folid: data.folid, + cntid: data.cntid, + filePath, + }); + } else { + await apiCall('files/export-file', { + folder, + file: data.file, + filePath, + }); + } }, { formatLabel: handler.label, formatExtension: handler.format, defaultFileName: data.file } ); }; async function openTab() { - const resp = await apiCall('files/load', { folder, file: data.file, format: handler.format }); + let dataContent; + if (data.folid && data.cntid) { + const resp = await apiCall('cloud/get-content', { + folid: data.folid, + cntid: data.cntid, + }); + dataContent = resp.content; + } else { + dataContent = await apiCall('files/load', { folder, file: data.file, format: handler.format }); + } - const connProps: any = {}; let tooltip = undefined; + const connProps: any = {}; if (handler.currentConnection) { const connection = _.get($currentDatabase, 'connection') || {}; @@ -270,10 +310,12 @@ savedFile: data.file, savedFolder: handler.folder, savedFormat: handler.format, + savedCloudFolderId: data.folid, + savedCloudContentId: data.cntid, ...connProps, }, }, - { editor: resp } + { editor: dataContent } ); } diff --git a/packages/web/src/appobj/SubCloudItemsList.svelte b/packages/web/src/appobj/SubCloudItemsList.svelte new file mode 100644 index 000000000..ca7cf1329 --- /dev/null +++ b/packages/web/src/appobj/SubCloudItemsList.svelte @@ -0,0 +1,10 @@ + + +{#if data.conid && $cloudConnectionsStore[data.conid]} + +{/if} diff --git a/packages/web/src/commands/stdCommands.ts b/packages/web/src/commands/stdCommands.ts index 44be01135..10f22e56c 100644 --- a/packages/web/src/commands/stdCommands.ts +++ b/packages/web/src/commands/stdCommands.ts @@ -1,4 +1,5 @@ import { + cloudSigninTokenHolder, currentDatabase, currentTheme, emptyConnectionGroupNames, @@ -123,6 +124,27 @@ registerCommand({ }, }); +registerCommand({ + id: 'new.connectionOnCloud', + toolbar: true, + icon: 'img cloud-connection', + toolbarName: 'Add connection on cloud', + category: 'New', + toolbarOrder: 1, + name: 'Connection on Cloud', + testEnabled: () => !getCurrentConfig()?.runAsPortal && !getCurrentConfig()?.storageDatabase && isProApp(), + onClick: () => { + openNewTab({ + title: 'New Connection on Cloud', + icon: 'img cloud-connection', + tabComponent: 'ConnectionTab', + props: { + saveOnCloud: true, + }, + }); + }, +}); + registerCommand({ id: 'new.connection.folder', toolbar: true, @@ -662,6 +684,15 @@ if (hasPermission('settings/change')) { }); } +registerCommand({ + id: 'cloud.logout', + category: 'Cloud', + name: 'Logout', + onClick: () => { + cloudSigninTokenHolder.set(null); + }, +}); + registerCommand({ id: 'file.exit', category: 'File', diff --git a/packages/web/src/forms/FormCloudFolderSelect.svelte b/packages/web/src/forms/FormCloudFolderSelect.svelte new file mode 100644 index 000000000..28d5bf08f --- /dev/null +++ b/packages/web/src/forms/FormCloudFolderSelect.svelte @@ -0,0 +1,27 @@ + + + diff --git a/packages/web/src/icons/FontIcon.svelte b/packages/web/src/icons/FontIcon.svelte index 86bee43eb..dcc1a68a5 100644 --- a/packages/web/src/icons/FontIcon.svelte +++ b/packages/web/src/icons/FontIcon.svelte @@ -39,6 +39,9 @@ 'icon minus-thick': 'mdi mdi-minus-thick', 'icon invisible-box': 'mdi mdi-minus-box-outline icon-invisible', 'icon cloud-upload': 'mdi mdi-cloud-upload', + 'icon cloud': 'mdi mdi-cloud', + 'icon cloud-public': 'mdi mdi-cloud-search', + 'icon cloud-private': 'mdi mdi-cloud-key', 'icon import': 'mdi mdi-application-import', 'icon export': 'mdi mdi-application-export', 'icon new-connection': 'mdi mdi-database-plus', @@ -112,6 +115,9 @@ 'icon square': 'mdi mdi-square', 'icon data-deploy': 'mdi mdi-database-settings', + 'icon cloud-account': 'mdi mdi-account-remove-outline', + 'icon cloud-account-connected': 'mdi mdi-account-check-outline', + 'icon edit': 'mdi mdi-pencil', 'icon delete': 'mdi mdi-delete', 'icon arrow-up': 'mdi mdi-arrow-up', @@ -264,6 +270,7 @@ '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 cloud-connection': 'mdi mdi-cloud-lock 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/modals/ChooseCloudFolderModal.svelte b/packages/web/src/modals/ChooseCloudFolderModal.svelte new file mode 100644 index 000000000..955e50cd0 --- /dev/null +++ b/packages/web/src/modals/ChooseCloudFolderModal.svelte @@ -0,0 +1,40 @@ + + +{#if $cloudContentList} + x.isPrivate)?.folid }}> + + Choose cloud folder + +
{message}
+ + + + + { + closeCurrentModal(); + console.log('onConfirm', e.detail); + onConfirm(e.detail.cloudFolder); + }} + /> + + +
+
+{/if} diff --git a/packages/web/src/modals/LicenseLimitMessageModal.svelte b/packages/web/src/modals/LicenseLimitMessageModal.svelte new file mode 100644 index 000000000..4121159cc --- /dev/null +++ b/packages/web/src/modals/LicenseLimitMessageModal.svelte @@ -0,0 +1,71 @@ + + + + +
License limit error
+ +
+
+ +
+
+

+ Cloud operation ended with error:
+ {message} +

+ +

+ This is a limitation of the free version of DbGate. To continue using cloud operations, please {#if !isProApp()}download + and{/if} purchase DbGate Premium. +

+

Free version limit:

+
    + {#each licenseLimits || [] as limit} +
  • {limit}
  • + {/each} +
+
+
+ +
+ + {#if !isProApp()} + openWebLink('https://dbgate.io/download/')} + skipWidth + /> + {/if} + openWebLink('https://dbgate.io/purchase/premium/')} + skipWidth + /> +
+
+
+ + diff --git a/packages/web/src/modals/SaveFileModal.svelte b/packages/web/src/modals/SaveFileModal.svelte index c324028e1..15ebd67e1 100644 --- a/packages/web/src/modals/SaveFileModal.svelte +++ b/packages/web/src/modals/SaveFileModal.svelte @@ -1,15 +1,18 @@ - + Save file + {#if $cloudSigninTokenHolder} + + {/if} + {#if electron} @@ -79,4 +128,4 @@ {/if} - + diff --git a/packages/web/src/stores.ts b/packages/web/src/stores.ts index dac8c4f02..5dfdb4c99 100644 --- a/packages/web/src/stores.ts +++ b/packages/web/src/stores.ts @@ -182,6 +182,10 @@ export const focusedConnectionOrDatabase = writable<{ conid: string; database?: export const focusedTreeDbKey = writable<{ key: string; root: string; type: string; text: string }>(null); +export const cloudSigninTokenHolder = writableSettingsValue(null, 'cloudSigninTokenHolder'); + +export const cloudConnectionsStore = writable({}); + export const DEFAULT_OBJECT_SEARCH_SETTINGS = { pureName: true, schemaName: false, @@ -453,4 +457,10 @@ focusedTreeDbKey.subscribe(value => { }); export const getFocusedTreeDbKey = () => focusedTreeDbKeyValue; +let cloudConnectionsStoreValue = {}; +cloudConnectionsStore.subscribe(value => { + cloudConnectionsStoreValue = value; +}); +export const getCloudConnectionsStore = () => cloudConnectionsStoreValue; + window['__changeCurrentTheme'] = theme => currentTheme.set(theme); diff --git a/packages/web/src/tabs/ConnectionTab.svelte b/packages/web/src/tabs/ConnectionTab.svelte index 8c1da2f5c..d15209e07 100644 --- a/packages/web/src/tabs/ConnectionTab.svelte +++ b/packages/web/src/tabs/ConnectionTab.svelte @@ -36,6 +36,7 @@ import ConnectionAdvancedDriverFields from '../settings/ConnectionAdvancedDriverFields.svelte'; import DatabaseLoginModal from '../modals/DatabaseLoginModal.svelte'; import { _t } from '../translations'; + import ChooseCloudFolderModal from '../modals/ChooseCloudFolderModal.svelte'; export let connection; export let tabid; @@ -44,6 +45,7 @@ export let inlineTabs = false; export let onlyTestButton; + export let saveOnCloud = false; let isTesting; let sqlConnectResult; @@ -157,43 +159,96 @@ $: currentConnection = getCurrentConnectionCore($values, driver); async function handleSave() { - let connection = getCurrentConnection(); - connection = { - ...connection, - unsaved: false, - }; - const saved = await apiCall('connections/save', connection); - $values = { - ...$values, - _id: saved._id, - unsaved: false, - }; - changeTab(tabid, tab => ({ - ...tab, - title: getConnectionLabel(saved), - props: { - ...tab.props, - conid: saved._id, - }, - })); - showSnackbarSuccess('Connection saved'); + if (saveOnCloud && !getCurrentConnection()?._id) { + showModal(ChooseCloudFolderModal, { + requiredRoleVariants: ['write', 'admin'], + message: 'Choose cloud folder to saved connection', + onConfirm: async folid => { + let connection = getCurrentConnection(); + const saved = await apiCall('cloud/save-connection', { folid, connection }); + if (saved?._id) { + $values = { + ...$values, + _id: saved._id, + unsaved: false, + }; + changeTab(tabid, tab => ({ + ...tab, + title: getConnectionLabel(saved), + props: { + ...tab.props, + conid: saved._id, + }, + })); + showSnackbarSuccess('Connection saved'); + } + }, + }); + } else if ( + // @ts-ignore + getCurrentConnection()?._id?.startsWith('cloud://') + ) { + let connection = getCurrentConnection(); + const resp = await apiCall('cloud/save-connection', { connection }); + if (resp?._id) { + showSnackbarSuccess('Connection saved'); + changeTab(tabid, tab => ({ + ...tab, + title: getConnectionLabel(connection), + })); + } + } else { + let connection = getCurrentConnection(); + connection = { + ...connection, + unsaved: false, + }; + const saved = await apiCall('connections/save', connection); + $values = { + ...$values, + _id: saved._id, + unsaved: false, + }; + changeTab(tabid, tab => ({ + ...tab, + title: getConnectionLabel(saved), + props: { + ...tab.props, + conid: saved._id, + }, + })); + showSnackbarSuccess('Connection saved'); + } } async function handleConnect() { let connection = getCurrentConnection(); - if (!connection._id) { - connection = { - ...connection, - unsaved: true, + + if ( + // @ts-ignore + connection?._id?.startsWith('cloud://') + ) { + const saved = await apiCall('cloud/save-connection', { connection }); + changeTab(tabid, tab => ({ + ...tab, + title: getConnectionLabel(connection), + })); + openConnection(saved); + } else { + if (!connection._id) { + connection = { + ...connection, + unsaved: true, + }; + } + const saved = await apiCall('connections/save', connection); + $values = { + ...$values, + unsaved: connection.unsaved, + _id: saved._id, }; + openConnection(saved); } - const saved = await apiCall('connections/save', connection); - $values = { - ...$values, - unsaved: connection.unsaved, - _id: saved._id, - }; - openConnection(saved); // closeMultipleTabs(x => x.tabid == tabid, true); } @@ -287,7 +342,9 @@ {:else if isConnected} {:else} - + {#if $values._id || !saveOnCloud} + + {/if} {#if isTesting} {:else} diff --git a/packages/web/src/utility/api.ts b/packages/web/src/utility/api.ts index bfe827cfe..043699ff0 100644 --- a/packages/web/src/utility/api.ts +++ b/packages/web/src/utility/api.ts @@ -14,6 +14,8 @@ import { batchDispatchCacheTriggers, dispatchCacheChange } from './cache'; import { isAdminPage, isOneOfPage } from './pageDefs'; import { openWebLink } from './simpleTools'; import { serializeJsTypesReplacer } from 'dbgate-tools'; +import { cloudSigninTokenHolder } from '../stores'; +import LicenseLimitMessageModal from '../modals/LicenseLimitMessageModal.svelte'; export const strmid = uuidv1(); @@ -120,7 +122,14 @@ async function processApiResponse(route, args, resp) { // missingCredentials: true, // }; } else if (resp?.apiErrorMessage) { - showSnackbarError('API error:' + resp?.apiErrorMessage); + if (resp?.apiErrorIsLicenseLimit) { + showModal(LicenseLimitMessageModal, { + message: resp.apiErrorMessage, + licenseLimits: resp.apiErrorLimitedLicenseLimits, + }); + } else { + showSnackbarError('API error:' + resp?.apiErrorMessage); + } return { errorMessage: resp.apiErrorMessage, }; @@ -279,6 +288,13 @@ export function installNewVolatileConnectionListener() { }); } +export function installNewCloudTokenListener() { + apiOn('got-cloud-token', async tokenHolder => { + console.log('HOLDER', tokenHolder); + cloudSigninTokenHolder.set(tokenHolder); + }); +} + export function getAuthCategory(config) { if (config.isBasicAuth) { return 'basic'; @@ -292,6 +308,15 @@ export function getAuthCategory(config) { return 'token'; } +export function refreshPublicCloudFiles() { + if (sessionStorage.getItem('publicCloudFilesLoaded')) { + return; + } + + apiCall('cloud/refresh-public-files'); + sessionStorage.setItem('publicCloudFilesLoaded', 'true'); +} + function enableApiLog() { apiLogging = true; console.log('API loggin enabled'); diff --git a/packages/web/src/utility/cloudListeners.ts b/packages/web/src/utility/cloudListeners.ts new file mode 100644 index 000000000..bdb3b1bc6 --- /dev/null +++ b/packages/web/src/utility/cloudListeners.ts @@ -0,0 +1,57 @@ +import { derived } from 'svelte/store'; +import { + cloudConnectionsStore, + currentDatabase, + getCloudConnectionsStore, + openedConnections, + openedSingleDatabaseConnections, +} from '../stores'; +import { apiCall, apiOn } from './api'; +import _ from 'lodash'; + +export const possibleCloudConnectionSources = derived( + [currentDatabase, openedSingleDatabaseConnections, openedConnections], + ([$currentDatabase, $openedSingleDatabaseConnections, $openedConnections]) => { + const conids = new Set(); + if ($currentDatabase?.connection?._id) { + conids.add($currentDatabase.connection._id); + } + $openedSingleDatabaseConnections.forEach(x => conids.add(x)); + $openedConnections.forEach(x => conids.add(x)); + return Array.from(conids).filter(x => x?.startsWith('cloud://')); + } +); + +async function loadCloudConnection(conid) { + const conn = await apiCall('connections/get', { conid }); + cloudConnectionsStore.update(store => ({ + ...store, + [conid]: conn, + })); +} + +function ensureCloudConnectionsLoaded(...conids) { + const conns = getCloudConnectionsStore(); + + cloudConnectionsStore.update(store => _.pick(store, conids)); + + conids.forEach(conid => { + if (!conns[conid]) { + loadCloudConnection(conid); + } + }); +} + +export function installCloudListeners() { + possibleCloudConnectionSources.subscribe(conids => { + ensureCloudConnectionsLoaded(...conids); + }); + + apiOn('cloud-content-updated', () => { + const conids = Object.keys(getCloudConnectionsStore()); + cloudConnectionsStore.set({}); + for (const conn of conids) { + loadCloudConnection(conn); + } + }); +} diff --git a/packages/web/src/utility/metadataLoaders.ts b/packages/web/src/utility/metadataLoaders.ts index 2fc374a02..d334efc08 100644 --- a/packages/web/src/utility/metadataLoaders.ts +++ b/packages/web/src/utility/metadataLoaders.ts @@ -166,6 +166,17 @@ const authTypesLoader = ({ engine }) => ({ errorValue: null, }); +const publicCloudFilesLoader = () => ({ + url: 'cloud/public-files', + params: {}, + reloadTrigger: { key: `public-cloud-changed` }, +}); +const cloudContentListLoader = () => ({ + url: 'cloud/content-list', + params: {}, + reloadTrigger: { key: `cloud-content-changed` }, +}); + async function getCore(loader, args) { const { url, params, reloadTrigger, transform, onLoaded, errorValue } = loader(args); const key = stableStringify({ url, ...params }); @@ -456,3 +467,17 @@ export function getSchemaList(args) { export function useSchemaList(args) { return useCore(schemaListLoader, args); } + +export function getPublicCloudFiles(args) { + return getCore(publicCloudFilesLoader, args); +} +export function usePublicCloudFiles(args = {}) { + return useCore(publicCloudFilesLoader, args); +} + +export function getCloudContentList(args) { + return getCore(cloudContentListLoader, args); +} +export function useCloudContentList(args = {}) { + return useCore(cloudContentListLoader, args); +} diff --git a/packages/web/src/utility/openElectronFile.ts b/packages/web/src/utility/openElectronFile.ts index f771cdcdf..51c265f8e 100644 --- a/packages/web/src/utility/openElectronFile.ts +++ b/packages/web/src/utility/openElectronFile.ts @@ -111,6 +111,8 @@ async function openSavedElectronFile(filePath, parsed, folder) { props: { savedFile: null, savedFolder: null, + savedCloudFolderId: null, + savedCloudContentId: null, savedFilePath: filePath, savedFormat: handler.format, ...connProps, diff --git a/packages/web/src/utility/openNewTab.ts b/packages/web/src/utility/openNewTab.ts index 59fab9db4..6b4070ded 100644 --- a/packages/web/src/utility/openNewTab.ts +++ b/packages/web/src/utility/openNewTab.ts @@ -30,7 +30,8 @@ export default async function openNewTab(newTab, initialData: any = undefined, o }; } - const { savedFile, savedFolder, savedFilePath, conid, database } = newTab.props || {}; + const { savedFile, savedFolder, savedFilePath, savedCloudFolderId, savedCloudContentId, conid, database } = + newTab.props || {}; if (conid && database) { const connection = await getConnectionInfo({ conid }); @@ -49,7 +50,9 @@ export default async function openNewTab(newTab, initialData: any = undefined, o x.closedTime == null && x.props.savedFile == savedFile && x.props.savedFolder == savedFolder && - x.props.savedFilePath == savedFilePath + x.props.savedFilePath == savedFilePath && + x.props.savedCloudFolderId == savedCloudFolderId && + x.props.savedCloudContentId == savedCloudContentId ); } diff --git a/packages/web/src/utility/saveTabFile.ts b/packages/web/src/utility/saveTabFile.ts index 60351ecdb..b4519fc9d 100644 --- a/packages/web/src/utility/saveTabFile.ts +++ b/packages/web/src/utility/saveTabFile.ts @@ -15,16 +15,31 @@ export default async function saveTabFile(editor, saveMode, folder, format, file const tabs = get(openedTabs); const tabid = editor.activator.tabid; const data = editor.getData(); - const { savedFile, savedFilePath, savedFolder } = tabs.find(x => x.tabid == tabid).props || {}; + const { savedFile, savedFilePath, savedFolder, savedCloudFolderId, savedCloudContentId } = + tabs.find(x => x.tabid == tabid).props || {}; const handleSave = async () => { - if (savedFile) { - await apiCall('files/save', { folder: savedFolder || folder, file: savedFile, data, format }); + if (savedCloudFolderId && savedCloudContentId) { + const resp = await apiCall('cloud/save-file', { + folid: savedCloudFolderId, + fileName: savedFile, + data, + contentFolder: folder, + format, + cntid: savedCloudContentId, + }); + if (resp.cntid) { + markTabSaved(tabid); + } + } else { + if (savedFile) { + await apiCall('files/save', { folder: savedFolder || folder, file: savedFile, data, format }); + } + if (savedFilePath) { + await apiCall('files/save-as', { filePath: savedFilePath, data, format }); + } + markTabSaved(tabid); } - if (savedFilePath) { - await apiCall('files/save-as', { filePath: savedFilePath, data, format }); - } - markTabSaved(tabid); }; const onSave = (title, newProps) => { @@ -60,6 +75,8 @@ export default async function saveTabFile(editor, saveMode, folder, format, file savedFile: null, savedFolder: null, savedFilePath: file, + savedCloudFolderId: null, + savedCloudContentId: null, }); } } else if ((savedFile || savedFilePath) && saveMode == 'save') { @@ -73,6 +90,8 @@ export default async function saveTabFile(editor, saveMode, folder, format, file name: savedFile || 'newFile', filePath: savedFilePath, onSave, + folid: savedCloudFolderId, + // cntid: savedCloudContentId, }); } } diff --git a/packages/web/src/utility/simpleTools.ts b/packages/web/src/utility/simpleTools.ts index f655445ab..77164e211 100644 --- a/packages/web/src/utility/simpleTools.ts +++ b/packages/web/src/utility/simpleTools.ts @@ -1,11 +1,40 @@ import getElectron from './getElectron'; -export function openWebLink(href) { +export function openWebLink(href, usePopup = false) { const electron = getElectron(); if (electron) { electron.send('open-link', href); } else { - window.open(href, '_blank'); + if (usePopup) { + const w = 500; + const h = 650; + + const dualScreenLeft = window.screenLeft ?? window.screenX; // X of parent + const dualScreenTop = window.screenTop ?? window.screenY; // Y of parent + + // 2. How big is the parent window? + const parentWidth = window.outerWidth; + const parentHeight = window.outerHeight; + + // 3. Centre the popup inside that rectangle + const left = dualScreenLeft + (parentWidth - w) / 2; + const top = dualScreenTop + (parentHeight - h) / 2; + + const features = [ + `width=${w}`, + `height=${h}`, + `left=${left}`, + `top=${top}`, + 'scrollbars=yes', + 'resizable=yes', + 'noopener', + 'noreferrer', + ]; + + window.open(href, 'dbgateCloudLoginPopup', features.join(',')); + } else { + window.open(href, '_blank'); + } } } diff --git a/packages/web/src/widgets/ConnectionList.svelte b/packages/web/src/widgets/ConnectionList.svelte index 463e46e23..0e9227335 100644 --- a/packages/web/src/widgets/ConnectionList.svelte +++ b/packages/web/src/widgets/ConnectionList.svelte @@ -351,6 +351,7 @@ isExpandable={data => $openedConnections.includes(data._id) && !data.singleDatabase} {filter} passProps={{ + ...passProps, connectionColorFactory: $connectionColorFactory, showPinnedInsteadOfUnpin: true, searchSettings: $connectionAppObjectSearchSettings, diff --git a/packages/web/src/widgets/DatabaseWidget.svelte b/packages/web/src/widgets/DatabaseWidget.svelte index 7e525c090..1d2c5300c 100644 --- a/packages/web/src/widgets/DatabaseWidget.svelte +++ b/packages/web/src/widgets/DatabaseWidget.svelte @@ -1,31 +1,20 @@ @@ -45,69 +34,14 @@ height="35%" storageName="connectionsWidget" > - domSqlObjectList.focus() }} /> + domSqlObjectList.focus(), + cloudContentList: $cloudContentList, + }} + /> {/if} - x && x.conid == conid && x.database == $currentDatabase?.name)} - > - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/packages/web/src/widgets/DatabaseWidgetDetailContent.svelte b/packages/web/src/widgets/DatabaseWidgetDetailContent.svelte new file mode 100644 index 000000000..1c79e46d5 --- /dev/null +++ b/packages/web/src/widgets/DatabaseWidgetDetailContent.svelte @@ -0,0 +1,135 @@ + + + x && x.conid == conid && x.database == $currentDatabase?.name)} + positiveCondition={correctCloudStatus} +> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ { + $selectedWidget = conid?.startsWith('cloud://') ? 'cloud-private' : 'database'; + }} + /> +
+
+
+ + diff --git a/packages/web/src/widgets/PrivateCloudWidget.svelte b/packages/web/src/widgets/PrivateCloudWidget.svelte new file mode 100644 index 000000000..1a53663d5 --- /dev/null +++ b/packages/web/src/widgets/PrivateCloudWidget.svelte @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + data.folid} + mapGroupTitle={folid => `${contentGroupMap[folid]?.name} - ${contentGroupMap[folid]?.role}`} + {filter} + subItemsComponent={() => SubCloudItemsList} + expandIconFunc={plusExpandIcon} + isExpandable={data => + data.conid && + $cloudConnectionsStore[data.conid] && + !$cloudConnectionsStore[data.conid].singleDatabase && + $openedConnections.includes(data.conid)} + getIsExpanded={data => $expandedConnections.includes(data.conid)} + setIsExpanded={(data, value) => { + expandedConnections.update(old => (value ? [...old, data.conid] : old.filter(x => x != data.conid))); + }} + passProps={{ + onFocusSqlObjectList: () => domSqlObjectList.focus(), + }} + groupContextMenu={createGroupContextMenu} + /> + + + + + diff --git a/packages/web/src/widgets/PublicCloudWidget.svelte b/packages/web/src/widgets/PublicCloudWidget.svelte new file mode 100644 index 000000000..9b137f2fb --- /dev/null +++ b/packages/web/src/widgets/PublicCloudWidget.svelte @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + data.folder || undefined} + {filter} + /> + + + diff --git a/packages/web/src/widgets/SavedFilesList.svelte b/packages/web/src/widgets/SavedFilesList.svelte index 6ac56d0bc..73f83a244 100644 --- a/packages/web/src/widgets/SavedFilesList.svelte +++ b/packages/web/src/widgets/SavedFilesList.svelte @@ -74,24 +74,24 @@ } - - - - - - - - - + + + + + + + + + dataFolderTitle(data.folder)} {filter} /> diff --git a/packages/web/src/widgets/SqlObjectList.svelte b/packages/web/src/widgets/SqlObjectList.svelte index 09fd7e748..33f070b2c 100644 --- a/packages/web/src/widgets/SqlObjectList.svelte +++ b/packages/web/src/widgets/SqlObjectList.svelte @@ -163,6 +163,8 @@ ($focusedConnectionOrDatabase.conid != conid || ($focusedConnectionOrDatabase?.database && $focusedConnectionOrDatabase?.database != extractDbNameFromComposite(database))); + + // $: console.log('STATUS', $status); {#if $status && $status.name == 'error'} diff --git a/packages/web/src/widgets/StatusBar.svelte b/packages/web/src/widgets/StatusBar.svelte index b41f3644d..aad507563 100644 --- a/packages/web/src/widgets/StatusBar.svelte +++ b/packages/web/src/widgets/StatusBar.svelte @@ -9,6 +9,7 @@ import { activeTabId, appUpdateStatus, + cloudSigninTokenHolder, currentArchive, currentDatabase, currentThemeDefinition, @@ -154,7 +155,7 @@ {/if} - {#if $currentArchive} + {#if $currentArchive && $currentArchive != 'default'}
{/if} + {#if $cloudSigninTokenHolder?.email} +
+ + {$cloudSigninTokenHolder?.email} +
+ {/if} + {#if $appUpdateStatus}
diff --git a/packages/web/src/widgets/WidgetColumnBar.svelte b/packages/web/src/widgets/WidgetColumnBar.svelte index 22c8e3cb1..45cafb9ca 100644 --- a/packages/web/src/widgets/WidgetColumnBar.svelte +++ b/packages/web/src/widgets/WidgetColumnBar.svelte @@ -29,7 +29,7 @@ const visibleItemsCount = defs.filter(x => !x.collapsed && !x.skip).length; for (let index = 0; index < defs.length; index++) { const definition = defs[index]; - const splitterVisible = !!defs.slice(index + 1).find(x => x && !x.collapsed && !x.skip); + const splitterVisible = !!defs.slice(index + 1).find(x => x && !x.collapsed && !x.skip && x.positiveCondition); dynamicPropsCollection[index].set({ splitterVisible, visibleItemsCount }); } } diff --git a/packages/web/src/widgets/WidgetColumnBarItem.svelte b/packages/web/src/widgets/WidgetColumnBarItem.svelte index 142927aea..01d8d9e9b 100644 --- a/packages/web/src/widgets/WidgetColumnBarItem.svelte +++ b/packages/web/src/widgets/WidgetColumnBarItem.svelte @@ -12,6 +12,7 @@ export let title; export let name; export let skip = false; + export let positiveCondition = true; export let height = null; export let collapsed = null; @@ -33,11 +34,12 @@ collapsed, height, skip, + positiveCondition, }, dynamicProps ); - $: updateWidgetItemDefinition(widgetItemIndex, { collapsed: !visible, height, skip }); + $: updateWidgetItemDefinition(widgetItemIndex, { collapsed: !visible, height, skip, positiveCondition }); $: setInitialSize(height, $widgetColumnBarHeight); @@ -67,7 +69,7 @@ $: collapsible = $dynamicProps.visibleItemsCount != 1 || !visible; -{#if !skip} +{#if !skip && positiveCondition} (visible = !visible) : null} diff --git a/packages/web/src/widgets/WidgetContainer.svelte b/packages/web/src/widgets/WidgetContainer.svelte index 814a60c96..a08a52d35 100644 --- a/packages/web/src/widgets/WidgetContainer.svelte +++ b/packages/web/src/widgets/WidgetContainer.svelte @@ -9,6 +9,8 @@ import AppWidget from './AppWidget.svelte'; import AdminMenuWidget from './AdminMenuWidget.svelte'; import AdminPremiumPromoWidget from './AdminPremiumPromoWidget.svelte'; + import PublicCloudWidget from './PublicCloudWidget.svelte'; + import PrivateCloudWidget from './PrivateCloudWidget.svelte';