Merge branch 'master' into feature/svelte4

This commit is contained in:
SPRINX0\prochazka
2025-12-19 13:26:16 +01:00
464 changed files with 30266 additions and 5200 deletions

View File

@@ -2,6 +2,7 @@ DEVMODE=1
SHELL_SCRIPTING=1
ALLOW_DBGATE_PRIVATE_CLOUD=1
DEVWEB=1
# LOCAL_AUTH_PROXY=1
# LOCAL_AI_GATEWAY=true
# REDIRECT_TO_DBGATE_CLOUD_LOGIN=1

46
packages/api/env/sfill/.env vendored Normal file
View File

@@ -0,0 +1,46 @@
DEVMODE=1
DEVWEB=1
STORAGE_SERVER=localhost
STORAGE_USER=root
STORAGE_PASSWORD=Pwd2020Db
STORAGE_PORT=3306
STORAGE_DATABASE=dbgate-filled
STORAGE_ENGINE=mysql@dbgate-plugin-mysql
CONNECTIONS=mysql,postgres,mongo,redis
LABEL_mysql=MySql
SERVER_mysql=dbgatedckstage1.sprinx.cz
USER_mysql=root
PASSWORD_mysql=Pwd2020Db
PORT_mysql=3306
ENGINE_mysql=mysql@dbgate-plugin-mysql
LABEL_postgres=Postgres
SERVER_postgres=dbgatedckstage1.sprinx.cz
USER_postgres=postgres
PASSWORD_postgres=Pwd2020Db
PORT_postgres=5432
ENGINE_postgres=postgres@dbgate-plugin-postgres
LABEL_mongo=Mongo
SERVER_mongo=dbgatedckstage1.sprinx.cz
USER_mongo=root
PASSWORD_mongo=Pwd2020Db
PORT_mongo=27017
ENGINE_mongo=mongo@dbgate-plugin-mongo
LABEL_redis=Redis
SERVER_redis=dbgatedckstage1.sprinx.cz
ENGINE_redis=redis@dbgate-plugin-redis
PORT_redis=6379
ROLE_test1_CONNECTIONS=mysql
ROLE_test1_PERMISSIONS=widgets/*
ROLE_test1_DATABASES_db1_CONNECTION=mysql
ROLE_test1_DATABASES_db1_PERMISSION=run_script
ROLE_test1_DATABASES_db1_DATABASES=db1
ROLE_test1_DATABASES_db2_CONNECTION=redis
ROLE_test1_DATABASES_db2_PERMISSION=run_script
ROLE_test1_DATABASES_db2_DATABASES=db2

View File

@@ -31,7 +31,7 @@
"cors": "^2.8.5",
"cross-env": "^6.0.3",
"dbgate-datalib": "^6.0.0-alpha.1",
"dbgate-query-splitter": "^4.11.5",
"dbgate-query-splitter": "^4.11.9",
"dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1",
"debug": "^4.3.4",
@@ -75,6 +75,7 @@
"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:sfill": "env-cmd -f env/sfill/.env node src/index.js --listen-api",
"start:storage:built": "env-cmd -f env/storage/.env cross-env DEVMODE= BUILTWEBMODE=1 node dist/bundle.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",
"start:azure": "env-cmd -f env/azure/.env node src/index.js --listen-api",

View File

@@ -10,7 +10,13 @@ function getTokenSecret() {
return tokenSecret;
}
function getStaticTokenSecret() {
// TODO static not fixed
return '14813c43-a91b-4ad1-9dcd-a81bd7dbb05f';
}
module.exports = {
getTokenLifetime,
getTokenSecret,
getStaticTokenSecret,
};

View File

@@ -10,6 +10,7 @@ const logger = getLogger('authProvider');
class AuthProviderBase {
amoid = 'none';
skipInList = false;
async login(login, password, options = undefined, req = undefined) {
return {
@@ -36,12 +37,28 @@ class AuthProviderBase {
return !!req?.user || !!req?.auth;
}
getCurrentPermissions(req) {
async getCurrentPermissions(req) {
const login = this.getCurrentLogin(req);
const permissions = process.env[`LOGIN_PERMISSIONS_${login}`];
return permissions || process.env.PERMISSIONS;
}
async checkCurrentConnectionPermission(req, conid) {
return true;
}
async getCurrentDatabasePermissions(req) {
return [];
}
async getCurrentTablePermissions(req) {
return [];
}
async getCurrentFilePermissions(req) {
return [];
}
getLoginPageConnections() {
return null;
}

View File

@@ -1,233 +1,99 @@
const fs = require('fs-extra');
const _ = require('lodash');
const path = require('path');
const { appdir } = require('../utility/directories');
const { appdir, filesdir } = require('../utility/directories');
const socket = require('../utility/socket');
const connections = require('./connections');
const {
loadPermissionsFromRequest,
loadFilePermissionsFromRequest,
hasPermission,
getFilePermissionRole,
} = require('../utility/hasPermission');
module.exports = {
folders_meta: true,
async folders() {
const folders = await fs.readdir(appdir());
return [
...folders.map(name => ({
name,
})),
];
},
createFolder_meta: true,
async createFolder({ folder }) {
const name = await this.getNewAppFolder({ name: folder });
await fs.mkdir(path.join(appdir(), name));
socket.emitChanged('app-folders-changed');
this.emitChangedDbApp(folder);
return name;
},
files_meta: true,
async files({ folder }) {
if (!folder) return [];
const dir = path.join(appdir(), folder);
getAllApps_meta: true,
async getAllApps({}, req) {
const dir = path.join(filesdir(), 'apps');
if (!(await fs.exists(dir))) return [];
const files = await fs.readdir(dir);
function fileType(ext, type) {
return files
.filter(name => name.endsWith(ext))
.map(name => ({
name: name.slice(0, -ext.length),
label: path.parse(name.slice(0, -ext.length)).base,
type,
}));
}
return [
...fileType('.command.sql', 'command.sql'),
...fileType('.query.sql', 'query.sql'),
...fileType('.config.json', 'config.json'),
];
},
async emitChangedDbApp(folder) {
const used = await this.getUsedAppFolders();
if (used.includes(folder)) {
socket.emitChanged('used-apps-changed');
}
},
refreshFiles_meta: true,
async refreshFiles({ folder }) {
socket.emitChanged('app-files-changed', { app: folder });
},
refreshFolders_meta: true,
async refreshFolders() {
socket.emitChanged(`app-folders-changed`);
},
deleteFile_meta: true,
async deleteFile({ folder, file, fileType }) {
await fs.unlink(path.join(appdir(), folder, `${file}.${fileType}`));
socket.emitChanged('app-files-changed', { app: folder });
this.emitChangedDbApp(folder);
},
renameFile_meta: true,
async renameFile({ folder, file, newFile, fileType }) {
await fs.rename(
path.join(path.join(appdir(), folder), `${file}.${fileType}`),
path.join(path.join(appdir(), folder), `${newFile}.${fileType}`)
);
socket.emitChanged('app-files-changed', { app: folder });
this.emitChangedDbApp(folder);
},
renameFolder_meta: true,
async renameFolder({ folder, newFolder }) {
const uniqueName = await this.getNewAppFolder({ name: newFolder });
await fs.rename(path.join(appdir(), folder), path.join(appdir(), uniqueName));
socket.emitChanged(`app-folders-changed`);
},
deleteFolder_meta: true,
async deleteFolder({ folder }) {
if (!folder) throw new Error('Missing folder parameter');
await fs.rmdir(path.join(appdir(), folder), { recursive: true });
socket.emitChanged(`app-folders-changed`);
socket.emitChanged('app-files-changed', { app: folder });
socket.emitChanged('used-apps-changed');
},
async getNewAppFolder({ name }) {
if (!(await fs.exists(path.join(appdir(), name)))) return name;
let index = 2;
while (await fs.exists(path.join(appdir(), `${name}${index}`))) {
index += 1;
}
return `${name}${index}`;
},
getUsedAppFolders_meta: true,
async getUsedAppFolders() {
const list = await connections.list();
const apps = [];
for (const connection of list) {
for (const db of connection.databases || []) {
for (const key of _.keys(db || {})) {
if (key.startsWith('useApp:') && db[key]) {
apps.push(key.substring('useApp:'.length));
}
}
}
}
return _.intersection(_.uniq(apps), await fs.readdir(appdir()));
},
getUsedApps_meta: true,
async getUsedApps() {
const apps = await this.getUsedAppFolders();
const res = [];
const loadedPermissions = await loadPermissionsFromRequest(req);
const filePermissions = await loadFilePermissionsFromRequest(req);
for (const folder of apps) {
res.push(await this.loadApp({ folder }));
}
return res;
},
// getAppsForDb_meta: true,
// async getAppsForDb({ conid, database }) {
// const connection = await connections.get({ conid });
// if (!connection) return [];
// const db = (connection.databases || []).find(x => x.name == database);
// const apps = [];
// const res = [];
// if (db) {
// for (const key of _.keys(db || {})) {
// if (key.startsWith('useApp:') && db[key]) {
// apps.push(key.substring('useApp:'.length));
// }
// }
// }
// for (const folder of apps) {
// res.push(await this.loadApp({ folder }));
// }
// return res;
// },
loadApp_meta: true,
async loadApp({ folder }) {
const res = {
queries: [],
commands: [],
name: folder,
};
const dir = path.join(appdir(), folder);
if (await fs.exists(dir)) {
const files = await fs.readdir(dir);
async function processType(ext, field) {
for (const file of files) {
if (file.endsWith(ext)) {
res[field].push({
name: file.slice(0, -ext.length),
sql: await fs.readFile(path.join(dir, file), { encoding: 'utf-8' }),
});
}
}
for (const file of await fs.readdir(dir)) {
if (!hasPermission(`all-disk-files`, loadedPermissions)) {
const role = getFilePermissionRole('apps', file, filePermissions);
if (role == 'deny') continue;
}
const content = await fs.readFile(path.join(dir, file), { encoding: 'utf-8' });
const appJson = JSON.parse(content);
// const app = {
// appid: file,
// name: appJson.applicationName,
// usageRules: appJson.usageRules || [],
// icon: appJson.applicationIcon || 'img app',
// color: appJson.applicationColor,
// queries: Object.values(appJson.files || {})
// .filter(x => x.type == 'query')
// .map(x => ({
// name: x.label,
// sql: x.sql,
// })),
// commands: Object.values(appJson.files || {})
// .filter(x => x.type == 'command')
// .map(x => ({
// name: x.label,
// sql: x.sql,
// })),
// virtualReferences: appJson.virtualReferences,
// dictionaryDescriptions: appJson.dictionaryDescriptions,
// };
const app = {
...appJson,
appid: file,
};
await processType('.command.sql', 'commands');
await processType('.query.sql', 'queries');
res.push(app);
}
try {
res.virtualReferences = JSON.parse(
await fs.readFile(path.join(dir, 'virtual-references.config.json'), { encoding: 'utf-8' })
);
} catch (err) {
res.virtualReferences = [];
}
try {
res.dictionaryDescriptions = JSON.parse(
await fs.readFile(path.join(dir, 'dictionary-descriptions.config.json'), { encoding: 'utf-8' })
);
} catch (err) {
res.dictionaryDescriptions = [];
}
return res;
},
async saveConfigFile(appFolder, filename, filterFunc, newItem) {
const file = path.join(appdir(), appFolder, filename);
let json;
try {
json = JSON.parse(await fs.readFile(file, { encoding: 'utf-8' }));
} catch (err) {
json = [];
createAppFromDb_meta: true,
async createAppFromDb({ appName, server, database }, req) {
const appdir = path.join(filesdir(), 'apps');
if (!fs.existsSync(appdir)) {
await fs.mkdir(appdir);
}
if (filterFunc) {
json = json.filter(filterFunc);
const appId = _.kebabCase(appName);
let suffix = undefined;
while (fs.existsSync(path.join(appdir, `${appId}${suffix || ''}`))) {
if (!suffix) suffix = 2;
else suffix++;
}
const finalAppId = `${appId}${suffix || ''}`;
json = [...json, newItem];
const appJson = {
applicationName: appName,
usageRules: [
{
serverHostsList: server,
databaseNamesList: database,
},
],
};
await fs.writeFile(file, JSON.stringify(json, undefined, 2));
await fs.writeFile(path.join(appdir, `${finalAppId}`), JSON.stringify(appJson, undefined, 2));
socket.emitChanged('app-files-changed', { app: appFolder });
socket.emitChanged('used-apps-changed');
socket.emitChanged(`files-changed`, { folder: 'apps' });
return finalAppId;
},
saveVirtualReference_meta: true,
async saveVirtualReference({ appFolder, schemaName, pureName, refSchemaName, refTableName, columns }) {
await this.saveConfigFile(
appFolder,
'virtual-references.config.json',
async saveVirtualReference({ appid, schemaName, pureName, refSchemaName, refTableName, columns }) {
await this.saveConfigItem(
appid,
'virtualReferences',
columns.length == 1
? x =>
!(
@@ -245,14 +111,17 @@ module.exports = {
columns,
}
);
socket.emitChanged(`files-changed`, { folder: 'apps' });
return true;
},
saveDictionaryDescription_meta: true,
async saveDictionaryDescription({ appFolder, pureName, schemaName, expression, columns, delimiter }) {
await this.saveConfigFile(
appFolder,
'dictionary-descriptions.config.json',
async saveDictionaryDescription({ appid, pureName, schemaName, expression, columns, delimiter }) {
await this.saveConfigItem(
appid,
'dictionaryDescriptions',
x => !(x.schemaName == schemaName && x.pureName == pureName),
{
schemaName,
@@ -263,18 +132,271 @@ module.exports = {
}
);
socket.emitChanged(`files-changed`, { folder: 'apps' });
return true;
},
createConfigFile_meta: true,
async createConfigFile({ appFolder, fileName, content }) {
const file = path.join(appdir(), appFolder, fileName);
if (!(await fs.exists(file))) {
await fs.writeFile(file, JSON.stringify(content, undefined, 2));
socket.emitChanged('app-files-changed', { app: appFolder });
socket.emitChanged('used-apps-changed');
return true;
async saveConfigItem(appid, fieldName, filterFunc, newItem) {
const file = path.join(filesdir(), 'apps', appid);
const appJson = JSON.parse(await fs.readFile(file, { encoding: 'utf-8' }));
let json = appJson[fieldName] || [];
if (filterFunc) {
json = json.filter(filterFunc);
}
return false;
json = [...json, newItem];
await fs.writeFile(
file,
JSON.stringify(
{
...appJson,
[fieldName]: json,
},
undefined,
2
)
);
socket.emitChanged('files-changed', { folder: 'apps' });
},
// folders_meta: true,
// async folders() {
// const folders = await fs.readdir(appdir());
// return [
// ...folders.map(name => ({
// name,
// })),
// ];
// },
// createFolder_meta: true,
// async createFolder({ folder }) {
// const name = await this.getNewAppFolder({ name: folder });
// await fs.mkdir(path.join(appdir(), name));
// socket.emitChanged('app-folders-changed');
// this.emitChangedDbApp(folder);
// return name;
// },
// files_meta: true,
// async files({ folder }) {
// if (!folder) return [];
// const dir = path.join(appdir(), folder);
// if (!(await fs.exists(dir))) return [];
// const files = await fs.readdir(dir);
// function fileType(ext, type) {
// return files
// .filter(name => name.endsWith(ext))
// .map(name => ({
// name: name.slice(0, -ext.length),
// label: path.parse(name.slice(0, -ext.length)).base,
// type,
// }));
// }
// return [
// ...fileType('.command.sql', 'command.sql'),
// ...fileType('.query.sql', 'query.sql'),
// ...fileType('.config.json', 'config.json'),
// ];
// },
// async emitChangedDbApp(folder) {
// const used = await this.getUsedAppFolders();
// if (used.includes(folder)) {
// socket.emitChanged('used-apps-changed');
// }
// },
// refreshFiles_meta: true,
// async refreshFiles({ folder }) {
// socket.emitChanged('app-files-changed', { app: folder });
// },
// refreshFolders_meta: true,
// async refreshFolders() {
// socket.emitChanged(`app-folders-changed`);
// },
// deleteFile_meta: true,
// async deleteFile({ folder, file, fileType }) {
// await fs.unlink(path.join(appdir(), folder, `${file}.${fileType}`));
// socket.emitChanged('app-files-changed', { app: folder });
// this.emitChangedDbApp(folder);
// },
// renameFile_meta: true,
// async renameFile({ folder, file, newFile, fileType }) {
// await fs.rename(
// path.join(path.join(appdir(), folder), `${file}.${fileType}`),
// path.join(path.join(appdir(), folder), `${newFile}.${fileType}`)
// );
// socket.emitChanged('app-files-changed', { app: folder });
// this.emitChangedDbApp(folder);
// },
// renameFolder_meta: true,
// async renameFolder({ folder, newFolder }) {
// const uniqueName = await this.getNewAppFolder({ name: newFolder });
// await fs.rename(path.join(appdir(), folder), path.join(appdir(), uniqueName));
// socket.emitChanged(`app-folders-changed`);
// },
// deleteFolder_meta: true,
// async deleteFolder({ folder }) {
// if (!folder) throw new Error('Missing folder parameter');
// await fs.rmdir(path.join(appdir(), folder), { recursive: true });
// socket.emitChanged(`app-folders-changed`);
// socket.emitChanged('app-files-changed', { app: folder });
// socket.emitChanged('used-apps-changed');
// },
// async getNewAppFolder({ name }) {
// if (!(await fs.exists(path.join(appdir(), name)))) return name;
// let index = 2;
// while (await fs.exists(path.join(appdir(), `${name}${index}`))) {
// index += 1;
// }
// return `${name}${index}`;
// },
// getUsedAppFolders_meta: true,
// async getUsedAppFolders() {
// const list = await connections.list();
// const apps = [];
// for (const connection of list) {
// for (const db of connection.databases || []) {
// for (const key of _.keys(db || {})) {
// if (key.startsWith('useApp:') && db[key]) {
// apps.push(key.substring('useApp:'.length));
// }
// }
// }
// }
// return _.intersection(_.uniq(apps), await fs.readdir(appdir()));
// },
// // getAppsForDb_meta: true,
// // async getAppsForDb({ conid, database }) {
// // const connection = await connections.get({ conid });
// // if (!connection) return [];
// // const db = (connection.databases || []).find(x => x.name == database);
// // const apps = [];
// // const res = [];
// // if (db) {
// // for (const key of _.keys(db || {})) {
// // if (key.startsWith('useApp:') && db[key]) {
// // apps.push(key.substring('useApp:'.length));
// // }
// // }
// // }
// // for (const folder of apps) {
// // res.push(await this.loadApp({ folder }));
// // }
// // return res;
// // },
// loadApp_meta: true,
// async loadApp({ folder }) {
// const res = {
// queries: [],
// commands: [],
// name: folder,
// };
// const dir = path.join(appdir(), folder);
// if (await fs.exists(dir)) {
// const files = await fs.readdir(dir);
// async function processType(ext, field) {
// for (const file of files) {
// if (file.endsWith(ext)) {
// res[field].push({
// name: file.slice(0, -ext.length),
// sql: await fs.readFile(path.join(dir, file), { encoding: 'utf-8' }),
// });
// }
// }
// }
// await processType('.command.sql', 'commands');
// await processType('.query.sql', 'queries');
// }
// try {
// res.virtualReferences = JSON.parse(
// await fs.readFile(path.join(dir, 'virtual-references.config.json'), { encoding: 'utf-8' })
// );
// } catch (err) {
// res.virtualReferences = [];
// }
// try {
// res.dictionaryDescriptions = JSON.parse(
// await fs.readFile(path.join(dir, 'dictionary-descriptions.config.json'), { encoding: 'utf-8' })
// );
// } catch (err) {
// res.dictionaryDescriptions = [];
// }
// return res;
// },
// async saveConfigFile(appFolder, filename, filterFunc, newItem) {
// const file = path.join(appdir(), appFolder, filename);
// let json;
// try {
// json = JSON.parse(await fs.readFile(file, { encoding: 'utf-8' }));
// } catch (err) {
// json = [];
// }
// if (filterFunc) {
// json = json.filter(filterFunc);
// }
// json = [...json, newItem];
// await fs.writeFile(file, JSON.stringify(json, undefined, 2));
// socket.emitChanged('app-files-changed', { app: appFolder });
// socket.emitChanged('used-apps-changed');
// },
// saveDictionaryDescription_meta: true,
// async saveDictionaryDescription({ appFolder, pureName, schemaName, expression, columns, delimiter }) {
// await this.saveConfigFile(
// appFolder,
// 'dictionary-descriptions.config.json',
// x => !(x.schemaName == schemaName && x.pureName == pureName),
// {
// schemaName,
// pureName,
// expression,
// columns,
// delimiter,
// }
// );
// return true;
// },
// createConfigFile_meta: true,
// async createConfigFile({ appFolder, fileName, content }) {
// const file = path.join(appdir(), appFolder, fileName);
// if (!(await fs.exists(file))) {
// await fs.writeFile(file, JSON.stringify(content, undefined, 2));
// socket.emitChanged('app-files-changed', { app: appFolder });
// socket.emitChanged('used-apps-changed');
// return true;
// }
// return false;
// },
};

View File

@@ -51,6 +51,7 @@ function authMiddleware(req, res, next) {
'/auth/oauth-token',
'/auth/login',
'/auth/redirect',
'/redirect',
'/stream',
'/storage/get-connections-for-login-page',
'/storage/set-admin-password',
@@ -139,9 +140,9 @@ module.exports = {
const accessToken = jwt.sign(
{
login: 'superadmin',
permissions: await storage.loadSuperadminPermissions(),
roleId: -3,
licenseUid,
amoid: 'superadmin',
},
getTokenSecret(),
{
@@ -173,7 +174,9 @@ module.exports = {
getProviders_meta: true,
getProviders() {
return {
providers: getAuthProviders().map(x => x.toJson()),
providers: getAuthProviders()
.filter(x => !x.skipInList)
.map(x => x.toJson()),
default: getDefaultAuthProvider()?.amoid,
};
},

View File

@@ -8,6 +8,9 @@ const {
getCloudContent,
putCloudContent,
removeCloudCachedConnection,
getPromoWidgetData,
getPromoWidgetList,
getPromoWidgetPreview,
} = require('../utility/cloudIntf');
const connections = require('./connections');
const socket = require('../utility/socket');
@@ -32,8 +35,8 @@ module.exports = {
},
refreshPublicFiles_meta: true,
async refreshPublicFiles({ isRefresh }) {
await refreshPublicFiles(isRefresh);
async refreshPublicFiles({ isRefresh }, req) {
await refreshPublicFiles(isRefresh, req?.headers?.['x-ui-language']);
return {
status: 'ok',
};
@@ -283,6 +286,28 @@ module.exports = {
return getAiGatewayServer();
},
premiumPromoWidget_meta: true,
async premiumPromoWidget() {
const data = await getPromoWidgetData();
if (data?.state != 'data') {
return null;
}
if (data.validTo && new Date().getTime() > new Date(data.validTo).getTime()) {
return null;
}
return data;
},
promoWidgetList_meta: true,
async promoWidgetList() {
return getPromoWidgetList();
},
promoWidgetPreview_meta: true,
async promoWidgetPreview({ campaign, variant }) {
return getPromoWidgetPreview(campaign, variant);
},
// chatStream_meta: {
// raw: true,
// method: 'post',

View File

@@ -3,7 +3,7 @@ const os = require('os');
const path = require('path');
const axios = require('axios');
const { datadir, getLogsFilePath } = require('../utility/directories');
const { hasPermission } = require('../utility/hasPermission');
const { hasPermission, loadPermissionsFromRequest } = require('../utility/hasPermission');
const socket = require('../utility/socket');
const _ = require('lodash');
const AsyncLock = require('async-lock');
@@ -46,7 +46,7 @@ module.exports = {
async get(_params, req) {
const authProvider = getAuthProviderFromReq(req);
const login = authProvider.getCurrentLogin(req);
const permissions = authProvider.getCurrentPermissions(req);
const permissions = await authProvider.getCurrentPermissions(req);
const isUserLoggedIn = authProvider.isUserLoggedIn(req);
const singleConid = authProvider.getSingleConnectionId(req);
@@ -71,6 +71,7 @@ module.exports = {
const isLicenseValid = checkedLicense?.status == 'ok';
const logoutUrl = storageConnectionError ? null : await authProvider.getLogoutUrl();
const adminConfig = storageConnectionError ? null : await storage.readConfig({ group: 'admin' });
const settingsConfig = storageConnectionError ? null : await storage.readConfig({ group: 'settings' });
storage.startRefreshLicense();
@@ -121,6 +122,7 @@ module.exports = {
allowPrivateCloud: platformInfo.isElectron || !!process.env.ALLOW_DBGATE_PRIVATE_CLOUD,
...currentVersion,
redirectToDbGateCloudLogin: !!process.env.REDIRECT_TO_DBGATE_CLOUD_LOGIN,
preferrendLanguage: settingsConfig?.['storage.language'] || process.env.LANGUAGE || null,
};
return configResult;
@@ -280,22 +282,18 @@ module.exports = {
updateSettings_meta: true,
async updateSettings(values, req) {
if (!hasPermission(`settings/change`, req)) return false;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`settings/change`, loadedPermissions)) return false;
cachedSettingsValue = null;
const res = await lock.acquire('settings', async () => {
const currentValue = await this.loadSettings();
try {
let updated = currentValue;
let updated = {
...currentValue,
...values,
};
if (process.env.STORAGE_DATABASE) {
updated = {
...currentValue,
..._.mapValues(values, v => {
if (v === true) return 'true';
if (v === false) return 'false';
return v;
}),
};
await storage.writeConfig({
group: 'settings',
config: updated,
@@ -392,7 +390,8 @@ module.exports = {
exportConnectionsAndSettings_meta: true,
async exportConnectionsAndSettings(_params, req) {
if (!hasPermission(`admin/config`, req)) {
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`admin/config`, loadedPermissions)) {
throw new Error('Permission denied: admin/config');
}
@@ -416,7 +415,8 @@ module.exports = {
importConnectionsAndSettings_meta: true,
async importConnectionsAndSettings({ db }, req) {
if (!hasPermission(`admin/config`, req)) {
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`admin/config`, loadedPermissions)) {
throw new Error('Permission denied: admin/config');
}

View File

@@ -14,11 +14,16 @@ const JsonLinesDatabase = require('../utility/JsonLinesDatabase');
const processArgs = require('../utility/processArgs');
const { safeJsonParse, getLogger, extractErrorLogData } = require('dbgate-tools');
const platformInfo = require('../utility/platformInfo');
const { connectionHasPermission, testConnectionPermission } = require('../utility/hasPermission');
const {
connectionHasPermission,
testConnectionPermission,
loadPermissionsFromRequest,
} = require('../utility/hasPermission');
const pipeForkLogs = require('../utility/pipeForkLogs');
const requireEngineDriver = require('../utility/requireEngineDriver');
const { getAuthProviderById } = require('../auth/authProvider');
const { startTokenChecking } = require('../utility/authProxy');
const { extractConnectionsFromEnv } = require('../utility/envtools');
const logger = getLogger('connections');
@@ -57,55 +62,7 @@ function getDatabaseFileLabel(databaseFile) {
function getPortalCollections() {
if (process.env.CONNECTIONS) {
const connections = _.compact(process.env.CONNECTIONS.split(',')).map(id => ({
_id: id,
engine: process.env[`ENGINE_${id}`],
server: process.env[`SERVER_${id}`],
user: process.env[`USER_${id}`],
password: process.env[`PASSWORD_${id}`],
passwordMode: process.env[`PASSWORD_MODE_${id}`],
port: process.env[`PORT_${id}`],
databaseUrl: process.env[`URL_${id}`],
useDatabaseUrl: !!process.env[`URL_${id}`],
databaseFile: process.env[`FILE_${id}`]?.replace(
'%%E2E_TEST_DATA_DIRECTORY%%',
path.join(path.dirname(path.dirname(__dirname)), 'e2e-tests', 'tmpdata')
),
socketPath: process.env[`SOCKET_PATH_${id}`],
serviceName: process.env[`SERVICE_NAME_${id}`],
authType: process.env[`AUTH_TYPE_${id}`] || (process.env[`SOCKET_PATH_${id}`] ? 'socket' : undefined),
defaultDatabase:
process.env[`DATABASE_${id}`] ||
(process.env[`FILE_${id}`] ? getDatabaseFileLabel(process.env[`FILE_${id}`]) : null),
singleDatabase: !!process.env[`DATABASE_${id}`] || !!process.env[`FILE_${id}`],
displayName: process.env[`LABEL_${id}`],
isReadOnly: process.env[`READONLY_${id}`],
databases: process.env[`DBCONFIG_${id}`] ? safeJsonParse(process.env[`DBCONFIG_${id}`]) : null,
allowedDatabases: process.env[`ALLOWED_DATABASES_${id}`]?.replace(/\|/g, '\n'),
allowedDatabasesRegex: process.env[`ALLOWED_DATABASES_REGEX_${id}`],
parent: process.env[`PARENT_${id}`] || undefined,
useSeparateSchemas: !!process.env[`USE_SEPARATE_SCHEMAS_${id}`],
localDataCenter: process.env[`LOCAL_DATA_CENTER_${id}`],
// SSH tunnel
useSshTunnel: process.env[`USE_SSH_${id}`],
sshHost: process.env[`SSH_HOST_${id}`],
sshPort: process.env[`SSH_PORT_${id}`],
sshMode: process.env[`SSH_MODE_${id}`],
sshLogin: process.env[`SSH_LOGIN_${id}`],
sshPassword: process.env[`SSH_PASSWORD_${id}`],
sshKeyfile: process.env[`SSH_KEY_FILE_${id}`],
sshKeyfilePassword: process.env[`SSH_KEY_FILE_PASSWORD_${id}`],
// SSL
useSsl: process.env[`USE_SSL_${id}`],
sslCaFile: process.env[`SSL_CA_FILE_${id}`],
sslCertFile: process.env[`SSL_CERT_FILE_${id}`],
sslCertFilePassword: process.env[`SSL_CERT_FILE_PASSWORD_${id}`],
sslKeyFile: process.env[`SSL_KEY_FILE_${id}`],
sslRejectUnauthorized: process.env[`SSL_REJECT_UNAUTHORIZED_${id}`],
trustServerCertificate: process.env[`SSL_TRUST_CERTIFICATE_${id}`],
}));
const connections = extractConnectionsFromEnv(process.env);
for (const conn of connections) {
for (const prop in process.env) {
@@ -116,7 +73,10 @@ function getPortalCollections() {
}
}
logger.info({ connections: connections.map(pickSafeConnectionInfo) }, 'DBGM-00005 Using connections from ENV variables');
logger.info(
{ connections: connections.map(pickSafeConnectionInfo) },
'DBGM-00005 Using connections from ENV variables'
);
const noengine = connections.filter(x => !x.engine);
if (noengine.length > 0) {
logger.warn(
@@ -222,11 +182,21 @@ module.exports = {
);
}
await this.checkUnsavedConnectionsLimit();
if (process.env.STORAGE_DATABASE && process.env.CONNECTIONS) {
const storage = require('./storage');
try {
await storage.fillStorageConnectionsFromEnv();
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00268 Error filling storage connections from env');
}
}
},
list_meta: true,
async list(_params, req) {
const storage = require('./storage');
const loadedPermissions = await loadPermissionsFromRequest(req);
const storageConnections = await storage.connections(req);
if (storageConnections) {
@@ -234,9 +204,9 @@ module.exports = {
}
if (portalConnections) {
if (platformInfo.allowShellConnection) return portalConnections;
return portalConnections.map(maskConnection).filter(x => connectionHasPermission(x, req));
return portalConnections.map(maskConnection).filter(x => connectionHasPermission(x, loadedPermissions));
}
return (await this.datastore.find()).filter(x => connectionHasPermission(x, req));
return (await this.datastore.find()).filter(x => connectionHasPermission(x, loadedPermissions));
},
async getUsedEngines() {
@@ -375,7 +345,7 @@ module.exports = {
update_meta: true,
async update({ _id, values }, req) {
if (portalConnections) return;
testConnectionPermission(_id, req);
await testConnectionPermission(_id, req);
const res = await this.datastore.patch(_id, values);
socket.emitChanged('connection-list-changed');
return res;
@@ -392,7 +362,7 @@ module.exports = {
updateDatabase_meta: true,
async updateDatabase({ conid, database, values }, req) {
if (portalConnections) return;
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const conn = await this.datastore.get(conid);
let databases = (conn && conn.databases) || [];
if (databases.find(x => x.name == database)) {
@@ -410,7 +380,7 @@ module.exports = {
delete_meta: true,
async delete(connection, req) {
if (portalConnections) return;
testConnectionPermission(connection, req);
await testConnectionPermission(connection, req);
const res = await this.datastore.remove(connection._id);
socket.emitChanged('connection-list-changed');
return res;
@@ -452,7 +422,7 @@ module.exports = {
_id: '__model',
};
}
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.getCore({ conid, mask: true });
},
@@ -501,7 +471,11 @@ module.exports = {
state,
client: 'web',
});
res.redirect(authResp.url);
if (authResp?.url) {
res.redirect(authResp.url);
return;
}
res.json({ error: 'No URL returned from auth provider' });
},
dbloginApp_meta: true,

View File

@@ -29,7 +29,17 @@ const generateDeploySql = require('../shell/generateDeploySql');
const { createTwoFilesPatch } = require('diff');
const diff2htmlPage = require('../utility/diff2htmlPage');
const processArgs = require('../utility/processArgs');
const { testConnectionPermission } = require('../utility/hasPermission');
const {
testConnectionPermission,
hasPermission,
loadPermissionsFromRequest,
loadTablePermissionsFromRequest,
getTablePermissionRole,
loadDatabasePermissionsFromRequest,
getDatabasePermissionRole,
getTablePermissionRoleLevelIndex,
testDatabaseRolePermission,
} = require('../utility/hasPermission');
const { MissingCredentialsError } = require('../utility/exceptions');
const pipeForkLogs = require('../utility/pipeForkLogs');
const crypto = require('crypto');
@@ -235,7 +245,7 @@ module.exports = {
queryData_meta: true,
async queryData({ conid, database, sql }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
logger.info({ conid, database, sql }, 'DBGM-00007 Processing query');
const opened = await this.ensureOpened(conid, database);
// if (opened && opened.status && opened.status.name == 'error') {
@@ -247,7 +257,7 @@ module.exports = {
sqlSelect_meta: true,
async sqlSelect({ conid, database, select, auditLogSessionGroup }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(
opened,
@@ -282,7 +292,9 @@ module.exports = {
runScript_meta: true,
async runScript({ conid, database, sql, useTransaction, logMessage }, req) {
testConnectionPermission(conid, req);
const loadedPermissions = await loadPermissionsFromRequest(req);
await testConnectionPermission(conid, req, loadedPermissions);
await testDatabaseRolePermission(conid, database, 'run_script', req);
logger.info({ conid, database, sql }, 'DBGM-00008 Processing script');
const opened = await this.ensureOpened(conid, database);
sendToAuditLog(req, {
@@ -303,7 +315,7 @@ module.exports = {
runOperation_meta: true,
async runOperation({ conid, database, operation, useTransaction }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
logger.info({ conid, database, operation }, 'DBGM-00009 Processing operation');
sendToAuditLog(req, {
@@ -325,7 +337,7 @@ module.exports = {
collectionData_meta: true,
async collectionData({ conid, database, options, auditLogSessionGroup }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(
opened,
@@ -356,7 +368,7 @@ module.exports = {
},
async loadDataCore(msgtype, { conid, database, ...args }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(opened, { msgtype, ...args });
if (res.errorMessage) {
@@ -371,7 +383,7 @@ module.exports = {
schemaList_meta: true,
async schemaList({ conid, database }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('schemaList', { conid, database });
},
@@ -383,43 +395,43 @@ module.exports = {
loadKeys_meta: true,
async loadKeys({ conid, database, root, filter, limit }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('loadKeys', { conid, database, root, filter, limit });
},
scanKeys_meta: true,
async scanKeys({ conid, database, root, pattern, cursor, count }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('scanKeys', { conid, database, root, pattern, cursor, count });
},
exportKeys_meta: true,
async exportKeys({ conid, database, options }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('exportKeys', { conid, database, options });
},
loadKeyInfo_meta: true,
async loadKeyInfo({ conid, database, key }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('loadKeyInfo', { conid, database, key });
},
loadKeyTableRange_meta: true,
async loadKeyTableRange({ conid, database, key, cursor, count }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('loadKeyTableRange', { conid, database, key, cursor, count });
},
loadFieldValues_meta: true,
async loadFieldValues({ conid, database, schemaName, pureName, field, search, dataType }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('loadFieldValues', { conid, database, schemaName, pureName, field, search, dataType });
},
callMethod_meta: true,
async callMethod({ conid, database, method, args }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
return this.loadDataCore('callMethod', { conid, database, method, args });
// const opened = await this.ensureOpened(conid, database);
@@ -432,7 +444,8 @@ module.exports = {
updateCollection_meta: true,
async updateCollection({ conid, database, changeSet }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(opened, { msgtype: 'updateCollection', changeSet });
if (res.errorMessage) {
@@ -443,6 +456,44 @@ module.exports = {
return res.result || null;
},
saveTableData_meta: true,
async saveTableData({ conid, database, changeSet }, req) {
await testConnectionPermission(conid, req);
const databasePermissions = await loadDatabasePermissionsFromRequest(req);
const tablePermissions = await loadTablePermissionsFromRequest(req);
const fieldsAndRoles = [
[changeSet.inserts, 'create_update_delete'],
[changeSet.deletes, 'create_update_delete'],
[changeSet.updates, 'update_only'],
];
for (const [operations, requiredRole] of fieldsAndRoles) {
for (const operation of operations) {
const role = getTablePermissionRole(
conid,
database,
'tables',
operation.schemaName,
operation.pureName,
tablePermissions,
databasePermissions
);
if (getTablePermissionRoleLevelIndex(role) < getTablePermissionRoleLevelIndex(requiredRole)) {
throw new Error('DBGM-00262 Permission not granted');
}
}
}
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(opened, { msgtype: 'saveTableData', changeSet });
if (res.errorMessage) {
return {
errorMessage: res.errorMessage,
};
}
return res.result || null;
},
status_meta: true,
async status({ conid, database }, req) {
if (!conid) {
@@ -451,7 +502,7 @@ module.exports = {
message: 'No connection',
};
}
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const existing = this.opened.find(x => x.conid == conid && x.database == database);
if (existing) {
return {
@@ -474,7 +525,7 @@ module.exports = {
ping_meta: true,
async ping({ conid, database }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
let existing = this.opened.find(x => x.conid == conid && x.database == database);
if (existing) {
@@ -502,7 +553,7 @@ module.exports = {
refresh_meta: true,
async refresh({ conid, database, keepOpen }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
if (!keepOpen) this.close(conid, database);
await this.ensureOpened(conid, database);
@@ -516,7 +567,7 @@ module.exports = {
return { status: 'ok' };
}
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const conn = await this.ensureOpened(conid, database);
conn.subprocess.send({ msgtype: 'syncModel', isFullRefresh });
return { status: 'ok' };
@@ -553,7 +604,7 @@ module.exports = {
disconnect_meta: true,
async disconnect({ conid, database }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
await this.close(conid, database, true);
return { status: 'ok' };
},
@@ -563,8 +614,9 @@ module.exports = {
if (!conid || !database) {
return {};
}
const loadedPermissions = await loadPermissionsFromRequest(req);
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req, loadedPermissions);
if (conid == '__model') {
const model = await importDbModel(database);
const trans = await loadModelTransform(modelTransFile);
@@ -586,6 +638,46 @@ module.exports = {
message: `Loaded database structure for ${database}`,
});
if (process.env.STORAGE_DATABASE && !hasPermission(`all-tables`, loadedPermissions)) {
// filter databases by permissions
const tablePermissions = await loadTablePermissionsFromRequest(req);
const databasePermissions = await loadDatabasePermissionsFromRequest(req);
const databasePermissionRole = getDatabasePermissionRole(conid, database, databasePermissions);
function applyTablePermissionRole(list, objectTypeField) {
const res = [];
for (const item of list ?? []) {
const tablePermissionRole = getTablePermissionRole(
conid,
database,
objectTypeField,
item.schemaName,
item.pureName,
tablePermissions,
databasePermissionRole
);
if (tablePermissionRole != 'deny') {
res.push({
...item,
tablePermissionRole,
});
}
}
return res;
}
const res = {
...opened.structure,
tables: applyTablePermissionRole(opened.structure.tables, 'tables'),
views: applyTablePermissionRole(opened.structure.views, 'views'),
procedures: applyTablePermissionRole(opened.structure.procedures, 'procedures'),
functions: applyTablePermissionRole(opened.structure.functions, 'functions'),
triggers: applyTablePermissionRole(opened.structure.triggers, 'triggers'),
collections: applyTablePermissionRole(opened.structure.collections, 'collections'),
};
return res;
}
return opened.structure;
// const existing = this.opened.find((x) => x.conid == conid && x.database == database);
// if (existing) return existing.status;
@@ -600,7 +692,7 @@ module.exports = {
if (!conid) {
return null;
}
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
if (!conid) return null;
const opened = await this.ensureOpened(conid, database);
return opened.serverVersion || null;
@@ -608,7 +700,7 @@ module.exports = {
sqlPreview_meta: true,
async sqlPreview({ conid, database, objects, options }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
// wait for structure
await this.structure({ conid, database });
@@ -619,7 +711,7 @@ module.exports = {
exportModel_meta: true,
async exportModel({ conid, database, outputFolder, schema }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const realFolder = outputFolder.startsWith('archive:')
? resolveArchiveFolder(outputFolder.substring('archive:'.length))
@@ -637,7 +729,7 @@ module.exports = {
exportModelSql_meta: true,
async exportModelSql({ conid, database, outputFolder, outputFile, schema }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const connection = await connections.getCore({ conid });
const driver = requireEngineDriver(connection);
@@ -651,7 +743,7 @@ module.exports = {
generateDeploySql_meta: true,
async generateDeploySql({ conid, database, archiveFolder }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(opened, {
msgtype: 'generateDeploySql',
@@ -923,9 +1015,12 @@ module.exports = {
executeSessionQuery_meta: true,
async executeSessionQuery({ sesid, conid, database, sql }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
logger.info({ sesid, sql }, 'DBGM-00010 Processing query');
sessions.dispatchMessage(sesid, 'Query execution started');
sessions.dispatchMessage(sesid, {
message: 'Query execution started',
sql,
});
const opened = await this.ensureOpened(conid, database);
opened.subprocess.send({ msgtype: 'executeSessionQuery', sql, sesid });
@@ -935,7 +1030,7 @@ module.exports = {
evalJsonScript_meta: true,
async evalJsonScript({ conid, database, script, runid }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
opened.subprocess.send({ msgtype: 'evalJsonScript', script, runid });

View File

@@ -3,7 +3,12 @@ const path = require('path');
const crypto = require('crypto');
const { filesdir, archivedir, resolveArchiveFolder, uploadsdir, appdir, jsldir } = require('../utility/directories');
const getChartExport = require('../utility/getChartExport');
const { hasPermission } = require('../utility/hasPermission');
const {
hasPermission,
loadPermissionsFromRequest,
loadFilePermissionsFromRequest,
getFilePermissionRole,
} = require('../utility/hasPermission');
const socket = require('../utility/socket');
const scheduler = require('./scheduler');
const getDiagramExport = require('../utility/getDiagramExport');
@@ -31,7 +36,8 @@ function deserialize(format, text) {
module.exports = {
list_meta: true,
async list({ folder }, req) {
if (!hasPermission(`files/${folder}/read`, req)) return [];
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/${folder}/read`, loadedPermissions)) return [];
const dir = path.join(filesdir(), folder);
if (!(await fs.exists(dir))) return [];
const files = (await fs.readdir(dir)).map(file => ({ folder, file }));
@@ -40,10 +46,11 @@ module.exports = {
listAll_meta: true,
async listAll(_params, req) {
const loadedPermissions = await loadPermissionsFromRequest(req);
const folders = await fs.readdir(filesdir());
const res = [];
for (const folder of folders) {
if (!hasPermission(`files/${folder}/read`, req)) continue;
if (!hasPermission(`files/${folder}/read`, loadedPermissions)) continue;
const dir = path.join(filesdir(), folder);
const files = (await fs.readdir(dir)).map(file => ({ folder, file }));
res.push(...files);
@@ -53,7 +60,8 @@ module.exports = {
delete_meta: true,
async delete({ folder, file }, req) {
if (!hasPermission(`files/${folder}/write`, req)) return false;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/${folder}/write`, loadedPermissions)) return false;
if (!checkSecureFilePathsWithoutDirectory(folder, file)) {
return false;
}
@@ -65,7 +73,8 @@ module.exports = {
rename_meta: true,
async rename({ folder, file, newFile }, req) {
if (!hasPermission(`files/${folder}/write`, req)) return false;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/${folder}/write`, loadedPermissions)) return false;
if (!checkSecureFilePathsWithoutDirectory(folder, file, newFile)) {
return false;
}
@@ -86,10 +95,11 @@ module.exports = {
copy_meta: true,
async copy({ folder, file, newFile }, req) {
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!checkSecureFilePathsWithoutDirectory(folder, file, newFile)) {
return false;
}
if (!hasPermission(`files/${folder}/write`, req)) return false;
if (!hasPermission(`files/${folder}/write`, loadedPermissions)) return false;
await fs.copyFile(path.join(filesdir(), folder, file), path.join(filesdir(), folder, newFile));
socket.emitChanged(`files-changed`, { folder });
socket.emitChanged(`all-files-changed`);
@@ -113,7 +123,8 @@ module.exports = {
});
return deserialize(format, text);
} else {
if (!hasPermission(`files/${folder}/read`, req)) return null;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/${folder}/read`, loadedPermissions)) return null;
const text = await fs.readFile(path.join(filesdir(), folder, file), { encoding: 'utf-8' });
return deserialize(format, text);
}
@@ -131,18 +142,19 @@ module.exports = {
save_meta: true,
async save({ folder, file, data, format }, req) {
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!checkSecureFilePathsWithoutDirectory(folder, file)) {
return false;
}
if (folder.startsWith('archive:')) {
if (!hasPermission(`archive/write`, req)) return false;
if (!hasPermission(`archive/write`, loadedPermissions)) return false;
const dir = resolveArchiveFolder(folder.substring('archive:'.length));
await fs.writeFile(path.join(dir, file), serialize(format, data));
socket.emitChanged(`archive-files-changed`, { folder: folder.substring('archive:'.length) });
return true;
} else if (folder.startsWith('app:')) {
if (!hasPermission(`apps/write`, req)) return false;
if (!hasPermission(`apps/write`, loadedPermissions)) return false;
const app = folder.substring('app:'.length);
await fs.writeFile(path.join(appdir(), app, file), serialize(format, data));
socket.emitChanged(`app-files-changed`, { app });
@@ -150,7 +162,7 @@ module.exports = {
apps.emitChangedDbApp(folder);
return true;
} else {
if (!hasPermission(`files/${folder}/write`, req)) return false;
if (!hasPermission(`files/${folder}/write`, loadedPermissions)) return false;
const dir = path.join(filesdir(), folder);
if (!(await fs.exists(dir))) {
await fs.mkdir(dir);
@@ -177,7 +189,8 @@ module.exports = {
favorites_meta: true,
async favorites(_params, req) {
if (!hasPermission(`files/favorites/read`, req)) return [];
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/favorites/read`, loadedPermissions)) return [];
const dir = path.join(filesdir(), 'favorites');
if (!(await fs.exists(dir))) return [];
const files = await fs.readdir(dir);
@@ -234,16 +247,17 @@ module.exports = {
getFileRealPath_meta: true,
async getFileRealPath({ folder, file }, req) {
const loadedPermissions = await loadPermissionsFromRequest(req);
if (folder.startsWith('archive:')) {
if (!hasPermission(`archive/write`, req)) return false;
if (!hasPermission(`archive/write`, loadedPermissions)) return false;
const dir = resolveArchiveFolder(folder.substring('archive:'.length));
return path.join(dir, file);
} else if (folder.startsWith('app:')) {
if (!hasPermission(`apps/write`, req)) return false;
if (!hasPermission(`apps/write`, loadedPermissions)) return false;
const app = folder.substring('app:'.length);
return path.join(appdir(), app, file);
} else {
if (!hasPermission(`files/${folder}/write`, req)) return false;
if (!hasPermission(`files/${folder}/write`, loadedPermissions)) return false;
const dir = path.join(filesdir(), folder);
if (!(await fs.exists(dir))) {
await fs.mkdir(dir);
@@ -297,7 +311,8 @@ module.exports = {
exportFile_meta: true,
async exportFile({ folder, file, filePath }, req) {
if (!hasPermission(`files/${folder}/read`, req)) return false;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/${folder}/read`, loadedPermissions)) return false;
await fs.copyFile(path.join(filesdir(), folder, file), filePath);
return true;
},

View File

@@ -7,7 +7,7 @@ const socket = require('../utility/socket');
const compareVersions = require('compare-versions');
const requirePlugin = require('../shell/requirePlugin');
const downloadPackage = require('../utility/downloadPackage');
const { hasPermission } = require('../utility/hasPermission');
const { hasPermission, loadPermissionsFromRequest } = require('../utility/hasPermission');
const _ = require('lodash');
const packagedPluginsContent = require('../packagedPluginsContent');
@@ -118,7 +118,8 @@ module.exports = {
install_meta: true,
async install({ packageName }, req) {
if (!hasPermission(`plugins/install`, req)) return;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`plugins/install`, loadedPermissions)) return;
const dir = path.join(pluginsdir(), packageName);
// @ts-ignore
if (!(await fs.exists(dir))) {
@@ -132,7 +133,8 @@ module.exports = {
uninstall_meta: true,
async uninstall({ packageName }, req) {
if (!hasPermission(`plugins/install`, req)) return;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`plugins/install`, loadedPermissions)) return;
const dir = path.join(pluginsdir(), packageName);
await fs.rmdir(dir, { recursive: true });
socket.emitChanged(`installed-plugins-changed`);
@@ -143,7 +145,8 @@ module.exports = {
upgrade_meta: true,
async upgrade({ packageName }, req) {
if (!hasPermission(`plugins/install`, req)) return;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`plugins/install`, loadedPermissions)) return;
const dir = path.join(pluginsdir(), packageName);
// @ts-ignore
if (await fs.exists(dir)) {

View File

@@ -21,6 +21,7 @@ const processArgs = require('../utility/processArgs');
const platformInfo = require('../utility/platformInfo');
const { checkSecureDirectories, checkSecureDirectoriesInScript } = require('../utility/security');
const { sendToAuditLog, logJsonRunnerScript } = require('../utility/auditlog');
const { testStandardPermission } = require('../utility/hasPermission');
const logger = getLogger('runners');
function extractPlugins(script) {
@@ -288,6 +289,8 @@ module.exports = {
return this.startCore(runid, scriptTemplate(js, false));
}
await testStandardPermission('run-shell-script', req);
if (!platformInfo.allowShellScripting) {
sendToAuditLog(req, {
category: 'shell',

View File

@@ -3,7 +3,7 @@ const fs = require('fs-extra');
const path = require('path');
const cron = require('node-cron');
const runners = require('./runners');
const { hasPermission } = require('../utility/hasPermission');
const { hasPermission, loadPermissionsFromRequest } = require('../utility/hasPermission');
const { getLogger } = require('dbgate-tools');
const logger = getLogger('scheduler');
@@ -30,7 +30,8 @@ module.exports = {
},
async reload(_params, req) {
if (!hasPermission('files/shell/read', req)) return;
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission('files/shell/read', loadedPermissions)) return;
const shellDir = path.join(filesdir(), 'shell');
await this.unload();
if (!(await fs.exists(shellDir))) return;

View File

@@ -8,7 +8,13 @@ const { handleProcessCommunication } = require('../utility/processComm');
const lock = new AsyncLock();
const config = require('./config');
const processArgs = require('../utility/processArgs');
const { testConnectionPermission } = require('../utility/hasPermission');
const {
testConnectionPermission,
loadPermissionsFromRequest,
hasPermission,
loadDatabasePermissionsFromRequest,
getDatabasePermissionRole,
} = require('../utility/hasPermission');
const { MissingCredentialsError } = require('../utility/exceptions');
const pipeForkLogs = require('../utility/pipeForkLogs');
const { getLogger, extractErrorLogData } = require('dbgate-tools');
@@ -135,7 +141,7 @@ module.exports = {
disconnect_meta: true,
async disconnect({ conid }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
await this.close(conid, true);
return { status: 'ok' };
},
@@ -144,7 +150,9 @@ module.exports = {
async listDatabases({ conid }, req) {
if (!conid) return [];
if (conid == '__model') return [];
testConnectionPermission(conid, req);
const loadedPermissions = await loadPermissionsFromRequest(req);
await testConnectionPermission(conid, req, loadedPermissions);
const opened = await this.ensureOpened(conid);
sendToAuditLog(req, {
category: 'serverop',
@@ -157,12 +165,29 @@ module.exports = {
sessionGroup: 'listDatabases',
message: `Loaded databases for connection`,
});
if (process.env.STORAGE_DATABASE && !hasPermission(`all-databases`, loadedPermissions)) {
// filter databases by permissions
const databasePermissions = await loadDatabasePermissionsFromRequest(req);
const res = [];
for (const db of opened?.databases ?? []) {
const databasePermissionRole = getDatabasePermissionRole(db.id, db.name, databasePermissions);
if (databasePermissionRole != 'deny') {
res.push({
...db,
databasePermissionRole,
});
}
}
return res;
}
return opened?.databases ?? [];
},
version_meta: true,
async version({ conid }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid);
return opened?.version ?? null;
},
@@ -184,11 +209,11 @@ module.exports = {
return Promise.resolve();
}
this.lastPinged[conid] = new Date().getTime();
const opened = await this.ensureOpened(conid);
if (!opened) {
return Promise.resolve();
}
try {
const opened = await this.ensureOpened(conid);
if (!opened) {
return Promise.resolve();
}
opened.subprocess.send({ msgtype: 'ping' });
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00121 Error pinging server connection');
@@ -202,7 +227,7 @@ module.exports = {
refresh_meta: true,
async refresh({ conid, keepOpen }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
if (!keepOpen) this.close(conid);
await this.ensureOpened(conid);
@@ -210,7 +235,7 @@ module.exports = {
},
async sendDatabaseOp({ conid, msgtype, name }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid);
if (!opened) {
return null;
@@ -252,7 +277,7 @@ module.exports = {
},
async loadDataCore(msgtype, { conid, ...args }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid);
if (!opened) {
return null;
@@ -270,13 +295,43 @@ module.exports = {
serverSummary_meta: true,
async serverSummary({ conid }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
logger.info({ conid }, 'DBGM-00260 Processing server summary');
return this.loadDataCore('serverSummary', { conid });
},
listDatabaseProcesses_meta: true,
async listDatabaseProcesses(ctx, req) {
const { conid } = ctx;
// logger.info({ conid }, 'DBGM-00261 Listing processes of database server');
testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid);
if (!opened) {
return null;
}
if (opened.connection.isReadOnly) return false;
return this.sendRequest(opened, { msgtype: 'listDatabaseProcesses' });
},
killDatabaseProcess_meta: true,
async killDatabaseProcess(ctx, req) {
const { conid, pid } = ctx;
testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid);
if (!opened) {
return null;
}
if (opened.connection.isReadOnly) return false;
return this.sendRequest(opened, { msgtype: 'killDatabaseProcess', pid });
},
summaryCommand_meta: true,
async summaryCommand({ conid, command, row }, req) {
testConnectionPermission(conid, req);
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid);
if (!opened) {
return null;

View File

@@ -8,10 +8,13 @@ const path = require('path');
const { handleProcessCommunication } = require('../utility/processComm');
const processArgs = require('../utility/processArgs');
const { appdir } = require('../utility/directories');
const { getLogger, extractErrorLogData } = require('dbgate-tools');
const { getLogger, extractErrorLogData, removeSqlFrontMatter } = require('dbgate-tools');
const pipeForkLogs = require('../utility/pipeForkLogs');
const config = require('./config');
const { sendToAuditLog } = require('../utility/auditlog');
const { testStandardPermission, testDatabaseRolePermission } = require('../utility/hasPermission');
const { getStaticTokenSecret } = require('../auth/authCommon');
const jwt = require('jsonwebtoken');
const logger = getLogger('sessions');
@@ -80,6 +83,16 @@ module.exports = {
socket.emit(`session-recordset-${sesid}`, { jslid, resultIndex });
},
handle_endrecordset(sesid, props) {
const { jslid, rowCount, durationMs } = props;
this.dispatchMessage(sesid, {
message: `Query returned ${rowCount} rows in ${durationMs} ms`,
rowCount,
durationMs,
jslid,
});
},
handle_stats(sesid, stats) {
jsldata.notifyChangedStats(stats);
},
@@ -94,6 +107,12 @@ module.exports = {
socket.emit(`session-initialize-file-${jslid}`);
},
handle_changedCurrentDatabase(sesid, props) {
const { database } = props;
this.dispatchMessage(sesid, `Current database changed to ${database}`);
socket.emit(`session-changedb-${sesid}`, { database });
},
handle_ping() {},
create_meta: true,
@@ -148,10 +167,23 @@ module.exports = {
executeQuery_meta: true,
async executeQuery({ sesid, sql, autoCommit, autoDetectCharts, limitRows, frontMatter }, req) {
let useTokenIsOk = false;
if (frontMatter?.useToken) {
const decoded = jwt.verify(frontMatter.useToken, getStaticTokenSecret());
if (decoded?.['contentHash'] == crypto.createHash('md5').update(removeSqlFrontMatter(sql)).digest('hex')) {
useTokenIsOk = true;
}
}
if (!useTokenIsOk) {
await testStandardPermission('dbops/query', req);
}
const session = this.opened.find(x => x.sesid == sesid);
if (!session) {
throw new Error('Invalid session');
}
if (!useTokenIsOk) {
await testDatabaseRolePermission(session.conid, session.database, 'run_script', req);
}
sendToAuditLog(req, {
category: 'dbop',
@@ -166,7 +198,10 @@ module.exports = {
});
logger.info({ sesid, sql }, 'DBGM-00019 Processing query');
this.dispatchMessage(sesid, 'Query execution started');
this.dispatchMessage(sesid, {
message: 'Query execution started',
sql,
});
session.subprocess.send({
msgtype: 'executeQuery',
sql,

View File

@@ -0,0 +1,6 @@
module.exports = {
list_meta: true,
async list(req) {
return [];
},
};

View File

@@ -1,19 +1,8 @@
const crypto = require('crypto');
const path = require('path');
const { uploadsdir, getLogsFilePath, filesdir } = require('../utility/directories');
const { getLogger, extractErrorLogData } = require('dbgate-tools');
const { uploadsdir } = require('../utility/directories');
const { getLogger } = require('dbgate-tools');
const logger = getLogger('uploads');
const axios = require('axios');
const os = require('os');
const fs = require('fs/promises');
const { read } = require('./queryHistory');
const platformInfo = require('../utility/platformInfo');
const _ = require('lodash');
const serverConnections = require('./serverConnections');
const config = require('./config');
const gistSecret = require('../gistSecret');
const currentVersion = require('../currentVersion');
const socket = require('../utility/socket');
module.exports = {
upload_meta: {
@@ -51,88 +40,70 @@ module.exports = {
res.sendFile(path.join(uploadsdir(), req.query.file));
},
async getGistToken() {
const settings = await config.getSettings();
// uploadErrorToGist_meta: true,
// async uploadErrorToGist() {
// const logs = await fs.readFile(getLogsFilePath(), { encoding: 'utf-8' });
// const connections = await serverConnections.getOpenedConnectionReport();
// try {
// const response = await axios.default.post(
// 'https://api.github.com/gists',
// {
// description: `DbGate ${currentVersion.version} error report`,
// public: false,
// files: {
// 'logs.jsonl': {
// content: logs,
// },
// 'os.json': {
// content: JSON.stringify(
// {
// release: os.release(),
// arch: os.arch(),
// machine: os.machine(),
// platform: os.platform(),
// type: os.type(),
// },
// null,
// 2
// ),
// },
// 'platform.json': {
// content: JSON.stringify(
// _.omit(
// {
// ...platformInfo,
// },
// ['defaultKeyfile', 'sshAuthSock']
// ),
// null,
// 2
// ),
// },
// 'connections.json': {
// content: JSON.stringify(connections, null, 2),
// },
// 'version.json': {
// content: JSON.stringify(currentVersion, null, 2),
// },
// },
// },
// {
// headers: {
// Authorization: `token ${await this.getGistToken()}`,
// 'Content-Type': 'application/json',
// Accept: 'application/vnd.github.v3+json',
// },
// }
// );
return settings['other.gistCreateToken'] || gistSecret;
},
// return response.data;
// } catch (err) {
// logger.error(extractErrorLogData(err), 'DBGM-00148 Error uploading gist');
uploadErrorToGist_meta: true,
async uploadErrorToGist() {
const logs = await fs.readFile(getLogsFilePath(), { encoding: 'utf-8' });
const connections = await serverConnections.getOpenedConnectionReport();
try {
const response = await axios.default.post(
'https://api.github.com/gists',
{
description: `DbGate ${currentVersion.version} error report`,
public: false,
files: {
'logs.jsonl': {
content: logs,
},
'os.json': {
content: JSON.stringify(
{
release: os.release(),
arch: os.arch(),
machine: os.machine(),
platform: os.platform(),
type: os.type(),
},
null,
2
),
},
'platform.json': {
content: JSON.stringify(
_.omit(
{
...platformInfo,
},
['defaultKeyfile', 'sshAuthSock']
),
null,
2
),
},
'connections.json': {
content: JSON.stringify(connections, null, 2),
},
'version.json': {
content: JSON.stringify(currentVersion, null, 2),
},
},
},
{
headers: {
Authorization: `token ${await this.getGistToken()}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.github.v3+json',
},
}
);
return response.data;
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00148 Error uploading gist');
return {
apiErrorMessage: err.message,
};
// console.error('Error creating gist:', error.response ? error.response.data : error.message);
}
},
deleteGist_meta: true,
async deleteGist({ url }) {
const response = await axios.default.delete(url, {
headers: {
Authorization: `token ${await this.getGistToken()}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.github.v3+json',
},
});
return true;
},
// return {
// apiErrorMessage: err.message,
// };
// // console.error('Error creating gist:', error.response ? error.response.data : error.message);
// }
// },
};

View File

@@ -1 +0,0 @@
module.exports = process.env.GIST_UPLOAD_SECRET;

View File

@@ -5,6 +5,7 @@ const moment = require('moment');
const path = require('path');
const { logsdir, setLogsFilePath, getLogsFilePath } = require('./utility/directories');
const currentVersion = require('./currentVersion');
const _ = require('lodash');
const logger = getLogger('apiIndex');
@@ -68,7 +69,7 @@ function configureLogger() {
}
const additionals = {};
const finalMsg =
msg.msg && msg.msg.match(/^DBGM-\d\d\d\d\d/)
_.isString(msg.msg) && msg.msg.match(/^DBGM-\d\d\d\d\d/)
? {
...msg,
msg: msg.msg.substring(10).trimStart(),

View File

@@ -29,6 +29,8 @@ const files = require('./controllers/files');
const scheduler = require('./controllers/scheduler');
const queryHistory = require('./controllers/queryHistory');
const cloud = require('./controllers/cloud');
const teamFiles = require('./controllers/teamFiles');
const onFinished = require('on-finished');
const processArgs = require('./utility/processArgs');
@@ -264,6 +266,7 @@ function useAllControllers(app, electron) {
useController(app, electron, '/apps', apps);
useController(app, electron, '/auth', auth);
useController(app, electron, '/cloud', cloud);
useController(app, electron, '/team-files', teamFiles);
}
function setElectronSender(electronSender) {

View File

@@ -17,13 +17,14 @@ const requireEngineDriver = require('../utility/requireEngineDriver');
const { connectUtility } = require('../utility/connectUtility');
const { handleProcessCommunication } = require('../utility/processComm');
const generateDeploySql = require('../shell/generateDeploySql');
const { dumpSqlSelect } = require('dbgate-sqltree');
const { dumpSqlSelect, scriptToSql } = require('dbgate-sqltree');
const { allowExecuteCustomScript, handleQueryStream } = require('../utility/handleQueryStream');
const dbgateApi = require('../shell');
const requirePlugin = require('../shell/requirePlugin');
const path = require('path');
const { rundir } = require('../utility/directories');
const fs = require('fs-extra');
const { changeSetToSql } = require('dbgate-datalib');
const logger = getLogger('dbconnProcess');
@@ -348,6 +349,25 @@ async function handleUpdateCollection({ msgid, changeSet }) {
}
}
async function handleSaveTableData({ msgid, changeSet }) {
await waitStructure();
try {
const driver = requireEngineDriver(storedConnection);
const script = driver.createSaveChangeSetScript(changeSet, analysedStructure, () =>
changeSetToSql(changeSet, analysedStructure, driver.dialect)
);
const sql = scriptToSql(driver, script);
await driver.script(dbhan, sql, { useTransaction: true });
process.send({ msgtype: 'response', msgid });
} catch (err) {
process.send({
msgtype: 'response',
msgid,
errorMessage: extractErrorMessage(err, 'Error executing SQL script'),
});
}
}
async function handleSqlPreview({ msgid, objects, options }) {
await waitStructure();
const driver = requireEngineDriver(storedConnection);
@@ -464,6 +484,7 @@ const messageHandlers = {
runScript: handleRunScript,
runOperation: handleRunOperation,
updateCollection: handleUpdateCollection,
saveTableData: handleSaveTableData,
collectionData: handleCollectionData,
loadKeys: handleLoadKeys,
scanKeys: handleScanKeys,

View File

@@ -146,6 +146,30 @@ async function handleServerSummary({ msgid }) {
return handleDriverDataCore(msgid, driver => driver.serverSummary(dbhan));
}
async function handleKillDatabaseProcess({ msgid, pid }) {
await waitConnected();
const driver = requireEngineDriver(storedConnection);
try {
const result = await driver.killProcess(dbhan, Number(pid));
process.send({ msgtype: 'response', msgid, result });
} catch (err) {
process.send({ msgtype: 'response', msgid, errorMessage: err.message });
}
}
async function handleListDatabaseProcesses({ msgid }) {
await waitConnected();
const driver = requireEngineDriver(storedConnection);
try {
const result = await driver.listProcesses(dbhan);
process.send({ msgtype: 'response', msgid, result });
} catch (err) {
process.send({ msgtype: 'response', msgid, errorMessage: err.message });
}
}
async function handleSummaryCommand({ msgid, command, row }) {
return handleDriverDataCore(msgid, driver => driver.summaryCommand(dbhan, command, row));
}
@@ -154,6 +178,8 @@ const messageHandlers = {
connect: handleConnect,
ping: handlePing,
serverSummary: handleServerSummary,
killDatabaseProcess: handleKillDatabaseProcess,
listDatabaseProcesses: handleListDatabaseProcesses,
summaryCommand: handleSummaryCommand,
createDatabase: props => handleDatabaseOp('createDatabase', props),
dropDatabase: props => handleDatabaseOp('dropDatabase', props),

View File

@@ -65,6 +65,8 @@ async function copyStream(input, output, options) {
});
}
} catch (err) {
logger.error(extractErrorLogData(err, { progressName }), 'DBGM-00157 Import/export job failed');
process.send({
msgtype: 'copyStreamError',
copyStreamError: {
@@ -82,8 +84,6 @@ async function copyStream(input, output, options) {
errorMessage: extractErrorMessage(err),
});
}
logger.error(extractErrorLogData(err, { progressName }), 'DBGM-00157 Import/export job failed');
// throw err;
}
}

View File

@@ -64,6 +64,7 @@ async function dataReplicator({
createNew: compileOperationFunction(item.createNew, item.createCondition),
updateExisting: compileOperationFunction(item.updateExisting, item.updateCondition),
deleteMissing: !!item.deleteMissing,
skipUpdateColumns: item.skipUpdateColumns,
deleteRestrictionColumns: item.deleteRestrictionColumns ?? [],
openStream: item.openStream
? item.openStream

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,12 @@ 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;
let promoWidgetData = null;
let promoWidgetDataLoaded = false;
const DBGATE_IDENTITY_URL = process.env.LOCAL_DBGATE_IDENTITY
? 'http://localhost:3103'
@@ -192,7 +193,7 @@ async function getCloudSigninHeaders(holder = null) {
return null;
}
async function updateCloudFiles(isRefresh) {
async function updateCloudFiles(isRefresh, language) {
let lastCloudFilesTags;
try {
lastCloudFilesTags = await fs.readFile(path.join(datadir(), 'cloud-files-tags.txt'), 'utf-8');
@@ -200,8 +201,6 @@ async function updateCloudFiles(isRefresh) {
lastCloudFilesTags = '';
}
const ipInfo = await getPublicIpInfo();
const tags = (await collectCloudFilesSearchTags()).join(',');
let lastCheckedTm = 0;
if (tags == lastCloudFilesTags && cloudFiles.length > 0) {
@@ -213,12 +212,13 @@ async function updateCloudFiles(isRefresh) {
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 getCloudInstanceHeaders()),
'x-app-version': currentVersion.version,
'x-app-language': language || 'en',
},
}
);
@@ -262,15 +262,62 @@ async function getPublicFileData(path) {
return resp.data;
}
async function refreshPublicFiles(isRefresh) {
async function ensurePromoWidgetDataLoaded() {
if (promoWidgetDataLoaded) {
return;
}
try {
const fileContent = await fs.readFile(path.join(datadir(), 'promo-widget.json'), 'utf-8');
promoWidgetData = JSON.parse(fileContent);
} catch (err) {
promoWidgetData = null;
}
promoWidgetDataLoaded = true;
}
async function updatePremiumPromoWidget(language) {
await ensurePromoWidgetDataLoaded();
const tags = (await collectCloudFilesSearchTags()).join(',');
const resp = await axios.default.get(
`${DBGATE_CLOUD_URL}/premium-promo-widget?identifier=${promoWidgetData?.identifier ?? 'empty'}&tags=${tags}`,
{
headers: {
...getLicenseHttpHeaders(),
...(await getCloudInstanceHeaders()),
'x-app-version': currentVersion.version,
'x-app-language': language || 'en',
},
}
);
if (!resp.data || resp.data?.state == 'unchanged') {
return;
}
promoWidgetData = resp.data;
await fs.writeFile(path.join(datadir(), 'promo-widget.json'), JSON.stringify(promoWidgetData, null, 2));
socket.emitChanged(`promo-widget-changed`);
}
async function refreshPublicFiles(isRefresh, uiLanguage) {
const language = platformInfo.isElectron
? (await config.getCachedSettings())?.['localization.language'] || 'en'
: uiLanguage;
if (!cloudFiles) {
await loadCloudFiles();
}
try {
await updateCloudFiles(isRefresh);
await updateCloudFiles(isRefresh, language);
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00166 Error updating cloud files');
}
const configSettings = await config.get();
if (!isProApp() || configSettings?.trialDaysLeft != null) {
await updatePremiumPromoWidget(language);
}
}
async function callCloudApiGet(endpoint, signinHolder = null, additionalHeaders = {}) {
@@ -423,6 +470,33 @@ function removeCloudCachedConnection(folid, cntid) {
delete cloudConnectionCache[cacheKey];
}
async function getPublicIpInfo() {
try {
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/ipinfo`);
if (!resp.data?.ip) {
return { ip: 'unknown-ip' };
}
return resp.data;
} catch (err) {
return { ip: 'unknown-ip' };
}
}
async function getPromoWidgetData() {
await ensurePromoWidgetDataLoaded();
return promoWidgetData;
}
async function getPromoWidgetPreview(campaign, variant) {
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/premium-promo-widget-preview/${campaign}/${variant}`);
return resp.data;
}
async function getPromoWidgetList() {
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/promo-widget-list`);
return resp.data;
}
module.exports = {
createDbGateIdentitySession,
startCloudTokenChecking,
@@ -439,4 +513,8 @@ module.exports = {
removeCloudCachedConnection,
readCloudTokenHolder,
readCloudTestTokenHolder,
getPublicIpInfo,
getPromoWidgetData,
getPromoWidgetPreview,
getPromoWidgetList,
};

View File

@@ -0,0 +1,445 @@
const path = require('path');
const _ = require('lodash');
const { safeJsonParse, getDatabaseFileLabel } = require('dbgate-tools');
const crypto = require('crypto');
function extractConnectionsFromEnv(env) {
if (!env?.CONNECTIONS) {
return null;
}
const connections = _.compact(env.CONNECTIONS.split(',')).map(id => ({
_id: id,
engine: env[`ENGINE_${id}`],
server: env[`SERVER_${id}`],
user: env[`USER_${id}`],
password: env[`PASSWORD_${id}`],
passwordMode: env[`PASSWORD_MODE_${id}`],
port: env[`PORT_${id}`],
databaseUrl: env[`URL_${id}`],
useDatabaseUrl: !!env[`URL_${id}`],
databaseFile: env[`FILE_${id}`]?.replace(
'%%E2E_TEST_DATA_DIRECTORY%%',
path.join(path.dirname(path.dirname(__dirname)), 'e2e-tests', 'tmpdata')
),
socketPath: env[`SOCKET_PATH_${id}`],
serviceName: env[`SERVICE_NAME_${id}`],
authType: env[`AUTH_TYPE_${id}`] || (env[`SOCKET_PATH_${id}`] ? 'socket' : undefined),
defaultDatabase: env[`DATABASE_${id}`] || (env[`FILE_${id}`] ? getDatabaseFileLabel(env[`FILE_${id}`]) : null),
singleDatabase: !!env[`DATABASE_${id}`] || !!env[`FILE_${id}`],
displayName: env[`LABEL_${id}`],
isReadOnly: env[`READONLY_${id}`],
databases: env[`DBCONFIG_${id}`] ? safeJsonParse(env[`DBCONFIG_${id}`]) : null,
allowedDatabases: env[`ALLOWED_DATABASES_${id}`]?.replace(/\|/g, '\n'),
allowedDatabasesRegex: env[`ALLOWED_DATABASES_REGEX_${id}`],
parent: env[`PARENT_${id}`] || undefined,
useSeparateSchemas: !!env[`USE_SEPARATE_SCHEMAS_${id}`],
localDataCenter: env[`LOCAL_DATA_CENTER_${id}`],
// SSH tunnel
useSshTunnel: env[`USE_SSH_${id}`],
sshHost: env[`SSH_HOST_${id}`],
sshPort: env[`SSH_PORT_${id}`],
sshMode: env[`SSH_MODE_${id}`],
sshLogin: env[`SSH_LOGIN_${id}`],
sshPassword: env[`SSH_PASSWORD_${id}`],
sshKeyfile: env[`SSH_KEY_FILE_${id}`],
sshKeyfilePassword: env[`SSH_KEY_FILE_PASSWORD_${id}`],
// SSL
useSsl: env[`USE_SSL_${id}`],
sslCaFile: env[`SSL_CA_FILE_${id}`],
sslCertFile: env[`SSL_CERT_FILE_${id}`],
sslCertFilePassword: env[`SSL_CERT_FILE_PASSWORD_${id}`],
sslKeyFile: env[`SSL_KEY_FILE_${id}`],
sslRejectUnauthorized: env[`SSL_REJECT_UNAUTHORIZED_${id}`],
trustServerCertificate: env[`SSL_TRUST_CERTIFICATE_${id}`],
}));
return connections;
}
function extractImportEntitiesFromEnv(env) {
const portalConnections = extractConnectionsFromEnv(env) || [];
const connections = portalConnections.map((conn, index) => ({
...conn,
id_original: conn._id,
import_source_id: -1,
conid: crypto.randomUUID(),
_id: undefined,
id: index + 1, // autoincrement id
}));
const connectionEnvIdToDbId = {};
for (const conn of connections) {
connectionEnvIdToDbId[conn.id_original] = conn.id;
}
const connectionsRegex = /^ROLE_(.+)_CONNECTIONS$/;
const permissionsRegex = /^ROLE_(.+)_PERMISSIONS$/;
const dbConnectionRegex = /^ROLE_(.+)_DATABASES_(.+)_CONNECTION$/;
const dbDatabasesRegex = /^ROLE_(.+)_DATABASES_(.+)_DATABASES$/;
const dbDatabasesRegexRegex = /^ROLE_(.+)_DATABASES_(.+)_DATABASES_REGEX$/;
const dbPermissionRegex = /^ROLE_(.+)_DATABASES_(.+)_PERMISSION$/;
const tableConnectionRegex = /^ROLE_(.+)_TABLES_(.+)_CONNECTION$/;
const tableDatabasesRegex = /^ROLE_(.+)_TABLES_(.+)_DATABASES$/;
const tableDatabasesRegexRegex = /^ROLE_(.+)_TABLES_(.+)_DATABASES_REGEX$/;
const tableSchemasRegex = /^ROLE_(.+)_TABLES_(.+)_SCHEMAS$/;
const tableSchemasRegexRegex = /^ROLE_(.+)_TABLES_(.+)_SCHEMAS_REGEX$/;
const tableTablesRegex = /^ROLE_(.+)_TABLES_(.+)_TABLES$/;
const tableTablesRegexRegex = /^ROLE_(.+)_TABLES_(.+)_TABLES_REGEX$/;
const tablePermissionRegex = /^ROLE_(.+)_TABLES_(.+)_PERMISSION$/;
const tableScopeRegex = /^ROLE_(.+)_TABLES_(.+)_SCOPE$/;
const roles = [];
const role_connections = [];
const role_permissions = [];
const role_databases = [];
const role_tables = [];
// Permission name to ID mappings
const databasePermissionMap = {
view: -1,
read_content: -2,
write_data: -3,
run_script: -4,
deny: -5,
};
const tablePermissionMap = {
read: -1,
update_only: -2,
create_update_delete: -3,
run_script: -4,
deny: -5,
};
const tableScopeMap = {
all_objects: -1,
tables: -2,
views: -3,
tables_views_collections: -4,
procedures: -5,
functions: -6,
triggers: -7,
sql_objects: -8,
collections: -9,
};
// Collect database and table permissions data
const databasePermissions = {};
const tablePermissions = {};
// First pass: collect all database and table permission data
for (const key in env) {
const dbConnMatch = key.match(dbConnectionRegex);
const dbDatabasesMatch = key.match(dbDatabasesRegex);
const dbDatabasesRegexMatch = key.match(dbDatabasesRegexRegex);
const dbPermMatch = key.match(dbPermissionRegex);
const tableConnMatch = key.match(tableConnectionRegex);
const tableDatabasesMatch = key.match(tableDatabasesRegex);
const tableDatabasesRegexMatch = key.match(tableDatabasesRegexRegex);
const tableSchemasMatch = key.match(tableSchemasRegex);
const tableSchemasRegexMatch = key.match(tableSchemasRegexRegex);
const tableTablesMatch = key.match(tableTablesRegex);
const tableTablesRegexMatch = key.match(tableTablesRegexRegex);
const tablePermMatch = key.match(tablePermissionRegex);
const tableScopeMatch = key.match(tableScopeRegex);
// Database permissions
if (dbConnMatch) {
const [, roleName, permId] = dbConnMatch;
if (!databasePermissions[roleName]) databasePermissions[roleName] = {};
if (!databasePermissions[roleName][permId]) databasePermissions[roleName][permId] = {};
databasePermissions[roleName][permId].connection = env[key];
}
if (dbDatabasesMatch) {
const [, roleName, permId] = dbDatabasesMatch;
if (!databasePermissions[roleName]) databasePermissions[roleName] = {};
if (!databasePermissions[roleName][permId]) databasePermissions[roleName][permId] = {};
databasePermissions[roleName][permId].databases = env[key]?.replace(/\|/g, '\n');
}
if (dbDatabasesRegexMatch) {
const [, roleName, permId] = dbDatabasesRegexMatch;
if (!databasePermissions[roleName]) databasePermissions[roleName] = {};
if (!databasePermissions[roleName][permId]) databasePermissions[roleName][permId] = {};
databasePermissions[roleName][permId].databasesRegex = env[key];
}
if (dbPermMatch) {
const [, roleName, permId] = dbPermMatch;
if (!databasePermissions[roleName]) databasePermissions[roleName] = {};
if (!databasePermissions[roleName][permId]) databasePermissions[roleName][permId] = {};
databasePermissions[roleName][permId].permission = env[key];
}
// Table permissions
if (tableConnMatch) {
const [, roleName, permId] = tableConnMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].connection = env[key];
}
if (tableDatabasesMatch) {
const [, roleName, permId] = tableDatabasesMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].databases = env[key]?.replace(/\|/g, '\n');
}
if (tableDatabasesRegexMatch) {
const [, roleName, permId] = tableDatabasesRegexMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].databasesRegex = env[key];
}
if (tableSchemasMatch) {
const [, roleName, permId] = tableSchemasMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].schemas = env[key];
}
if (tableSchemasRegexMatch) {
const [, roleName, permId] = tableSchemasRegexMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].schemasRegex = env[key];
}
if (tableTablesMatch) {
const [, roleName, permId] = tableTablesMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].tables = env[key]?.replace(/\|/g, '\n');
}
if (tableTablesRegexMatch) {
const [, roleName, permId] = tableTablesRegexMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].tablesRegex = env[key];
}
if (tablePermMatch) {
const [, roleName, permId] = tablePermMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].permission = env[key];
}
if (tableScopeMatch) {
const [, roleName, permId] = tableScopeMatch;
if (!tablePermissions[roleName]) tablePermissions[roleName] = {};
if (!tablePermissions[roleName][permId]) tablePermissions[roleName][permId] = {};
tablePermissions[roleName][permId].scope = env[key];
}
}
// Second pass: process roles, connections, and permissions
for (const key in env) {
const connMatch = key.match(connectionsRegex);
const permMatch = key.match(permissionsRegex);
if (connMatch) {
const roleName = connMatch[1];
let role = roles.find(r => r.name === roleName);
if (!role) {
role = {
id: roles.length + 1,
name: roleName,
import_source_id: -1,
};
roles.push(role);
}
const connIds = env[key]
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0);
for (const connId of connIds) {
const dbId = connectionEnvIdToDbId[connId];
if (dbId) {
role_connections.push({
role_id: role.id,
connection_id: dbId,
import_source_id: -1,
});
}
}
}
if (permMatch) {
const roleName = permMatch[1];
let role = roles.find(r => r.name === roleName);
if (!role) {
role = {
id: roles.length + 1,
name: roleName,
import_source_id: -1,
};
roles.push(role);
}
const permissions = env[key]
.split(',')
.map(p => p.trim())
.filter(p => p.length > 0);
for (const permission of permissions) {
role_permissions.push({
role_id: role.id,
permission,
import_source_id: -1,
});
}
}
}
// Process database permissions
for (const roleName in databasePermissions) {
let role = roles.find(r => r.name === roleName);
if (!role) {
role = {
id: roles.length + 1,
name: roleName,
import_source_id: -1,
};
roles.push(role);
}
for (const permId in databasePermissions[roleName]) {
const perm = databasePermissions[roleName][permId];
if (perm.connection && perm.permission) {
const dbId = connectionEnvIdToDbId[perm.connection];
const permissionId = databasePermissionMap[perm.permission];
if (dbId && permissionId) {
role_databases.push({
role_id: role.id,
connection_id: dbId,
database_names_list: perm.databases || null,
database_names_regex: perm.databasesRegex || null,
database_permission_role_id: permissionId,
id_original: permId,
import_source_id: -1,
});
}
}
}
}
// Process table permissions
for (const roleName in tablePermissions) {
let role = roles.find(r => r.name === roleName);
if (!role) {
role = {
id: roles.length + 1,
name: roleName,
import_source_id: -1,
};
roles.push(role);
}
for (const permId in tablePermissions[roleName]) {
const perm = tablePermissions[roleName][permId];
if (perm.connection && perm.permission) {
const dbId = connectionEnvIdToDbId[perm.connection];
const permissionId = tablePermissionMap[perm.permission];
const scopeId = tableScopeMap[perm.scope || 'all_objects'];
if (dbId && permissionId && scopeId) {
role_tables.push({
role_id: role.id,
connection_id: dbId,
database_names_list: perm.databases || null,
database_names_regex: perm.databasesRegex || null,
schema_names_list: perm.schemas || null,
schema_names_regex: perm.schemasRegex || null,
table_names_list: perm.tables || null,
table_names_regex: perm.tablesRegex || null,
table_permission_role_id: permissionId,
table_permission_scope_id: scopeId,
id_original: permId,
import_source_id: -1,
});
}
}
}
}
if (connections.length == 0 && roles.length == 0) {
return null;
}
return {
connections,
roles,
role_connections,
role_permissions,
role_databases,
role_tables,
};
}
function createStorageFromEnvReplicatorItems(importEntities) {
return [
{
name: 'connections',
findExisting: true,
createNew: true,
updateExisting: true,
matchColumns: ['id_original', 'import_source_id'],
deleteMissing: true,
deleteRestrictionColumns: ['import_source_id'],
skipUpdateColumns: ['conid'],
jsonArray: importEntities.connections,
},
{
name: 'roles',
findExisting: true,
createNew: true,
updateExisting: true,
matchColumns: ['name', 'import_source_id'],
deleteMissing: true,
deleteRestrictionColumns: ['import_source_id'],
jsonArray: importEntities.roles,
},
{
name: 'role_connections',
findExisting: true,
createNew: true,
updateExisting: false,
deleteMissing: true,
matchColumns: ['role_id', 'connection_id', 'import_source_id'],
jsonArray: importEntities.role_connections,
deleteRestrictionColumns: ['import_source_id'],
},
{
name: 'role_permissions',
findExisting: true,
createNew: true,
updateExisting: false,
deleteMissing: true,
matchColumns: ['role_id', 'permission', 'import_source_id'],
jsonArray: importEntities.role_permissions,
deleteRestrictionColumns: ['import_source_id'],
},
{
name: 'role_databases',
findExisting: true,
createNew: true,
updateExisting: true,
deleteMissing: true,
matchColumns: ['role_id', 'id_original', 'import_source_id'],
jsonArray: importEntities.role_databases,
deleteRestrictionColumns: ['import_source_id'],
},
{
name: 'role_tables',
findExisting: true,
createNew: true,
updateExisting: true,
deleteMissing: true,
matchColumns: ['role_id', 'id_original', 'import_source_id'],
jsonArray: importEntities.role_tables,
deleteRestrictionColumns: ['import_source_id'],
},
];
}
module.exports = {
extractConnectionsFromEnv,
extractImportEntitiesFromEnv,
createStorageFromEnvReplicatorItems,
};

View File

@@ -53,7 +53,7 @@ const getChartExport = (title, config, imageFile, plugins) => {
</div>
<div class="footer">
Exported from <a href='https://dbgate.io/' target='_blank'>DbGate</a>, powered by <a href='https://www.chartjs.org/' target='_blank'>Chart.js</a>
Exported from <a href='https://www.dbgate.io/' target='_blank'>DbGate</a>, powered by <a href='https://www.chartjs.org/' target='_blank'>Chart.js</a>
</div>
</body>

View File

@@ -18,7 +18,7 @@ const getMapExport = (geoJson) => {
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '<a href="https://dbgate.io" title="Exported from DbGate">DbGate</a> | © OpenStreetMap',
attribution: '<a href="https://www.dbgate.io" title="Exported from DbGate">DbGate</a> | © OpenStreetMap',
})
.addTo(map);

View File

@@ -14,9 +14,10 @@ class QueryStreamTableWriter {
this.currentChangeIndex = 1;
this.initializedFile = false;
this.sesid = sesid;
this.started = new Date().getTime();
}
initializeFromQuery(structure, resultIndex, chartDefinition, autoDetectCharts = false) {
initializeFromQuery(structure, resultIndex, chartDefinition, autoDetectCharts = false, options = {}) {
this.jslid = crypto.randomUUID();
this.currentFile = path.join(jsldir(), `${this.jslid}.jsonl`);
fs.writeFileSync(
@@ -24,6 +25,7 @@ class QueryStreamTableWriter {
JSON.stringify({
...structure,
__isStreamHeader: true,
...options
}) + '\n'
);
this.currentStream = fs.createWriteStream(this.currentFile, { flags: 'a' });
@@ -118,6 +120,13 @@ class QueryStreamTableWriter {
this.chartProcessor = null;
}
}
process.send({
msgtype: 'endrecordset',
jslid: this.jslid,
rowCount: this.currentRowCount,
sesid: this.sesid,
durationMs: new Date().getTime() - this.started,
});
resolve();
});
} else {
@@ -148,6 +157,7 @@ class StreamHandler {
// this.error = this.error.bind(this);
this.done = this.done.bind(this);
this.info = this.info.bind(this);
this.changedCurrentDatabase = this.changedCurrentDatabase.bind(this);
// use this for cancelling - not implemented
// this.stream = null;
@@ -166,7 +176,11 @@ class StreamHandler {
}
}
recordset(columns) {
changedCurrentDatabase(database) {
process.send({ msgtype: 'changedCurrentDatabase', database, sesid: this.sesid });
}
recordset(columns, options) {
if (this.rowsLimitOverflow) {
return;
}
@@ -176,7 +190,8 @@ class StreamHandler {
Array.isArray(columns) ? { columns } : columns,
this.queryStreamInfoHolder.resultIndex,
this.frontMatter?.[`chart-${this.queryStreamInfoHolder.resultIndex + 1}`],
this.autoDetectCharts
this.autoDetectCharts,
options
);
this.queryStreamInfoHolder.resultIndex += 1;
this.rowCounter = 0;

View File

@@ -3,18 +3,6 @@ const os = require('os');
const crypto = require('crypto');
const platformInfo = require('./platformInfo');
async function getPublicIpInfo() {
try {
const resp = await axios.default.get('https://ipinfo.io/json');
if (!resp.data?.ip) {
return { ip: 'unknown-ip' };
}
return resp.data;
} catch (err) {
return { ip: 'unknown-ip' };
}
}
function getMacAddress() {
try {
const interfaces = os.networkInterfaces();
@@ -32,6 +20,7 @@ function getMacAddress() {
}
async function getHardwareFingerprint() {
const { getPublicIpInfo } = require('./cloudIntf');
const publicIpInfo = await getPublicIpInfo();
const macAddress = getMacAddress();
const platform = os.platform();
@@ -42,8 +31,6 @@ async function getHardwareFingerprint() {
return {
publicIp: publicIpInfo.ip,
country: publicIpInfo.country,
region: publicIpInfo.region,
city: publicIpInfo.city,
macAddress,
platform,
release,
@@ -68,9 +55,7 @@ async function getPublicHardwareFingerprint() {
hash,
payload: {
platform: fingerprint.platform,
city: fingerprint.city,
country: fingerprint.country,
region: fingerprint.region,
isDocker: platformInfo.isDocker,
isAwsUbuntuLayout: platformInfo.isAwsUbuntuLayout,
isAzureUbuntuLayout: platformInfo.isAzureUbuntuLayout,
@@ -87,5 +72,4 @@ module.exports = {
getHardwareFingerprint,
getHardwareFingerprintHash,
getPublicHardwareFingerprint,
getPublicIpInfo,
};

View File

@@ -1,96 +1,350 @@
const { compilePermissions, testPermission } = require('dbgate-tools');
const { compilePermissions, testPermission, getPermissionsCacheKey } = require('dbgate-tools');
const _ = require('lodash');
const { getAuthProviderFromReq } = require('../auth/authProvider');
const cachedPermissions = {};
function hasPermission(tested, req) {
async function loadPermissionsFromRequest(req) {
const authProvider = getAuthProviderFromReq(req);
if (!req) {
// request object not available, allow all
return null;
}
const loadedPermissions = await authProvider.getCurrentPermissions(req);
return loadedPermissions;
}
function hasPermission(tested, loadedPermissions) {
if (!loadedPermissions) {
// not available, allow all
return true;
}
const permissions = getAuthProviderFromReq(req).getCurrentPermissions(req);
if (!cachedPermissions[permissions]) {
cachedPermissions[permissions] = compilePermissions(permissions);
const permissionsKey = getPermissionsCacheKey(loadedPermissions);
if (!cachedPermissions[permissionsKey]) {
cachedPermissions[permissionsKey] = compilePermissions(loadedPermissions);
}
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]);
return testPermission(tested, cachedPermissions[permissionsKey]);
}
// let loginsCache = null;
// let loginsLoaded = false;
// 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);
// }
// }
// loginsCache = res.length > 0 ? res : null;
// loginsLoaded = true;
// return loginsCache;
// }
function connectionHasPermission(connection, req) {
function connectionHasPermission(connection, loadedPermissions) {
if (!connection) {
return true;
}
if (_.isString(connection)) {
return hasPermission(`connections/${connection}`, req);
return hasPermission(`connections/${connection}`, loadedPermissions);
} else {
return hasPermission(`connections/${connection._id}`, req);
return hasPermission(`connections/${connection._id}`, loadedPermissions);
}
}
function testConnectionPermission(connection, req) {
if (!connectionHasPermission(connection, req)) {
throw new Error('Connection permission not granted');
async function testConnectionPermission(connection, req, loadedPermissions) {
if (!loadedPermissions) {
loadedPermissions = await loadPermissionsFromRequest(req);
}
if (process.env.STORAGE_DATABASE) {
if (hasPermission(`all-connections`, loadedPermissions)) {
return;
}
const conid = _.isString(connection) ? connection : connection?._id;
if (hasPermission('internal-storage', loadedPermissions) && conid == '__storage') {
return;
}
const authProvider = getAuthProviderFromReq(req);
if (!req) {
return;
}
if (!(await authProvider.checkCurrentConnectionPermission(req, conid))) {
throw new Error('DBGM-00263 Connection permission not granted');
}
} else {
if (!connectionHasPermission(connection, loadedPermissions)) {
throw new Error('DBGM-00264 Connection permission not granted');
}
}
}
async function loadDatabasePermissionsFromRequest(req) {
const authProvider = getAuthProviderFromReq(req);
if (!req) {
return null;
}
const databasePermissions = await authProvider.getCurrentDatabasePermissions(req);
return databasePermissions;
}
async function loadTablePermissionsFromRequest(req) {
const authProvider = getAuthProviderFromReq(req);
if (!req) {
return null;
}
const tablePermissions = await authProvider.getCurrentTablePermissions(req);
return tablePermissions;
}
async function loadFilePermissionsFromRequest(req) {
const authProvider = getAuthProviderFromReq(req);
if (!req) {
return null;
}
const filePermissions = await authProvider.getCurrentFilePermissions(req);
return filePermissions;
}
function matchDatabasePermissionRow(conid, database, permissionRow) {
if (permissionRow.connection_id) {
if (conid != permissionRow.connection_id) {
return false;
}
}
if (permissionRow.database_names_list) {
const items = permissionRow.database_names_list.split('\n');
if (!items.find(item => item.trim()?.toLowerCase() === database?.toLowerCase())) {
return false;
}
}
if (permissionRow.database_names_regex) {
const regex = new RegExp(permissionRow.database_names_regex, 'i');
if (!regex.test(database)) {
return false;
}
}
return true;
}
function matchTablePermissionRow(objectTypeField, schemaName, pureName, permissionRow) {
if (permissionRow.table_names_list) {
const items = permissionRow.table_names_list.split('\n');
if (!items.find(item => item.trim()?.toLowerCase() === pureName?.toLowerCase())) {
return false;
}
}
if (permissionRow.table_names_regex) {
const regex = new RegExp(permissionRow.table_names_regex, 'i');
if (!regex.test(pureName)) {
return false;
}
}
if (permissionRow.schema_names_list) {
const items = permissionRow.schema_names_list.split('\n');
if (!items.find(item => item.trim()?.toLowerCase() === schemaName?.toLowerCase())) {
return false;
}
}
if (permissionRow.schema_names_regex) {
const regex = new RegExp(permissionRow.schema_names_regex, 'i');
if (!regex.test(schemaName)) {
return false;
}
}
return true;
}
function matchFilePermissionRow(folder, file, permissionRow) {
if (permissionRow.folder_name) {
if (folder != permissionRow.folder_name) {
return false;
}
}
if (permissionRow.file_names_list) {
const items = permissionRow.file_names_list.split('\n');
if (!items.find(item => item.trim()?.toLowerCase() === file?.toLowerCase())) {
return false;
}
}
if (permissionRow.file_names_regex) {
const regex = new RegExp(permissionRow.file_names_regex, 'i');
if (!regex.test(file)) {
return false;
}
}
return true;
}
const DATABASE_ROLE_ID_NAMES = {
'-1': 'view',
'-2': 'read_content',
'-3': 'write_data',
'-4': 'run_script',
'-5': 'deny',
};
const FILE_ROLE_ID_NAMES = {
'-1': 'allow',
'-2': 'deny',
};
function getDatabaseRoleLevelIndex(roleName) {
if (!roleName) {
return 6;
}
if (roleName == 'run_script') {
return 5;
}
if (roleName == 'write_data') {
return 4;
}
if (roleName == 'read_content') {
return 3;
}
if (roleName == 'view') {
return 2;
}
if (roleName == 'deny') {
return 1;
}
return 6;
}
function getTablePermissionRoleLevelIndex(roleName) {
if (!roleName) {
return 6;
}
if (roleName == 'run_script') {
return 5;
}
if (roleName == 'create_update_delete') {
return 4;
}
if (roleName == 'update_only') {
return 3;
}
if (roleName == 'read') {
return 2;
}
if (roleName == 'deny') {
return 1;
}
return 6;
}
function getDatabasePermissionRole(conid, database, loadedDatabasePermissions) {
let res = 'deny';
for (const permissionRow of loadedDatabasePermissions) {
if (!matchDatabasePermissionRow(conid, database, permissionRow)) {
continue;
}
res = DATABASE_ROLE_ID_NAMES[permissionRow.database_permission_role_id];
}
return res;
}
function getFilePermissionRole(folder, file, loadedFilePermissions) {
let res = 'deny';
for (const permissionRow of loadedFilePermissions) {
if (!matchFilePermissionRow(folder, file, permissionRow)) {
continue;
}
res = FILE_ROLE_ID_NAMES[permissionRow.file_permission_role_id];
}
return res;
}
const TABLE_ROLE_ID_NAMES = {
'-1': 'read',
'-2': 'update_only',
'-3': 'create_update_delete',
'-4': 'run_script',
'-5': 'deny',
};
const TABLE_SCOPE_ID_NAMES = {
'-1': 'all_objects',
'-2': 'tables',
'-3': 'views',
'-4': 'tables_views_collections',
'-5': 'procedures',
'-6': 'functions',
'-7': 'triggers',
'-8': 'sql_objects',
'-9': 'collections',
};
function getTablePermissionRole(
conid,
database,
objectTypeField,
schemaName,
pureName,
loadedTablePermissions,
databasePermissionRole
) {
let res =
databasePermissionRole == 'read_content'
? 'read'
: databasePermissionRole == 'write_data'
? 'create_update_delete'
: databasePermissionRole == 'run_script'
? 'run_script'
: 'deny';
for (const permissionRow of loadedTablePermissions) {
if (!matchDatabasePermissionRow(conid, database, permissionRow)) {
continue;
}
if (!matchTablePermissionRow(objectTypeField, schemaName, pureName, permissionRow)) {
continue;
}
const scope = TABLE_SCOPE_ID_NAMES[permissionRow.table_permission_scope_id];
switch (scope) {
case 'tables':
if (objectTypeField != 'tables') continue;
break;
case 'views':
if (objectTypeField != 'views') continue;
break;
case 'tables_views_collections':
if (objectTypeField != 'tables' && objectTypeField != 'views' && objectTypeField != 'collections') continue;
break;
case 'procedures':
if (objectTypeField != 'procedures') continue;
break;
case 'functions':
if (objectTypeField != 'functions') continue;
break;
case 'triggers':
if (objectTypeField != 'triggers') continue;
break;
case 'sql_objects':
if (objectTypeField != 'procedures' && objectTypeField != 'functions' && objectTypeField != 'triggers')
continue;
break;
case 'collections':
if (objectTypeField != 'collections') continue;
break;
}
res = TABLE_ROLE_ID_NAMES[permissionRow.table_permission_role_id];
}
return res;
}
async function testStandardPermission(permission, req, loadedPermissions) {
if (!loadedPermissions) {
loadedPermissions = await loadPermissionsFromRequest(req);
}
if (!hasPermission(permission, loadedPermissions)) {
throw new Error(`DBGM-00265 Permission ${permission} not granted`);
}
}
async function testDatabaseRolePermission(conid, database, requiredRole, req) {
if (!process.env.STORAGE_DATABASE) {
return;
}
const loadedPermissions = await loadPermissionsFromRequest(req);
if (hasPermission(`all-databases`, loadedPermissions)) {
return;
}
const databasePermissions = await loadDatabasePermissionsFromRequest(req);
const role = getDatabasePermissionRole(conid, database, databasePermissions);
const requiredIndex = getDatabaseRoleLevelIndex(requiredRole);
const roleIndex = getDatabaseRoleLevelIndex(role);
if (roleIndex < requiredIndex) {
throw new Error(`DBGM-00266 Permission ${requiredRole} not granted`);
}
}
@@ -98,4 +352,14 @@ module.exports = {
hasPermission,
connectionHasPermission,
testConnectionPermission,
loadPermissionsFromRequest,
loadDatabasePermissionsFromRequest,
loadTablePermissionsFromRequest,
loadFilePermissionsFromRequest,
getDatabasePermissionRole,
getTablePermissionRole,
getFilePermissionRole,
testStandardPermission,
testDatabaseRolePermission,
getTablePermissionRoleLevelIndex,
};

View File

@@ -2,4 +2,5 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['js'],
reporters: ['default', 'github-actions'],
};

View File

@@ -3,6 +3,10 @@
"name": "dbgate-datalib",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
},
"scripts": {
"build": "tsc",
"test": "jest",

View File

@@ -23,6 +23,7 @@ export interface DataReplicatorItem {
deleteMissing: boolean;
deleteRestrictionColumns: string[];
matchColumns: string[];
skipUpdateColumns?: string[];
}
export interface DataReplicatorOptions {
@@ -151,7 +152,12 @@ class ReplicatorItemHolder {
chunk,
this.table.columns.map(x => x.columnName)
),
[this.autoColumn, ...this.backReferences.map(x => x.columnName), ...this.references.map(x => x.columnName)]
[
this.autoColumn,
...this.backReferences.map(x => x.columnName),
...this.references.map(x => x.columnName),
...(this.item.skipUpdateColumns || []),
]
);
return res;

View File

@@ -31,6 +31,8 @@ export interface GridConfig extends GridConfigColumns {
formFilterColumns: string[];
multiColumnFilter?: string;
searchInColumns?: string;
disabledFilterColumns: string[];
disabledMultiColumnFilter?: boolean;
}
export interface GridCache {
@@ -48,6 +50,7 @@ export function createGridConfig(): GridConfig {
focusedColumns: null,
grouping: {},
formFilterColumns: [],
disabledFilterColumns: [],
};
}

View File

@@ -13,7 +13,7 @@ import type {
FilterBehaviour,
} from 'dbgate-types';
import { parseFilter } from 'dbgate-filterparser';
import { filterName } from 'dbgate-tools';
import { filterName, shortenIdentifier } from 'dbgate-tools';
import { ChangeSetFieldDefinition, ChangeSetRowDefinition } from './ChangeSet';
import { Expression, Select, treeToSql, dumpSqlSelect, Condition, CompoudCondition } from 'dbgate-sqltree';
import { isTypeLogical, standardFilterBehaviours, detectSqlFilterBehaviour, stringFilterBehaviour } from 'dbgate-tools';
@@ -24,6 +24,7 @@ export interface DisplayColumn {
columnName: string;
headerText: string;
uniqueName: string;
uniqueNameShorten?: string;
uniquePath: string[];
notNull?: boolean;
autoIncrement?: boolean;
@@ -232,6 +233,7 @@ export abstract class GridDisplay {
if (!filter) continue;
const column = displayedColumnInfo[uniqueName];
if (!column) continue;
if (this.isFilterDisabled(uniqueName)) continue;
try {
const condition = parseFilter(
filter,
@@ -258,7 +260,7 @@ export abstract class GridDisplay {
}
}
if (this.baseTableOrView && this.config.multiColumnFilter) {
if (this.baseTableOrView && this.config.multiColumnFilter && !this.isMultiColumnFilterDisabled()) {
const orCondition: CompoudCondition = {
conditionType: 'or',
conditions: [],
@@ -415,6 +417,7 @@ export abstract class GridDisplay {
[uniqueName]: value,
},
formViewRecordNumber: 0,
disabledFilterColumns: cfg.disabledFilterColumns.filter(x => x != uniqueName),
}));
this.reload();
}
@@ -424,6 +427,7 @@ export abstract class GridDisplay {
...cfg,
multiColumnFilter: value,
formViewRecordNumber: 0,
disabledMultiColumnFilter: false,
}));
this.reload();
}
@@ -447,6 +451,7 @@ export abstract class GridDisplay {
...cfg,
filters: _.omit(cfg.filters, [uniqueName]),
formFilterColumns: (cfg.formFilterColumns || []).filter(x => x != uniqueName),
disabledFilterColumns: (cfg.disabledFilterColumns).filter(x => x != uniqueName),
}));
this.reload();
}
@@ -462,6 +467,37 @@ export abstract class GridDisplay {
this.reload();
}
toggleFilterEnabled(uniqueName) {
if (this.isFilterDisabled(uniqueName)) {
this.setConfig(cfg => ({
...cfg,
disabledFilterColumns: cfg.disabledFilterColumns.filter(x => x != uniqueName),
}));
} else {
this.setConfig(cfg => ({
...cfg,
disabledFilterColumns: [...cfg.disabledFilterColumns, uniqueName],
}));
}
this.reload();
}
isFilterDisabled(uniqueName: string) {
return this.config.disabledFilterColumns.includes(uniqueName);
}
toggleMultiColumnFilterEnabled() {
this.setConfig(cfg => ({
...cfg,
disabledMultiColumnFilter: !cfg.disabledMultiColumnFilter,
}));
this.reload();
}
isMultiColumnFilterDisabled() {
return this.config.disabledMultiColumnFilter;
}
setSort(uniqueName, order) {
this.setConfig(cfg => ({
...cfg,
@@ -606,7 +642,9 @@ export abstract class GridDisplay {
}
return {
exprType: 'column',
...(!this.dialect.omitTableAliases && { alias: alias || col.columnName }),
...(!this.dialect.omitTableAliases && {
alias: alias ?? col.columnName,
}),
source,
...col,
};

View File

@@ -1,5 +1,5 @@
import _ from 'lodash';
import { filterName, isTableColumnUnique } from 'dbgate-tools';
import { filterName, isTableColumnUnique, shortenIdentifier } from 'dbgate-tools';
import { GridDisplay, ChangeCacheFunc, DisplayColumn, DisplayedColumnInfo, ChangeConfigFunc } from './GridDisplay';
import type {
TableInfo,
@@ -39,7 +39,8 @@ export class TableGridDisplay extends GridDisplay {
public getDictionaryDescription: DictionaryDescriptionFunc = null,
isReadOnly = false,
public isRawMode = false,
public currentSettings = null
public currentSettings = null,
public areReferencesAllowed = true
) {
super(config, setConfig, cache, setCache, driver, dbinfo, serverVersion, currentSettings);
@@ -93,7 +94,7 @@ export class TableGridDisplay extends GridDisplay {
);
}
getDisplayColumns(table: TableInfo, parentPath: string[]) {
getDisplayColumns(table: TableInfo, parentPath: string[]): DisplayColumn[] {
return (
table?.columns
?.map(col => this.getDisplayColumn(table, col, parentPath))
@@ -101,11 +102,12 @@ export class TableGridDisplay extends GridDisplay {
...col,
isChecked: this.isColumnChecked(col),
hintColumnNames:
this.getFkDictionaryDescription(col.isForeignKeyUnique ? col.foreignKey : null)?.columns?.map(
columnName => `hint_${col.uniqueName}_${columnName}`
this.getFkDictionaryDescription(col.isForeignKeyUnique ? col.foreignKey : null)?.columns?.map(columnName =>
shortenIdentifier(`hint_${col.uniqueName}_${columnName}`, this.driver?.dialect?.maxIdentifierLength)
) || null,
hintColumnDelimiter: this.getFkDictionaryDescription(col.isForeignKeyUnique ? col.foreignKey : null)
?.delimiter,
uniqueNameShorten: shortenIdentifier(col.uniqueName, this.driver?.dialect?.maxIdentifierLength),
isExpandable: !!col.foreignKey,
})) || []
);
@@ -116,7 +118,7 @@ export class TableGridDisplay extends GridDisplay {
if (this.isExpandedColumn(column.uniqueName)) {
const table = this.getFkTarget(column);
if (table) {
const childAlias = `${column.uniqueName}_ref`;
const childAlias = shortenIdentifier(`${column.uniqueName}_ref`, this.driver?.dialect?.maxIdentifierLength);
const subcolumns = this.getDisplayColumns(table, column.uniquePath);
this.addReferenceToSelect(select, parentAlias, column);
@@ -129,7 +131,7 @@ export class TableGridDisplay extends GridDisplay {
}
addReferenceToSelect(select: Select, parentAlias: string, column: DisplayColumn) {
const childAlias = `${column.uniqueName}_ref`;
const childAlias = shortenIdentifier(`${column.uniqueName}_ref`, this.driver?.dialect?.maxIdentifierLength);
if ((select.from.relations || []).find(x => x.alias == childAlias)) return;
const table = this.getFkTarget(column);
if (table && table.primaryKey) {
@@ -191,15 +193,24 @@ export class TableGridDisplay extends GridDisplay {
const hintDescription = this.getDictionaryDescription(table);
if (hintDescription) {
const parentUniqueName = column.uniquePath.slice(0, -1).join('.');
this.addReferenceToSelect(select, parentUniqueName ? `${parentUniqueName}_ref` : 'basetbl', column);
const childAlias = `${column.uniqueName}_ref`;
this.addReferenceToSelect(
select,
parentUniqueName
? shortenIdentifier(`${parentUniqueName}_ref`, this.driver?.dialect?.maxIdentifierLength)
: 'basetbl',
column
);
const childAlias = shortenIdentifier(`${column.uniqueName}_ref`, this.driver?.dialect?.maxIdentifierLength);
select.columns.push(
...hintDescription.columns.map(
columnName =>
({
exprType: 'column',
columnName,
alias: `hint_${column.uniqueName}_${columnName}`,
alias: shortenIdentifier(
`hint_${column.uniqueName}_${columnName}`,
this.driver?.dialect?.maxIdentifierLength
),
source: { alias: childAlias },
} as ColumnRefExpression)
)
@@ -230,7 +241,7 @@ export class TableGridDisplay extends GridDisplay {
}
getFkTarget(column: DisplayColumn) {
const { uniqueName, foreignKey, isForeignKeyUnique } = column;
const { foreignKey, isForeignKeyUnique } = column;
if (!isForeignKeyUnique) return null;
const pureName = foreignKey.refTableName;
const schemaName = foreignKey.refSchemaName;
@@ -238,6 +249,7 @@ export class TableGridDisplay extends GridDisplay {
}
processReferences(select: Select, displayedColumnInfo: DisplayedColumnInfo, options) {
if (!this.areReferencesAllowed) return;
this.addJoinsFromExpandedColumns(select, this.columns, 'basetbl', displayedColumnInfo);
if (!options.isExport && this.displayOptions.showHintColumns) {
this.addHintsToSelect(select);
@@ -298,7 +310,12 @@ export class TableGridDisplay extends GridDisplay {
for (const column of columns) {
if (this.addAllExpandedColumnsToSelected || this.config.addedColumns.includes(column.uniqueName)) {
select.columns.push(
this.createColumnExpression(column, { name: column, alias: parentAlias }, column.uniqueName, 'view')
this.createColumnExpression(
column,
{ name: column, alias: parentAlias },
column.uniqueNameShorten ?? column.uniqueName,
'view'
)
);
displayedColumnInfo[column.uniqueName] = {
...column,

View File

@@ -4,6 +4,7 @@ export type ChartXTransformFunction =
| 'date:minute'
| 'date:hour'
| 'date:day'
| 'date:week'
| 'date:month'
| 'date:year';
export type ChartYAggregateFunction = 'sum' | 'first' | 'last' | 'min' | 'max' | 'count' | 'avg';
@@ -70,6 +71,7 @@ export interface ChartDateParsed {
minute?: number;
second?: number;
fraction?: string;
week?: number;
}
export interface ChartAvailableColumn {

View File

@@ -9,7 +9,7 @@ import {
ChartYFieldDefinition,
ProcessedChart,
} from './chartDefinitions';
import { addMinutes, addHours, addDays, addMonths, addYears } from 'date-fns';
import { addMinutes, addHours, addDays, addMonths, addWeeks, addYears, getWeek } from 'date-fns';
export function getChartDebugPrint(chart: ProcessedChart) {
let res = '';
@@ -29,6 +29,7 @@ export function tryParseChartDate(dateInput: any): ChartDateParsed | null {
return {
year: dateInput.getFullYear(),
month: dateInput.getMonth() + 1,
week: getWeek(dateInput),
day: dateInput.getDate(),
hour: dateInput.getHours(),
minute: dateInput.getMinutes(),
@@ -42,15 +43,21 @@ export function tryParseChartDate(dateInput: any): ChartDateParsed | null {
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?)?$/
);
const monthMatch = dateInput.match(/^(\d{4})-(\d{2})$/);
const weekMatch = dateInput.match(/^(\d{4})\@(\d{2})$/);
// const yearMatch = dateInput.match(/^(\d{4})$/);
if (dateMatch) {
const [_notUsed, year, month, day, hour, minute, second, fraction] = dateMatch;
const [_notUsed, yearStr, monthStr, dayStr, hour, minute, second, fraction] = dateMatch;
const year = parseInt(yearStr, 10);
const month = parseInt(monthStr, 10);
const day = parseInt(dayStr, 10);
return {
year: parseInt(year, 10),
month: parseInt(month, 10),
day: parseInt(day, 10),
year,
month,
week: getWeek(new Date(year, month - 1, day)),
day,
hour: parseInt(hour, 10) || 0,
minute: parseInt(minute, 10) || 0,
second: parseInt(second, 10) || 0,
@@ -71,6 +78,19 @@ export function tryParseChartDate(dateInput: any): ChartDateParsed | null {
};
}
if (weekMatch) {
const [_notUsed, year, week] = weekMatch;
return {
year: parseInt(year, 10),
week: parseInt(week, 10),
day: 1,
hour: 0,
minute: 0,
second: 0,
fraction: undefined,
};
}
// if (yearMatch) {
// const [_notUsed, year] = yearMatch;
// return {
@@ -97,6 +117,8 @@ export function stringifyChartDate(value: ChartDateParsed, transform: ChartXTran
return `${value.year}`;
case 'date:month':
return `${value.year}-${pad2Digits(value.month)}`;
case 'date:week':
return `${value.year}@${pad2Digits(getWeek(new Date(value.year, (value.month ?? 1) - 1, value.day ?? 1)))}`;
case 'date:day':
return `${value.year}-${pad2Digits(value.month)}-${pad2Digits(value.day)}`;
case 'date:hour':
@@ -126,6 +148,9 @@ export function incrementChartDate(value: ChartDateParsed, transform: ChartXTran
case 'date:month':
newDateRepresentation = addMonths(dateRepresentation, 1);
break;
case 'date:week':
newDateRepresentation = addWeeks(dateRepresentation, 1);
break;
case 'date:day':
newDateRepresentation = addDays(dateRepresentation, 1);
break;
@@ -144,6 +169,11 @@ export function incrementChartDate(value: ChartDateParsed, transform: ChartXTran
year: newDateRepresentation.getFullYear(),
month: newDateRepresentation.getMonth() + 1,
};
case 'date:week':
return {
year: newDateRepresentation.getFullYear(),
week: getWeek(newDateRepresentation),
};
case 'date:day':
return {
year: newDateRepresentation.getFullYear(),
@@ -175,6 +205,8 @@ export function runTransformFunction(value: string, transformFunction: ChartXTra
return dateParsed ? `${dateParsed.year}` : null;
case 'date:month':
return dateParsed ? `${dateParsed.year}-${pad2Digits(dateParsed.month)}` : null;
case 'date:week':
return dateParsed ? `${dateParsed.year}@${pad2Digits(dateParsed.week)}` : null;
case 'date:day':
return dateParsed ? `${dateParsed.year}-${pad2Digits(dateParsed.month)}-${pad2Digits(dateParsed.day)}` : null;
case 'date:hour':
@@ -211,6 +243,14 @@ export function computeChartBucketKey(
month: dateParsed.month,
},
];
case 'date:week':
return [
dateParsed ? `${dateParsed.year}@${pad2Digits(dateParsed.week)}` : null,
{
year: dateParsed.year,
week: dateParsed.week,
},
];
case 'date:day':
return [
dateParsed ? `${dateParsed.year}-${pad2Digits(dateParsed.month)}-${pad2Digits(dateParsed.day)}` : null,
@@ -265,6 +305,8 @@ export function computeDateBucketDistance(
return end.year - begin.year;
case 'date:month':
return (end.year - begin.year) * 12 + (end.month - begin.month);
case 'date:week':
return (end.year - begin.year) * 52 + (end.week - begin.week);
case 'date:day':
return (
(end.year - begin.year) * 365 +
@@ -302,6 +344,8 @@ export function compareChartDatesParsed(
return a.year - b.year;
case 'date:month':
return a.year === b.year ? a.month - b.month : a.year - b.year;
case 'date:week':
return a.year === b.year ? a.week - b.week : a.year - b.year;
case 'date:day':
return a.year === b.year && a.month === b.month
? a.day - b.day
@@ -356,6 +400,8 @@ function getParentDateBucketKey(
return null; // no parent for year
case 'date:month':
return bucketKey.slice(0, 4);
case 'date:week':
return bucketKey.slice(0, 4);
case 'date:day':
return bucketKey.slice(0, 7);
case 'date:hour':
@@ -371,6 +417,8 @@ function getParentDateBucketTransform(transform: ChartXTransformFunction): Chart
return null; // no parent for year
case 'date:month':
return 'date:year';
case 'date:week':
return 'date:year';
case 'date:day':
return 'date:month';
case 'date:hour':
@@ -388,6 +436,8 @@ function getParentKeyParsed(date: ChartDateParsed, transform: ChartXTransformFun
return null; // no parent for year
case 'date:month':
return { year: date.year };
case 'date:week':
return { year: date.week };
case 'date:day':
return { year: date.year, month: date.month };
case 'date:hour':

View File

@@ -35,6 +35,12 @@ program
.option('-u, --user <user>', 'user name')
.option('-p, --password <password>', 'password')
.option('-d, --database <database>', 'database name')
.option('--url <url>', 'database url')
.option('--file <file>', 'database file')
.option('--socket-path <socketPath>', 'socket path')
.option('--service-name <serviceName>', 'service name (for Oracle)')
.option('--auth-type <authType>', 'authentication type')
.option('--use-ssl', 'use SSL connection')
.option('--auto-index-foreign-keys', 'automatically adds indexes to all foreign keys')
.option(
'--load-data-condition <condition>',
@@ -48,7 +54,7 @@ program
.command('deploy <modelFolder>')
.description('Deploys model to database')
.action(modelFolder => {
const { engine, server, user, password, database, transaction } = program.opts();
const { engine, server, user, password, database, url, file, transaction } = program.opts();
// const hooks = [];
// if (program.autoIndexForeignKeys) hooks.push(dbmodel.hooks.autoIndexForeignKeys);
@@ -60,6 +66,13 @@ program
user,
password,
database,
databaseUrl: url,
useDatabaseUrl: !!url,
databaseFile: file,
socketPath: program.socketPath,
serviceName: program.serviceName,
authType: program.authType,
useSsl: program.useSsl,
},
modelFolder,
useTransaction: transaction,

View File

@@ -2,4 +2,5 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['js'],
reporters: ['default', 'github-actions'],
};

View File

@@ -3,6 +3,10 @@
"name": "dbgate-filterparser",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
},
"scripts": {
"build": "tsc",
"start": "tsc --watch",

View File

@@ -1,4 +1,4 @@
import { arrayToHexString, evalFilterBehaviour, isTypeDateTime } from 'dbgate-tools';
import { arrayToHexString, base64ToHex, evalFilterBehaviour, isTypeDateTime } from 'dbgate-tools';
import { format, toDate } from 'date-fns';
import _isString from 'lodash/isString';
import _cloneDeepWith from 'lodash/cloneDeepWith';
@@ -21,10 +21,13 @@ export function getFilterValueExpression(value, dataType?) {
if (value === false) return 'FALSE';
if (value.$oid) return `ObjectId("${value.$oid}")`;
if (value.$bigint) return value.$bigint;
if (value.$decimal) return value.$decimal;
if (value.type == 'Buffer' && Array.isArray(value.data)) {
return '0x' + arrayToHexString(value.data);
}
if (value?.$binary?.base64) {
return base64ToHex(value.$binary.base64);
}
return `="${value}"`;
}

View File

@@ -2,7 +2,7 @@ import P from 'parsimmon';
import moment from 'moment';
import { Condition } from 'dbgate-sqltree';
import { interpretEscapes, token, word, whitespace } from './common';
import { hexStringToArray, parseNumberSafe } from 'dbgate-tools';
import { hexToBase64, parseNumberSafe } from 'dbgate-tools';
import { FilterBehaviour, TransformType } from 'dbgate-types';
const binaryCondition =
@@ -385,10 +385,7 @@ const createParser = (filterBehaviour: FilterBehaviour) => {
hexstring: () =>
token(P.regexp(/0x(([0-9a-fA-F][0-9a-fA-F])+)/, 1))
.map(x => ({
type: 'Buffer',
data: hexStringToArray(x),
}))
.map(x => ({ $binary: { base64: hexToBase64(x) } }))
.desc('hex string'),
noQuotedString: () => P.regexp(/[^\s^,^'^"]+/).desc('string unquoted'),

View File

@@ -1,7 +1,7 @@
import type { SqlDumper } from 'dbgate-types';
import { Command, Select, Update, Delete, Insert } from './types';
import { dumpSqlExpression } from './dumpSqlExpression';
import { dumpSqlFromDefinition, dumpSqlSourceRef } from './dumpSqlSource';
import { dumpSqlFromDefinition, dumpSqlSourceDef, dumpSqlSourceRef } from './dumpSqlSource';
import { dumpSqlCondition } from './dumpSqlCondition';
export function dumpSqlSelect(dmp: SqlDumper, cmd: Select) {
@@ -115,7 +115,10 @@ export function dumpSqlInsert(dmp: SqlDumper, cmd: Insert) {
cmd.fields.map(x => x.targetColumn)
);
dmp.putCollection(',', cmd.fields, x => dumpSqlExpression(dmp, x));
if (dmp.dialect.requireFromDual) {
if (cmd.whereNotExistsSource) {
dmp.put(' ^from ');
dumpSqlSourceDef(dmp, cmd.whereNotExistsSource);
} else if (dmp.dialect.requireFromDual) {
dmp.put(' ^from ^dual ');
}
dmp.put(' ^where ^not ^exists (^select * ^from %f ^where ', cmd.targetTable);

View File

@@ -2,6 +2,7 @@ import _ from 'lodash';
import type { SqlDumper } from 'dbgate-types';
import { Expression, ColumnRefExpression } from './types';
import { dumpSqlSourceRef } from './dumpSqlSource';
import { dumpSqlSelect } from './dumpSqlCommand';
export function dumpSqlExpression(dmp: SqlDumper, expr: Expression) {
switch (expr.exprType) {
@@ -67,5 +68,11 @@ export function dumpSqlExpression(dmp: SqlDumper, expr: Expression) {
});
dmp.put(')');
break;
case 'select':
dmp.put('(');
dumpSqlSelect(dmp, expr.select);
dmp.put(')');
break;
}
}

View File

@@ -19,6 +19,7 @@ function isLike(value, test) {
function extractRawValue(value) {
if (value?.$bigint) return value.$bigint;
if (value?.$oid) return value.$oid;
if (value?.$decimal) return value.$decimal;
return value;
}

View File

@@ -44,6 +44,7 @@ export interface Insert {
fields: UpdateField[];
targetTable: NamedObjectInfo;
insertWhereNotExistsCondition?: Condition;
whereNotExistsSource?: Source;
}
export interface AllowIdentityInsert {
@@ -226,6 +227,11 @@ export interface RowNumberExpression {
orderBy: OrderByExpression[];
}
export interface SelectExpression {
exprType: 'select';
select: Select;
}
export type Expression =
| ColumnRefExpression
| ValueExpression
@@ -235,7 +241,8 @@ export type Expression =
| CallExpression
| MethodCallExpression
| TranformExpression
| RowNumberExpression;
| RowNumberExpression
| SelectExpression;
export type OrderByExpression = Expression & { direction: 'ASC' | 'DESC' };
export type ResultField = Expression & { alias?: string };

View File

@@ -2,4 +2,5 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['js'],
reporters: ['default', 'github-actions'],
};

View File

@@ -32,7 +32,8 @@
"typescript": "^4.4.3"
},
"dependencies": {
"dbgate-query-splitter": "^4.11.5",
"blueimp-md5": "^2.19.0",
"dbgate-query-splitter": "^4.11.9",
"dbgate-sqltree": "^6.0.0-alpha.1",
"debug": "^4.3.4",
"json-stable-stringify": "^1.0.1",

View File

@@ -49,6 +49,8 @@ export class DatabaseAnalyser<TClient = any> {
singleObjectId: string = null;
dialect: SqlDialect;
logger: Logger;
startedTm = Date.now();
analyseIdentifier = Math.random().toString().substring(2);
constructor(public dbhan: DatabaseHandle<TClient>, public driver: EngineDriver, version) {
this.dialect = (driver?.dialectByVersion && driver?.dialectByVersion(version)) || driver?.dialect;
@@ -78,14 +80,24 @@ export class DatabaseAnalyser<TClient = any> {
}
getLogDbInfo() {
return this.driver.getLogDbInfo(this.dbhan);
return {
...this.driver.getLogDbInfo(this.dbhan),
analyserTime: Date.now() - this.startedTm,
analyseIdentifier: this.analyseIdentifier,
};
}
async fullAnalysis() {
logger.debug(this.getLogDbInfo(), 'DBGM-00126 Performing full analysis');
const res = this.addEngineField(await this._runAnalysis());
try {
const res = this.addEngineField(await this._runAnalysis());
logger.debug(this.getLogDbInfo(), 'DBGM-00271 Full analysis finished successfully');
return res;
} catch (err) {
logger.error(extractErrorLogData(err, this.getLogDbInfo()), 'DBGM-00272 Error during full analysis');
throw err;
}
// console.log('FULL ANALYSIS', res);
return res;
}
async singleObjectAnalysis(name, typeField) {
@@ -106,31 +118,40 @@ export class DatabaseAnalyser<TClient = any> {
logger.info(this.getLogDbInfo(), 'DBGM-00127 Performing incremental analysis');
this.structure = structure;
const modifications = await this.getModifications();
if (modifications == null) {
// modifications not implemented, perform full analysis
this.structure = null;
return this.addEngineField(await this._runAnalysis());
}
const structureModifications = modifications.filter(x => x.action != 'setTableRowCounts');
const setTableRowCounts = modifications.find(x => x.action == 'setTableRowCounts');
let structureWithRowCounts = null;
if (setTableRowCounts) {
const newStructure = mergeTableRowCounts(structure, setTableRowCounts.rowCounts);
if (areDifferentRowCounts(structure, newStructure)) {
structureWithRowCounts = newStructure;
try {
const modifications = await this.getModifications();
if (modifications == null) {
// modifications not implemented, perform full analysis
this.structure = null;
return this.addEngineField(await this._runAnalysis());
}
}
const structureModifications = modifications.filter(x => x.action != 'setTableRowCounts');
const setTableRowCounts = modifications.find(x => x.action == 'setTableRowCounts');
if (structureModifications.length == 0) {
return structureWithRowCounts ? this.addEngineField(structureWithRowCounts) : null;
}
let structureWithRowCounts = null;
if (setTableRowCounts) {
const newStructure = mergeTableRowCounts(structure, setTableRowCounts.rowCounts);
if (areDifferentRowCounts(structure, newStructure)) {
structureWithRowCounts = newStructure;
}
}
this.modifications = structureModifications;
if (structureWithRowCounts) this.structure = structureWithRowCounts;
logger.info({ ...this.getLogDbInfo(), modifications: this.modifications }, 'DBGM-00128 DB modifications detected');
return this.addEngineField(this.mergeAnalyseResult(await this._runAnalysis()));
if (structureModifications.length == 0) {
logger.debug(this.getLogDbInfo(), 'DBGM-00267 No changes in database structure detected');
return structureWithRowCounts ? this.addEngineField(structureWithRowCounts) : null;
}
this.modifications = structureModifications;
if (structureWithRowCounts) this.structure = structureWithRowCounts;
logger.info(
{ ...this.getLogDbInfo(), modifications: this.modifications },
'DBGM-00128 DB modifications detected'
);
return this.addEngineField(this.mergeAnalyseResult(await this._runAnalysis()));
} catch (err) {
logger.error(extractErrorLogData(err, this.getLogDbInfo()), 'DBGM-00273 Error during incremental analysis');
throw err;
}
}
mergeAnalyseResult(newlyAnalysed) {
@@ -143,6 +164,11 @@ export class DatabaseAnalyser<TClient = any> {
const res = {};
for (const field of STRUCTURE_FIELDS) {
const isAll = this.modifications.some(x => x.action == 'all' && x.objectTypeField == field);
if (isAll) {
res[field] = newlyAnalysed[field] || [];
continue;
}
const removedIds = this.modifications
.filter(x => x.action == 'remove' && x.objectTypeField == field)
.map(x => x.objectId);

View File

@@ -26,6 +26,7 @@ import _isDate from 'lodash/isDate';
import _isArray from 'lodash/isArray';
import _isPlainObject from 'lodash/isPlainObject';
import _keys from 'lodash/keys';
import _cloneDeep from 'lodash/cloneDeep';
import uuidv1 from 'uuid/v1';
export class SqlDumper implements AlterProcessor {
@@ -78,7 +79,16 @@ export class SqlDumper implements AlterProcessor {
else if (_isNumber(value)) this.putRaw(value.toString());
else if (_isDate(value)) this.putStringValue(new Date(value).toISOString());
else if (value?.type == 'Buffer' && _isArray(value?.data)) this.putByteArrayValue(value?.data);
else if (value?.$binary?.base64) {
const binary = atob(value.$binary.base64);
const bytes = new Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
this.putByteArrayValue(bytes);
}
else if (value?.$bigint) this.putRaw(value?.$bigint);
else if (value?.$decimal) this.putRaw(value?.$decimal);
else if (_isPlainObject(value) || _isArray(value)) this.putStringValue(JSON.stringify(value));
else this.put('^null');
}
@@ -658,6 +668,68 @@ export class SqlDumper implements AlterProcessor {
}
}
sanitizeTableConstraints(table: TableInfo): TableInfo {
// Create a deep copy of the table
const sanitized = _cloneDeep(table);
// Get the set of existing column names
const existingColumns = new Set(sanitized.columns.map(col => col.columnName));
// Filter primary key columns to only include existing columns
if (sanitized.primaryKey) {
const validPkColumns = sanitized.primaryKey.columns.filter(col => existingColumns.has(col.columnName));
if (validPkColumns.length === 0) {
// If no valid columns remain, remove the primary key entirely
sanitized.primaryKey = null;
} else if (validPkColumns.length < sanitized.primaryKey.columns.length) {
// Update primary key with only valid columns
sanitized.primaryKey = {
...sanitized.primaryKey,
columns: validPkColumns
};
}
}
// Filter sorting key columns to only include existing columns
if (sanitized.sortingKey) {
const validSkColumns = sanitized.sortingKey.columns.filter(col => existingColumns.has(col.columnName));
if (validSkColumns.length === 0) {
sanitized.sortingKey = null;
} else if (validSkColumns.length < sanitized.sortingKey.columns.length) {
sanitized.sortingKey = {
...sanitized.sortingKey,
columns: validSkColumns
};
}
}
// Filter foreign keys to only include those with all columns present
if (sanitized.foreignKeys) {
sanitized.foreignKeys = sanitized.foreignKeys.filter(fk =>
fk.columns.every(col => existingColumns.has(col.columnName))
);
}
// Filter indexes to only include those with all columns present
if (sanitized.indexes) {
sanitized.indexes = sanitized.indexes.filter(idx =>
idx.columns.every(col => existingColumns.has(col.columnName))
);
}
// Filter unique constraints to only include those with all columns present
if (sanitized.uniques) {
sanitized.uniques = sanitized.uniques.filter(uq =>
uq.columns.every(col => existingColumns.has(col.columnName))
);
}
// Filter dependencies (references from other tables) - these should remain as-is
// since they don't affect the CREATE TABLE statement for this table
return sanitized;
}
recreateTable(oldTable: TableInfo, newTable: TableInfo) {
if (!oldTable.pairingId || !newTable.pairingId || oldTable.pairingId != newTable.pairingId) {
throw new Error('Recreate is not possible: oldTable.paringId != newTable.paringId');
@@ -672,48 +744,51 @@ export class SqlDumper implements AlterProcessor {
}))
.filter(x => x.newcol);
// Create a sanitized version of newTable with constraints that only reference existing columns
const sanitizedNewTable = this.sanitizeTableConstraints(newTable);
if (this.driver.supportsTransactions) {
this.dropConstraints(oldTable, true);
this.renameTable(oldTable, tmpTable);
this.createTable(newTable);
this.createTable(sanitizedNewTable);
const autoinc = newTable.columns.find(x => x.autoIncrement);
const autoinc = sanitizedNewTable.columns.find(x => x.autoIncrement);
if (autoinc) {
this.allowIdentityInsert(newTable, true);
this.allowIdentityInsert(sanitizedNewTable, true);
}
this.putCmd(
'^insert ^into %f (%,i) select %,i ^from %f',
newTable,
sanitizedNewTable,
columnPairs.map(x => x.newcol.columnName),
columnPairs.map(x => x.oldcol.columnName),
{ ...oldTable, pureName: tmpTable }
);
if (autoinc) {
this.allowIdentityInsert(newTable, false);
this.allowIdentityInsert(sanitizedNewTable, false);
}
if (this.dialect.dropForeignKey) {
newTable.dependencies.forEach(cnt => this.createConstraint(cnt));
sanitizedNewTable.dependencies.forEach(cnt => this.createConstraint(cnt));
}
this.dropTable({ ...oldTable, pureName: tmpTable });
} else {
// we have to preserve old table as long as possible
this.createTable({ ...newTable, pureName: tmpTable });
this.createTable({ ...sanitizedNewTable, pureName: tmpTable });
this.putCmd(
'^insert ^into %f (%,i) select %,s ^from %f',
{ ...newTable, pureName: tmpTable },
{ ...sanitizedNewTable, pureName: tmpTable },
columnPairs.map(x => x.newcol.columnName),
columnPairs.map(x => x.oldcol.columnName),
oldTable
);
this.dropTable(oldTable);
this.renameTable({ ...newTable, pureName: tmpTable }, newTable.pureName);
this.renameTable({ ...sanitizedNewTable, pureName: tmpTable }, newTable.pureName);
}
}

View File

@@ -91,8 +91,8 @@ interface AlterOperation_RenameConstraint {
}
interface AlterOperation_RecreateTable {
operationType: 'recreateTable';
table: TableInfo;
operations: AlterOperation[];
oldTable: TableInfo;
newTable: TableInfo;
}
interface AlterOperation_FillPreloadedRows {
operationType: 'fillPreloadedRows';
@@ -249,11 +249,11 @@ export class AlterPlan {
});
}
recreateTable(table: TableInfo, operations: AlterOperation[]) {
recreateTable(oldTable: TableInfo, newTable: TableInfo) {
this.operations.push({
operationType: 'recreateTable',
table,
operations,
oldTable,
newTable,
});
this.recreates.tables += 1;
}
@@ -337,7 +337,13 @@ export class AlterPlan {
return opRes;
}),
op,
];
].filter(op => {
// filter duplicated drops
const existingDrop = this.operations.find(
o => o.operationType == 'dropConstraint' && o.oldObject === op['oldObject']
);
return existingDrop == null;
});
return res;
}
@@ -498,53 +504,121 @@ export class AlterPlan {
return [];
}
const table = this.wholeNewDb.tables.find(
const oldTable = this.wholeOldDb.tables.find(
x => x.pureName == op[objectField].pureName && x.schemaName == op[objectField].schemaName
);
const newTable = this.wholeNewDb.tables.find(
x => x.pureName == op[objectField].pureName && x.schemaName == op[objectField].schemaName
);
this.recreates.tables += 1;
return [
{
operationType: 'recreateTable',
table,
operations: [op],
oldTable,
newTable,
// operations: [op],
},
];
}
return null;
}
_groupTableRecreations(): AlterOperation[] {
const res = [];
const recreates = {};
_removeRecreatedTableAlters(): AlterOperation[] {
const res: AlterOperation[] = [];
const recreates = new Set<string>();
for (const op of this.operations) {
if (op.operationType == 'recreateTable' && op.table) {
const existingRecreate = recreates[`${op.table.schemaName}||${op.table.pureName}`];
if (existingRecreate) {
existingRecreate.operations.push(...op.operations);
} else {
const recreate = {
...op,
operations: [...op.operations],
};
res.push(recreate);
recreates[`${op.table.schemaName}||${op.table.pureName}`] = recreate;
}
} else {
// @ts-ignore
const oldObject: TableInfo = op.oldObject || op.object;
if (oldObject) {
const recreated = recreates[`${oldObject.schemaName}||${oldObject.pureName}`];
if (recreated) {
recreated.operations.push(op);
continue;
}
}
res.push(op);
if (op.operationType == 'recreateTable' && op.oldTable && op.newTable) {
const key = `${op.oldTable.schemaName}||${op.oldTable.pureName}`;
recreates.add(key);
}
}
for (const op of this.operations) {
switch (op.operationType) {
case 'createColumn':
case 'createConstraint':
{
const key = `${op.newObject.schemaName}||${op.newObject.pureName}`;
if (recreates.has(key)) {
// skip create inside recreated table
continue;
}
}
break;
case 'dropColumn':
case 'dropConstraint':
case 'changeColumn':
{
const key = `${op.oldObject.schemaName}||${op.oldObject.pureName}`;
if (recreates.has(key)) {
// skip drop/change inside recreated table
continue;
}
}
break;
case 'renameColumn':
{
const key = `${op.object.schemaName}||${op.object.pureName}`;
if (recreates.has(key)) {
// skip rename inside recreated table
continue;
}
}
break;
}
res.push(op);
}
return res;
}
_groupTableRecreations(): AlterOperation[] {
const res = [];
const recreates = new Set<string>();
for (const op of this.operations) {
if (op.operationType == 'recreateTable' && op.oldTable && op.newTable) {
const key = `${op.oldTable.schemaName}||${op.oldTable.pureName}`;
if (recreates.has(key)) {
// prevent duplicate recreates
continue;
}
recreates.add(key);
}
res.push(op);
}
return res;
// const res = [];
// const recreates = {};
// for (const op of this.operations) {
// if (op.operationType == 'recreateTable' && op.table) {
// const existingRecreate = recreates[`${op.table.schemaName}||${op.table.pureName}`];
// if (existingRecreate) {
// existingRecreate.operations.push(...op.operations);
// } else {
// const recreate = {
// ...op,
// operations: [...op.operations],
// };
// res.push(recreate);
// recreates[`${op.table.schemaName}||${op.table.pureName}`] = recreate;
// }
// } else {
// // @ts-ignore
// const oldObject: TableInfo = op.oldObject || op.object;
// if (oldObject) {
// const recreated = recreates[`${oldObject.schemaName}||${oldObject.pureName}`];
// if (recreated) {
// recreated.operations.push(op);
// continue;
// }
// }
// res.push(op);
// }
// }
// return res;
}
_moveForeignKeysToLast(): AlterOperation[] {
if (!this.dialect.createForeignKey) {
return this.operations;
@@ -611,6 +685,8 @@ export class AlterPlan {
// console.log('*****************OPERATIONS3', this.operations);
this.operations = this._removeRecreatedTableAlters();
this.operations = this._moveForeignKeysToLast();
// console.log('*****************OPERATIONS4', this.operations);
@@ -673,16 +749,16 @@ export function runAlterOperation(op: AlterOperation, processor: AlterProcessor)
break;
case 'recreateTable':
{
const oldTable = generateTablePairingId(op.table);
const newTable = _.cloneDeep(oldTable);
const newDb = DatabaseAnalyser.createEmptyStructure();
newDb.tables.push(newTable);
// console.log('////////////////////////////newTable1', newTable);
op.operations.forEach(child => runAlterOperation(child, new DatabaseInfoAlterProcessor(newDb)));
// console.log('////////////////////////////op.operations', op.operations);
// console.log('////////////////////////////op.table', op.table);
// console.log('////////////////////////////newTable2', newTable);
processor.recreateTable(oldTable, newTable);
// const oldTable = generateTablePairingId(op.table);
// const newTable = _.cloneDeep(oldTable);
// const newDb = DatabaseAnalyser.createEmptyStructure();
// newDb.tables.push(newTable);
// // console.log('////////////////////////////newTable1', newTable);
// op.operations.forEach(child => runAlterOperation(child, new DatabaseInfoAlterProcessor(newDb)));
// // console.log('////////////////////////////op.operations', op.operations);
// // console.log('////////////////////////////op.table', op.table);
// // console.log('////////////////////////////newTable2', newTable);
processor.recreateTable(op.oldTable, op.newTable);
}
break;
}

View File

@@ -60,4 +60,4 @@ export function chooseTopTables(tables: TableInfo[], count: number, tableFilter:
export const DIAGRAM_ZOOMS = [0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1, 1.25, 1.5, 1.75, 2];
export const DIAGRAM_DEFAULT_WATERMARK = 'Powered by [dbgate.io](https://dbgate.io)';
export const DIAGRAM_DEFAULT_WATERMARK = 'Powered by [dbgate.io](https://www.dbgate.io)';

View File

@@ -47,6 +47,7 @@ export const mongoFilterBehaviour: FilterBehaviour = {
allowStringToken: true,
allowNumberDualTesting: true,
allowObjectIdTesting: true,
allowHexString: true,
};
export const evalFilterBehaviour: FilterBehaviour = {

View File

@@ -1,4 +1,5 @@
import _cloneDeep from 'lodash/cloneDeep';
import _uniq from 'lodash/uniq';
import _isString from 'lodash/isString';
import type {
ColumnInfo,
@@ -75,9 +76,27 @@ export function findForeignKeyForColumn(table: TableInfo, column: ColumnInfo | s
return (table.foreignKeys || []).find(fk => fk.columns.find(col => col.columnName == column.columnName));
}
export function getConflictingColumnNames(columns: ColumnInfo[]): Set<string> {
const conflictingNames = new Set(
_uniq(columns.map(x => x.columnName).filter((item, index, arr) => arr.indexOf(item) !== index))
);
return conflictingNames;
}
export function makeUniqueColumnNames(res: ColumnInfo[]) {
const usedNames = new Set();
const conflictingNames = getConflictingColumnNames(res);
for (let i = 0; i < res.length; i++) {
if (
conflictingNames.has(res[i].columnName) &&
res[i].pureName &&
!usedNames.has(`${res[i].pureName}_${res[i].columnName}`)
) {
res[i].columnName = `${res[i].pureName}_${res[i].columnName}`;
usedNames.add(res[i].columnName);
continue;
}
if (usedNames.has(res[i].columnName)) {
let suffix = 2;
while (usedNames.has(`${res[i].columnName}${suffix}`)) suffix++;
@@ -111,3 +130,20 @@ export function fillConstraintNames(table: TableInfo, dialect: SqlDialect) {
}
return res;
}
export const DATA_FOLDER_NAMES = [
{ name: 'sql', label: 'SQL scripts' },
{ name: 'shell', label: 'Shell scripts' },
{ name: 'markdown', label: 'Markdown files' },
{ name: 'charts', label: 'Charts' },
{ name: 'query', label: 'Query designs' },
{ name: 'sqlite', label: 'SQLite files' },
{ name: 'duckdb', label: 'DuckDB files' },
{ name: 'diagrams', label: 'Diagrams' },
{ name: 'perspectives', label: 'Perspectives' },
{ name: 'impexp', label: 'Import/Export jobs' },
{ name: 'modtrans', label: 'Model transforms' },
{ name: 'datadeploy', label: 'Data deploy jobs' },
{ name: 'dbcompare', label: 'Database compare jobs' },
{ name: 'apps', label: 'Applications' },
];

View File

@@ -9,6 +9,7 @@ import _isEmpty from 'lodash/isEmpty';
import _omitBy from 'lodash/omitBy';
import { DataEditorTypesBehaviour } from 'dbgate-types';
import isPlainObject from 'lodash/isPlainObject';
import md5 from 'blueimp-md5';
export const MAX_GRID_TEXT_LENGTH = 1000; // maximum length of text in grid cell, longer text is truncated
@@ -42,6 +43,20 @@ export function hexStringToArray(inputString) {
return res;
}
export function base64ToHex(base64String) {
const binaryString = atob(base64String);
const hexString = Array.from(binaryString, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('');
return '0x' + hexString.toUpperCase();
}
export function hexToBase64(hexString) {
const binaryString = hexString
.match(/.{1,2}/g)
.map(byte => String.fromCharCode(parseInt(byte, 16)))
.join('');
return btoa(binaryString);
}
export function parseCellValue(value, editorTypes?: DataEditorTypesBehaviour) {
if (!_isString(value)) return value;
@@ -53,8 +68,9 @@ export function parseCellValue(value, editorTypes?: DataEditorTypesBehaviour) {
const mHex = value.match(/^0x([0-9a-fA-F][0-9a-fA-F])+$/);
if (mHex) {
return {
type: 'Buffer',
data: hexStringToArray(value.substring(2)),
$binary: {
base64: hexToBase64(value.substring(2)),
},
};
}
}
@@ -185,6 +201,26 @@ function stringifyJsonToGrid(value): ReturnType<typeof stringifyCellValue> {
return { value: '(JSON)', gridStyle: 'nullCellStyle' };
}
function formatNumberCustomSeparator(value, thousandsSeparator) {
const [intPart, decPart] = value.split('.');
const intPartWithSeparator = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandsSeparator);
return decPart ? `${intPartWithSeparator}.${decPart}` : intPartWithSeparator;
}
function formatCellNumber(value, gridFormattingOptions?: { thousandsSeparator?: string }) {
const separator = gridFormattingOptions?.thousandsSeparator;
if (_isNumber(value)) {
if (separator === 'none' || (value < 1000 && value > -1000)) return value.toString();
if (separator === 'system') return value.toLocaleString();
}
// fallback for system locale
if (separator === 'space' || separator === 'system') return formatNumberCustomSeparator(value.toString(), ' ');
if (separator === 'narrowspace') return formatNumberCustomSeparator(value.toString(), '\u202F');
if (separator === 'comma') return formatNumberCustomSeparator(value.toString(), ',');
if (separator === 'dot') return formatNumberCustomSeparator(value.toString(), '.');
return value.toString();
}
export function stringifyCellValue(
value,
intent:
@@ -195,7 +231,7 @@ export function stringifyCellValue(
| 'exportIntent'
| 'clipboardIntent',
editorTypes?: DataEditorTypesBehaviour,
gridFormattingOptions?: { useThousandsSeparator?: boolean },
gridFormattingOptions?: { thousandsSeparator?: string },
jsonParsedValue?: any
): {
value: string;
@@ -229,11 +265,26 @@ export function stringifyCellValue(
if (value === true) return { value: 'true', gridStyle: 'valueCellStyle' };
if (value === false) return { value: 'false', gridStyle: 'valueCellStyle' };
if (editorTypes?.parseHexAsBuffer) {
if (value?.type == 'Buffer' && _isArray(value.data)) {
return { value: '0x' + arrayToHexString(value.data), gridStyle: 'valueCellStyle' };
}
if (value?.$binary?.base64) {
return {
value: base64ToHex(value.$binary.base64),
gridStyle: 'valueCellStyle',
};
}
if (value?.$decimal) {
return {
value: formatCellNumber(value.$decimal, gridFormattingOptions),
gridStyle: 'valueCellStyle',
};
}
if (editorTypes?.parseHexAsBuffer) {
// if (value?.type == 'Buffer' && _isArray(value.data)) {
// return { value: '0x' + arrayToHexString(value.data), gridStyle: 'valueCellStyle' };
// }
}
if (editorTypes?.parseObjectIdAsDollar) {
if (value?.$oid) {
switch (intent) {
@@ -247,13 +298,13 @@ export function stringifyCellValue(
}
if (value?.$bigint) {
return {
value: value.$bigint,
value: formatCellNumber(value.$bigint, gridFormattingOptions),
gridStyle: 'valueCellStyle',
};
}
if (typeof value === 'bigint') {
return {
value: value.toString(),
value: formatCellNumber(value.toString(), gridFormattingOptions),
gridStyle: 'valueCellStyle',
};
}
@@ -328,13 +379,8 @@ export function stringifyCellValue(
if (_isNumber(value)) {
switch (intent) {
case 'gridCellIntent':
return {
value:
gridFormattingOptions?.useThousandsSeparator && (value >= 10000 || value <= -10000)
? value.toLocaleString()
: value.toString(),
gridStyle: 'valueCellStyle',
};
const separator = gridFormattingOptions?.thousandsSeparator;
return { value: formatCellNumber(value, gridFormattingOptions), gridStyle: 'valueCellStyle' };
default:
return { value: value.toString() };
}
@@ -386,6 +432,9 @@ export function safeJsonParse(json, defaultValue?, logError = false) {
if (_isArray(json) || _isPlainObject(json)) {
return json;
}
if (!json) {
return defaultValue;
}
try {
return JSON.parse(json);
} catch (err) {
@@ -423,6 +472,9 @@ export function shouldOpenMultilineDialog(value) {
if (value?.$bigint) {
return false;
}
if (value?.$decimal) {
return false;
}
if (_isPlainObject(value) || _isArray(value)) {
return true;
}
@@ -478,6 +530,9 @@ export function getAsImageSrc(obj) {
if (obj?.type == 'Buffer' && _isArray(obj?.data)) {
return `data:image/png;base64, ${arrayBufferToBase64(obj?.data)}`;
}
if (obj?.$binary?.base64) {
return `data:image/png;base64, ${obj.$binary.base64}`;
}
if (_isString(obj) && (obj.startsWith('http://') || obj.startsWith('https://'))) {
return obj;
@@ -670,6 +725,9 @@ export function deserializeJsTypesFromJsonParse(obj) {
if (value?.$bigint) {
return BigInt(value.$bigint);
}
if (value?.$decimal) {
return value.$decimal;
}
});
}
@@ -684,6 +742,9 @@ export function deserializeJsTypesReviver(key, value) {
if (value?.$bigint) {
return BigInt(value.$bigint);
}
if (value?.$decimal) {
return value.$decimal;
}
return value;
}
@@ -734,3 +795,12 @@ export function setSqlFrontMatter(text: string, data: { [key: string]: any }, ya
const frontMatterContent = `-- >>>\n${yamlContentMapped}\n-- <<<\n`;
return frontMatterContent + (textClean || '');
}
export function shortenIdentifier(s: string, maxLength?: number) {
if (!maxLength || maxLength < 10) return s;
if (s.length <= maxLength) return s;
const hash = md5(s).substring(0, 8);
const partLength = Math.floor((maxLength - 9) / 2);
const restLength = maxLength - 10 - partLength;
return s.substring(0, partLength) + '_' + hash + '_' + s.substring(s.length - restLength);
}

View File

@@ -57,6 +57,12 @@ export function compilePermissions(permissions: string[] | string): CompiledPerm
return res;
}
export function getPermissionsCacheKey(permissions: string[] | string) {
if (!permissions) return null;
if (_isString(permissions)) return permissions;
return permissions.join('|');
}
export function testPermission(tested: string, permissions: CompiledPermissions) {
let allow = true;
@@ -103,9 +109,25 @@ export function getPredefinedPermissions(predefinedRoleName: string) {
case 'superadmin':
return ['*', '~widgets/*', 'widgets/admin', 'widgets/database', '~all-connections'];
case 'logged-user':
return ['*', '~widgets/admin', '~admin/*', '~internal-storage', '~all-connections'];
return [
'*',
'~widgets/admin',
'~admin/*',
'~internal-storage',
'~all-connections',
'~run-shell-script',
'~all-team-files/*',
];
case 'anonymous-user':
return ['*', '~widgets/admin', '~admin/*', '~internal-storage', '~all-connections'];
return [
'*',
'~widgets/admin',
'~admin/*',
'~internal-storage',
'~all-connections',
'~run-shell-script',
'~all-team-files/*',
];
default:
return null;
}

View File

@@ -1,12 +1,12 @@
interface ApplicationCommand {
name: string;
sql: string;
}
// interface ApplicationCommand {
// name: string;
// sql: string;
// }
interface ApplicationQuery {
name: string;
sql: string;
}
// interface ApplicationQuery {
// name: string;
// sql: string;
// }
interface VirtualReferenceDefinition {
pureName: string;
@@ -27,11 +27,31 @@ interface DictionaryDescriptionDefinition {
delimiter: string;
}
export interface ApplicationDefinition {
name: string;
queries: ApplicationQuery[];
commands: ApplicationCommand[];
virtualReferences: VirtualReferenceDefinition[];
dictionaryDescriptions: DictionaryDescriptionDefinition[];
interface ApplicationUsageRule {
conditionGroup?: string;
serverHostsRegex?: string;
serverHostsList?: string[];
databaseNamesRegex?: string;
databaseNamesList?: string[];
tableNamesRegex?: string;
tableNamesList?: string[];
columnNamesRegex?: string;
columnNamesList?: string[];
}
export interface ApplicationDefinition {
appid: string;
applicationName: string;
applicationIcon?: string;
applicationColor?: string;
usageRules?: ApplicationUsageRule[];
files?: {
[key: string]: {
label: string;
sql: string;
type: 'query' | 'command';
};
};
virtualReferences?: VirtualReferenceDefinition[];
dictionaryDescriptions?: DictionaryDescriptionDefinition[];
}

View File

@@ -22,7 +22,7 @@ export interface ColumnsConstraintInfo extends ConstraintInfo {
columns: ColumnReference[];
}
export interface PrimaryKeyInfo extends ColumnsConstraintInfo {}
export interface PrimaryKeyInfo extends ColumnsConstraintInfo { }
export interface ForeignKeyInfo extends ColumnsConstraintInfo {
refSchemaName?: string;
@@ -39,7 +39,7 @@ export interface IndexInfo extends ColumnsConstraintInfo {
filterDefinition?: string;
}
export interface UniqueInfo extends ColumnsConstraintInfo {}
export interface UniqueInfo extends ColumnsConstraintInfo { }
export interface CheckInfo extends ConstraintInfo {
definition: string;
@@ -77,6 +77,7 @@ export interface DatabaseObjectInfo extends NamedObjectInfo {
hashCode?: string;
objectTypeField?: string;
objectComment?: string;
tablePermissionRole?: 'read' | 'update_only' | 'create_update_delete' | 'deny';
}
export interface SqlObjectInfo extends DatabaseObjectInfo {
@@ -134,7 +135,7 @@ export interface CallableObjectInfo extends SqlObjectInfo {
parameters?: ParameterInfo[];
}
export interface ProcedureInfo extends CallableObjectInfo {}
export interface ProcedureInfo extends CallableObjectInfo { }
export interface FunctionInfo extends CallableObjectInfo {
returnType?: string;
@@ -145,17 +146,17 @@ export interface TriggerInfo extends SqlObjectInfo {
functionName?: string;
tableName?: string;
triggerTiming?:
| 'BEFORE'
| 'AFTER'
| 'INSTEAD OF'
| 'BEFORE EACH ROW'
| 'INSTEAD OF'
| 'AFTER EACH ROW'
| 'AFTER STATEMENT'
| 'BEFORE STATEMENT'
| 'AFTER EVENT'
| 'BEFORE EVENT'
| null;
| 'BEFORE'
| 'AFTER'
| 'INSTEAD OF'
| 'BEFORE EACH ROW'
| 'INSTEAD OF'
| 'AFTER EACH ROW'
| 'AFTER STATEMENT'
| 'BEFORE STATEMENT'
| 'AFTER EVENT'
| 'BEFORE EVENT'
| null;
triggerLevel?: 'ROW' | 'STATEMENT';
eventType?: 'INSERT' | 'UPDATE' | 'DELETE' | 'TRUNCATE';
}

View File

@@ -22,6 +22,7 @@ export interface SqlDialect {
requireStandaloneSelectForScopeIdentity?: boolean;
allowMultipleValuesInsert?: boolean;
useServerDatabaseFile?: boolean;
maxIdentifierLength?: number;
dropColumnDependencies?: string[];
changeColumnDependencies?: string[];

View File

@@ -16,6 +16,7 @@ export interface SqlDumper extends AlterProcessor {
transform(type: TransformType, dumpExpr: () => void);
createDatabase(name: string);
dropDatabase(name: string);
comment(value: string);
callableTemplate(func: CallableObjectInfo);

View File

@@ -21,6 +21,7 @@ export interface StreamOptions {
error?: (error) => void;
done?: (result) => void;
info?: (info) => void;
changedCurrentDatabase?: (database: string) => void;
}
export type CollectionOperationInfo =
@@ -99,19 +100,46 @@ export interface SupportedDbKeyType {
showItemList?: boolean;
}
export type DatabaseProcess = {
processId: number;
connectionId: number;
client: string;
operation?: string;
namespace?: string;
command?: any;
runningTime: number;
state?: any;
waitingFor?: boolean;
locks?: any;
progress?: any;
};
export type DatabaseVariable = {
variable: string;
value: any;
};
export interface SqlBackupDumper {
run();
}
export interface SummaryColumn {
fieldName: string;
header: string;
dataType: 'string' | 'number' | 'bytes';
export interface ServerSummaryDatabases {
rows: any[];
columns: SummaryDatabaseColumn[];
}
export interface ServerSummaryDatabase {}
export type SummaryDatabaseColumn = {
header: string;
fieldName: string;
type: 'data' | 'fileSize';
filterable?: boolean;
sortable?: boolean;
};
export interface ServerSummary {
columns: SummaryColumn[];
databases: ServerSummaryDatabase[];
processes: DatabaseProcess[];
variables: DatabaseVariable[];
databases: ServerSummaryDatabases;
}
export type CollectionAggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
@@ -161,12 +189,12 @@ export interface FilterBehaviourProvider {
getFilterBehaviour(dataType: string, standardFilterBehaviours: { [id: string]: FilterBehaviour }): FilterBehaviour;
}
export interface DatabaseHandle<TClient = any> {
export interface DatabaseHandle<TClient = any, TDataBase = any> {
client: TClient;
database?: string;
conid?: string;
feedback?: (message: any) => void;
getDatabase?: () => any;
getDatabase?: () => TDataBase;
connectionType?: string;
treeKeySeparator?: string;
}
@@ -196,7 +224,7 @@ export interface RestoreDatabaseSettings extends BackupRestoreSettingsBase {
inputFile: string;
}
export interface EngineDriver<TClient = any> extends FilterBehaviourProvider {
export interface EngineDriver<TClient = any, TDataBase = any> extends FilterBehaviourProvider {
engine: string;
title: string;
defaultPort?: number;
@@ -210,6 +238,7 @@ export interface EngineDriver<TClient = any> extends FilterBehaviourProvider {
supportsDatabaseRestore?: boolean;
supportsServerSummary?: boolean;
supportsDatabaseProfiler?: boolean;
supportsIncrementalAnalysis?: boolean;
requiresDefaultSortCriteria?: boolean;
profilerFormatterFunction?: string;
profilerTimestampFunction?: string;
@@ -242,61 +271,88 @@ export interface EngineDriver<TClient = any> extends FilterBehaviourProvider {
defaultSocketPath?: string;
authTypeLabel?: string;
importExportArgs?: any[];
connect({ server, port, user, password, database, connectionDefinition }): Promise<DatabaseHandle<TClient>>;
close(dbhan: DatabaseHandle<TClient>): Promise<any>;
query(dbhan: DatabaseHandle<TClient>, sql: string, options?: QueryOptions): Promise<QueryResult>;
stream(dbhan: DatabaseHandle<TClient>, sql: string, options: StreamOptions);
readQuery(dbhan: DatabaseHandle<TClient>, sql: string, structure?: TableInfo): Promise<StreamResult>;
readJsonQuery(dbhan: DatabaseHandle<TClient>, query: any, structure?: TableInfo): Promise<StreamResult>;
connect({
server,
port,
user,
password,
database,
connectionDefinition,
}): Promise<DatabaseHandle<TClient, TDataBase>>;
close(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<any>;
query(dbhan: DatabaseHandle<TClient, TDataBase>, sql: string, options?: QueryOptions): Promise<QueryResult>;
stream(dbhan: DatabaseHandle<TClient, TDataBase>, sql: string, options: StreamOptions);
readQuery(dbhan: DatabaseHandle<TClient, TDataBase>, sql: string, structure?: TableInfo): Promise<StreamResult>;
readJsonQuery(dbhan: DatabaseHandle<TClient, TDataBase>, query: any, structure?: TableInfo): Promise<StreamResult>;
// eg. PostgreSQL COPY FROM stdin
writeQueryFromStream(dbhan: DatabaseHandle<TClient>, sql: string): Promise<StreamResult>;
writeTable(dbhan: DatabaseHandle<TClient>, name: NamedObjectInfo, options: WriteTableOptions): Promise<StreamResult>;
writeQueryFromStream(dbhan: DatabaseHandle<TClient, TDataBase>, sql: string): Promise<StreamResult>;
writeTable(
dbhan: DatabaseHandle<TClient, TDataBase>,
name: NamedObjectInfo,
options: WriteTableOptions
): Promise<StreamResult>;
analyseSingleObject(
dbhan: DatabaseHandle<TClient>,
dbhan: DatabaseHandle<TClient, TDataBase>,
name: NamedObjectInfo,
objectTypeField: keyof DatabaseInfo
): Promise<TableInfo | ViewInfo | ProcedureInfo | FunctionInfo | TriggerInfo>;
analyseSingleTable(dbhan: DatabaseHandle<TClient>, name: NamedObjectInfo): Promise<TableInfo>;
getVersion(dbhan: DatabaseHandle<TClient>): Promise<{ version: string; versionText?: string }>;
listDatabases(dbhan: DatabaseHandle<TClient>): Promise<
analyseSingleTable(dbhan: DatabaseHandle<TClient, TDataBase>, name: NamedObjectInfo): Promise<TableInfo>;
getVersion(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<{ version: string; versionText?: string }>;
listDatabases(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<
{
name: string;
sizeOnDisk?: number;
empty?: boolean;
}[]
>;
loadKeys(dbhan: DatabaseHandle<TClient>, root: string, filter?: string): Promise;
scanKeys(dbhan: DatabaseHandle<TClient>, root: string, pattern: string, cursor: string, count: number): Promise;
exportKeys(dbhan: DatabaseHandle<TClient>, options: {}): Promise;
loadKeyInfo(dbhan: DatabaseHandle<TClient>, key): Promise;
loadKeyTableRange(dbhan: DatabaseHandle<TClient>, key, cursor, count): Promise;
loadKeys(dbhan: DatabaseHandle<TClient, TDataBase>, root: string, filter?: string): Promise;
scanKeys(
dbhan: DatabaseHandle<TClient, TDataBase>,
root: string,
pattern: string,
cursor: string,
count: number
): Promise;
exportKeys(dbhan: DatabaseHandle<TClient, TDataBase>, options: {}): Promise;
loadKeyInfo(dbhan: DatabaseHandle<TClient, TDataBase>, key): Promise;
loadKeyTableRange(dbhan: DatabaseHandle<TClient, TDataBase>, key, cursor, count): Promise;
loadFieldValues(
dbhan: DatabaseHandle<TClient>,
dbhan: DatabaseHandle<TClient, TDataBase>,
name: NamedObjectInfo,
field: string,
search: string,
dataType: string
): Promise;
analyseFull(dbhan: DatabaseHandle<TClient>, serverVersion): Promise<DatabaseInfo>;
analyseIncremental(dbhan: DatabaseHandle<TClient>, structure: DatabaseInfo, serverVersion): Promise<DatabaseInfo>;
analyseFull(dbhan: DatabaseHandle<TClient, TDataBase>, serverVersion): Promise<DatabaseInfo>;
analyseIncremental(
dbhan: DatabaseHandle<TClient, TDataBase>,
structure: DatabaseInfo,
serverVersion
): Promise<DatabaseInfo>;
dialect: SqlDialect;
dialectByVersion(version): SqlDialect;
createDumper(options = null): SqlDumper;
createBackupDumper(dbhan: DatabaseHandle<TClient>, options): Promise<SqlBackupDumper>;
createBackupDumper(dbhan: DatabaseHandle<TClient, TDataBase>, options): Promise<SqlBackupDumper>;
getAuthTypes(): EngineAuthType[];
readCollection(dbhan: DatabaseHandle<TClient>, options: ReadCollectionOptions): Promise<any>;
updateCollection(dbhan: DatabaseHandle<TClient>, changeSet: any): Promise<any>;
readCollection(dbhan: DatabaseHandle<TClient, TDataBase>, options: ReadCollectionOptions): Promise<any>;
updateCollection(dbhan: DatabaseHandle<TClient, TDataBase>, changeSet: any): Promise<any>;
getCollectionUpdateScript(changeSet: any, collectionInfo: CollectionInfo): string;
createDatabase(dbhan: DatabaseHandle<TClient>, name: string): Promise;
dropDatabase(dbhan: DatabaseHandle<TClient>, name: string): Promise;
createDatabase(dbhan: DatabaseHandle<TClient, TDataBase>, name: string): Promise;
dropDatabase(dbhan: DatabaseHandle<TClient, TDataBase>, name: string): Promise;
getQuerySplitterOptions(usage: 'stream' | 'script' | 'editor' | 'import'): any;
script(dbhan: DatabaseHandle<TClient>, sql: string, options?: RunScriptOptions): Promise;
operation(dbhan: DatabaseHandle<TClient>, operation: CollectionOperationInfo, options?: RunScriptOptions): Promise;
script(dbhan: DatabaseHandle<TClient, TDataBase>, sql: string, options?: RunScriptOptions): Promise;
operation(
dbhan: DatabaseHandle<TClient, TDataBase>,
operation: CollectionOperationInfo,
options?: RunScriptOptions
): Promise;
getNewObjectTemplates(): NewObjectTemplate[];
// direct call of dbhan.client method, only some methods could be supported, on only some drivers
callMethod(dbhan: DatabaseHandle<TClient>, method, args);
serverSummary(dbhan: DatabaseHandle<TClient>): Promise<ServerSummary>;
summaryCommand(dbhan: DatabaseHandle<TClient>, command, row): Promise<void>;
startProfiler(dbhan: DatabaseHandle<TClient>, options): Promise<any>;
stopProfiler(dbhan: DatabaseHandle<TClient>, profiler): Promise<void>;
callMethod(dbhan: DatabaseHandle<TClient, TDataBase>, method, args);
serverSummary(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<ServerSummary>;
summaryCommand(dbhan: DatabaseHandle<TClient, TDataBase>, command, row): Promise<void>;
startProfiler(dbhan: DatabaseHandle<TClient, TDataBase>, options): Promise<any>;
stopProfiler(dbhan: DatabaseHandle<TClient, TDataBase>, profiler): Promise<void>;
getRedirectAuthUrl(connection, options): Promise<{ url: string; sid: string }>;
getAuthTokenFromCode(connection, options): Promise<string>;
getAccessTokenFromAuth(connection, req): Promise<string | null>;
@@ -313,7 +369,10 @@ export interface EngineDriver<TClient = any> extends FilterBehaviourProvider {
adaptTableInfo(table: TableInfo): TableInfo;
// simple data type adapter
adaptDataType(dataType: string): string;
listSchemas(dbhan: DatabaseHandle<TClient>): Promise<SchemaInfo[] | null>;
listSchemas(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<SchemaInfo[] | null>;
listProcesses(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<DatabaseProcess[] | null>;
listVariables(dbhan: DatabaseHandle<TClient, TDataBase>): Promise<DatabaseVariable[] | null>;
killProcess(dbhan: DatabaseHandle<TClient, TDataBase>, pid: number): Promise<any>;
backupDatabaseCommand(
connection: any,
settings: BackupDatabaseSettings,
@@ -337,7 +396,7 @@ export interface EngineDriver<TClient = any> extends FilterBehaviourProvider {
analyserClass?: any;
dumperClass?: any;
singleConnectionOnly?: boolean;
getLogDbInfo(dbhan: DatabaseHandle<TClient>): {
getLogDbInfo(dbhan: DatabaseHandle<TClient, TDataBase>): {
database?: string;
engine: string;
conid?: string;

View File

@@ -96,4 +96,6 @@ export type TestEngineInfo = {
}>;
objects?: Array<TestObjectInfo>;
binaryDataType?: string;
};

View File

@@ -26,12 +26,23 @@
<script lang="javascript">
window.dbgate_page = '{{page}}';
</script>
if (localStorage.getItem('currentThemeType') == 'dark') {
document.documentElement.style.setProperty('--theme-background', '#111');
document.documentElement.style.setProperty('--theme-foreground', '#e3e3e3');
} else {
document.documentElement.style.setProperty('--theme-background', '#fff');
document.documentElement.style.setProperty('--theme-foreground', '#262626');
}
</script>
<script defer src="build/bundle.js"></script>
<style>
body {
background-color: var(--theme-background);
}
.lds-ellipsis {
display: inline-block;
position: relative;
@@ -44,7 +55,7 @@
width: 13px;
height: 13px;
border-radius: 50%;
background: #000;
background: var(--theme-foreground);
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {

View File

@@ -9,6 +9,10 @@
"build:index": "node build-index.js",
"prepublishOnly": "yarn build"
},
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
},
"files": [
"public"
],
@@ -28,7 +32,7 @@
"chartjs-plugin-datalabels": "^2.2.0",
"cross-env": "^7.0.3",
"dbgate-datalib": "^6.0.0-alpha.1",
"dbgate-query-splitter": "^4.11.5",
"dbgate-query-splitter": "^4.11.9",
"dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1",
"dbgate-types": "^6.0.0-alpha.1",
@@ -60,6 +64,9 @@
"uuid": "^3.4.0"
},
"dependencies": {
"@langchain/core": "^0.3.72",
"@langchain/langgraph": "^0.4.9",
"@langchain/openai": "^0.6.9",
"@messageformat/core": "^3.4.0",
"chartjs-plugin-zoom": "^1.2.0",
"date-fns": "^4.1.0",
@@ -71,6 +78,7 @@
"leaflet": "^1.8.0",
"openai": "^5.10.1",
"wellknown": "^0.5.0",
"xml-formatter": "^3.6.4"
"xml-formatter": "^3.6.4",
"zod": "^4.1.5"
}
}

View File

@@ -30,3 +30,20 @@
.color-icon-inv-red {
color: var(--theme-icon-inv-red);
}
.premium-background-gradient {
background: linear-gradient(135deg, #1686c8, #8a25b1);
}
.premium-gradient {
background: linear-gradient(135deg, #1686c8, #8a25b1);
color: white;
}
.web-color-primary {
background: #1686c8;
}
.web-color-secondary {
background: #8a25b1;
}

View File

@@ -37,7 +37,7 @@ export default [
{
input: 'src/query/QueryParserWorker.js',
output: {
sourcemap: true,
sourcemap: !production,
format: 'iife',
file: 'public/build/query-parser-worker.js',
},
@@ -56,7 +56,7 @@ export default [
{
input: 'src/main.ts',
output: {
sourcemap: true,
sourcemap: !production,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js',

View File

@@ -20,14 +20,14 @@
installNewVolatileConnectionListener,
refreshPublicCloudFiles,
} from './utility/api';
import { getConfig, getSettings, getUsedApps } from './utility/metadataLoaders';
import { getAllApps, getConfig, getSettings } 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 } from './clientAuth';
import { initializeAppUpdates } from './utility/appUpdate';
import { _t } from './translations';
import { _t, getCurrentTranslations, saveSelectedLanguageToCache } from './translations';
import { installCloudListeners } from './utility/cloudListeners';
export let isAdminPage = false;
@@ -49,7 +49,7 @@
const connections = await apiCall('connections/list');
const settings = await getSettings();
const apps = await getUsedApps();
const apps = await getAllApps();
const loadedApiValue = !!(settings && connections && config && apps);
if (loadedApiValue) {
@@ -61,6 +61,13 @@
initializeAppUpdates();
installCloudListeners();
refreshPublicCloudFiles();
saveSelectedLanguageToCache(config.preferrendLanguage);
const electron = getElectron();
if (electron) {
electron.send('translation-data', JSON.stringify(getCurrentTranslations()));
global.TRANSLATION_DATA = getCurrentTranslations();
}
}
loadedApi = loadedApiValue;

View File

@@ -14,6 +14,7 @@
import ErrorInfo from './elements/ErrorInfo.svelte';
import { isOneOfPage } from './utility/pageDefs';
import { openWebLink } from './utility/simpleTools';
import FontIcon from './icons/FontIcon.svelte';
const config = useConfig();
const values = writable({ amoid: null, databaseServer: null });
@@ -22,17 +23,15 @@
$: trialDaysLeft = $config?.trialDaysLeft;
let errorMessage = '';
let expiredMessageSet = false;
$: if (isExpired && !expiredMessageSet) {
errorMessage = 'Your license is expired';
expiredMessageSet = true;
}
let isInsertingLicense = false;
$: trialButtonAvailable = !isExpired && trialDaysLeft == null;
// $: console.log('CONFIG', $config);
$: {
if ($config?.isLicenseValid) {
if ($config?.isLicenseValid && trialDaysLeft == null) {
internalRedirectTo(isOneOfPage('admin-license') ? '/admin.html' : '/index.html');
}
}
@@ -41,83 +40,124 @@
<FormProviderCore {values}>
<SpecialPageLayout>
{#if getElectron() || ($config?.storageDatabase && hasPermission('admin/license'))}
<div class="heading">License</div>
<FormTextAreaField label="Enter your license key" name="licenseKey" rows={5} />
<div class="heading">Thank you for using DbGate!</div>
<div class="submit">
<FormSubmit
value="Save license"
on:click={async e => {
sessionStorage.setItem('continueTrialConfirmed', '1');
const { licenseKey } = e.detail;
const resp = await apiCall('config/save-license-key', { licenseKey, tryToRenew: true });
if (resp?.status == 'ok') {
internalRedirectTo(isOneOfPage('admin-license') ? '/admin.html' : '/index.html');
} else {
errorMessage = resp?.errorMessage || 'Error saving license key';
}
}}
/>
</div>
{#if isExpired}
<div class="infotext"><FontIcon icon="img warn" /> Your license has expired. Please insert new license.</div>
{:else if trialDaysLeft > 0}
<div class="infotext">
<FontIcon icon="img warn" /> Your trial period will expire in {trialDaysLeft} day{trialDaysLeft != 1
? 's'
: ''}.
</div>
{:else}
<div class="infotext">
<FontIcon icon="img info" /> Proceed by selecting a licensing option or providing your license key.
</div>
{/if}
{#if !isExpired && trialDaysLeft == null}
{#if isInsertingLicense}
<FormTextAreaField label="Enter your license key" name="licenseKey" rows={5} />
<div class="submit">
<div class="flex flex1">
<div class="col-6 flex">
<FormSubmit
value="Save license"
on:click={async e => {
sessionStorage.setItem('continueTrialConfirmed', '1');
const { licenseKey } = e.detail;
const resp = await apiCall('config/save-license-key', { licenseKey, tryToRenew: true });
if (resp?.status == 'ok') {
internalRedirectTo(isOneOfPage('admin-license') ? '/admin.html' : '/index.html');
} else {
errorMessage = resp?.errorMessage || 'Error saving license key';
}
}}
/>
</div>
<div class="col-6 flex">
<FormStyledButton
value="Cancel"
on:click={() => {
isInsertingLicense = false;
errorMessage = '';
}}
/>
</div>
</div>
</div>
{/if}
{#if !isInsertingLicense}
<div class="submit">
<FormStyledButton
value="Start 30-day trial"
on:click={async e => {
errorMessage = '';
const license = await apiCall('config/start-trial');
if (license?.status == 'ok') {
value="Insert license key"
on:click={() => {
isInsertingLicense = true;
}}
/>
</div>
{#if trialButtonAvailable}
<div class="submit">
<FormStyledButton
value="Start 30-day trial"
on:click={async e => {
errorMessage = '';
const license = await apiCall('config/start-trial');
if (license?.status == 'ok') {
sessionStorage.setItem('continueTrialConfirmed', '1');
internalRedirectTo(isOneOfPage('admin-license') ? '/admin.html' : '/index.html');
} else {
errorMessage = license?.errorMessage || 'Error starting trial';
}
}}
/>
</div>
{/if}
{#if trialDaysLeft > 0}
<div class="submit">
<FormStyledButton
value={`Continue trial (${trialDaysLeft} days left)`}
on:click={async e => {
sessionStorage.setItem('continueTrialConfirmed', '1');
internalRedirectTo(isOneOfPage('admin-license') ? '/admin.html' : '/index.html');
} else {
errorMessage = license?.errorMessage || 'Error starting trial';
}
}}
/>
</div>
{/if}
}}
/>
</div>
{/if}
{#if trialDaysLeft > 0}
<div class="submit">
<FormStyledButton
value={`Continue trial (${trialDaysLeft} days left)`}
value="Purchase DbGate Premium"
on:click={async e => {
sessionStorage.setItem('continueTrialConfirmed', '1');
internalRedirectTo(isOneOfPage('admin-license') ? '/admin.html' : '/index.html');
// openWebLink(
// `https://auth.dbgate.eu/create-checkout-session-simple?source=trial-${isExpired ? 'expired' : (trialDaysLeft ?? 'no')}`
// );
// openWebLink(
// `https://auth-proxy.dbgate.udolni.net/redirect-to-purchase?product=${getElectron() ? 'premium' : 'teram-premium'}&source=trial-${isExpired ? 'expired' : (trialDaysLeft ?? 'no')}`
// );
openWebLink(
`https://auth.dbgate.eu/redirect-to-purchase?product=${getElectron() ? 'premium' : 'team-premium'}&source=trial-${isExpired ? 'expired' : (trialDaysLeft ?? 'no')}`
);
}}
/>
</div>
{/if}
<div class="submit">
<FormStyledButton
value="Purchase DbGate Premium"
on:click={async e => {
// openWebLink(
// `https://auth.dbgate.eu/create-checkout-session-simple?source=trial-${isExpired ? 'expired' : (trialDaysLeft ?? 'no')}`
// );
// openWebLink(
// `https://auth-proxy.dbgate.udolni.net/redirect-to-purchase?product=${getElectron() ? 'premium' : 'teram-premium'}&source=trial-${isExpired ? 'expired' : (trialDaysLeft ?? 'no')}`
// );
openWebLink(
`https://auth.dbgate.eu/redirect-to-purchase?product=${getElectron() ? 'premium' : 'team-premium'}&source=trial-${isExpired ? 'expired' : (trialDaysLeft ?? 'no')}`
);
}}
/>
</div>
{#if getElectron()}
<div class="submit">
<FormStyledButton
value="Exit"
on:click={e => {
getElectron().send('quit-app');
}}
/>
</div>
{#if getElectron()}
<div class="submit">
<FormStyledButton
value="Exit"
on:click={e => {
getElectron().send('quit-app');
}}
/>
</div>
{/if}
{/if}
{#if errorMessage}
@@ -125,8 +165,8 @@
{/if}
<div class="purchase-info">
For more info about DbGate licensing, you could visit <Link href="https://dbgate.eu/">dbgate.eu</Link> web or contact
us at <Link href="mailto:sales@dbgate.eu">sales@dbgate.eu</Link>
For more info about DbGate licensing, you could visit <Link href="https://dbgate.io/">dbgate.io</Link> web or contact
us at <Link href="mailto:sales@dbgate.io">sales@dbgate.io</Link>
</div>
{:else}
<ErrorInfo message="License for DbGate is not valid. Please contact administrator." />
@@ -141,6 +181,10 @@
font-size: xx-large;
}
.infotext {
margin: 1em;
}
.submit {
margin: var(--dim-large-form-margin);
display: flex;

View File

@@ -16,7 +16,7 @@
<div class="heading">Configuration error</div>
{#if $config?.checkedLicense?.status == 'error'}
<ErrorInfo
message={`Invalid license. Please contact sales@dbgate.eu for more details. ${$config?.checkedLicense?.error}`}
message={`Invalid license. Please contact sales@dbgate.io for more details. ${$config?.checkedLicense?.error || ''}`}
/>
{:else if $config?.configurationError}
<ErrorInfo message={$config?.configurationError} />

View File

@@ -0,0 +1,39 @@
<script lang='ts'>
import PermissionCheckBox from './PermissionCheckBox.svelte';
import { getFormContext } from '../forms/FormProviderCore.svelte';
const { values } = getFormContext();
export let onSetPermission;
export let label;
export let folder;
export let disabled = false;
</script>
<PermissionCheckBox
{label}
permission={`files/${folder}/*`}
permissions={$values.permissions}
basePermissions={$values.basePermissions}
{onSetPermission}
{disabled}
/>
<div class="ml-4">
<PermissionCheckBox
label="Read"
permission={`files/${folder}/read`}
permissions={$values.permissions}
basePermissions={$values.basePermissions}
{onSetPermission}
{disabled}
/>
<PermissionCheckBox
label="Write"
permission={`files/${folder}/write`}
permissions={$values.permissions}
basePermissions={$values.basePermissions}
{onSetPermission}
{disabled}
/>
</div>

View File

@@ -0,0 +1 @@
This component is only for Premium edition

View File

@@ -53,14 +53,15 @@
import InputTextModal from '../modals/InputTextModal.svelte';
import ConfirmModal from '../modals/ConfirmModal.svelte';
import { apiCall } from '../utility/api';
import { _t } from '../translations';
export let data;
const handleRename = () => {
showModal(InputTextModal, {
value: data.fileName,
label: 'New file name',
header: 'Rename file',
label: _t('appFile.newFileName', { defaultMessage: 'New file name' }),
header: _t('appFile.renameFile', { defaultMessage: 'Rename file' }),
onConfirm: newFile => {
apiCall('apps/rename-file', {
file: data.fileName,
@@ -74,7 +75,7 @@
const handleDelete = () => {
showModal(ConfirmModal, {
message: `Really delete file ${data.fileName}?`,
message: _t('appFile.deleteFileConfirm', { defaultMessage: 'Really delete file {fileName}?', values: { fileName: data.fileName } }),
onConfirm: () => {
apiCall('apps/delete-file', {
file: data.fileName,
@@ -101,10 +102,10 @@
function createMenu() {
return [
{ text: 'Delete', onClick: handleDelete },
{ text: 'Rename', onClick: handleRename },
data.fileType.endsWith('.sql') && { text: 'Open SQL', onClick: handleOpenSqlFile },
data.fileType.endsWith('.json') && { text: 'Open JSON', onClick: handleOpenJsonFile },
{ text: _t('common.delete', { defaultMessage: 'Delete' }), onClick: handleDelete },
{ text: _t('common.rename', { defaultMessage: 'Rename' }), onClick: handleRename },
data.fileType.endsWith('.sql') && { text: _t('common.openSql', { defaultMessage: 'Open SQL' }), onClick: handleOpenSqlFile },
data.fileType.endsWith('.json') && { text: _t('common.openJson', { defaultMessage: 'Open JSON' }), onClick: handleOpenJsonFile },
// data.fileType.endsWith('.yaml') && { text: 'Open YAML', onClick: handleOpenYamlFile },
];

View File

@@ -15,6 +15,7 @@
import InputTextModal from '../modals/InputTextModal.svelte';
import { apiCall } from '../utility/api';
import { useConnectionList } from '../utility/metadataLoaders';
import { _t } from '../translations';
export let data;
@@ -34,8 +35,8 @@
showModal(InputTextModal, {
value: name,
label: 'New application name',
header: 'Rename application',
label: _t('appFolder.newApplicationName', { defaultMessage: 'New application name' }),
header: _t('appFolder.renameApplication', { defaultMessage: 'Rename application' }),
onConfirm: async newFolder => {
await apiCall('apps/rename-folder', {
folder: data.name,
@@ -60,16 +61,16 @@
function createMenu() {
return [
{ text: 'Delete', onClick: handleDelete },
{ text: 'Rename', onClick: handleRename },
{ text: _t('common.delete', { defaultMessage: 'Delete' }), onClick: handleDelete },
{ text: _t('common.rename', { defaultMessage: 'Rename' }), onClick: handleRename },
$currentDatabase && [
!isOnCurrentDb($currentDatabase, $connections) && {
text: 'Enable on current database',
text: _t('appFolder.enableOnCurrentDatabase', { defaultMessage: 'Enable on current database' }),
onClick: () => setOnCurrentDb(true),
},
isOnCurrentDb($currentDatabase, $connections) && {
text: 'Disable on current database',
text: _t('appFolder.disableOnCurrentDatabase', { defaultMessage: 'Disable on current database' }),
onClick: () => setOnCurrentDb(false),
},
],
@@ -90,7 +91,7 @@
title={data.name}
icon={'img app'}
statusIcon={isOnCurrentDb($currentDatabase, $connections) ? 'icon check' : null}
statusTitle={`Application ${data.name} is used for database ${$currentDatabase?.name}`}
statusTitle={_t('appFolder.applicationUsedForDatabase', { defaultMessage: 'Application {application} is used for database {database}', values: { application: data.name, database: $currentDatabase?.name } })}
isBold={data.name == $currentApplication}
on:click={() => ($currentApplication = data.name)}
menu={createMenu}

View File

@@ -36,6 +36,7 @@
export let filter = null;
export let disableHover = false;
export let divProps = {};
export let additionalIcons = null;
$: isChecked =
checkedObjectsStore && $checkedObjectsStore.find(x => module?.extractKey(data) == module?.extractKey(x));
@@ -160,6 +161,11 @@
/>
</span>
{/if}
{#if additionalIcons}
{#each additionalIcons as ic}
<FontIcon icon={ic.icon} title={ic.title} colorClass={ic.colorClass} />
{/each}
{/if}
{#if extInfo}
<span class="ext-info">
<TokenizedFilteredText text={extInfo} {filter} />

View File

@@ -77,7 +77,7 @@
</div>
{/if}
<div on:drop={handleDrop}>
<div on:drop={handleDrop} data-testid={`app-object-group-items-${_.kebabCase(group)}`}>
{#each items as item}
<AppObjectListItem
isHidden={!item.isMatched}

View File

@@ -8,6 +8,7 @@
import Link from '../elements/Link.svelte';
import { focusedConnectionOrDatabase } from '../stores';
import { tick } from 'svelte';
import { _tval } from '../translations';
export let list;
export let module;
@@ -38,8 +39,19 @@
$: matcher = module.createMatcher && module.createMatcher(filter, passProps?.searchSettings);
$: listTranslated = (list || []).map(data => ({
...data,
group: data?.group && _tval(data.group),
title: data?.title && _tval(data.title),
description: data?.description && _tval(data.description),
args: (data?.args || []).map(x => ({
...x,
label: x?.label && _tval(x.label),
})),
}));
$: dataLabeled = _.compact(
(list || []).map(data => {
(listTranslated || []).map(data => {
const matchResult = matcher ? matcher(data) : true;
let isMatched = true;
@@ -102,7 +114,8 @@
$: groups = groupFunc ? extendGroups(_.groupBy(dataLabeled, 'group'), emptyGroupNames) : null;
$: listLimited = isExpandedBySearch && !expandLimited ? filtered.slice(0, filter.trim().length < 3 ? 1 : 3) : list;
$: listLimited =
isExpandedBySearch && !expandLimited ? filtered.slice(0, filter.trim().length < 3 ? 1 : 3) : listTranslated;
$: isListLimited = isExpandedBySearch && listLimited.length < filtered.length;
$: listMissingItems = isListLimited ? filtered.slice(listLimited.length) : [];

View File

@@ -82,6 +82,7 @@
import { apiCall } from '../utility/api';
import { openImportExportTab } from '../utility/importExportTools';
import { isProApp } from '../utility/proTools';
import { _t } from '../translations';
export let data;
$: isZipped = data.folderName?.endsWith('.zip');
@@ -89,8 +90,8 @@
const handleRename = () => {
showModal(InputTextModal, {
value: data.fileName,
label: 'New file name',
header: 'Rename file',
label: _t('archiveFile.newFileName', { defaultMessage: 'New file name' }),
header: _t('archiveFile.renameFile', { defaultMessage: 'Rename file' }),
onConfirm: newFile => {
apiCall('archive/rename-file', {
file: data.fileName,
@@ -104,7 +105,7 @@
const handleDelete = () => {
showModal(ConfirmModal, {
message: `Really delete file ${data.fileName}?`,
message: _t('archiveFile.deleteFileConfirm', { defaultMessage: 'Really delete file {fileName}?', values: { fileName: data.fileName } }),
onConfirm: () => {
apiCall('archive/delete-file', {
file: data.fileName,
@@ -147,10 +148,10 @@
}
return [
data.fileType == 'jsonl' && { text: 'Open', onClick: handleOpenArchive },
data.fileType == 'jsonl' && { text: 'Open in text editor', onClick: handleOpenJsonLinesText },
!isZipped && { text: 'Delete', onClick: handleDelete },
!isZipped && { text: 'Rename', onClick: handleRename },
data.fileType == 'jsonl' && { text: _t('common.open', { defaultMessage: 'Open' }), onClick: handleOpenArchive },
data.fileType == 'jsonl' && { text: _t('common.openInTextEditor', { defaultMessage: 'Open in text editor' }), onClick: handleOpenJsonLinesText },
!isZipped && { text: _t('common.delete', { defaultMessage: 'Delete' }), onClick: handleDelete },
!isZipped && { text: _t('common.rename', { defaultMessage: 'Rename' }), onClick: handleRename },
data.fileType == 'jsonl' &&
createQuickExportMenu(
fmt => async () => {
@@ -185,19 +186,19 @@
},
}
),
data.fileType.endsWith('.sql') && { text: 'Open SQL', onClick: handleOpenSqlFile },
data.fileType.endsWith('.yaml') && { text: 'Open YAML', onClick: handleOpenYamlFile },
data.fileType.endsWith('.sql') && { text: _t('common.openSql', { defaultMessage: 'Open SQL' }), onClick: handleOpenSqlFile },
data.fileType.endsWith('.yaml') && { text: _t('common.openYaml', { defaultMessage: 'Open YAML' }), onClick: handleOpenYamlFile },
!isZipped &&
isProApp() &&
data.fileType == 'jsonl' && {
text: 'Open in profiler',
text: _t('common.openInProfiler', { defaultMessage: 'Open in profiler' }),
submenu: getExtensions()
.drivers.filter(eng => eng.profilerFormatterFunction)
.map(eng => ({
text: eng.title,
onClick: () => {
openNewTab({
title: 'Profiler',
title: _t('common.profiler', { defaultMessage: 'Profiler' }),
icon: 'img profiler',
tabComponent: 'ProfilerTab',
props: {

View File

@@ -21,14 +21,15 @@
import { isProApp } from '../utility/proTools';
import { extractShellConnection } from '../impexp/createImpExpScript';
import { saveFileToDisk } from '../utility/exportFileTools';
import { _t } from '../translations';
export let data;
const handleDelete = () => {
showModal(ConfirmModal, {
message: data.name.endsWith('.link')
? `Really delete link to folder ${data.name}? Folder content remains untouched.`
: `Really delete folder ${data.name}?`,
? _t('archiveFolder.deleteLinkConfirm', { defaultMessage: 'Really delete link to folder {folderName}? Folder content remains untouched.', values: { folderName: data.name } })
: _t('archiveFolder.deleteFolderConfirm', { defaultMessage: 'Really delete folder {folderName}?', values: { folderName: data.name } }),
onConfirm: () => {
apiCall('archive/delete-folder', { folder: data.name });
},
@@ -42,8 +43,8 @@
showModal(InputTextModal, {
value: name,
label: 'New folder name',
header: 'Rename folder',
label: _t('archiveFolder.newFolderName', { defaultMessage: 'New folder name' }),
header: _t('archiveFolder.renameFolder', { defaultMessage: 'Rename folder' }),
onConfirm: async newFolder => {
await apiCall('archive/rename-folder', {
folder: data.name,
@@ -95,7 +96,7 @@ await dbgateApi.deployDb(${JSON.stringify(
const handleCompareWithCurrentDb = () => {
openNewTab(
{
title: 'Compare',
title: _t('common.compare', { defaultMessage: 'Compare' }),
icon: 'img compare',
tabComponent: 'CompareModelTab',
props: {
@@ -153,7 +154,7 @@ await dbgateApi.deployDb(${JSON.stringify(
});
},
{
formatLabel: 'ZIP files',
formatLabel: _t('common.zipFiles', { defaultMessage: 'ZIP files' }),
formatExtension: 'zip',
defaultFileName: data.name?.endsWith('.zip') ? data.name : data.name + '.zip',
}
@@ -162,28 +163,28 @@ await dbgateApi.deployDb(${JSON.stringify(
function createMenu() {
return [
data.name != 'default' && { text: 'Delete', onClick: handleDelete },
data.name != 'default' && { text: 'Rename', onClick: handleRename },
isProApp() && { text: 'Data deployer', onClick: handleOpenDataDeployTab },
data.name != 'default' && { text: _t('common.delete', { defaultMessage: 'Delete' }), onClick: handleDelete },
data.name != 'default' && { text: _t('common.rename', { defaultMessage: 'Rename' }), onClick: handleRename },
isProApp() && { text: _t('common.dataDeployer', { defaultMessage: 'Data deployer' }), onClick: handleOpenDataDeployTab },
$currentDatabase && [
{ text: 'Generate deploy DB SQL', onClick: handleGenerateDeploySql },
{ text: 'Shell: Deploy DB', onClick: handleGenerateDeployScript },
{ text: _t('archiveFolder.generateDeployDbSql', { defaultMessage: 'Generate deploy DB SQL' }), onClick: handleGenerateDeploySql },
hasPermission(`run-shell-script`) && { text: _t('archiveFolder.shellDeployDb', { defaultMessage: 'Shell: Deploy DB' }), onClick: handleGenerateDeployScript },
],
data.name != 'default' &&
isProApp() &&
data.name.endsWith('.zip') && { text: 'Unpack ZIP', onClick: () => handleZipUnzip('archive/unzip') },
data.name.endsWith('.zip') && { text: _t('archiveFolder.unpackZip', { defaultMessage: 'Unpack ZIP' }), onClick: () => handleZipUnzip('archive/unzip') },
data.name != 'default' &&
isProApp() &&
!data.name.endsWith('.zip') && { text: 'Pack (create ZIP)', onClick: () => handleZipUnzip('archive/zip') },
!data.name.endsWith('.zip') && { text: _t('archiveFolder.packZip', { defaultMessage: 'Pack (create ZIP)' }), onClick: () => handleZipUnzip('archive/zip') },
isProApp() && { text: 'Download ZIP', onClick: handleDownloadZip },
isProApp() && { text: _t('archiveFolder.downloadZip', { defaultMessage: 'Download ZIP' }), onClick: handleDownloadZip },
data.name != 'default' &&
hasPermission('dbops/model/compare') &&
isProApp() &&
_.get($currentDatabase, 'connection._id') && {
onClick: handleCompareWithCurrentDb,
text: `Compare with ${_.get($currentDatabase, 'name')}`,
text: _t('archiveFolder.compareWithCurrentDb', { defaultMessage: 'Compare with {name}', values: { name: _.get($currentDatabase, 'name') } }),
},
];
}

View File

@@ -122,6 +122,7 @@
getOpenedTabs,
openedConnections,
openedSingleDatabaseConnections,
pinnedDatabases,
} from '../stores';
import { filterName, filterNameCompoud } from 'dbgate-tools';
import { showModal } from '../modals/modalTools';
@@ -130,7 +131,7 @@
import openNewTab from '../utility/openNewTab';
import { getDatabaseMenuItems } from './DatabaseAppObject.svelte';
import getElectron from '../utility/getElectron';
import { getDatabaseList, useUsedApps } from '../utility/metadataLoaders';
import { getDatabaseList, useAllApps } from '../utility/metadataLoaders';
import { getLocalStorage } from '../utility/storageCache';
import { apiCall, removeVolatileMapping } from '../utility/api';
import { closeMultipleTabs } from '../tabpanel/TabsPanel.svelte';
@@ -152,6 +153,8 @@
let engineStatusIcon = null;
let engineStatusTitle = null;
$: isPinned = data.singleDatabase && !!$pinnedDatabases.find(x => x?.connection?._id == data?._id);
const electron = getElectron();
const handleConnect = (disableExpand = false) => {
@@ -276,7 +279,7 @@
showModal(InputTextModal, {
header: _t('connection.createDatabase', { defaultMessage: 'Create database' }),
value: 'newdb',
label: _t('connection.databaseName', { defaultMessage: 'Database name' }),
label: _t('connection.database', { defaultMessage: 'Database name' }),
onConfirm: name =>
apiCall('server-connections/create-database', {
conid: data._id,
@@ -382,7 +385,8 @@
$extensions,
$currentDatabase,
$apps,
$openedSingleDatabaseConnections
$openedSingleDatabaseConnections,
data.databasePermissionRole
),
],
@@ -426,7 +430,7 @@
}
}
$: apps = useUsedApps();
$: apps = useAllApps();
</script>
<AppObjectCore
@@ -454,6 +458,19 @@
.find(x => x.isNewQuery)
.onClick();
}}
onPin={!isPinned && data.singleDatabase
? () =>
pinnedDatabases.update(list => [
...list,
{
name: data.defaultDatabase,
connection: data,
},
])
: null}
onUnpin={isPinned && data.singleDatabase
? () => pinnedDatabases.update(list => list.filter(x => x?.connection?._id != data?._id))
: null}
isChoosed={data._id == $focusedConnectionOrDatabase?.conid &&
(data.singleDatabase
? $focusedConnectionOrDatabase?.database == data.defaultDatabase

View File

@@ -46,7 +46,8 @@
$extensions,
$currentDatabase,
$apps,
$openedSingleDatabaseConnections
$openedSingleDatabaseConnections,
databasePermissionRole
) {
const apps = filterAppsForDatabase(connection, name, $apps);
const handleNewQuery = () => {
@@ -404,19 +405,36 @@ await dbgateApi.executeQuery(${JSON.stringify(
});
};
const handleCreateNewApp = () => {
showModal(InputTextModal, {
header: _t('database.newApplication', { defaultMessage: 'New application' }),
label: _t('database.applicationName', { defaultMessage: 'Application name' }),
value: _.startCase(name),
onConfirm: async appName => {
const newAppId = await apiCall('apps/create-app-from-db', {
appName,
server: connection?.server,
database: name,
});
openApplicationEditor(newAppId);
},
});
};
const driver = findEngineDriver(connection, getExtensions());
const commands = _.flatten((apps || []).map(x => x.commands || []));
const commands = _.flatten((apps || []).map(x => Object.values(x.files || {}).filter(x => x.type == 'command')));
const isSqlOrDoc =
driver?.databaseEngineTypes?.includes('sql') || driver?.databaseEngineTypes?.includes('document');
return [
hasPermission(`dbops/query`) && {
onClick: handleNewQuery,
text: _t('database.newQuery', { defaultMessage: 'New query' }),
isNewQuery: true,
},
hasPermission(`dbops/query`) &&
isAllowedDatabaseRunScript(databasePermissionRole) && {
onClick: handleNewQuery,
text: _t('database.newQuery', { defaultMessage: 'New query' }),
isNewQuery: true,
},
hasPermission(`dbops/model/edit`) &&
!connection.isReadOnly &&
driver?.databaseEngineTypes?.includes('sql') && {
@@ -428,8 +446,7 @@ await dbgateApi.executeQuery(${JSON.stringify(
driver?.databaseEngineTypes?.includes('document') && {
onClick: handleNewCollection,
text: _t('database.newCollection', {
defaultMessage: 'New {collectionLabel}',
values: { collectionLabel: driver?.collectionSingularLabel ?? 'collection/container' },
defaultMessage: 'New collection/container'
}),
},
hasPermission(`dbops/query`) &&
@@ -545,12 +562,13 @@ await dbgateApi.executeQuery(${JSON.stringify(
{ divider: true },
driver?.databaseEngineTypes?.includes('sql') &&
hasPermission(`run-shell-script`) &&
hasPermission(`dbops/dropdb`) && {
onClick: handleGenerateDropAllObjectsScript,
text: _t('database.shellDropAllObjects', { defaultMessage: 'Shell: Drop all objects' }),
},
{
hasPermission(`run-shell-script`) && {
onClick: handleGenerateRunScript,
text: _t('database.shellRunScript', { defaultMessage: 'Shell: Run script' }),
},
@@ -561,11 +579,26 @@ await dbgateApi.executeQuery(${JSON.stringify(
text: _t('database.dataDeployer', { defaultMessage: 'Data deployer' }),
},
isProApp() &&
hasPermission(`files/apps/write`) && {
onClick: handleCreateNewApp,
text: _t('database.createNewApplication', { defaultMessage: 'Create new application' }),
},
isProApp() &&
apps?.length > 0 && {
text: _t('database.editApplications', { defaultMessage: 'Edit application' }),
submenu: apps.map((app: any) => ({
text: app.applicationName,
onClick: () => openApplicationEditor(app.appid),
})),
},
{ divider: true },
commands.length > 0 && [
commands.map((cmd: any) => ({
text: cmd.name,
text: cmd.label,
onClick: () => {
showModal(ConfirmSqlModal, {
sql: cmd.sql,
@@ -615,17 +648,17 @@ await dbgateApi.executeQuery(${JSON.stringify(
getConnectionLabel,
} from 'dbgate-tools';
import InputTextModal from '../modals/InputTextModal.svelte';
import { getDatabaseInfo, useUsedApps } from '../utility/metadataLoaders';
import { getDatabaseInfo, useAllApps, useDatabaseInfoPeek } from '../utility/metadataLoaders';
import { openJsonDocument } from '../tabs/JsonTab.svelte';
import { apiCall } from '../utility/api';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
import ConfirmSqlModal, { runOperationOnDatabase, saveScriptToDatabase } from '../modals/ConfirmSqlModal.svelte';
import { filterAppsForDatabase } from '../utility/appTools';
import { filterAppsForDatabase, openApplicationEditor } from '../utility/appTools';
import newQuery from '../query/newQuery';
import ConfirmModal from '../modals/ConfirmModal.svelte';
import { closeMultipleTabs } from '../tabpanel/TabsPanel.svelte';
import NewCollectionModal from '../modals/NewCollectionModal.svelte';
import hasPermission from '../utility/hasPermission';
import hasPermission, { isAllowedDatabaseRunScript } from '../utility/hasPermission';
import { openImportExportTab } from '../utility/importExportTools';
import newTable from '../tableeditor/newTable';
import { loadSchemaList, switchCurrentDatabase } from '../utility/common';
@@ -636,6 +669,7 @@ await dbgateApi.executeQuery(${JSON.stringify(
import { getNumberIcon } from '../icons/FontIcon.svelte';
import { getDatabaseClickActionSetting } from '../settings/settingsTools';
import { _t } from '../translations';
import { tick } from 'svelte';
export let data;
export let passProps;
@@ -647,13 +681,19 @@ await dbgateApi.executeQuery(${JSON.stringify(
$extensions,
$currentDatabase,
$apps,
$openedSingleDatabaseConnections
$openedSingleDatabaseConnections,
data.databasePermissionRole
);
}
$: isPinned = !!$pinnedDatabases.find(x => x?.name == data.name && x?.connection?._id == data.connection?._id);
$: apps = useUsedApps();
$: apps = useAllApps();
$: isLoadingSchemas = $loadingSchemaLists[`${data?.connection?._id}::${data?.name}`];
$: dbInfo = useDatabaseInfoPeek({ conid: data?.connection?._id, database: data?.name });
$: appsForDb = filterAppsForDatabase(data?.connection, data?.name, $apps, $dbInfo);
// $: console.log('AppsForDB:', data?.name, appsForDb);
</script>
<AppObjectCore
@@ -676,6 +716,13 @@ await dbgateApi.executeQuery(${JSON.stringify(
switchCurrentDatabase(data);
}
}}
additionalIcons={appsForDb?.length > 0
? appsForDb.map(ic => ({
icon: ic.applicationIcon || 'img app',
title: ic.applicationName,
colorClass: ic.applicationColor ? `color-icon-${ic.applicationColor}` : undefined,
}))
: null}
on:mousedown={() => {
$focusedConnectionOrDatabase = { conid: data.connection?._id, database: data.name, connection: data.connection };
}}
@@ -697,6 +744,9 @@ await dbgateApi.executeQuery(${JSON.stringify(
).length
)
: ''}
statusIconBefore={data.databasePermissionRole == 'read_content' || data.databasePermissionRole == 'view'
? 'icon lock'
: null}
menu={createMenu}
showPinnedInsteadOfUnpin={passProps?.showPinnedInsteadOfUnpin}
onPin={isPinned ? null : () => pinnedDatabases.update(list => [...list, data])}

View File

@@ -1,10 +1,22 @@
<script lang="ts" context="module">
import { copyTextToClipboard } from '../utility/clipboard';
import { _t, _tval, DefferedTranslationResult } from '../translations';
import sqlFormatter from 'sql-formatter';
export const extractKey = ({ schemaName, pureName }) => (schemaName ? `${schemaName}.${pureName}` : pureName);
export const createMatcher =
(filter, cfg = DEFAULT_OBJECT_SEARCH_SETTINGS) =>
({ schemaName, pureName, objectComment, tableEngine, columns, objectTypeField, tableName, createSql }) => {
({
schemaName,
pureName,
objectComment,
tableEngine,
columns,
objectTypeField,
tableName,
createSql,
tableRowCount,
}) => {
const mainArgs = [];
const childArgs = [];
if (cfg.schemaName) mainArgs.push(schemaName);
@@ -12,6 +24,7 @@
if (objectTypeField == 'tables') {
if (cfg.tableComment) mainArgs.push(objectComment);
if (cfg.tableEngine) mainArgs.push(tableEngine);
if (cfg.tablesWithRows && !tableRowCount) return 'none';
for (const column of columns || []) {
if (cfg.columnName) childArgs.push(column.columnName);
@@ -45,26 +58,26 @@
schedulerEvents: 'icon scheduler-event',
};
const defaultTabs = {
tables: 'TableDataTab',
collections: 'CollectionDataTab',
views: 'ViewDataTab',
matviews: 'ViewDataTab',
queries: 'QueryDataTab',
procedures: 'SqlObjectTab',
functions: 'SqlObjectTab',
triggers: 'SqlObjectTab',
};
// const defaultTabs = {
// tables: 'TableDataTab',
// collections: 'CollectionDataTab',
// views: 'ViewDataTab',
// matviews: 'ViewDataTab',
// queries: 'QueryDataTab',
// procedures: 'SqlObjectTab',
// functions: 'SqlObjectTab',
// triggers: 'SqlObjectTab',
// };
function createScriptTemplatesSubmenu(objectTypeField) {
return {
label: 'SQL template',
label: _t('dbObject.sqlTemplate', { defaultMessage: 'SQL template' }),
submenu: getSupportedScriptTemplates(objectTypeField),
};
}
interface DbObjMenuItem {
label?: string;
label?: string | DefferedTranslationResult;
tab?: string;
forceNewTab?: boolean;
initialData?: any;
@@ -76,7 +89,8 @@
isRename?: boolean;
isTruncate?: boolean;
isCopyTableName?: boolean;
isDuplicateTable?: boolean;
isTableBackup?: boolean;
isTableRestore?: boolean;
isDiagram?: boolean;
functionName?: string;
isExport?: boolean;
@@ -94,6 +108,8 @@
}
function createMenusCore(objectTypeField, driver, data): DbObjMenuItem[] {
const backupMatch = data.objectTypeField === 'tables' ? data.pureName.match(TABLE_BACKUP_REGEX) : null;
switch (objectTypeField) {
case 'tables':
return [
@@ -102,19 +118,19 @@
divider: true,
},
isProApp() && {
label: 'Design query',
label: _t('dbObject.designQuery', { defaultMessage: 'Design query' }),
isQueryDesigner: true,
requiresWriteAccess: true,
},
isProApp() && {
label: 'Design perspective query',
label: _t('dbObject.designPerspectiveQuery', { defaultMessage: 'Design perspective query' }),
tab: 'PerspectiveTab',
forceNewTab: true,
icon: 'img perspective',
},
createScriptTemplatesSubmenu('tables'),
{
label: 'SQL generator',
label: _t('dbObject.sqlGenerator', { defaultMessage: 'SQL generator' }),
submenu: [
{
label: 'CREATE TABLE',
@@ -143,45 +159,52 @@
divider: true,
},
hasPermission('dbops/model/edit') && {
label: 'Drop table',
label: _t('dbObject.dropTable', { defaultMessage: 'Drop table' }),
isDrop: true,
requiresWriteAccess: true,
},
hasPermission('dbops/table/rename') &&
!driver?.dialect.disableRenameTable && {
label: 'Rename table',
label: _t('dbObject.renameTable', { defaultMessage: 'Rename table' }),
isRename: true,
requiresWriteAccess: true,
},
hasPermission('dbops/table/truncate') && {
label: 'Truncate table',
label: _t('dbObject.truncateTable', { defaultMessage: 'Truncate table' }),
isTruncate: true,
requiresWriteAccess: true,
},
{
label: 'Copy table name',
label: _t('dbObject.copyTableName', { defaultMessage: 'Copy table name' }),
isCopyTableName: true,
requiresWriteAccess: false,
},
hasPermission('dbops/table/backup') && {
label: 'Create table backup',
isDuplicateTable: true,
requiresWriteAccess: true,
},
hasPermission('dbops/table/backup') &&
!backupMatch && {
label: _t('dbObject.createTableBackup', { defaultMessage: 'Create table backup' }),
isTableBackup: true,
requiresWriteAccess: true,
},
hasPermission('dbops/table/restore') &&
backupMatch && {
label: _t('dbObject.createRestoreScript', { defaultMessage: 'Create restore script' }),
isTableRestore: true,
requiresWriteAccess: true,
},
hasPermission('dbops/model/view') && {
label: 'Show diagram',
label: _t('dbObject.showDiagram', { defaultMessage: 'Show diagram' }),
isDiagram: true,
},
{
divider: true,
},
hasPermission('dbops/export') && {
label: 'Export',
label: _t('common.export', { defaultMessage: 'Export' }),
functionName: 'tableReader',
isExport: true,
},
hasPermission('dbops/import') && {
label: 'Import',
label: _t('common.import', { defaultMessage: 'Import' }),
isImport: true,
requiresWriteAccess: true,
},
@@ -193,18 +216,18 @@
divider: true,
},
isProApp() && {
label: 'Design query',
label: _t('dbObject.designQuery', { defaultMessage: 'Design query' }),
isQueryDesigner: true,
},
isProApp() && {
label: 'Design perspective query',
label: _t('dbObject.designPerspectiveQuery', { defaultMessage: 'Design perspective query' }),
tab: 'PerspectiveTab',
forceNewTab: true,
icon: 'img perspective',
},
createScriptTemplatesSubmenu('views'),
{
label: 'SQL generator',
label: _t('dbObject.sqlGenerator', { defaultMessage: 'SQL generator' }),
submenu: [
{
label: 'CREATE VIEW',
@@ -224,12 +247,12 @@
divider: true,
},
hasPermission('dbops/model/edit') && {
label: 'Drop view',
label: _t('dbObject.dropView', { defaultMessage: 'Drop view' }),
isDrop: true,
requiresWriteAccess: true,
},
hasPermission('dbops/model/edit') && {
label: 'Rename view',
label: _t('dbObject.renameView', { defaultMessage: 'Rename view' }),
isRename: true,
requiresWriteAccess: true,
},
@@ -237,7 +260,7 @@
divider: true,
},
{
label: 'Export',
label: _t('common.export', { defaultMessage: 'Export' }),
isExport: true,
functionName: 'tableReader',
},
@@ -249,12 +272,12 @@
divider: true,
},
hasPermission('dbops/model/edit') && {
label: 'Drop view',
label: _t('dbObject.dropView', { defaultMessage: 'Drop view' }),
isDrop: true,
requiresWriteAccess: true,
},
hasPermission('dbops/model/edit') && {
label: 'Rename view',
label: _t('dbObject.renameView', { defaultMessage: 'Rename view' }),
isRename: true,
requiresWriteAccess: true,
},
@@ -262,12 +285,12 @@
divider: true,
},
{
label: 'Query designer',
label: _t('dbObject.queryDesigner', { defaultMessage: 'Query designer' }),
isQueryDesigner: true,
},
createScriptTemplatesSubmenu('matviews'),
{
label: 'SQL generator',
label: _t('dbObject.sqlGenerator', { defaultMessage: 'SQL generator' }),
submenu: [
{
label: 'CREATE MATERIALIZED VIEW',
@@ -287,7 +310,7 @@
divider: true,
},
{
label: 'Export',
label: _t('common.export', { defaultMessage: 'Export' }),
isExport: true,
functionName: 'tableReader',
},
@@ -295,7 +318,7 @@
case 'queries':
return [
{
label: 'Open data',
label: _t('dbObject.openData', { defaultMessage: 'Open data' }),
tab: 'QueryDataTab',
forceNewTab: true,
},
@@ -307,18 +330,18 @@
divider: true,
},
hasPermission('dbops/model/edit') && {
label: 'Drop procedure',
label: _t('dbObject.dropProcedure', { defaultMessage: 'Drop procedure' }),
isDrop: true,
requiresWriteAccess: true,
},
hasPermission('dbops/model/edit') && {
label: 'Rename procedure',
label: _t('dbObject.renameProcedure', { defaultMessage: 'Rename procedure' }),
isRename: true,
requiresWriteAccess: true,
},
createScriptTemplatesSubmenu('procedures'),
{
label: 'SQL generator',
label: _t('dbObject.sqlGenerator', { defaultMessage: 'SQL generator' }),
submenu: [
{
label: 'CREATE PROCEDURE',
@@ -341,7 +364,7 @@
return [
...defaultDatabaseObjectAppObjectActions['triggers'],
hasPermission('dbops/model/edit') && {
label: 'Drop trigger',
label: _t('dbObject.dropTrigger', { defaultMessage: 'Drop trigger' }),
isDrop: true,
requiresWriteAccess: true,
},
@@ -349,7 +372,7 @@
divider: true,
},
{
label: 'SQL generator',
label: _t('dbObject.sqlGenerator', { defaultMessage: 'SQL generator' }),
submenu: [
{
label: 'CREATE TRIGGER',
@@ -373,28 +396,28 @@
divider: true,
},
isProApp() && {
label: 'Design perspective query',
label: _t('dbObject.designPerspectiveQuery', { defaultMessage: 'Design perspective query' }),
tab: 'PerspectiveTab',
forceNewTab: true,
icon: 'img perspective',
},
hasPermission('dbops/export') && {
label: 'Export',
label: _t('common.export', { defaultMessage: 'Export' }),
isExport: true,
functionName: 'tableReader',
},
hasPermission('dbops/model/edit') && {
label: `Drop ${driver?.collectionSingularLabel ?? 'collection/container'}`,
label: _t('dbObject.dropCollection', { defaultMessage: 'Drop collection/container' }),
isDropCollection: true,
requiresWriteAccess: true,
},
hasPermission('dbops/table/rename') && {
label: `Rename ${driver?.collectionSingularLabel ?? 'collection/container'}`,
label: _t('dbObject.renameCollection', { defaultMessage: 'Rename collection/container' }),
isRenameCollection: true,
requiresWriteAccess: true,
},
hasPermission('dbops/table/backup') && {
label: `Create ${driver?.collectionSingularLabel ?? 'collection/container'} backup`,
label: _t('dbObject.createCollectionBackup', { defaultMessage: 'Create collection/container backup' }),
isDuplicateCollection: true,
requiresWriteAccess: true,
},
@@ -407,7 +430,7 @@
const menu: DbObjMenuItem[] = [
...defaultDatabaseObjectAppObjectActions['schedulerEvents'],
hasPermission('dbops/model/edit') && {
label: 'Drop event',
label: _t('dbObject.dropEvent', { defaultMessage: 'Drop event' }),
isDrop: true,
requiresWriteAccess: true,
},
@@ -415,12 +438,12 @@
if (data?.status === 'ENABLED') {
menu.push({
label: 'Disable',
label: _t('dbObject.disable', { defaultMessage: 'Disable' }),
isDisableEvent: true,
});
} else {
menu.push({
label: 'Enable',
label: _t('dbObject.enable', { defaultMessage: 'Enable' }),
isEnableEvent: true,
});
}
@@ -430,7 +453,7 @@
divider: true,
},
{
label: 'SQL generator',
label: _t('dbObject.sqlGenerator', { defaultMessage: 'SQL generator' }),
submenu: [
{
label: 'CREATE SCHEDULER EVENT',
@@ -463,7 +486,7 @@
if (menu.isQueryDesigner) {
openNewTab(
{
title: 'Query #',
title: _t('dbObject.query', { defaultMessage: 'Query #' }),
icon: 'img query-design',
tabComponent: 'QueryDesignTab',
focused: true,
@@ -488,7 +511,7 @@
} else if (menu.isDiagram) {
openNewTab(
{
title: 'Diagram #',
title: _t('dbObject.diagram', { defaultMessage: 'Diagram #' }),
icon: 'img diagram',
tabComponent: 'DiagramTab',
props: {
@@ -578,7 +601,10 @@
});
} else if (menu.isDropCollection) {
showModal(ConfirmModal, {
message: `Really drop collection ${data.pureName}?`,
message: _t('dbObject.confirmDropCollection', {
defaultMessage: 'Really drop collection {name}?',
values: { name: data.pureName },
}),
onConfirm: async () => {
const dbid = _.pick(data, ['conid', 'database']);
runOperationOnDatabase(dbid, {
@@ -592,8 +618,8 @@
} else if (menu.isRenameCollection) {
const driver = await getDriver();
showModal(InputTextModal, {
label: `New ${driver?.collectionSingularLabel ?? 'collection/container'} name`,
header: `Rename ${driver?.collectionSingularLabel ?? 'collection/container'}`,
label: _t('dbObject.newCollectionName', { defaultMessage: 'New collection/container name' }),
header: _t('dbObject.renameCollection', { defaultMessage: 'Rename collection/container' }),
value: data.pureName,
onConfirm: async newName => {
const dbid = _.pick(data, ['conid', 'database']);
@@ -609,7 +635,10 @@
const driver = await getDriver();
showModal(ConfirmModal, {
message: `Really create ${driver?.collectionSingularLabel ?? 'collection/container'} copy named ${newName}?`,
message: _t('dbObject.confirmCloneCollection', {
defaultMessage: 'Really create collection/container copy named {name}?',
values: { name: newName },
}),
onConfirm: async () => {
const dbid = _.pick(data, ['conid', 'database']);
runOperationOnDatabase(dbid, {
@@ -619,7 +648,7 @@
});
},
});
} else if (menu.isDuplicateTable) {
} else if (menu.isTableBackup) {
const driver = await getDriver();
const dmp = driver.createDumper();
const newTable = _.cloneDeep(data);
@@ -653,6 +682,25 @@
},
engine: driver.engine,
});
} else if (menu.isTableRestore) {
const backupMatch = data.objectTypeField === 'tables' ? data.pureName.match(TABLE_BACKUP_REGEX) : null;
const driver = await getDriver();
const dmp = driver.createDumper();
const db = await getDatabaseInfo(data);
if (db) {
const originalTable = db?.tables?.find(x => x.pureName == backupMatch[1] && x.schemaName == data.schemaName);
if (originalTable) {
createTableRestoreScript(data, originalTable, dmp);
newQuery({
title: _t('dbObject.restoreScript', {
defaultMessage: 'Restore {name} #',
values: { name: backupMatch[1] },
}),
initialData: sqlFormatter.format(dmp.s),
});
}
}
} else if (menu.isImport) {
const { conid, database } = data;
openImportExportTab({
@@ -703,15 +751,30 @@
}
function createMenus(objectTypeField, driver, data): ReturnType<typeof createMenusCore> {
return createMenusCore(objectTypeField, driver, data).filter(x => {
if (x.scriptTemplate) {
return hasPermission(`dbops/sql-template/${x.scriptTemplate}`);
const coreMenus = createMenusCore(objectTypeField, driver, data);
const filteredSumenus = coreMenus.map(item => {
if (!item) return item;
if (!item.submenu) {
return { ...item, label: _tval(item.label) };
}
if (x.sqlGeneratorProps) {
return hasPermission(`dbops/sql-generator`);
}
return true;
return {
...item,
submenu: item.submenu.filter(x => {
if (x.scriptTemplate) {
return hasPermission(`dbops/sql-template/${x.scriptTemplate}`);
}
if (x.sqlGeneratorProps) {
return hasPermission(`dbops/sql-generator`);
}
return true;
}),
};
});
const filteredNoEmptySubmenus = _.compact(filteredSumenus).filter(x => !x.submenu || x.submenu.length > 0);
return filteredNoEmptySubmenus;
}
function getObjectTitle(connection, schemaName, pureName) {
@@ -727,7 +790,7 @@
export async function openDatabaseObjectDetail(
tabComponent,
scriptTemplate,
{ schemaName, pureName, conid, database, objectTypeField, defaultActionId, isRawMode },
{ schemaName, pureName, conid, database, objectTypeField, defaultActionId, isRawMode, sql },
forceNewTab?,
initialData?,
icon?,
@@ -743,7 +806,9 @@
openNewTab(
{
// title: getObjectTitle(connection, schemaName, pureName),
title: tabComponent ? getObjectTitle(connection, schemaName, pureName) : 'Query #',
title: tabComponent
? getObjectTitle(connection, schemaName, pureName)
: _t('dbObject.query', { defaultMessage: 'Query #' }),
focused: tabComponent == null,
tooltip,
icon:
@@ -762,6 +827,7 @@
initialArgs: scriptTemplate ? { scriptTemplate } : null,
defaultActionId,
isRawMode,
sql,
},
},
initialData,
@@ -783,7 +849,7 @@
data,
{ forceNewTab = false, tabPreviewMode = false, focusTab = false } = {}
) {
const { schemaName, pureName, conid, database, objectTypeField } = data;
const { schemaName, pureName, conid, database, objectTypeField, sql } = data;
const driver = findEngineDriver(data, getExtensions());
const activeTab = getActiveTab();
@@ -829,6 +895,7 @@
objectTypeField,
defaultActionId: prefferedAction.defaultActionId,
isRawMode: prefferedAction?.isRawMode ?? false,
sql,
},
forceNewTab,
prefferedAction?.initialData,
@@ -970,6 +1037,8 @@
return handleDatabaseObjectClick(data, { forceNewTab, tabPreviewMode, focusTab });
}
export const TABLE_BACKUP_REGEX = /^_(.*)_(\d\d\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)-(\d\d)$/;
</script>
<script lang="ts">
@@ -987,7 +1056,7 @@
} from '../stores';
import openNewTab from '../utility/openNewTab';
import { extractDbNameFromComposite, filterNameCompoud, getConnectionLabel } from 'dbgate-tools';
import { getConnectionInfo } from '../utility/metadataLoaders';
import { getConnectionInfo, getDatabaseInfo } from '../utility/metadataLoaders';
import fullDisplayName from '../utility/fullDisplayName';
import { showModal } from '../modals/modalTools';
import { findEngineDriver } from 'dbgate-tools';
@@ -1008,6 +1077,9 @@
import { getSupportedScriptTemplates } from '../utility/applyScriptTemplate';
import { getBoolSettingsValue, getOpenDetailOnArrowsSettings } from '../settings/settingsTools';
import { isProApp } from '../utility/proTools';
import formatFileSize from '../utility/formatFileSize';
import { createTableRestoreScript } from '../utility/tableRestoreScript';
import newQuery from '../query/newQuery';
export let data;
export let passProps;
@@ -1036,6 +1108,9 @@
if (data.tableRowCount != null) {
res.push(`${formatRowCount(data.tableRowCount)} rows`);
}
if (data.sizeBytes) {
res.push(formatFileSize(data.sizeBytes));
}
if (data.tableEngine) {
res.push(data.tableEngine);
}
@@ -1044,14 +1119,21 @@
}
$: isPinned = !!$pinnedTables.find(x => testEqual(data, x));
$: backupParsed = data.objectTypeField === 'tables' ? data.pureName.match(TABLE_BACKUP_REGEX) : null;
$: backupTitle =
backupParsed != null
? `${backupParsed[1]} (${backupParsed[2]}-${backupParsed[3]}-${backupParsed[4]} ${backupParsed[5]}:${backupParsed[6]}:${backupParsed[7]})`
: null;
</script>
<AppObjectCore
{...$$restProps}
module={$$props.module}
{data}
title={data.schemaName && !passProps?.hideSchemaName ? `${data.schemaName}.${data.pureName}` : data.pureName}
icon={databaseObjectIcons[data.objectTypeField]}
title={backupTitle ??
(data.schemaName && !passProps?.hideSchemaName ? `${data.schemaName}.${data.pureName}` : data.pureName)}
icon={backupParsed ? 'img table-backup' : databaseObjectIcons[data.objectTypeField]}
menu={createMenu}
showPinnedInsteadOfUnpin={passProps?.showPinnedInsteadOfUnpin}
onPin={passProps?.ingorePin ? null : isPinned ? null : () => pinnedTables.update(list => [...list, data])}
@@ -1062,6 +1144,7 @@
: null}
extInfo={getExtInfo(data)}
isChoosed={matchDatabaseObjectAppObject($selectedDatabaseObjectAppObject, data)}
statusIconBefore={data.tablePermissionRole == 'read' ? 'icon lock' : null}
on:click={() => handleObjectClick(data, 'leftClick')}
on:middleclick={() => handleObjectClick(data, 'middleClick')}
on:dblclick={() => handleObjectClick(data, 'dblClick')}

View File

@@ -142,6 +142,18 @@
label: 'Model transform file',
};
const apps: FileTypeHandler = isProApp()
? {
icon: 'img app',
format: 'json',
tabComponent: 'AppEditorTab',
folder: 'apps',
currentConnection: false,
extension: 'json',
label: 'Application file',
}
: undefined;
export const SAVED_FILE_HANDLERS = {
sql,
shell,
@@ -154,6 +166,7 @@
modtrans,
datadeploy,
dbcompare,
apps,
};
export const extractKey = data => data.file;
@@ -179,6 +192,8 @@
import { isProApp } from '../utility/proTools';
import { saveFileToDisk } from '../utility/exportFileTools';
import { getConnectionInfo } from '../utility/metadataLoaders';
import { showSnackbarError } from '../utility/snackbar';
import { _t } from '../translations';
export let data;
@@ -200,20 +215,30 @@
function createMenu() {
return [
handler?.tabComponent && { text: 'Open', onClick: openTab },
hasPermission(`files/${data.folder}/write`) && { text: 'Rename', onClick: handleRename },
hasPermission(`files/${data.folder}/write`) && { text: 'Create copy', onClick: handleCopy },
hasPermission(`files/${data.folder}/write`) && { text: 'Delete', onClick: handleDelete },
folder == 'markdown' && { text: 'Show page', onClick: showMarkdownPage },
{ text: 'Download', onClick: handleDownload },
handler?.tabComponent && { text: _t('common.open', { defaultMessage: 'Open' }), onClick: openTab },
!data.teamFileId && hasPermission(`files/${data.folder}/write`) && { text: _t('common.rename', { defaultMessage: 'Rename' }), onClick: handleRename },
!data.teamFileId && hasPermission(`files/${data.folder}/write`) && { text: _t('common.createCopy', { defaultMessage: 'Create copy' }), onClick: handleCopy },
!data.teamFileId && hasPermission(`files/${data.folder}/write`) && { text: _t('common.delete', { defaultMessage: 'Delete' }), onClick: handleDelete },
data.teamFileId && data.allowWrite && { text: _t('common.rename', { defaultMessage: 'Rename' }), onClick: handleRename },
data.teamFileId &&
data.allowRead &&
hasPermission('all-team-files/create') && { text: _t('common.createCopy', { defaultMessage: 'Create copy' }), onClick: handleCopy },
data.teamFileId && data.allowWrite && { text: _t('common.delete', { defaultMessage: 'Delete' }), onClick: handleDelete },
folder == 'markdown' && { text: _t('common.showPage', { defaultMessage: 'Show page' }), onClick: showMarkdownPage },
!data.teamFileId && { text: _t('common.download', { defaultMessage: 'Download' }), onClick: handleDownload },
data.teamFileId && data.allowRead && { text: _t('common.download', { defaultMessage: 'Download' }), onClick: handleDownload },
];
}
const handleDelete = () => {
showModal(ConfirmModal, {
message: `Really delete file ${data.file}?`,
message: _t('common.reallyDeleteFile', { defaultMessage: 'Really delete file {file}?', values: { file: data.file } }),
onConfirm: () => {
if (data.folid && data.cntid) {
if (data.teamFileId) {
apiCall('team-files/delete', { teamFileId: data.teamFileId });
} else if (data.folid && data.cntid) {
apiCall('cloud/delete-content', {
folid: data.folid,
cntid: data.cntid,
@@ -228,10 +253,12 @@
const handleRename = () => {
showModal(InputTextModal, {
value: data.file,
label: 'New file name',
header: 'Rename file',
label: _t('common.newFileName', { defaultMessage: 'New file name' }),
header: _t('common.renameFile', { defaultMessage: 'Rename file' }),
onConfirm: newFile => {
if (data.folid && data.cntid) {
if (data.teamFileId) {
apiCall('team-files/update', { teamFileId: data.teamFileId, name: newFile });
} else if (data.folid && data.cntid) {
apiCall('cloud/rename-content', {
folid: data.folid,
cntid: data.cntid,
@@ -247,10 +274,12 @@
const handleCopy = () => {
showModal(InputTextModal, {
value: data.file,
label: 'New file name',
header: 'Copy file',
label: _t('savedFile.newFileName', { defaultMessage: 'New file name' }),
header: _t('savedFile.copyFile', { defaultMessage: 'Copy file' }),
onConfirm: newFile => {
if (data.folid && data.cntid) {
if (data.teamFileId) {
apiCall('team-files/copy', { teamFileId: data.teamFileId, newName: newFile });
} else if (data.folid && data.cntid) {
apiCall('cloud/copy-file', {
folid: data.folid,
cntid: data.cntid,
@@ -266,7 +295,12 @@
const handleDownload = () => {
saveFileToDisk(
async filePath => {
if (data.folid && data.cntid) {
if (data.teamFileId) {
await apiCall('team-files/export-file', {
teamFileId: data.teamFileId,
filePath,
});
} else if (data.folid && data.cntid) {
await apiCall('cloud/export-file', {
folid: data.folid,
cntid: data.cntid,
@@ -286,7 +320,23 @@
async function openTab() {
let dataContent;
if (data.folid && data.cntid) {
if (data.teamFileId) {
if (data?.metadata?.autoExecute) {
if (!data.allowUse) {
showSnackbarError(_t('savedFile.noPermissionUseTeamFile', { defaultMessage: 'You do not have permission to use this team file' }));
return;
}
} else {
if (!data.allowRead) {
showSnackbarError(_t('savedFile.noPermissionReadTeamFile', { defaultMessage: 'You do not have permission to read this team file' }));
return;
}
}
const resp = await apiCall('team-files/get-content', {
teamFileId: data.teamFileId,
});
dataContent = resp.content;
} else if (data.folid && data.cntid) {
const resp = await apiCall('cloud/get-content', {
folid: data.folid,
cntid: data.cntid,
@@ -311,6 +361,11 @@
tooltip = `${getConnectionLabel(connection)}\n${database}`;
}
if (data?.metadata?.connectionId) {
connProps.conid = data.metadata.connectionId;
connProps.database = data.metadata.databaseName;
}
openNewTab(
{
title: data.file,
@@ -323,6 +378,8 @@
savedFormat: handler.format,
savedCloudFolderId: data.folid,
savedCloudContentId: data.cntid,
savedTeamFileId: data.teamFileId,
hideEditor: data.teamFileId && data?.metadata?.autoExecute && !data.allowRead,
...connProps,
},
},

View File

@@ -1,3 +1,4 @@
import { __t } from '../translations';
export function matchDatabaseObjectAppObject(obj1, obj2) {
return (
obj1?.objectTypeField == obj2?.objectTypeField &&
@@ -11,12 +12,12 @@ export function matchDatabaseObjectAppObject(obj1, obj2) {
function getTableLikeActions(dataTab) {
return [
{
label: 'Open data',
label: __t('dbObject.openData', { defaultMessage: 'Open data' }),
tab: dataTab,
defaultActionId: 'openTable',
},
{
label: 'Open raw data',
label: __t('dbObject.openRawData', { defaultMessage: 'Open raw data' }),
tab: dataTab,
defaultActionId: 'openRawTable',
isRawMode: true,
@@ -33,13 +34,13 @@ function getTableLikeActions(dataTab) {
// defaultActionId: 'openForm',
// },
{
label: 'Open structure',
label: __t('dbObject.openStructure', { defaultMessage: 'Open structure' }),
tab: 'TableStructureTab',
icon: 'img table-structure',
defaultActionId: 'openStructure',
},
{
label: 'Show SQL',
label: __t('dbObject.showSql', { defaultMessage: 'Show SQL' }),
tab: 'SqlObjectTab',
defaultActionId: 'showSql',
icon: 'img sql-file',
@@ -53,7 +54,7 @@ export const defaultDatabaseObjectAppObjectActions = {
matviews: getTableLikeActions('ViewDataTab'),
procedures: [
{
label: 'Show SQL',
label: __t('dbObject.showSql', { defaultMessage: 'Show SQL' }),
tab: 'SqlObjectTab',
defaultActionId: 'showSql',
icon: 'img sql-file',
@@ -61,7 +62,7 @@ export const defaultDatabaseObjectAppObjectActions = {
],
functions: [
{
label: 'Show SQL',
label: __t('dbObject.showSql', { defaultMessage: 'Show SQL' }),
tab: 'SqlObjectTab',
defaultActionId: 'showSql',
icon: 'img sql-file',
@@ -69,7 +70,7 @@ export const defaultDatabaseObjectAppObjectActions = {
],
triggers: [
{
label: 'Show SQL',
label: __t('dbObject.showSql', { defaultMessage: 'Show SQL' }),
tab: 'SqlObjectTab',
defaultActionId: 'showSql',
icon: 'img sql-file',
@@ -77,12 +78,12 @@ export const defaultDatabaseObjectAppObjectActions = {
],
collections: [
{
label: 'Open data',
label: __t('dbObject.openData', { defaultMessage: 'Open data' }),
tab: 'CollectionDataTab',
defaultActionId: 'openTable',
},
{
label: 'Open JSON',
label: __t('dbObject.openJson', { defaultMessage: 'Open JSON' }),
tab: 'CollectionDataTab',
defaultActionId: 'openJson',
initialData: {
@@ -94,10 +95,18 @@ export const defaultDatabaseObjectAppObjectActions = {
],
schedulerEvents: [
{
label: 'Show SQL',
label: __t('dbObject.showSql', { defaultMessage: 'Show SQL' }),
tab: 'SqlObjectTab',
defaultActionId: 'showSql',
icon: 'img sql-file',
},
],
queries: [
{
label: __t('dbObject.showQuery', { defaultMessage: 'Show query' }),
tab: 'QueryDataTab',
defaultActionId: 'showAppQuery',
icon: 'img app-query',
},
],
};

View File

@@ -0,0 +1,46 @@
<script lang="ts">
export let disabled = false;
export let title = null;
let domButton;
export function getBoundingClientRect() {
return domButton.getBoundingClientRect();
}
</script>
<button
class="cta-button"
{title}
{disabled}
on:click
bind:this={domButton}
data-testid={$$props['data-testid']}
>
<slot />
</button>
<style>
.cta-button {
background: none;
border: none;
padding: 0;
margin: 0;
color: var(--theme-font-link);
text-decoration: underline;
cursor: pointer;
font-size: inherit;
font-family: inherit;
display: inline;
}
.cta-button:hover:not(:disabled) {
color: var(--theme-font-hover);
}
.cta-button:disabled {
color: var(--theme-font-3);
cursor: not-allowed;
text-decoration: none;
}
</style>

View File

@@ -9,6 +9,7 @@
export let title = null;
export let skipWidth = false;
export let outline = false;
export let colorClass = '';
function handleClick() {
if (!disabled) dispatch('click');
@@ -31,6 +32,8 @@
bind:this={domButton}
class:skipWidth
class:outline
class={colorClass}
class:setBackgroundColor={!colorClass}
/>
<style>
@@ -38,19 +41,26 @@
border: 1px solid var(--theme-bg-button-inv-2);
padding: 5px;
margin: 2px;
background-color: var(--theme-bg-button-inv);
color: var(--theme-font-inv-1);
border-radius: 2px;
}
.setBackgroundColor {
background-color: var(--theme-bg-button-inv);
}
input:not(.skipWidth) {
width: 100px;
}
input:hover:not(.disabled):not(.outline) {
input:not(.setBackgroundColor) {
cursor: pointer;
}
input.setBackgroundColor:hover:not(.disabled):not(.outline) {
background-color: var(--theme-bg-button-inv-2);
}
input:active:not(.disabled):not(.outline) {
input.setBackgroundColor:active:not(.disabled):not(.outline) {
background-color: var(--theme-bg-button-inv-3);
}
input.disabled {

View File

@@ -3,6 +3,7 @@
export let square = false;
export let narrow = false;
export let title = null;
export let inlineBlock=false;
let domButton;
@@ -17,6 +18,7 @@
class:disabled
class:square
class:narrow
class:inlineBlock
on:click
bind:this={domButton}
data-testid={$$props['data-testid']}
@@ -71,4 +73,8 @@
.square {
width: 18px;
}
.inlineBlock {
display: inline-block;
}
</style>

View File

@@ -5,6 +5,7 @@
import getElectron from '../utility/getElectron';
import InlineButtonLabel from '../buttons/InlineButtonLabel.svelte';
import resolveApi, { resolveApiHeaders } from '../utility/resolveApi';
import { _t } from '../translations';
import uuidv1 from 'uuid/v1';
@@ -49,11 +50,11 @@
</script>
{#if electron}
<InlineButton on:click={handleOpenElectronFile} title="Open file" data-testid={$$props['data-testid']}>
<InlineButton on:click={handleOpenElectronFile} title={_t('files.openFile', { defaultMessage: "Open file" })} data-testid={$$props['data-testid']}>
<FontIcon {icon} />
</InlineButton>
{:else}
<InlineButtonLabel on:click={() => {}} title="Upload file" data-testid={$$props['data-testid']} htmlFor={inputId}>
<InlineButtonLabel on:click={() => {}} title={_t('files.uploadFile', { defaultMessage: "Upload file" })} data-testid={$$props['data-testid']} htmlFor={inputId}>
<FontIcon {icon} />
</InlineButtonLabel>
{/if}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FontIcon from '../icons/FontIcon.svelte';
import { isProApp } from '../utility/proTools';
import { _t } from '../translations';
export let icon;
export let title;
@@ -21,7 +22,7 @@
data-testid={$$props['data-testid']}
title={disabled
? isProFeature && !isProApp()
? 'This feature is available only in DbGate Premium'
? _t('common.featurePremium', { defaultMessage: 'This feature is available only in DbGate Premium' })
: disabledMessage
: undefined}
>

View File

@@ -1,6 +1,6 @@
<script context="module">
function getCommandTitle(command) {
let res = command.text;
let res = _tval(command.text);
if (command.keyText || command.keyTextFromGroup) {
res += ` (${formatKeyText(command.keyText || command.keyTextFromGroup)})`;
}
@@ -12,6 +12,8 @@
import { commandsCustomized } from '../stores';
import { formatKeyText } from '../utility/common';
import ToolStripButton from './ToolStripButton.svelte';
import _ from 'lodash';
import { _tval } from '../translations';
export let command;
export let component = ToolStripButton;
@@ -32,6 +34,6 @@
{iconAfter}
{...$$restProps}
>
{buttonLabel || cmd.toolbarName || cmd.name}
{(_tval(buttonLabel) || _tval(cmd?.toolbarName) || _tval(cmd?.name))}
</svelte:component>
{/if}

View File

@@ -4,15 +4,17 @@
const thisInstance = get_current_component();
export const activator = createActivator('ToolStripContainer', true);
$: isComponentActive = $isComponentActiveStore('ToolStripContainer', thisInstance);
export let showAlways = false;
export const activator = showAlways ? null : createActivator('ToolStripContainer', true);
export function activate() {
activator?.activate();
}
export let scrollContent = false;
export let hideToolStrip = false;
$: isComponentActive = showAlways || ($isComponentActiveStore('ToolStripContainer', thisInstance) && !hideToolStrip);
</script>
<div class="wrapper">

View File

@@ -20,7 +20,7 @@
}
</script>
<svelte:component this={component} {title} {icon} on:click={handleClick}>
<svelte:component this={component} {title} {icon} on:click={handleClick} {...$$restProps}>
{label}
<FontIcon icon="icon chevron-down" />
</svelte:component>

View File

@@ -23,7 +23,8 @@
import hasPermission from '../utility/hasPermission';
import ToolStripCommandButton from './ToolStripCommandButton.svelte';
import ToolStripDropDownButton from './ToolStripDropDownButton.svelte';
import _ from 'lodash';
import { _tval } from '../translations';
export let quickExportHandlerRef = null;
export let command = 'sqlDataGrid.export';
export let label = 'Export';
@@ -39,7 +40,7 @@
{#if hasPermission('dbops/export')}
{#if quickExportHandlerRef}
<ToolStripDropDownButton menu={getExportMenu} {label} icon="icon export" />
<ToolStripDropDownButton menu={getExportMenu} label={_tval(label)} icon="icon export" />
{:else}
<ToolStripCommandButton {command} />
{/if}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormStyledButtonLikeLabel from '../buttons/FormStyledButtonLikeLabel.svelte';
import uploadFiles from '../utility/uploadFiles';
import { _t } from '../translations';
const handleChange = e => {
const files = [...e.target.files];
@@ -9,6 +10,6 @@
</script>
<div class="m-1">
<FormStyledButtonLikeLabel htmlFor="uploadFileButton">Upload file</FormStyledButtonLikeLabel>
<FormStyledButtonLikeLabel htmlFor="uploadFileButton">{_t('files.uploadFile', { defaultMessage: "Upload file" })}</FormStyledButtonLikeLabel>
<input type="file" id="uploadFileButton" hidden on:change={handleChange} />
</div>

View File

@@ -0,0 +1,398 @@
<script lang="ts">
import _ from 'lodash';
import { tick } from 'svelte';
import CellValue from '../datagrid/CellValue.svelte';
import { isJsonLikeLongString, safeJsonParse, parseCellValue, stringifyCellValue, filterName } from 'dbgate-tools';
import keycodes from '../utility/keycodes';
import createRef from '../utility/createRef';
import { showModal } from '../modals/modalTools';
import EditCellDataModal from '../modals/EditCellDataModal.svelte';
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
import SearchInput from '../elements/SearchInput.svelte';
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
import { _t } from '../translations';
import ColumnLabel from '../elements/ColumnLabel.svelte';
import CheckboxField from '../forms/CheckboxField.svelte';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import JSONTree from '../jsontree/JSONTree.svelte';
import Link from '../elements/Link.svelte';
export let selection;
$: firstSelection = selection?.[0];
$: rowData = firstSelection?.rowData;
$: editable = firstSelection?.editable;
$: editorTypes = firstSelection?.editorTypes;
$: displayColumns = firstSelection?.displayColumns || [];
$: realColumnUniqueNames = firstSelection?.realColumnUniqueNames || [];
$: grider = firstSelection?.grider;
$: uniqueRows = _.uniqBy(selection || [], 'row');
$: isMultipleRows = uniqueRows.length > 1;
function areValuesEqual(val1, val2) {
if (val1 === val2) return true;
if (val1 == null && val2 == null) return true;
if (val1 == null || val2 == null) return false;
return _.isEqual(val1, val2);
}
function getFieldValue(colName) {
if (!isMultipleRows) return { value: rowData?.[colName], hasMultipleValues: false };
const values = uniqueRows.map(sel => sel.rowData?.[colName]);
const firstValue = values[0];
const allSame = values.every(v => areValuesEqual(v, firstValue));
return allSame ? { value: firstValue, hasMultipleValues: false } : { value: null, hasMultipleValues: true };
}
let filter = '';
let notNull = getLocalStorage('dataGridCellDataFormNotNull') === 'true';
$: orderedFields = realColumnUniqueNames
.map(colName => {
const col = displayColumns.find(c => c.uniqueName === colName);
if (!col) return null;
const { value, hasMultipleValues } = getFieldValue(colName);
return {
...col,
value,
hasMultipleValues,
// columnName: col.columnName || colName,
// uniqueName: colName,
// value,
// hasMultipleValues,
// col,
};
})
.filter(Boolean);
$: filteredFields = orderedFields
.filter(field => filterName(filter, field.columnName))
.filter(field => {
if (notNull) {
return field.value != null || field.hasMultipleValues;
}
return true;
});
let editingColumn = null;
let editValue = '';
let domEditor = null;
const isChangedRef = createRef(false);
function isJsonValue(value) {
if (
_.isPlainObject(value) &&
!(value?.type == 'Buffer' && _.isArray(value.data)) &&
!value.$oid &&
!value.$bigint &&
!value.$decimal
) {
return true;
}
if (_.isArray(value)) return true;
if (typeof value !== 'string') return false;
if (!isJsonLikeLongString(value)) return false;
const parsed = safeJsonParse(value);
return parsed !== null && (_.isPlainObject(parsed) || _.isArray(parsed));
}
function getJsonObject(value) {
if (_.isPlainObject(value) || _.isArray(value)) return value;
if (typeof value === 'string') return safeJsonParse(value);
return null;
}
function handleClick(field) {
if (!editable || !grider) return;
if (isJsonValue(field.value)) return;
// if (isJsonValue(field.value) && !field.hasMultipleValues) {
// openEditModal(field);
// return;
// }
startEditing(field);
}
function handleDoubleClick(field) {
if (!editable || !grider) return;
if (isJsonValue(field.value) && !field.hasMultipleValues) {
openEditModal(field);
return;
}
startEditing(field);
}
function startEditing(field) {
if (!editable || !grider) return;
editingColumn = field.uniqueName;
editValue = field.hasMultipleValues ? '' : stringifyCellValue(field.value, 'inlineEditorIntent', editorTypes).value;
isChangedRef.set(false);
tick().then(() => {
if (!domEditor) return;
domEditor.focus();
if (!field.hasMultipleValues) domEditor.select();
});
}
function handleKeyDown(event, field) {
switch (event.keyCode) {
case keycodes.escape:
isChangedRef.set(false);
editingColumn = null;
break;
case keycodes.enter:
if (isChangedRef.get()) {
saveValue(field);
}
editingColumn = null;
event.preventDefault();
break;
case keycodes.tab:
case keycodes.upArrow:
case keycodes.downArrow:
const reverse = event.keyCode === keycodes.upArrow || (event.keyCode === keycodes.tab && event.shiftKey);
event.preventDefault();
moveToNextField(field, reverse);
break;
}
}
function moveToNextField(field, reverse) {
const currentIndex = filteredFields.findIndex(f => f.uniqueName === field.uniqueName);
const nextIndex = reverse ? currentIndex - 1 : currentIndex + 1;
const nextField = filteredFields[nextIndex];
if (!nextField) return;
if (isChangedRef.get()) {
saveValue(field);
}
editingColumn = null;
if (nextIndex < 0 || nextIndex >= filteredFields.length) return;
tick().then(() => {
startEditing(nextField);
// if (isJsonValue(nextField.value)) {
// openEditModal(nextField);
// } else {
// startEditing(nextField);
// }
});
}
function handleSearchKeyDown(e) {
if (e.keyCode === keycodes.backspace && (e.metaKey || e.ctrlKey)) {
filter = '';
e.stopPropagation();
e.preventDefault();
}
}
function handleBlur(field) {
if (isChangedRef.get()) {
saveValue(field);
}
editingColumn = null;
}
function setCellValue(fieldName, value) {
if (!grider) return;
if (selection.length > 0) {
const uniqueRowIndices = _.uniq(selection.map(x => x.row));
grider.beginUpdate();
for (const row of uniqueRowIndices) {
grider.setCellValue(row, fieldName, value);
}
grider.endUpdate();
}
}
function saveValue(field) {
if (!grider) return;
const parsedValue = parseCellValue(editValue, editorTypes);
setCellValue(field.uniqueName, parsedValue);
isChangedRef.set(false);
}
function openEditModal(field) {
if (!grider) return;
showModal(EditCellDataModal, {
value: field.value,
dataEditorTypesBehaviour: editorTypes,
onSave: value => setCellValue(field.uniqueName, value),
});
}
function getJsonParsedValue(value) {
if (editorTypes?.explicitDataType) return null;
if (!isJsonLikeLongString(value)) return null;
return safeJsonParse(value);
}
function handleEdit(field) {
editingColumn = null;
openEditModal(field);
}
</script>
<div class="outer">
<div class="content">
{#if rowData}
<div class="search-wrapper" on:keydown={handleSearchKeyDown}>
<SearchBoxWrapper noMargin>
<SearchInput
placeholder={_t('tableCell.filterColumns', { defaultMessage: 'Filter columns' })}
bind:value={filter}
/>
<CloseSearchButton bind:filter />
</SearchBoxWrapper>
<CheckboxField
defaultChecked={notNull}
on:change={e => {
// @ts-ignore
notNull = e.target.checked;
setLocalStorage('dataGridCellDataFormNotNull', notNull ? 'true' : 'false');
}}
/>
{_t('tableCell.hideNullValues', { defaultMessage: 'Hide NULL values' })}
</div>
{/if}
<div class="inner">
{#if !rowData}
<div class="no-data">{_t('tableCell.noDataSelected', { defaultMessage: 'No data selected' })}</div>
{:else}
{#each filteredFields as field (field.uniqueName)}
<div class="field">
<div class="field-name">
<ColumnLabel {...field} showDataType /><Link onClick={() => handleEdit(field)}
>{_t('tableCell.edit', { defaultMessage: 'Edit' })}
</Link>
</div>
<div class="field-value" class:editable on:click={() => handleClick(field)}>
{#if editingColumn === field.uniqueName}
<div class="editor-wrapper">
<input
type="text"
bind:this={domEditor}
bind:value={editValue}
on:input={() => isChangedRef.set(true)}
on:keydown={e => handleKeyDown(e, field)}
on:blur={() => handleBlur(field)}
class="inline-editor"
/>
</div>
{:else if field.hasMultipleValues}
<span class="multiple-values"
>({_t('tableCell.multipleValues', { defaultMessage: 'Multiple values' })})</span
>
{:else if isJsonValue(field.value)}
<JSONTree value={getJsonParsedValue(field.value)} />
{:else}
<CellValue
{rowData}
value={field.value}
jsonParsedValue={getJsonParsedValue(field.value)}
{editorTypes}
/>
{/if}
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.outer {
flex: 1;
position: relative;
}
.content {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
}
.search-wrapper {
padding: 4px 4px 0 4px;
flex-shrink: 0;
border: 1px solid var(--theme-border);
border-bottom: none;
}
.inner {
overflow: auto;
flex: 1;
padding: 4px;
}
.no-data {
color: var(--theme-font-3);
font-style: italic;
padding: 8px;
}
.field {
margin-bottom: 8px;
border: 1px solid var(--theme-border);
border-radius: 3px;
overflow: hidden;
}
.field-name {
background: var(--theme-bg-1);
padding: 4px 8px;
font-weight: 500;
font-size: 11px;
color: var(--theme-font-2);
border-bottom: 1px solid var(--theme-border);
display: flex;
justify-content: space-between;
}
.field-value {
padding: 6px 8px;
background: var(--theme-bg-0);
min-height: 20px;
word-break: break-all;
position: relative;
}
.field-value.editable {
cursor: text;
}
.editor-wrapper {
display: flex;
align-items: center;
}
.inline-editor {
flex: 1;
border: none;
outline: none;
background: var(--theme-bg-0);
color: var(--theme-font-1);
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
}
.inline-editor:focus {
outline: none;
}
.multiple-values {
color: var(--theme-font-3);
font-style: italic;
}
</style>

View File

@@ -10,6 +10,9 @@
if (value?.type == 'Buffer' && _.isArray(value?.data)) {
return 'data:image/png;base64, ' + btoa(String.fromCharCode.apply(null, value?.data));
}
if (value?.$binary?.base64) {
return 'data:image/png;base64, ' + value.$binary.base64;
}
return null;
} catch (err) {
console.log('Error showing picture', err);

Some files were not shown because too many files have changed in this diff Show More