diff --git a/.vscode/launch.json b/.vscode/launch.json index a70cac109e..b33fae1f67 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,7 +30,7 @@ "request": "launch", "mainClass": "org.eclipse.equinox.launcher.Main", "classPaths": [ - "${workspaceFolder}/../eclipse/eclipse/plugins/org.eclipse.equinox.launcher_1.6.500.v20230717-2134.jar" + "${workspaceFolder}/../eclipse/eclipse/plugins/org.eclipse.equinox.launcher_1.6.600.v20231106-1826.jar" ], "args": [ "-product", @@ -65,7 +65,7 @@ "request": "launch", "mainClass": "org.eclipse.equinox.launcher.Main", "classPaths": [ - "${workspaceFolder}/../eclipse/Eclipse.app/Contents/Eclipse/plugins/org.eclipse.equinox.launcher_1.6.500.v20230717-2134.jar" + "${workspaceFolder}/../eclipse/Eclipse.app/Contents/Eclipse/plugins/org.eclipse.equinox.launcher_1.6.600.v20231106-1826.jar" ], "args": [ "-product", diff --git a/config/sample-databases/DefaultConfiguration/cloudbeaver.conf b/config/sample-databases/DefaultConfiguration/cloudbeaver.conf index daa718284c..f65b0eee16 100644 --- a/config/sample-databases/DefaultConfiguration/cloudbeaver.conf +++ b/config/sample-databases/DefaultConfiguration/cloudbeaver.conf @@ -19,6 +19,13 @@ enableSecurityManager: false, + sm: { + enableBruteForceProtection: "${CLOUDBEAVER_BRUTE_FORCE_PROTECTION_ENABLED:true}", + maxFailedLogin: "${CLOUDBEAVER_MAX_FAILED_LOGINS:10}", + minimumLoginTimeout: "${CLOUDBEAVER_MINIMUM_LOGIN_TIMEOUT:1}", + blockLoginPeriod: "${CLOUDBEAVER_BLOCK_PERIOD:300}" + }, + database: { driver="h2_embedded_v2", url: "jdbc:h2:${workspace}/.data/cb.h2v2.dat", diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java new file mode 100644 index 0000000000..ec32704024 --- /dev/null +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMBruteForceProtected.java @@ -0,0 +1,25 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.auth; + +import org.jkiss.code.NotNull; + +import java.util.Map; + +public interface SMBruteForceProtected { + Object getInputUsername(@NotNull Map cred); +} diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/BaseWebApplication.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/BaseWebApplication.java index a76c4db1bb..42f932b8e3 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/BaseWebApplication.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/app/BaseWebApplication.java @@ -276,4 +276,8 @@ protected Gson getGson() { protected abstract GsonBuilder getGsonBuilder(); + @Override + public boolean isEnvironmentVariablesAccessible() { + return false; + } } diff --git a/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql index 300af38bdb..03c7a6b5bb 100644 --- a/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql +++ b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_create.sql @@ -277,6 +277,7 @@ CREATE TABLE {table_prefix}CB_AUTH_ATTEMPT SESSION_TYPE VARCHAR(64) NOT NULL, APP_SESSION_STATE TEXT NOT NULL, IS_MAIN_AUTH CHAR(1) DEFAULT 'Y' NOT NULL, + AUTH_USERNAME VARCHAR(128) NULL, CREATE_TIME TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, PRIMARY KEY (AUTH_ID), diff --git a/server/bundles/io.cloudbeaver.service.security/db/cb_schema_update_15.sql b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_update_15.sql new file mode 100644 index 0000000000..50324a3188 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/db/cb_schema_update_15.sql @@ -0,0 +1,2 @@ +ALTER TABLE {table_prefix}CB_AUTH_ATTEMPT + ADD COLUMN AUTH_USERNAME VARCHAR(128) NULL; \ No newline at end of file diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java index 9559f513b4..c230413da7 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java @@ -16,6 +16,7 @@ */ package io.cloudbeaver.auth.provider.local; +import io.cloudbeaver.auth.SMBruteForceProtected; import io.cloudbeaver.model.session.WebSession; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebAuthProviderRegistry; @@ -35,7 +36,7 @@ /** * Local auth provider */ -public class LocalAuthProvider implements SMAuthProvider { +public class LocalAuthProvider implements SMAuthProvider, SMBruteForceProtected { public static final String PROVIDER_ID = LocalAuthProviderConstants.PROVIDER_ID; public static final String CRED_USER = LocalAuthProviderConstants.CRED_USER; @@ -126,4 +127,8 @@ public static boolean changeUserPassword(@NotNull WebSession webSession, @NotNul return true; } + @Override + public Object getInputUsername(@NotNull Map cred) { + return cred.get("user"); + } } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java index 09e794f9c9..f6063e732d 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java @@ -16,19 +16,17 @@ */ package io.cloudbeaver.service.security; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.google.gson.*; import com.google.gson.reflect.TypeToken; -import io.cloudbeaver.auth.SMAuthProviderAssigner; -import io.cloudbeaver.auth.SMAuthProviderExternal; -import io.cloudbeaver.auth.SMAuthProviderFederated; -import io.cloudbeaver.auth.SMAutoAssign; +import io.cloudbeaver.auth.*; import io.cloudbeaver.model.app.WebAppConfiguration; import io.cloudbeaver.model.app.WebAuthApplication; import io.cloudbeaver.model.app.WebAuthConfiguration; import io.cloudbeaver.registry.WebAuthProviderDescriptor; import io.cloudbeaver.registry.WebAuthProviderRegistry; import io.cloudbeaver.registry.WebMetaParametersRegistry; +import io.cloudbeaver.service.security.bruteforce.BruteForceUtils; +import io.cloudbeaver.service.security.bruteforce.UserLoginRecord; import io.cloudbeaver.service.security.db.CBDatabase; import io.cloudbeaver.service.security.internal.AuthAttemptSessionInfo; import io.cloudbeaver.service.security.internal.SMTokenInfo; @@ -348,7 +346,7 @@ public SMUser getCurrentUser() throws DBException { public int countUsers(@NotNull SMUserFilter filter) throws DBCException { try (Connection dbCon = database.openConnection()) { try (PreparedStatement dbStat = dbCon.prepareStatement(database.normalizeTableNames( - "SELECT COUNT(*) FROM {table_prefix}CB_USER" + buildUsersFilter(filter)))) { + "SELECT COUNT(*) FROM {table_prefix}CB_USER" + buildUsersFilter(filter)))) { setUsersFilterValues(dbStat, filter, 1); try (ResultSet dbResult = dbStat.executeQuery()) { if (dbResult.next()) { @@ -484,7 +482,7 @@ private void readSubjectMetas(Connection dbCon, SMSubject subject) throws SQLExc } private void readSubjectsMetas(Connection dbCon, SMSubjectType subjectType, String userIdMask, - Map result) throws SQLException { + Map result) throws SQLException { // Read metas try (PreparedStatement dbStat = dbCon.prepareStatement( database.normalizeTableNames("SELECT m.SUBJECT_ID,m.META_ID,m.META_VALUE FROM {table_prefix}CB_AUTH_SUBJECT s, " + @@ -921,8 +919,8 @@ public SMTeam[] readAllTeams() throws DBCException { } try (ResultSet dbResult = dbStat.executeQuery( database.normalizeTableNames("SELECT SUBJECT_ID,PERMISSION_ID\n" + - "FROM {table_prefix}CB_AUTH_PERMISSIONS AP, {table_prefix}CB_TEAM R\n" + - "WHERE AP.SUBJECT_ID=R.TEAM_ID\n"))) { + "FROM {table_prefix}CB_AUTH_PERMISSIONS AP, {table_prefix}CB_TEAM R\n" + + "WHERE AP.SUBJECT_ID=R.TEAM_ID\n"))) { while (dbResult.next()) { SMTeam team = teams.get(dbResult.getString(1)); if (team != null) { @@ -1303,21 +1301,36 @@ public SMAuthInfo authenticate( ? null : application.getAuthConfiguration().getAuthProviderConfiguration(authProviderConfigurationId); - if (SMAuthProviderExternal.class.isAssignableFrom(authProviderInstance.getClass())) { - var authProviderExternal = (SMAuthProviderExternal) authProviderInstance; - securedUserIdentifyingCredentials = authProviderExternal.authExternalUser( - authProgressMonitor, - providerConfig, - userCredentials - ); - } - var filteredUserCreds = filterSecuredUserData( securedUserIdentifyingCredentials, authProviderDescriptor ); + String authAttemptId; + if (SMAuthProviderExternal.class.isAssignableFrom(authProviderInstance.getClass())) { + var authProviderExternal = (SMAuthProviderExternal) authProviderInstance; + try { + securedUserIdentifyingCredentials = authProviderExternal.authExternalUser( + authProgressMonitor, + providerConfig, + userCredentials + ); + } catch (DBException e) { + createNewAuthAttempt( + SMAuthStatus.ERROR, + authProviderId, + authProviderConfigurationId, + filteredUserCreds, + appSessionId, + previousSmSessionId, + sessionType, + sessionParameters, + isMainSession + ); + throw e; + } + } - var authAttemptId = createNewAuthAttempt( + authAttemptId = createNewAuthAttempt( SMAuthStatus.IN_PROGRESS, authProviderId, authProviderConfigurationId, @@ -1386,12 +1399,17 @@ private String createNewAuthAttempt( String authAttemptId = UUID.randomUUID().toString(); try (Connection dbCon = database.openConnection()) { try (JDBCTransaction txn = new JDBCTransaction(dbCon)) { + if (smConfig.isCheckBruteforce() + && this.getAuthProvider(authProviderId).getInstance() instanceof SMBruteForceProtected bruteforceProtected) { + BruteForceUtils.checkBruteforce(smConfig, + getLatestUserLogins(dbCon, authProviderId, bruteforceProtected.getInputUsername(authData).toString())); + } try (PreparedStatement dbStat = dbCon.prepareStatement( database.normalizeTableNames( "INSERT INTO {table_prefix}CB_AUTH_ATTEMPT" + "(AUTH_ID,AUTH_STATUS,APP_SESSION_ID,SESSION_TYPE,APP_SESSION_STATE," + - "SESSION_ID,IS_MAIN_AUTH) " + - "VALUES(?,?,?,?,?,?,?)" + "SESSION_ID,IS_MAIN_AUTH, AUTH_USERNAME) " + + "VALUES(?,?,?,?,?,?,?,?)" ) )) { dbStat.setString(1, authAttemptId); @@ -1405,6 +1423,11 @@ private String createNewAuthAttempt( dbStat.setNull(6, Types.VARCHAR); } dbStat.setString(7, isMainSession ? CHAR_BOOL_TRUE : CHAR_BOOL_FALSE); + if (this.getAuthProvider(authProviderId).getInstance() instanceof SMBruteForceProtected bruteforceProtected) { + dbStat.setString(8, bruteforceProtected.getInputUsername(authData).toString()); + } else { + dbStat.setString(8, null); + } dbStat.execute(); } @@ -1429,6 +1452,37 @@ private String createNewAuthAttempt( } } + private List getLatestUserLogins(Connection dbCon, String authProviderId, String inputLogin) throws SQLException { + List userLoginRecords = new ArrayList<>(); + try (PreparedStatement dbStat = dbCon.prepareStatement( + database.normalizeTableNames( + "SELECT" + + " attempt.AUTH_STATUS," + + " attempt.CREATE_TIME" + + " FROM" + + " {table_prefix}CB_AUTH_ATTEMPT attempt" + + " JOIN" + + " {table_prefix}CB_AUTH_ATTEMPT_INFO info ON attempt.AUTH_ID = info.AUTH_ID" + + " WHERE AUTH_PROVIDER_ID = ? AND AUTH_USERNAME = ?" + + " ORDER BY attempt.CREATE_TIME DESC " + + database.getDialect().getOffsetLimitQueryPart(0, smConfig.getMaxFailedLogin()) + ) + )) { + dbStat.setString(1, authProviderId); + dbStat.setString(2, inputLogin); + try (ResultSet dbResult = dbStat.executeQuery()) { + while (dbResult.next()) { + UserLoginRecord loginDto = new UserLoginRecord( + SMAuthStatus.valueOf(dbResult.getString(1)), + dbResult.getTimestamp(2).toLocalDateTime() + ); + userLoginRecords.add(loginDto); + } + } + } + return userLoginRecords; + } + private boolean isSmSessionNotExpired(String prevSessionId) { //TODO: implement after we start tracking user logout return true; diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java index 02b7971792..199a038cda 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java @@ -27,6 +27,16 @@ public class SMControllerConfiguration { private int refreshTokenTtl = DEFAULT_REFRESH_TOKEN_TTL; private int expiredAuthAttemptInfoTtl = DEFAULT_EXPIRED_AUTH_ATTEMPT_INFO_TTL; + private boolean enableBruteForceProtection = true; + + //in seconds + public static final int DEFAULT_MAX_FAILED_LOGIN = 10; + public static final int DEFAULT_MINIMUM_LOGIN_TIMEOUT = 1; //1sec + public static final int DEFAULT_BLOCK_LOGIN_PERIOD = 300; //5min + private int maxFailedLogin = DEFAULT_MAX_FAILED_LOGIN; + private int minimumLoginTimeout = DEFAULT_MINIMUM_LOGIN_TIMEOUT; + private int blockLoginPeriod = DEFAULT_BLOCK_LOGIN_PERIOD; + public int getAccessTokenTtl() { return accessTokenTtl; } @@ -50,4 +60,36 @@ public int getExpiredAuthAttemptInfoTtl() { public void setExpiredAuthAttemptInfoTtl(int expiredAuthAttemptInfoTtl) { this.expiredAuthAttemptInfoTtl = expiredAuthAttemptInfoTtl; } + + public void setCheckBruteforce(boolean checkBruteforce) { + this.enableBruteForceProtection = checkBruteforce; + } + + public boolean isCheckBruteforce() { + return enableBruteForceProtection; + } + + public int getMaxFailedLogin() { + return maxFailedLogin; + } + + public int getMinimumLoginTimeout() { + return minimumLoginTimeout; + } + + public int getBlockLoginPeriod() { + return blockLoginPeriod; + } + + public void setMaxFailedLogin(int maxFailed) { + this.maxFailedLogin = maxFailed; + } + + public void setMinimumLoginTimeout(int minimumTimeout) { + this.minimumLoginTimeout = minimumTimeout; + } + + public void setBlockLoginPeriod(int blockPeriod) { + this.blockLoginPeriod = blockPeriod; + } } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/BruteForceUtils.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/BruteForceUtils.java new file mode 100644 index 0000000000..0652a5a1a6 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/BruteForceUtils.java @@ -0,0 +1,67 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.security.bruteforce; + +import io.cloudbeaver.service.security.SMControllerConfiguration; +import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.Log; +import org.jkiss.dbeaver.model.auth.SMAuthStatus; +import org.jkiss.dbeaver.model.security.exception.SMException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +public class BruteForceUtils { + + private static final Log log = Log.getLog(BruteForceUtils.class); + + public static void checkBruteforce(SMControllerConfiguration smConfig, List latestLogins) throws DBException { + if (latestLogins.isEmpty()) { + return; + } + + var latestLogin = latestLogins.get(0); + checkLoginInterval(latestLogin.time(), smConfig.getMinimumLoginTimeout()); + + long errorsCount = latestLogins.stream() + .filter(authAttemptSessionInfo -> authAttemptSessionInfo.smAuthStatus() == SMAuthStatus.ERROR).count(); + + boolean shouldBlock = errorsCount >= smConfig.getMaxFailedLogin(); + if (shouldBlock) { + int blockPeriod = smConfig.getBlockLoginPeriod(); + LocalDateTime unblockTime = latestLogin.time().plusSeconds(blockPeriod); + + LocalDateTime now = LocalDateTime.now(); + shouldBlock = unblockTime.isAfter(now); + + if (shouldBlock) { + log.error("User login is blocked due to exceeding the limit of incorrect password entry"); + Duration lockDuration = Duration.ofSeconds(smConfig.getBlockLoginPeriod()); + + throw new SMException("User blocked for " + + lockDuration.minus(Duration.between(latestLogin.time(), now)).getSeconds() + " seconds"); + } + } + } + + private static void checkLoginInterval(LocalDateTime createTime, int timeout) throws DBException { + if (createTime != null && Duration.between(createTime, LocalDateTime.now()).getSeconds() < timeout) { + throw new DBException("You are trying to log in too fast"); + } + } +} diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java new file mode 100644 index 0000000000..7e0185078f --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/bruteforce/UserLoginRecord.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.security.bruteforce; + +import org.jkiss.dbeaver.model.auth.SMAuthStatus; + +import java.time.LocalDateTime; + +public record UserLoginRecord(SMAuthStatus smAuthStatus, LocalDateTime time) { +} diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java index b02eb0f698..623f08168e 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/db/CBDatabase.java @@ -73,7 +73,7 @@ public class CBDatabase { public static final String SCHEMA_UPDATE_SQL_PATH = "db/cb_schema_update_"; private static final int LEGACY_SCHEMA_VERSION = 1; - private static final int CURRENT_SCHEMA_VERSION = 14; + private static final int CURRENT_SCHEMA_VERSION = 15; private static final String DEFAULT_DB_USER_NAME = "cb-data"; private static final String DEFAULT_DB_PWD_FILE = ".database-credentials.dat"; diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java index 7354896a78..275e3f11f2 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/internal/AuthAttemptSessionInfo.java @@ -19,8 +19,10 @@ import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; +import org.jkiss.dbeaver.model.auth.SMAuthStatus; import org.jkiss.dbeaver.model.auth.SMSessionType; +import java.time.LocalDateTime; import java.util.Map; public class AuthAttemptSessionInfo { @@ -72,4 +74,5 @@ public String getSmSessionId() { public boolean isMainAuth() { return mainAuth; } + } diff --git a/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf b/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf index 13b7b24448..c74507b25a 100644 --- a/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf +++ b/server/test/io.cloudbeaver.test.platform/workspace/conf/cloudbeaver.conf @@ -16,6 +16,10 @@ develMode: false, + sm: { + enableBruteForceProtection: "${CLOUDBEAVER_BRUTE_FORCE_PROTECTION_ENABLED:false}" + }, + database: { driver="h2_embedded_v2", url: "jdbc:h2:mem:testdb", diff --git a/webapp/packages/core-app/src/Body.m.css b/webapp/packages/core-app/src/Body.m.css index 60f9ce527a..82a6e1457b 100644 --- a/webapp/packages/core-app/src/Body.m.css +++ b/webapp/packages/core-app/src/Body.m.css @@ -1,8 +1,8 @@ .bodyContent { - composes: theme-background-surface theme-text-on-surface theme-typography from global; - height: 100vh; - display: flex; - padding: 0 !important; - flex-direction: column; - overflow: hidden; + composes: theme-background-surface theme-text-on-surface theme-typography from global; + height: var(--app-height); + display: flex; + padding: 0 !important; + flex-direction: column; + overflow: hidden; } diff --git a/webapp/packages/core-app/src/Body.tsx b/webapp/packages/core-app/src/Body.tsx index bf74491a03..1729fe301c 100644 --- a/webapp/packages/core-app/src/Body.tsx +++ b/webapp/packages/core-app/src/Body.tsx @@ -20,6 +20,7 @@ import { DNDProvider } from '@cloudbeaver/core-ui'; import { useAppVersion } from '@cloudbeaver/core-version'; import style from './Body.m.css'; +import { useAppHeight } from './useAppHeight'; export const Body = observer(function Body() { // const serverConfigLoader = useResource(Body, ServerConfigResource, undefined); @@ -42,6 +43,8 @@ export const Body = observer(function Body() { document.documentElement.dataset.backendVersion = backendVersion; }); + useAppHeight(); + return ( diff --git a/webapp/packages/core-app/src/useAppHeight.ts b/webapp/packages/core-app/src/useAppHeight.ts new file mode 100644 index 0000000000..21f5241fd9 --- /dev/null +++ b/webapp/packages/core-app/src/useAppHeight.ts @@ -0,0 +1,27 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { useLayoutEffect } from 'react'; + +import { throttle } from '@cloudbeaver/core-utils'; + +// we need it because 100vh cuts the bottom of the page on mobile devices +const handleBodyHeight = throttle(() => { + const doc = document.documentElement; + doc.style.setProperty('--app-height', `${window.innerHeight}px`); +}, 50); + +export function useAppHeight() { + useLayoutEffect(() => { + handleBodyHeight(); + window.addEventListener('resize', handleBodyHeight); + + return () => { + window.removeEventListener('resize', handleBodyHeight); + }; + }); +} diff --git a/webapp/packages/core-blocks/src/CommonDialog/CommonDialog/CommonDialogWrapper.m.css b/webapp/packages/core-blocks/src/CommonDialog/CommonDialog/CommonDialogWrapper.m.css index de8c1dcc8b..5b75c7d969 100644 --- a/webapp/packages/core-blocks/src/CommonDialog/CommonDialog/CommonDialogWrapper.m.css +++ b/webapp/packages/core-blocks/src/CommonDialog/CommonDialog/CommonDialogWrapper.m.css @@ -21,7 +21,7 @@ &.small { min-width: 404px; min-height: 262px; - max-height: max(100vh - 48px, 262px); + max-height: max(var(--app-height) - 48px, 262px); &.fixedSize { width: 404px; @@ -34,7 +34,7 @@ &.medium { min-width: 576px; min-height: 374px; - max-height: max(100vh - 48px, 374px); + max-height: max(var(--app-height) - 48px, 374px); &.fixedSize { width: 576px; @@ -47,7 +47,7 @@ &.large { min-width: 720px; min-height: 468px; - max-height: max(100vh - 48px, 468px); + max-height: max(var(--app-height) - 48px, 468px); &.fixedSize { width: 720px; diff --git a/webapp/packages/core-blocks/src/DisplayError.m.css b/webapp/packages/core-blocks/src/DisplayError.m.css index a9dca10b17..8254e30bc5 100644 --- a/webapp/packages/core-blocks/src/DisplayError.m.css +++ b/webapp/packages/core-blocks/src/DisplayError.m.css @@ -5,7 +5,7 @@ overflow: auto; &.root { - height: 100vh; + height: var(--app-height); } } .errorInnerBlock { diff --git a/webapp/packages/core-bootstrap/src/renderLayout.m.css b/webapp/packages/core-bootstrap/src/renderLayout.m.css index 028a292406..729727bf6c 100644 --- a/webapp/packages/core-bootstrap/src/renderLayout.m.css +++ b/webapp/packages/core-bootstrap/src/renderLayout.m.css @@ -1,3 +1,3 @@ .loader { - height: 100vh; -} \ No newline at end of file + height: var(--app-height); +} diff --git a/webapp/packages/core-theming/src/styles/_variables.scss b/webapp/packages/core-theming/src/styles/_variables.scss index d1a5ed2085..5e842c761a 100644 --- a/webapp/packages/core-theming/src/styles/_variables.scss +++ b/webapp/packages/core-theming/src/styles/_variables.scss @@ -26,20 +26,21 @@ $mdc-theme-extra-property-values: ( $mdc-theme-property-values: map-merge($mdc-theme-property-values, $mdc-theme-extra-property-values); -$theme-class: "theme-#{$theme-name}"; +$theme-class: 'theme-#{$theme-name}'; $theme-form-element-radius: 3px !default; $theme-group-element-radius: 4px !default; $theme-menu-bar-small-action-radius: 3px !default; +$app-height: 100vh !default; @mixin css-variables { .theme-form-element-radius { border-radius: $theme-form-element-radius; } - + .theme-group-element-radius { border-radius: $theme-group-element-radius; } - + .theme-menu-bar-small-action-radius { border-radius: $theme-menu-bar-small-action-radius; } diff --git a/webapp/packages/core-ui/src/Form/FormBaseService.ts b/webapp/packages/core-ui/src/Form/FormBaseService.ts index 6ee77040d9..783aa4898b 100644 --- a/webapp/packages/core-ui/src/Form/FormBaseService.ts +++ b/webapp/packages/core-ui/src/Form/FormBaseService.ts @@ -23,6 +23,7 @@ export class FormBaseService = IFormPr readonly onConfigure: IExecutorHandlersCollection>; readonly onFillDefaultConfig: IExecutorHandlersCollection>; readonly onPrepareConfig: IExecutorHandlersCollection; + readonly onFormat: IExecutorHandlersCollection>; readonly onValidate: IExecutorHandlersCollection>; readonly onSubmit: IExecutorHandlersCollection>; readonly onState: IExecutorHandlersCollection; @@ -33,6 +34,7 @@ export class FormBaseService = IFormPr this.onConfigure = new ExecutorHandlersCollection(); this.onFillDefaultConfig = new ExecutorHandlersCollection(); this.onPrepareConfig = new ExecutorHandlersCollection(); + this.onFormat = new ExecutorHandlersCollection(); this.onValidate = new ExecutorHandlersCollection(); this.onSubmit = new ExecutorHandlersCollection(); this.onState = new ExecutorHandlersCollection(); diff --git a/webapp/packages/core-ui/src/Form/FormPart.ts b/webapp/packages/core-ui/src/Form/FormPart.ts index b57ccee1a1..b6ce21dd99 100644 --- a/webapp/packages/core-ui/src/Form/FormPart.ts +++ b/webapp/packages/core-ui/src/Form/FormPart.ts @@ -35,6 +35,7 @@ export abstract class FormPart implements IFormPar this.formState.submitTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.save.bind(this))); this.formState.configureTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.configure.bind(this))); + this.formState.formatTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.format.bind(this))); this.formState.validationTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.validate.bind(this))); makeObservable(this, { @@ -141,6 +142,7 @@ export abstract class FormPart implements IFormPar } protected configure(data: IFormState, contexts: IExecutionContextProvider>): void | Promise {} + protected format(data: IFormState, contexts: IExecutionContextProvider>): void | Promise {} protected validate(data: IFormState, contexts: IExecutionContextProvider>): void | Promise {} protected abstract loader(): Promise; diff --git a/webapp/packages/core-ui/src/Form/FormState.ts b/webapp/packages/core-ui/src/Form/FormState.ts index 800a52e727..41541615ab 100644 --- a/webapp/packages/core-ui/src/Form/FormState.ts +++ b/webapp/packages/core-ui/src/Form/FormState.ts @@ -40,6 +40,7 @@ export class FormState implements IFormState { readonly formStateTask: IExecutor; readonly fillDefaultConfigTask: IExecutor>; readonly submitTask: IExecutor>; + readonly formatTask: IExecutor>; readonly validationTask: IExecutor>; constructor(app: App, service: FormBaseService, state: TState) { @@ -67,8 +68,11 @@ export class FormState implements IFormState { this.fillDefaultConfigTask = new Executor(this as IFormState, () => true); this.fillDefaultConfigTask.addCollection(service.onFillDefaultConfig).next(this.formStateTask, form => form.state); + this.formatTask = new Executor(this as IFormState, () => true); + this.formatTask.addCollection(service.onFormat); + this.validationTask = new Executor(this as IFormState, () => true); - this.validationTask.addCollection(service.onValidate); + this.validationTask.addCollection(service.onValidate).before(this.formatTask); this.submitTask = new Executor(this as IFormState, () => true); this.submitTask.addCollection(service.onSubmit).before(this.validationTask); diff --git a/webapp/packages/core-ui/src/Form/IFormState.ts b/webapp/packages/core-ui/src/Form/IFormState.ts index 2afc2b1d96..148c953081 100644 --- a/webapp/packages/core-ui/src/Form/IFormState.ts +++ b/webapp/packages/core-ui/src/Form/IFormState.ts @@ -33,6 +33,7 @@ export interface IFormState extends ILoadableState { readonly formStateTask: IExecutor; readonly fillDefaultConfigTask: IExecutor>; readonly submitTask: IExecutor>; + readonly formatTask: IExecutor>; readonly validationTask: IExecutor>; setMode(mode: FormMode): this; diff --git a/webapp/packages/core-utils/src/base64ToHex.ts b/webapp/packages/core-utils/src/base64ToHex.ts new file mode 100644 index 0000000000..4ca675dc69 --- /dev/null +++ b/webapp/packages/core-utils/src/base64ToHex.ts @@ -0,0 +1,19 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +// be careful with this when you calculate a big size blobs +// it can block the main thread and cause freezes +export function base64ToHex(base64String: string): string { + const raw = atob(base64String); + let result = ''; + for (let i = 0; i < raw.length; i++) { + const hex = raw.charCodeAt(i).toString(16); + result += hex.length === 2 ? hex : `0${hex}`; + } + return result.toUpperCase(); +} diff --git a/webapp/packages/core-utils/src/formatNumber.ts b/webapp/packages/core-utils/src/formatNumber.ts new file mode 100644 index 0000000000..6e119070e9 --- /dev/null +++ b/webapp/packages/core-utils/src/formatNumber.ts @@ -0,0 +1,24 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export function formatNumber(n: number, d: number) { + if (n < 1000) { + return n.toString(); + } + + const numStr = n.toString(); + const exponent = numStr.length - (numStr.length % 3); + + const power = Math.pow(10, d); + const rounded = Math.round((n * power) / Math.pow(10, exponent)) / power; + + const units = ' kMBTPE'; + const unit = units[exponent / 3]; + + return rounded + unit; +} diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index 9ccaa2f809..02613fa438 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -10,6 +10,7 @@ export * from './underscore'; export * from './base64ToBlob'; export * from './blobToBase64'; +export * from './base64ToHex'; export * from './bytesToSize'; export * from './cacheValue'; export * from './clsx'; @@ -69,3 +70,4 @@ export * from './removeMetadataFromBase64'; export * from './renamePathName'; export * from './removeLineBreak'; export * from './replaceSubstring'; +export * from './formatNumber'; diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.m.css b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.m.css index 94fd007403..f96a1e3e79 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.m.css +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.m.css @@ -6,7 +6,38 @@ * you may not use this file except in compliance with the License. */ -.staticImage { +.hasMoreIndicator { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 50%; + background-color: var(--theme-primary); + color: var(--theme-on-primary); + font-size: 12px; + font-weight: 700; + + &:hover { + opacity: 0.8; + } +} + +.staticImage, +.hasMoreIndicator { width: 24px; height: 24px; } + +.menu { + flex-direction: row !important; +} + +.menuItem { + cursor: auto !important; +} + +.menuButton { + padding: 0; + background: transparent; + outline: none; +} diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.tsx index f5fcfde8e0..0bc6731a17 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UsersTable/UserCredentialsList.tsx @@ -7,14 +7,18 @@ */ import { observer } from 'mobx-react-lite'; import { Fragment } from 'react'; +import { Menu, MenuButton, MenuItem, useMenuState } from 'reakit'; +import styled from 'reshadow'; import { AUTH_PROVIDER_LOCAL_ID } from '@cloudbeaver/core-authentication'; -import { PlaceholderComponent, s, StaticImage, useS, useTranslate } from '@cloudbeaver/core-blocks'; +import { BASE_DROPDOWN_STYLES, PlaceholderComponent, s, StaticImage, useS, useTranslate } from '@cloudbeaver/core-blocks'; import type { ObjectOrigin } from '@cloudbeaver/core-sdk'; import type { IUserDetailsInfoProps } from '../UsersAdministrationService'; import style from './UserCredentialsList.m.css'; +const MAX_VISIBLE_CREDENTIALS = 3; + interface IUserCredentialsProps { origin: ObjectOrigin; className?: string; @@ -32,11 +36,43 @@ export const UserCredentials = observer(function UserCred }); export const UserCredentialsList: PlaceholderComponent = observer(function UserCredentialsList({ user }) { - return ( + const styles = useS(style); + const translate = useTranslate(); + const menu = useMenuState({ + placement: 'top', + gutter: 8, + }); + + const visibleCredentials = user.origins.slice(0, MAX_VISIBLE_CREDENTIALS); + + return styled(BASE_DROPDOWN_STYLES)( - {user.origins.map(origin => ( - + {visibleCredentials.map(origin => ( + ))} - + + {user.origins.length > MAX_VISIBLE_CREDENTIALS && ( + <> + +
+ +{user.origins.length - MAX_VISIBLE_CREDENTIALS} +
+
+ + + {user.origins.slice(MAX_VISIBLE_CREDENTIALS).map(origin => { + const isLocal = origin.type === AUTH_PROVIDER_LOCAL_ID; + const title = isLocal ? translate('authentication_administration_user_local') : origin.displayName; + + return ( + + + + ); + })} + + + )} + , ); }); diff --git a/webapp/packages/plugin-authentication/src/Dialog/AuthDialog.m.css b/webapp/packages/plugin-authentication/src/Dialog/AuthDialog.m.css index 1ed904ac9b..a3db0276b3 100644 --- a/webapp/packages/plugin-authentication/src/Dialog/AuthDialog.m.css +++ b/webapp/packages/plugin-authentication/src/Dialog/AuthDialog.m.css @@ -1,6 +1,6 @@ .wrapper { min-height: 520px !important; - max-height: max(100vh - 48px, 520px) !important; + max-height: max(var(--app-height) - 48px, 520px) !important; } .submittingForm { overflow: auto; diff --git a/webapp/packages/plugin-d3js/src/index.ts b/webapp/packages/plugin-d3js/src/index.ts index 60b2f65e00..7c644ac5f8 100644 --- a/webapp/packages/plugin-d3js/src/index.ts +++ b/webapp/packages/plugin-d3js/src/index.ts @@ -17,5 +17,10 @@ export { interpolateRound, axisBottom, axisLeft, + scaleOrdinal, + pie, + arc, + schemeTableau10, + sum, } from 'd3'; -export type { Selection, ZoomBehavior, Line, DragBehavior, SubjectPosition } from 'd3'; +export type { Selection, ZoomBehavior, Line, DragBehavior, SubjectPosition, PieArcDatum, ScaleOrdinal } from 'd3'; diff --git a/webapp/packages/plugin-data-viewer-result-set-grouping/src/DVResultSetGroupingPluginBootstrap.ts b/webapp/packages/plugin-data-viewer-result-set-grouping/src/DVResultSetGroupingPluginBootstrap.ts index e2aaadda92..abf5adcc88 100644 --- a/webapp/packages/plugin-data-viewer-result-set-grouping/src/DVResultSetGroupingPluginBootstrap.ts +++ b/webapp/packages/plugin-data-viewer-result-set-grouping/src/DVResultSetGroupingPluginBootstrap.ts @@ -89,18 +89,25 @@ export class DVResultSetGroupingPluginBootstrap extends Bootstrap { if (!model.source.hasResult(resultIndex)) { return true; } - const selectionAction = model.source.getAction(resultIndex, ResultSetSelectAction); - const dataAction = model.source.getAction(resultIndex, ResultSetDataAction); - return !grouping.getColumns().some(name => { - const key = dataAction.findColumnKey(column => column.name === name); + const format = model.source.getResult(resultIndex)?.dataFormat; - if (!key) { - return false; - } + if (format === ResultDataFormat.Resultset) { + const selectionAction = model.source.getAction(resultIndex, ResultSetSelectAction); + const dataAction = model.source.getAction(resultIndex, ResultSetDataAction); - return selectionAction.isElementSelected({ column: key }); - }); + return !grouping.getColumns().some(name => { + const key = dataAction.findColumnKey(column => column.name === name); + + if (!key) { + return false; + } + + return selectionAction.isElementSelected({ column: key }); + }); + } + + return true; } } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetBinaryFileValue.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetBinaryFileValue.ts new file mode 100644 index 0000000000..84d282ac20 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/IResultSetBinaryFileValue.ts @@ -0,0 +1,5 @@ +import type { IResultSetContentValue } from './IResultSetContentValue'; + +export interface IResultSetBinaryFileValue extends IResultSetContentValue { + binary: string; +} diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue.ts new file mode 100644 index 0000000000..87afa5cb3e --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue.ts @@ -0,0 +1,6 @@ +import type { IResultSetBinaryFileValue } from './IResultSetBinaryFileValue'; +import type { IResultSetContentValue } from './IResultSetContentValue'; + +export function isResultSetBinaryFileValue(value: IResultSetContentValue): value is IResultSetBinaryFileValue { + return value.contentType === 'application/octet-stream' && Boolean(value?.binary); +} diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DataContext/DATA_CONTEXT_DV_DDM.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DataContext/DATA_CONTEXT_DV_DDM.ts index 95c4a6d980..b99240b645 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DataContext/DATA_CONTEXT_DV_DDM.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DataContext/DATA_CONTEXT_DV_DDM.ts @@ -10,4 +10,4 @@ import { createDataContext } from '@cloudbeaver/core-data-context'; import type { IDatabaseDataModel } from '../IDatabaseDataModel'; import type { IDatabaseDataOptions } from '../IDatabaseDataOptions'; -export const DATA_CONTEXT_DV_DDM = createDataContext>('data-viewer-database-data-model'); +export const DATA_CONTEXT_DV_DDM = createDataContext>('data-viewer-database-data-model'); diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentationBootstrap.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentationBootstrap.ts index 3f7658f7cc..08ea8f8a45 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentationBootstrap.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/ImageValuePresentationBootstrap.ts @@ -7,15 +7,12 @@ */ import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { ResultDataFormat } from '@cloudbeaver/core-sdk'; -import { getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils'; -import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue'; -import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; -import type { IResultSetValue } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; import { ResultSetViewAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetViewAction'; import { DataValuePanelService } from '../../TableViewer/ValuePanel/DataValuePanelService'; import { ImageValuePresentation } from './ImageValuePresentation'; +import { isImageValuePresentationAvailable } from './isImageValuePresentationAvailable'; @injectable() export class ImageValuePresentationBootstrap extends Bootstrap { @@ -46,7 +43,7 @@ export class ImageValuePresentationBootstrap extends Bootstrap { const cellValue = view.getCellValue(firstSelectedCell); - return !this.isImage(cellValue); + return !isImageValuePresentationAvailable(cellValue); } return true; @@ -55,19 +52,4 @@ export class ImageValuePresentationBootstrap extends Bootstrap { } load(): void {} - - private isImage(value: IResultSetValue) { - if (isResultSetContentValue(value) && value?.binary) { - return getMIME(value.binary || '') !== null; - } - if (isResultSetContentValue(value) || isResultSetBlobValue(value)) { - return value?.contentType?.startsWith('image/') ?? false; - } - - if (typeof value !== 'string') { - return false; - } - - return isValidUrl(value) && isImageFormat(value); - } } diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/isImageValuePresentationAvailable.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/isImageValuePresentationAvailable.ts new file mode 100644 index 0000000000..82d07d19ef --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/ImageValue/isImageValuePresentationAvailable.ts @@ -0,0 +1,27 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { getMIME, isImageFormat, isValidUrl } from '@cloudbeaver/core-utils'; + +import { isResultSetBlobValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue'; +import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import type { IResultSetValue } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; + +export function isImageValuePresentationAvailable(value: IResultSetValue) { + if (isResultSetContentValue(value) && value?.binary) { + return getMIME(value.binary || '') !== null; + } + if (isResultSetContentValue(value) || isResultSetBlobValue(value)) { + return value?.contentType?.startsWith('image/') ?? false; + } + + if (typeof value !== 'string') { + return false; + } + + return isValidUrl(value) && isImageFormat(value); +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index a7bc46d9c6..32a96da23b 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -24,14 +24,13 @@ import { ResultSetDataContentAction } from '../../DatabaseDataModel/Actions/Resu import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; -import { ResultSetViewAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetViewAction'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; import { QuotaPlaceholder } from '../QuotaPlaceholder'; import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES'; import { getTypeExtension } from './getTypeExtension'; import { TextValuePresentationService } from './TextValuePresentationService'; -import { useAutoFormat } from './useAutoFormat'; +import { useTextValue } from './useTextValue'; const styles = css` Tab { @@ -72,7 +71,7 @@ const styles = css` `; export const TextValuePresentation: TabContainerPanelComponent> = observer( - function TextValuePresentation({ model, resultIndex }) { + function TextValuePresentation({ model, resultIndex, dataFormat }) { const translate = useTranslate(); const notificationService = useService(NotificationService); const quotasService = useService(QuotasService); @@ -106,7 +105,6 @@ export const TextValuePresentation: TabContainerPanelComponent getTypeExtension(state.currentContentType) ?? [], [state.currentContentType]); const extensions = useCodemirrorExtensions(undefined, typeExtension); - const value = autoFormat ? formatter.format(state.currentContentType, stringValue) : stringValue; + const value = useTextValue({ + model, + resultIndex, + currentContentType: state.currentContentType, + }); return styled(style)( state.setContentType(tab.tabId)} > diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts index a3faaf8720..6d6d6bee01 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationBootstrap.ts @@ -11,6 +11,7 @@ import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { ResultDataFormat } from '@cloudbeaver/core-sdk'; import { DataValuePanelService } from '../../TableViewer/ValuePanel/DataValuePanelService'; +import { isBlobPresentationAvailable } from './isTextValuePresentationAvailable'; import { TextValuePresentationService } from './TextValuePresentationService'; const TextValuePresentation = lazy(async () => { @@ -47,18 +48,36 @@ export class TextValuePresentationBootstrap extends Bootstrap { name: 'data_viewer_presentation_value_text_html_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, + isHidden: (_, context) => isBlobPresentationAvailable(context), }); this.textValuePresentationService.add({ key: 'text/xml', name: 'data_viewer_presentation_value_text_xml_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, + isHidden: (_, context) => isBlobPresentationAvailable(context), }); this.textValuePresentationService.add({ key: 'application/json', name: 'data_viewer_presentation_value_text_json_title', order: Number.MAX_SAFE_INTEGER, panel: () => React.Fragment, + isHidden: (_, context) => isBlobPresentationAvailable(context), + }); + + this.textValuePresentationService.add({ + key: 'text/hex', + name: 'data_viewer_presentation_value_text_hex_title', + order: Number.MAX_SAFE_INTEGER, + panel: () => React.Fragment, + isHidden: (_, context) => !isBlobPresentationAvailable(context), + }); + this.textValuePresentationService.add({ + key: 'text/base64', + name: 'data_viewer_presentation_value_text_base64_title', + order: Number.MAX_SAFE_INTEGER, + panel: () => React.Fragment, + isHidden: (_, context) => !isBlobPresentationAvailable(context), }); } diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationService.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationService.ts index 5d08269de2..6f99127900 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationService.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentationService.ts @@ -8,19 +8,21 @@ import { injectable } from '@cloudbeaver/core-di'; import { ITabInfo, ITabInfoOptions, TabsContainer } from '@cloudbeaver/core-ui'; +import type { IDataValuePanelOptions, IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; + @injectable() export class TextValuePresentationService { - readonly tabs: TabsContainer; + readonly tabs: TabsContainer, IDataValuePanelOptions>; constructor() { this.tabs = new TabsContainer('Value presentation'); } - get(tabId: string): ITabInfo | undefined { + get(tabId: string): ITabInfo, IDataValuePanelOptions> | undefined { return this.tabs.getTabInfo(tabId); } - add(tabInfo: ITabInfoOptions): void { + add(tabInfo: ITabInfoOptions, IDataValuePanelOptions>): void { this.tabs.add(tabInfo); } } diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts new file mode 100644 index 0000000000..be74cb8bbf --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/isTextValuePresentationAvailable.ts @@ -0,0 +1,35 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { isResultSetBinaryFileValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue'; +import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; +import { ResultSetViewAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetViewAction'; +import type { IDatabaseDataResult } from '../../DatabaseDataModel/IDatabaseDataResult'; +import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; + +export function isBlobPresentationAvailable(context: IDataValuePanelProps | undefined): boolean { + if (!context?.model.source.hasResult(context.resultIndex)) { + return true; + } + + const selection = context.model.source.getAction(context.resultIndex, ResultSetSelectAction); + + const focusedElement = selection.getFocusedElement(); + + if (selection.elements.length > 0 || focusedElement) { + const view = context.model.source.getAction(context.resultIndex, ResultSetViewAction); + + const firstSelectedCell = selection.elements[0] || focusedElement; + + const cellValue = view.getCellValue(firstSelectedCell); + + return isResultSetContentValue(cellValue) && isResultSetBinaryFileValue(cellValue); + } + + return true; +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts index 80a7f8b711..783addb1dc 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useAutoFormat.ts @@ -6,6 +6,9 @@ * you may not use this file except in compliance with the License. */ import { useObjectRef } from '@cloudbeaver/core-blocks'; +import { base64ToHex } from '@cloudbeaver/core-utils'; + +import type { IResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetContentValue'; export function useAutoFormat() { return useObjectRef( @@ -25,6 +28,20 @@ export function useAutoFormat() { return value; } }, + formatBlob(type: string, value: IResultSetContentValue) { + if (!value.binary) { + return value.text; + } + + switch (type) { + case 'text/base64': + return value.binary; + case 'text/hex': + return base64ToHex(value.binary); + default: + return value.text; + } + }, }), false, ); diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts new file mode 100644 index 0000000000..62d8bf187e --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts @@ -0,0 +1,46 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2023 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; +import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; +import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; +import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; +import type { IDatabaseDataModel } from '../../DatabaseDataModel/IDatabaseDataModel'; +import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; +import { useAutoFormat } from './useAutoFormat'; + +interface IUseTextValueArgs { + resultIndex: number; + model: IDatabaseDataModel; + currentContentType: string; +} + +export function useTextValue({ model, resultIndex, currentContentType }: IUseTextValueArgs) { + const format = model.source.getAction(resultIndex, ResultSetFormatAction); + const editor = model.source.getAction(resultIndex, ResultSetEditAction); + const selection = model.source.getAction(resultIndex, ResultSetSelectAction); + const focusCell = selection.getFocusedElement(); + const firstSelectedCell = selection.elements?.[0] ?? focusCell; + const autoFormat = !!firstSelectedCell && !editor.isElementEdited(firstSelectedCell); + const formatter = useAutoFormat(); + + if (!autoFormat) { + return; + } + + const blob = format.get(firstSelectedCell); + + if (isResultSetContentValue(blob)) { + const value = formatter.formatBlob(currentContentType, blob); + + if (value) { + return value; + } + } + + return formatter.format(currentContentType, format.getText(firstSelectedCell)); +} diff --git a/webapp/packages/plugin-data-viewer/src/index.ts b/webapp/packages/plugin-data-viewer/src/index.ts index 78e4d15ae3..adb058ca96 100644 --- a/webapp/packages/plugin-data-viewer/src/index.ts +++ b/webapp/packages/plugin-data-viewer/src/index.ts @@ -17,6 +17,8 @@ export * from './DatabaseDataModel/Actions/ResultSet/IResultSetComplexValue'; export * from './DatabaseDataModel/Actions/ResultSet/IResultSetFileValue'; export * from './DatabaseDataModel/Actions/ResultSet/IResultSetContentValue'; export * from './DatabaseDataModel/Actions/ResultSet/IResultSetGeometryValue'; +export * from './DatabaseDataModel/Actions/ResultSet/IResultSetBinaryFileValue'; +export * from './DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue'; export * from './DatabaseDataModel/Actions/ResultSet/isResultSetBlobValue'; export * from './DatabaseDataModel/Actions/ResultSet/isResultSetComplexValue'; export * from './DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; diff --git a/webapp/packages/plugin-data-viewer/src/locales/en.ts b/webapp/packages/plugin-data-viewer/src/locales/en.ts index 3bdde1c450..fec9e03994 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/en.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/en.ts @@ -28,6 +28,8 @@ export default [ ['data_viewer_presentation_value_text_html_title', 'HTML'], ['data_viewer_presentation_value_text_xml_title', 'XML'], ['data_viewer_presentation_value_text_json_title', 'JSON'], + ['data_viewer_presentation_value_text_hex_title', 'HEX'], + ['data_viewer_presentation_value_text_base64_title', 'Base64'], ['data_viewer_presentation_value_image_title', 'Image'], ['data_viewer_presentation_value_image_fit', 'Fit Window'], ['data_viewer_presentation_value_image_original_size', 'Original Size'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/it.ts b/webapp/packages/plugin-data-viewer/src/locales/it.ts index c133992bc1..c4ea762cc5 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/it.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/it.ts @@ -24,6 +24,8 @@ export default [ ['data_viewer_presentation_value_text_html_title', 'HTML'], ['data_viewer_presentation_value_text_xml_title', 'XML'], ['data_viewer_presentation_value_text_json_title', 'JSON'], + ['data_viewer_presentation_value_text_hex_title', 'HEX'], + ['data_viewer_presentation_value_text_base64_title', 'Base64'], ['data_viewer_presentation_value_image_title', 'Immagine'], ['data_viewer_presentation_value_image_fit', 'Adatta alla Finestra'], ['data_viewer_presentation_value_image_original_size', 'Dimensioni Originali'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/zh.ts b/webapp/packages/plugin-data-viewer/src/locales/zh.ts index 93ebeb170e..2d03d261e8 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/zh.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/zh.ts @@ -28,6 +28,8 @@ export default [ ['data_viewer_presentation_value_text_html_title', 'HTML'], ['data_viewer_presentation_value_text_xml_title', 'XML'], ['data_viewer_presentation_value_text_json_title', 'JSON'], + ['data_viewer_presentation_value_text_hex_title', 'HEX'], + ['data_viewer_presentation_value_text_base64_title', 'Base64'], ['data_viewer_presentation_value_image_title', '图片'], ['data_viewer_presentation_value_image_fit', '适应窗口'], ['data_viewer_presentation_value_image_original_size', '原始尺寸'],