From 20bf646c4fe3eba35f0d42748db5a7acd2413177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 17 May 2021 08:51:15 +0200 Subject: [PATCH 01/14] Simplify type support check (#1658) Simplifies the class RepositoryTypeSupportChecker. There is no need to fall back to the super type Type`. So we do not need to manually check for type safety. Co-authored-by: Eduard Heimbuch --- .../resources/RepositoryExportResource.java | 4 +-- .../scm/importexport/FromBundleImporter.java | 4 +-- .../scm/importexport/FromUrlImporter.java | 4 +-- .../RepositoryTypeSupportChecker.java | 26 +++++++------------ 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java index ef8a5be38d..9ea9d0e3ed 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java @@ -39,7 +39,6 @@ import sonia.scm.BadRequestException; import sonia.scm.ConcurrentModificationException; import sonia.scm.NotFoundException; -import sonia.scm.Type; import sonia.scm.importexport.ExportFileExtensionResolver; import sonia.scm.importexport.ExportNotificationHandler; import sonia.scm.importexport.ExportService; @@ -50,6 +49,7 @@ import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.RepositoryType; import sonia.scm.repository.api.BundleCommandBuilder; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.ExportFailedException; @@ -442,7 +442,7 @@ private Repository getVerifiedRepository(String namespace, String name, String t if (!type.equals(repository.getType())) { throw new WrongTypeException(repository); } - Type repositoryType = type(manager, type); + RepositoryType repositoryType = type(manager, type); checkSupport(repositoryType, Command.BUNDLE); return repository; } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java index b006168790..cb4ad5a91b 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java @@ -28,7 +28,6 @@ import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import sonia.scm.Type; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.ImportRepositoryHookEvent; import sonia.scm.repository.InternalRepositoryException; @@ -38,6 +37,7 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.RepositoryType; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -78,7 +78,7 @@ public FromBundleImporter(RepositoryManager manager, RepositoryServiceFactory se public Repository importFromBundle(boolean compressed, InputStream inputStream, Repository repository) { RepositoryPermissions.create().check(); - Type t = type(manager, repository.getType()); + RepositoryType t = type(manager, repository.getType()); checkSupport(t, Command.UNBUNDLE); repository.setPermissions(singletonList( diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java index 649c6c9b9b..a93ca62c54 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java @@ -31,7 +31,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.AlreadyExistsException; -import sonia.scm.Type; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.Repository; @@ -39,6 +38,7 @@ import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.RepositoryType; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.ImportFailedException; import sonia.scm.repository.api.PullCommandBuilder; @@ -73,7 +73,7 @@ public FromUrlImporter(RepositoryManager manager, RepositoryServiceFactory servi } public Repository importFromUrl(RepositoryImportParameters parameters, Repository repository) { - Type t = type(manager, repository.getType()); + RepositoryType t = type(manager, repository.getType()); RepositoryPermissions.create().check(); checkSupport(t, Command.PULL); diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java index 87f55a46b6..a89cb7928f 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java @@ -27,14 +27,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.BadRequestException; -import sonia.scm.Type; import sonia.scm.repository.RepositoryHandler; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryType; import sonia.scm.repository.api.Command; -import javax.ws.rs.WebApplicationException; -import javax.ws.rs.core.Response; import java.util.Set; import static sonia.scm.ContextEntry.ContextBuilder.noContext; @@ -44,7 +41,7 @@ public class RepositoryTypeSupportChecker { private RepositoryTypeSupportChecker() { } - private static final Logger logger = LoggerFactory.getLogger(RepositoryTypeSupportChecker.class); + private static final Logger LOG = LoggerFactory.getLogger(RepositoryTypeSupportChecker.class); /** * Check repository type for support for the given command. @@ -52,31 +49,28 @@ private RepositoryTypeSupportChecker() { * @param type repository type * @param cmd command */ - public static void checkSupport(Type type, Command cmd) { - if (!(type instanceof RepositoryType)) { - logger.warn("type {} is not a repository type", type.getName()); - throw new WebApplicationException(Response.Status.BAD_REQUEST); - } - - Set cmds = ((RepositoryType) type).getSupportedCommands(); + public static void checkSupport(RepositoryType type, Command cmd) { + Set cmds = type.getSupportedCommands(); if (!cmds.contains(cmd)) { - logger.warn("type {} does not support this command {}", + LOG.debug("type {} does not support this command {}", type.getName(), - cmd.name()); + cmd); throw new IllegalTypeForImportException("type does not support command"); } } - @SuppressWarnings("javasecurity:S5145") // the type parameter is validated in the resource to only contain valid characters (\w) - public static Type type(RepositoryManager manager, String type) { + @SuppressWarnings("javasecurity:S5145") + // the type parameter is validated in the resource to only contain valid characters (\w) + public static RepositoryType type(RepositoryManager manager, String type) { RepositoryHandler handler = manager.getHandler(type); if (handler == null) { - logger.warn("no handler for type {} found", type); + LOG.debug("no handler for type {} found", type); throw new IllegalTypeForImportException("unsupported repository type: " + type); } return handler.getType(); } + @SuppressWarnings("java:S110") // this is fine for exceptions private static class IllegalTypeForImportException extends BadRequestException { public IllegalTypeForImportException(String message) { super(noContext(), message); From cb6806a36f726bd581c83540925562ba6ac8d174 Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Mon, 17 May 2021 09:13:33 +0200 Subject: [PATCH 02/14] fix repository form switcher button label translations for plugins --- .../src/repos/components/form/RepositoryFormSwitcher.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx b/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx index 4edff0992c..45c56639bb 100644 --- a/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx +++ b/scm-ui/ui-webapp/src/repos/components/form/RepositoryFormSwitcher.tsx @@ -26,6 +26,7 @@ import React, { FC } from "react"; import styled from "styled-components"; import { Button, ButtonAddons, Icon, Level, urls } from "@scm-manager/ui-components"; import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; const MarginIcon = styled(Icon)` padding-right: 0.5rem; @@ -58,11 +59,12 @@ const RepositoryFormButton: FC = ({ path, icon, label }) => { const location = useLocation(); const href = urls.concat("/repos/create", path); const isSelected = href === location.pathname; + const [t] = useTranslation(["repos", "plugins"]); return ( -

{label}

+

{t(`plugins:${label}`, label)}

); }; @@ -75,7 +77,7 @@ const RepositoryFormSwitcher: FC = ({ forms }) => ( - {(forms || []).map(form => ( + {(forms || []).map((form) => ( ))} From e3597d8cd06c8f7fb769e51cfc4166cecbd37adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 12 May 2021 17:33:03 +0200 Subject: [PATCH 03/14] Add POC for SVN mirror command --- .../Pkcs12ClientCertificateCredential.java | 46 ++++++++ .../scm/repository/SvnRepositoryHandler.java | 105 ++++++++--------- .../scm/repository/spi/SvnMirrorCommand.java | 106 ++++++++++++++++++ .../spi/SvnRepositoryServiceProvider.java | 16 ++- .../spi/SvnRepositoryServiceResolver.java | 8 +- 5 files changed, 219 insertions(+), 62 deletions(-) create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java create mode 100644 scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java new file mode 100644 index 0000000000..18235fa857 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import java.nio.file.Path; + +public class Pkcs12ClientCertificateCredential implements Credential { + + private final Path certificate; + private final char[] password; + + public Pkcs12ClientCertificateCredential(Path certificate, char[] password) { + this.certificate = certificate; + this.password = password; + } + + public Path getCertificate() { + return certificate; + } + + public char[] getPassword() { + return password; + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java index e1ea9dde09..8d2a23a544 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java @@ -53,14 +53,12 @@ //~--- JDK imports ------------------------------------------------------------ /** - * * @author Sebastian Sdorra */ @Singleton @Extension public class SvnRepositoryHandler - extends AbstractSimpleRepositoryHandler -{ + extends AbstractSimpleRepositoryHandler { public static final String PROPERTY_UUID = "svn.uuid"; @@ -71,8 +69,8 @@ public class SvnRepositoryHandler public static final String TYPE_NAME = "svn"; public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, - TYPE_DISPLAYNAME, - SvnRepositoryServiceProvider.COMMANDS); + TYPE_DISPLAYNAME, + SvnRepositoryServiceProvider.COMMANDS); private static final Logger logger = LoggerFactory.getLogger(SvnRepositoryHandler.class); @@ -82,8 +80,7 @@ public class SvnRepositoryHandler public SvnRepositoryHandler(ConfigurationStoreFactory storeFactory, HookEventFacade eventFacade, RepositoryLocationResolver repositoryLocationResolver, - PluginLoader pluginLoader) - { + PluginLoader pluginLoader) { super(storeFactory, repositoryLocationResolver, pluginLoader); // register logger @@ -93,102 +90,96 @@ public SvnRepositoryHandler(ConfigurationStoreFactory storeFactory, FSRepositoryFactory.setup(); // register hook - if (eventFacade != null) - { + if (eventFacade != null) { hook = new SvnRepositoryHook(eventFacade, this); FSHooks.registerHook(hook); - } - else if (logger.isWarnEnabled()) - { + } else if (logger.isWarnEnabled()) { logger.warn( "unable to register hook, beacause of missing repositorymanager"); } } @Override - public ImportHandler getImportHandler() - { + public ImportHandler getImportHandler() { return new SvnImportHandler(this); } @Override - public RepositoryType getType() - { + public RepositoryType getType() { return TYPE; } @Override - public String getVersionInformation() - { + public String getVersionInformation() { return getStringFromResource(RESOURCE_VERSION, DEFAULT_VERSION_INFORMATION); } @Override protected void create(Repository repository, File directory) throws InternalRepositoryException { - Compatibility comp = config.getCompatibility(); - - if (logger.isDebugEnabled()) - { - StringBuilder log = new StringBuilder("create svn repository \""); - - log.append(directory.getName()).append("\": pre14Compatible="); - log.append(comp.isPre14Compatible()).append(", pre15Compatible="); - log.append(comp.isPre15Compatible()).append(", pre16Compatible="); - log.append(comp.isPre16Compatible()).append(", pre17Compatible="); - log.append(comp.isPre17Compatible()).append(", with17Compatible="); - log.append(comp.isWith17Compatible()); - logger.debug(log.toString()); - } SVNRepository svnRepository = null; - try - { - SVNURL url = SVNRepositoryFactory.createLocalRepository(directory, null, - true, false, comp.isPre14Compatible(), - comp.isPre15Compatible(), comp.isPre16Compatible(), - comp.isPre17Compatible(), comp.isWith17Compatible()); + try { + SVNURL url = createSvnUrl(directory); svnRepository = SVNRepositoryFactory.create(url); String uuid = svnRepository.getRepositoryUUID(true); - if (Util.isNotEmpty(uuid)) - { - if (logger.isDebugEnabled()) - { + if (Util.isNotEmpty(uuid)) { + if (logger.isDebugEnabled()) { logger.debug("store repository uuid {} for {}", uuid, repository.getName()); } repository.setProperty(PROPERTY_UUID, uuid); - } - else if (logger.isWarnEnabled()) - { + } else if (logger.isWarnEnabled()) { logger.warn("could not read repository uuid for {}", repository.getName()); } - } - catch (SVNException ex) - { + } catch (SVNException ex) { logger.error("could not create svn repository", ex); - throw new InternalRepositoryException(entity(repository), "could not create repository", ex); - } - finally - { + throw new InternalRepositoryException(repository, "could not create repository", ex); + } finally { SvnUtil.closeSession(svnRepository); } } + public SVNURL createSvnUrl(File directory) { + Compatibility comp = config.getCompatibility(); + + if (logger.isDebugEnabled()) { + + logger.debug("create svn repository \"{}\": " + + "pre14Compatible={}, " + + "pre15Compatible={}, " + + "pre16Compatible={}, " + + "pre17Compatible={}, " + + "with17Compatible={}", + directory.getName(), + comp.isPre14Compatible(), + comp.isPre15Compatible(), + comp.isPre16Compatible(), + comp.isPre17Compatible(), + comp.isWith17Compatible()); + } + try { + return SVNRepositoryFactory.createLocalRepository(directory, null, + true, false, comp.isPre14Compatible(), + comp.isPre15Compatible(), comp.isPre16Compatible(), + comp.isPre17Compatible(), comp.isWith17Compatible()); + } catch (SVNException ex) { + throw new InternalRepositoryException(entity(File.class, directory.toString()), "could not create svn url", ex); + } + } + /** * Method description * - * * @return */ @Override - protected SvnConfig createInitialConfig() - { + protected SvnConfig createInitialConfig() { return new SvnConfig(); } @@ -197,12 +188,10 @@ protected SvnConfig createInitialConfig() /** * Method description * - * * @return */ @Override - protected Class getConfigClass() - { + protected Class getConfigClass() { return SvnConfig.class; } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java new file mode 100644 index 0000000000..3a5a67b2d5 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java @@ -0,0 +1,106 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.auth.BasicAuthenticationManager; +import org.tmatesoft.svn.core.auth.ISVNAuthenticationManager; +import org.tmatesoft.svn.core.auth.SVNAuthentication; +import org.tmatesoft.svn.core.auth.SVNPasswordAuthentication; +import org.tmatesoft.svn.core.auth.SVNSSLAuthentication; +import org.tmatesoft.svn.core.wc.SVNWCUtil; +import org.tmatesoft.svn.core.wc.admin.SVNAdminClient; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; +import sonia.scm.repository.api.UsernamePasswordCredential; + +import javax.net.ssl.TrustManager; +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.function.Function; + +public class SvnMirrorCommand extends AbstractSvnCommand implements MirrorCommand { + + private final TrustManager trustManager; + private final Function urlFactory; + + SvnMirrorCommand(SvnContext context, TrustManager trustManager, Function urlFactory) { + super(context); + this.trustManager = trustManager; + this.urlFactory = urlFactory; + } + + @Override + public MirrorCommandResult mirror(MirrorCommandRequest mirrorCommandRequest) { + + try { + SVNURL url = urlFactory.apply(context.getDirectory()); + + SVNURL next = SVNURL.parseURIEncoded(mirrorCommandRequest.getSourceUrl()); + + SVNAdminClient admin = createAdminClient(url, mirrorCommandRequest); + + admin.doCompleteSynchronize(next, url); + } catch (SVNException e) { + e.printStackTrace(); + } + return null; + } + + private SVNAdminClient createAdminClient(SVNURL url, MirrorCommandRequest mirrorCommandRequest) { + Collection authentications = new ArrayList<>(); + mirrorCommandRequest.getCredential(Pkcs12ClientCertificateCredential.class) + .map(c -> createTlsAuth(url, c)) + .ifPresent(authentications::add); + mirrorCommandRequest.getCredential(UsernamePasswordCredential.class) + .map(c -> SVNPasswordAuthentication.newInstance(c.username(), c.password(), false, url, false)) + .ifPresent(authentications::add); + ISVNAuthenticationManager authManager = new BasicAuthenticationManager( + authentications.toArray(new SVNAuthentication[authentications.size()])) { + @Override + public TrustManager getTrustManager(SVNURL url) { + return trustManager; + } + }; + + return new SVNAdminClient(authManager, SVNWCUtil.createDefaultOptions(true)); + } + + private SVNSSLAuthentication createTlsAuth(SVNURL url, Pkcs12ClientCertificateCredential c) { + return SVNSSLAuthentication.newInstance( + c.getCertificate().toFile(), + c.getPassword(), + false, + url, + true); + } + + @Override + public MirrorCommandResult update(MirrorCommandRequest mirrorCommandRequest) { + return null; + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java index c286891a4d..f715207708 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java @@ -32,6 +32,7 @@ import sonia.scm.repository.api.Command; import sonia.scm.repository.api.HookContextFactory; +import javax.net.ssl.TrustManager; import java.io.IOException; import java.util.Set; @@ -51,7 +52,8 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { Command.UNBUNDLE, Command.MODIFY, Command.LOOKUP, - Command.FULL_HEALTH_CHECK + Command.FULL_HEALTH_CHECK, + Command.MIRROR ); //J+ @@ -59,14 +61,19 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { private final SvnContext context; private final SvnWorkingCopyFactory workingCopyFactory; private final HookContextFactory hookContextFactory; + private final SvnRepositoryHandler handler; + private final TrustManager trustManager; SvnRepositoryServiceProvider(SvnRepositoryHandler handler, Repository repository, SvnWorkingCopyFactory workingCopyFactory, - HookContextFactory hookContextFactory) { + HookContextFactory hookContextFactory, + TrustManager trustManager) { this.context = new SvnContext(repository, handler.getDirectory(repository.getId())); + this.handler = handler; this.workingCopyFactory = workingCopyFactory; this.hookContextFactory = hookContextFactory; + this.trustManager = trustManager; } @Override @@ -133,4 +140,9 @@ public UnbundleCommand getUnbundleCommand() { public FullHealthCheckCommand getFullHealthCheckCommand() { return new SvnFullHealthCheckCommand(context); } + + @Override + public MirrorCommand getMirrorCommand() { + return new SvnMirrorCommand(context, trustManager, handler::createSvnUrl); + } } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java index 827e3827ec..eb27df1bc4 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java @@ -31,20 +31,24 @@ import sonia.scm.repository.SvnWorkingCopyFactory; import sonia.scm.repository.api.HookContextFactory; +import javax.net.ssl.TrustManager; + @Extension public class SvnRepositoryServiceResolver implements RepositoryServiceResolver { private final SvnRepositoryHandler handler; private final SvnWorkingCopyFactory workingCopyFactory; private final HookContextFactory hookContextFactory; + private final TrustManager trustManager; @Inject public SvnRepositoryServiceResolver(SvnRepositoryHandler handler, SvnWorkingCopyFactory workingCopyFactory, - HookContextFactory hookContextFactory) { + HookContextFactory hookContextFactory, TrustManager trustManager) { this.handler = handler; this.workingCopyFactory = workingCopyFactory; this.hookContextFactory = hookContextFactory; + this.trustManager = trustManager; } @Override @@ -52,7 +56,7 @@ public SvnRepositoryServiceProvider resolve(Repository repository) { SvnRepositoryServiceProvider provider = null; if (SvnRepositoryHandler.TYPE_NAME.equalsIgnoreCase(repository.getType())) { - provider = new SvnRepositoryServiceProvider(handler, repository, workingCopyFactory, hookContextFactory); + provider = new SvnRepositoryServiceProvider(handler, repository, workingCopyFactory, hookContextFactory, trustManager); } return provider; From d24d61144ef28bea98cb7f3fce48c8660062394f Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 17 May 2021 09:45:31 +0200 Subject: [PATCH 04/14] Cleanup / small improvements --- .../Pkcs12ClientCertificateCredential.java | 18 ++------ .../scm/repository/SvnRepositoryHandler.java | 46 +++++-------------- .../scm/repository/spi/SvnMirrorCommand.java | 5 +- .../spi/SvnRepositoryServiceResolver.java | 3 +- 4 files changed, 22 insertions(+), 50 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java index 18235fa857..77ad7bd6c4 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java @@ -24,23 +24,15 @@ package sonia.scm.repository.api; +import lombok.AllArgsConstructor; +import lombok.Getter; + import java.nio.file.Path; +@AllArgsConstructor +@Getter public class Pkcs12ClientCertificateCredential implements Credential { private final Path certificate; private final char[] password; - - public Pkcs12ClientCertificateCredential(Path certificate, char[] password) { - this.certificate = certificate; - this.password = password; - } - - public Path getCertificate() { - return certificate; - } - - public char[] getPassword() { - return password; - } } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java index 8d2a23a544..4c176de302 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/SvnRepositoryHandler.java @@ -24,8 +24,6 @@ package sonia.scm.repository; -//~--- non-JDK imports -------------------------------------------------------- - import com.google.inject.Inject; import com.google.inject.Singleton; import org.slf4j.Logger; @@ -50,30 +48,21 @@ import static sonia.scm.ContextEntry.ContextBuilder.entity; -//~--- JDK imports ------------------------------------------------------------ - /** * @author Sebastian Sdorra */ @Singleton @Extension -public class SvnRepositoryHandler - extends AbstractSimpleRepositoryHandler { +public class SvnRepositoryHandler extends AbstractSimpleRepositoryHandler { public static final String PROPERTY_UUID = "svn.uuid"; - public static final String RESOURCE_VERSION = "sonia/scm/version/scm-svn-plugin"; - public static final String TYPE_DISPLAYNAME = "Subversion"; - public static final String TYPE_NAME = "svn"; - public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, - TYPE_DISPLAYNAME, - SvnRepositoryServiceProvider.COMMANDS); + public static final RepositoryType TYPE = new RepositoryType(TYPE_NAME, TYPE_DISPLAYNAME, SvnRepositoryServiceProvider.COMMANDS); - private static final Logger logger = - LoggerFactory.getLogger(SvnRepositoryHandler.class); + private static final Logger LOG = LoggerFactory.getLogger(SvnRepositoryHandler.class); private SvnRepositoryHook hook; @Inject @@ -93,8 +82,8 @@ public SvnRepositoryHandler(ConfigurationStoreFactory storeFactory, if (eventFacade != null) { hook = new SvnRepositoryHook(eventFacade, this); FSHooks.registerHook(hook); - } else if (logger.isWarnEnabled()) { - logger.warn( + } else if (LOG.isWarnEnabled()) { + LOG.warn( "unable to register hook, beacause of missing repositorymanager"); } } @@ -127,18 +116,17 @@ protected void create(Repository repository, File directory) throws InternalRepo String uuid = svnRepository.getRepositoryUUID(true); if (Util.isNotEmpty(uuid)) { - if (logger.isDebugEnabled()) { - logger.debug("store repository uuid {} for {}", uuid, + if (LOG.isDebugEnabled()) { + LOG.debug("store repository uuid {} for {}", uuid, repository.getName()); } repository.setProperty(PROPERTY_UUID, uuid); - } else if (logger.isWarnEnabled()) { - logger.warn("could not read repository uuid for {}", + } else if (LOG.isWarnEnabled()) { + LOG.warn("could not read repository uuid for {}", repository.getName()); } } catch (SVNException ex) { - logger.error("could not create svn repository", ex); throw new InternalRepositoryException(repository, "could not create repository", ex); } finally { SvnUtil.closeSession(svnRepository); @@ -148,9 +136,9 @@ protected void create(Repository repository, File directory) throws InternalRepo public SVNURL createSvnUrl(File directory) { Compatibility comp = config.getCompatibility(); - if (logger.isDebugEnabled()) { + if (LOG.isDebugEnabled()) { - logger.debug("create svn repository \"{}\": " + + LOG.debug("create svn repository \"{}\": " + "pre14Compatible={}, " + "pre15Compatible={}, " + "pre16Compatible={}, " + @@ -173,23 +161,11 @@ public SVNURL createSvnUrl(File directory) { } } - /** - * Method description - * - * @return - */ @Override protected SvnConfig createInitialConfig() { return new SvnConfig(); } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * @return - */ @Override protected Class getConfigClass() { return SvnConfig.class; diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java index 3a5a67b2d5..6f1d8a029f 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java @@ -33,6 +33,7 @@ import org.tmatesoft.svn.core.auth.SVNSSLAuthentication; import org.tmatesoft.svn.core.wc.SVNWCUtil; import org.tmatesoft.svn.core.wc.admin.SVNAdminClient; +import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.api.MirrorCommandResult; import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; import sonia.scm.repository.api.UsernamePasswordCredential; @@ -43,6 +44,8 @@ import java.util.Collection; import java.util.function.Function; +import static sonia.scm.ContextEntry.ContextBuilder.entity; + public class SvnMirrorCommand extends AbstractSvnCommand implements MirrorCommand { private final TrustManager trustManager; @@ -66,7 +69,7 @@ public MirrorCommandResult mirror(MirrorCommandRequest mirrorCommandRequest) { admin.doCompleteSynchronize(next, url); } catch (SVNException e) { - e.printStackTrace(); + throw new InternalRepositoryException(entity(String.class, mirrorCommandRequest.getSourceUrl()), "could not mirror svn repository", e); } return null; } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java index eb27df1bc4..27d277a308 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceResolver.java @@ -44,7 +44,8 @@ public class SvnRepositoryServiceResolver implements RepositoryServiceResolver { @Inject public SvnRepositoryServiceResolver(SvnRepositoryHandler handler, SvnWorkingCopyFactory workingCopyFactory, - HookContextFactory hookContextFactory, TrustManager trustManager) { + HookContextFactory hookContextFactory, + TrustManager trustManager) { this.handler = handler; this.workingCopyFactory = workingCopyFactory; this.hookContextFactory = hookContextFactory; From 198d62677d907e8fe3964f9ef389ac2f8b5b62f8 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 17 May 2021 09:48:49 +0200 Subject: [PATCH 05/14] Add changelog entry --- gradle/changelog/svn_mirror_command.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 gradle/changelog/svn_mirror_command.yaml diff --git a/gradle/changelog/svn_mirror_command.yaml b/gradle/changelog/svn_mirror_command.yaml new file mode 100644 index 0000000000..ad0d6441c6 --- /dev/null +++ b/gradle/changelog/svn_mirror_command.yaml @@ -0,0 +1,2 @@ +- type: added + description: Implement Subversion mirror command ([#1660](https://github.com/scm-manager/scm-manager/pull/1660)) From fa0fee298b9547211fbd534b20952a210f20263a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Mon, 17 May 2021 14:07:03 +0200 Subject: [PATCH 06/14] Use byte array for certificate instead of file --- .../scm/repository/api/Pkcs12ClientCertificateCredential.java | 4 +--- .../main/java/sonia/scm/repository/spi/SvnMirrorCommand.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java index 77ad7bd6c4..02b06667db 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Pkcs12ClientCertificateCredential.java @@ -27,12 +27,10 @@ import lombok.AllArgsConstructor; import lombok.Getter; -import java.nio.file.Path; - @AllArgsConstructor @Getter public class Pkcs12ClientCertificateCredential implements Credential { - private final Path certificate; + private final byte[] certificate; private final char[] password; } diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java index 6f1d8a029f..b4b3de7d7f 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java @@ -95,7 +95,7 @@ public TrustManager getTrustManager(SVNURL url) { private SVNSSLAuthentication createTlsAuth(SVNURL url, Pkcs12ClientCertificateCredential c) { return SVNSSLAuthentication.newInstance( - c.getCertificate().toFile(), + c.getCertificate(), c.getPassword(), false, url, From 18ae503ba5ecb82504dcb2ee1819f592633d576c Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Mon, 17 May 2021 14:26:47 +0200 Subject: [PATCH 07/14] WIP --- .../scm/repository/spi/SvnMirrorCommand.java | 36 ++++-- .../spi/AbstractSvnCommandTestBase.java | 56 ++------- .../repository/spi/SvnMirrorCommandTest.java | 118 ++++++++++++++++++ 3 files changed, 149 insertions(+), 61 deletions(-) create mode 100644 scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java index b4b3de7d7f..20072d3722 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnMirrorCommand.java @@ -24,6 +24,10 @@ package sonia.scm.repository.spi; +import com.google.common.base.Stopwatch; +import com.google.common.collect.ImmutableList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.tmatesoft.svn.core.SVNException; import org.tmatesoft.svn.core.SVNURL; import org.tmatesoft.svn.core.auth.BasicAuthenticationManager; @@ -33,7 +37,6 @@ import org.tmatesoft.svn.core.auth.SVNSSLAuthentication; import org.tmatesoft.svn.core.wc.SVNWCUtil; import org.tmatesoft.svn.core.wc.admin.SVNAdminClient; -import sonia.scm.repository.InternalRepositoryException; import sonia.scm.repository.api.MirrorCommandResult; import sonia.scm.repository.api.Pkcs12ClientCertificateCredential; import sonia.scm.repository.api.UsernamePasswordCredential; @@ -44,10 +47,12 @@ import java.util.Collection; import java.util.function.Function; -import static sonia.scm.ContextEntry.ContextBuilder.entity; +import static java.util.Collections.emptyList; public class SvnMirrorCommand extends AbstractSvnCommand implements MirrorCommand { + private static final Logger LOG = LoggerFactory.getLogger(SvnMirrorCommand.class); + private final TrustManager trustManager; private final Function urlFactory; @@ -59,19 +64,31 @@ public class SvnMirrorCommand extends AbstractSvnCommand implements MirrorComman @Override public MirrorCommandResult mirror(MirrorCommandRequest mirrorCommandRequest) { - + Stopwatch stopwatch = Stopwatch.createStarted(); + long beforeUpdate; + long afterUpdate; try { + beforeUpdate = context.open().getLatestRevision(); SVNURL url = urlFactory.apply(context.getDirectory()); - SVNURL next = SVNURL.parseURIEncoded(mirrorCommandRequest.getSourceUrl()); - SVNAdminClient admin = createAdminClient(url, mirrorCommandRequest); admin.doCompleteSynchronize(next, url); + afterUpdate = context.open().getLatestRevision(); } catch (SVNException e) { - throw new InternalRepositoryException(entity(String.class, mirrorCommandRequest.getSourceUrl()), "could not mirror svn repository", e); + LOG.error("Could not mirror svn repository", e); + return new MirrorCommandResult(false, emptyList(), stopwatch.stop().elapsed()); } - return null; + return new MirrorCommandResult( + true, + ImmutableList.of("Updated from revision " + beforeUpdate + " to revision " + afterUpdate), + stopwatch.stop().elapsed() + ); + } + + @Override + public MirrorCommandResult update(MirrorCommandRequest request) { + return mirror(request); } private SVNAdminClient createAdminClient(SVNURL url, MirrorCommandRequest mirrorCommandRequest) { @@ -101,9 +118,4 @@ private SVNSSLAuthentication createTlsAuth(SVNURL url, Pkcs12ClientCertificateCr url, true); } - - @Override - public MirrorCommandResult update(MirrorCommandRequest mirrorCommandRequest) { - return null; - } } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java index 8228d42f39..962a940c2c 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/AbstractSvnCommandTestBase.java @@ -24,78 +24,36 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.junit.After; import java.io.IOException; -/** - * - * @author Sebastian Sdorra - */ -public class AbstractSvnCommandTestBase extends ZippedRepositoryTestBase -{ +public class AbstractSvnCommandTestBase extends ZippedRepositoryTestBase { - /** - * Method description - * - * - * @throws IOException - */ @After - public void close() throws IOException - { - if (context != null) - { + public void close() throws IOException { + if (context != null) { context.close(); } } - /** - * Method description - * - * - * @return - */ - public SvnContext createContext() - { - if (context == null) - { + public SvnContext createContext() { + if (context == null) { context = new SvnContext(repository, repositoryDirectory); } return context; } - //~--- get methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @return - */ @Override - protected String getType() - { + protected String getType() { return "svn"; } - /** - * Method description - * - * - * @return - */ @Override - protected String getZippedRepositoryResource() - { + protected String getZippedRepositoryResource() { return "sonia/scm/repository/spi/scm-svn-spi-test.zip"; } - //~--- fields --------------------------------------------------------------- - - /** Field description */ private SvnContext context; } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java new file mode 100644 index 0000000000..e23c924809 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnMirrorCommandTest.java @@ -0,0 +1,118 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.SVNURL; +import org.tmatesoft.svn.core.io.SVNRepositoryFactory; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.api.MirrorCommandResult; +import sonia.scm.repository.api.SimpleUsernamePasswordCredential; + +import javax.net.ssl.X509TrustManager; +import java.io.File; +import java.io.IOException; +import java.util.function.Consumer; + +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(MockitoJUnitRunner.class) +public class SvnMirrorCommandTest extends AbstractSvnCommandTestBase { + + @Mock + private X509TrustManager trustManager; + + private SvnContext emptyContext; + + @Before + public void bendContextToNewRepository() throws IOException, SVNException { + emptyContext = createEmptyContext(); + } + + @Test + public void shouldDoInitialMirror() { + MirrorCommandResult result = callMirror(emptyContext, repositoryDirectory, c -> { + }); + + assertThat(result.isSuccess()).isTrue(); + } + + @Test + public void shouldDoMirrorUpdate() { + MirrorCommandResult result = callMirrorUpdate(emptyContext, repositoryDirectory); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.getLog()).contains("Updated from revision 0 to revision 1"); + } + + @Test + public void shouldUseCredentials() { + MirrorCommandResult result = callMirror(emptyContext, repositoryDirectory, createCredential("svnadmin", "secret")); + + assertThat(result.isSuccess()).isTrue(); + } + + private MirrorCommandResult callMirrorUpdate(SvnContext context, File source) { + MirrorCommandRequest request = new MirrorCommandRequest(); + SvnMirrorCommand command = createMirrorCommand(context, source, request); + return command.update(request); + } + + private MirrorCommandResult callMirror(SvnContext context, File source, Consumer consumer) { + MirrorCommandRequest request = new MirrorCommandRequest(); + SvnMirrorCommand command = createMirrorCommand(context, source, request); + consumer.accept(request); + return command.mirror(request); + } + + private SvnMirrorCommand createMirrorCommand(SvnContext context, File source, MirrorCommandRequest request) { + return new SvnMirrorCommand(context, trustManager, c -> + { + try { + request.setSourceUrl(SVNURL.fromFile(c).toString()); + return SVNURL.fromFile(source); + } catch (SVNException e) { + throw new IllegalStateException(e); + } + }); + } + + private Consumer createCredential(String username, String password) { + return request -> request.setCredentials(singletonList(new SimpleUsernamePasswordCredential(username, password.toCharArray()))); + } + + private SvnContext createEmptyContext() throws SVNException, IOException { + File dir = tempFolder.newFolder(); + SVNRepositoryFactory.createLocalRepository(dir, true, true); + return new SvnContext(RepositoryTestData.createHappyVerticalPeopleTransporter(), dir); + } + +} From 7865341489a959a4dfdf6f7a75f30dbd1c7962e6 Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Tue, 18 May 2021 16:36:38 +0200 Subject: [PATCH 08/14] fix translation issue & expose react-query for usage in plugins --- scm-ui/ui-webapp/src/containers/loadBundle.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/scm-ui/ui-webapp/src/containers/loadBundle.ts b/scm-ui/ui-webapp/src/containers/loadBundle.ts index 87b127a13b..33847e18ef 100644 --- a/scm-ui/ui-webapp/src/containers/loadBundle.ts +++ b/scm-ui/ui-webapp/src/containers/loadBundle.ts @@ -31,6 +31,7 @@ import * as ReactDOM from "react-dom"; import * as ReactRouterDom from "react-router-dom"; import * as Redux from "redux"; import * as ReactRedux from "react-redux"; +import ReactQueryDefault, * as ReactQuery from "react-query"; import StyledComponentsDefault, * as StyledComponents from "styled-components"; import ReactHookFormDefault, * as ReactHookForm from "react-hook-form"; import * as ReactI18Next from "react-i18next"; @@ -39,6 +40,7 @@ import QueryStringDefault, * as QueryString from "query-string"; import * as UIExtensions from "@scm-manager/ui-extensions"; import * as UIComponents from "@scm-manager/ui-components"; import { urls } from "@scm-manager/ui-components"; +import * as UIApi from "@scm-manager/ui-api"; type PluginModule = { name: string; @@ -53,12 +55,12 @@ const BundleLoader = { headers: { Cache: "no-cache", // identify the request as ajax request - "X-Requested-With": "XMLHttpRequest" - } - }).then(response => { + "X-Requested-With": "XMLHttpRequest", + }, + }).then((response) => { return response.text(); }); - } + }, }; SystemJS.registry.set(BundleLoader.name, SystemJS.newModule(BundleLoader)); @@ -70,9 +72,9 @@ SystemJS.config({ // @ts-ignore typing missing, but seems required esModule: true, authorization: true, - loader: BundleLoader.name - } - } + loader: BundleLoader.name, + }, + }, }); // We have to patch the resolve methods of SystemJS @@ -87,13 +89,13 @@ const resolveModuleUrl = (key: string) => { }; const defaultResolve = SystemJS.resolve; -SystemJS.resolve = function(key, parentName) { +SystemJS.resolve = function (key, parentName) { const module = resolveModuleUrl(key); return defaultResolve.apply(this, [module, parentName]); }; const defaultResolveSync = SystemJS.resolveSync; -SystemJS.resolveSync = function(key, parentName) { +SystemJS.resolveSync = function (key, parentName) { const module = resolveModuleUrl(key); return defaultResolveSync.apply(this, [module, parentName]); }; @@ -105,7 +107,7 @@ const expose = (name: string, cmp: any, defaultCmp?: any) => { // https://github.com/systemjs/systemjs/issues/1749 mod = { ...cmp, - __useDefault: defaultCmp + __useDefault: defaultCmp, }; } SystemJS.set(name, SystemJS.newModule(mod)); @@ -117,10 +119,12 @@ expose("react-router-dom", ReactRouterDom); expose("styled-components", StyledComponents, StyledComponentsDefault); expose("react-i18next", ReactI18Next); expose("react-hook-form", ReactHookForm, ReactHookFormDefault); +expose("react-query", ReactQuery, ReactQueryDefault); expose("classnames", ClassNames, ClassNamesDefault); expose("query-string", QueryString, QueryStringDefault); expose("@scm-manager/ui-extensions", UIExtensions); expose("@scm-manager/ui-components", UIComponents); +expose("@scm-manager/ui-api", UIApi); // redux is deprecated in favor of ui-api, // which will be exported soon From dd33f59b27e2f3a858f4f5f7ea95a657a8d0896e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 19 May 2021 11:24:08 +0200 Subject: [PATCH 09/14] Use supplier to avoid circular calls --- scm-core/src/main/java/sonia/scm/store/AbstractStore.java | 8 +++++--- .../main/java/sonia/scm/store/JAXBConfigurationStore.java | 3 ++- .../sonia/scm/store/JAXBConfigurationStoreFactory.java | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/store/AbstractStore.java b/scm-core/src/main/java/sonia/scm/store/AbstractStore.java index 0cdb72ae4e..73f9d7a1c3 100644 --- a/scm-core/src/main/java/sonia/scm/store/AbstractStore.java +++ b/scm-core/src/main/java/sonia/scm/store/AbstractStore.java @@ -24,6 +24,8 @@ package sonia.scm.store; +import java.util.function.BooleanSupplier; + /** * Base class for {@link ConfigurationStore}. * @@ -38,9 +40,9 @@ public abstract class AbstractStore implements ConfigurationStore { * stored object */ protected T storeObject; - private final boolean readOnly; + private final BooleanSupplier readOnly; - protected AbstractStore(boolean readOnly) { + protected AbstractStore(BooleanSupplier readOnly) { this.readOnly = readOnly; } @@ -55,7 +57,7 @@ public T get() { @Override public void set(T object) { - if (readOnly) { + if (readOnly.getAsBoolean()) { throw new StoreReadOnlyException(object); } writeObject(object); diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java index 474a7b76a8..1b0de60299 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStore.java @@ -28,6 +28,7 @@ import org.slf4j.LoggerFactory; import java.io.File; +import java.util.function.BooleanSupplier; /** * JAXB implementation of {@link ConfigurationStore}. @@ -46,7 +47,7 @@ public class JAXBConfigurationStore extends AbstractStore { private final Class type; private final File configFile; - public JAXBConfigurationStore(TypedStoreContext context, Class type, File configFile, boolean readOnly) { + public JAXBConfigurationStore(TypedStoreContext context, Class type, File configFile, BooleanSupplier readOnly) { super(readOnly); this.context = context; this.type = type; diff --git a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java index cbbb901fd7..25f5f750e6 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java +++ b/scm-dao-xml/src/main/java/sonia/scm/store/JAXBConfigurationStoreFactory.java @@ -57,7 +57,7 @@ public JAXBConfigurationStore getStore(TypedStoreParameters storeParam getStoreLocation(storeParameters.getName().concat(StoreConstants.FILE_EXTENSION), storeParameters.getType(), storeParameters.getRepositoryId()), - mustBeReadOnly(storeParameters) + () -> mustBeReadOnly(storeParameters) ); } } From a1ef791c7e44ab55f693b527627027b314499dff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 19 May 2021 11:47:10 +0200 Subject: [PATCH 10/14] Show tags from extension points in details pages --- scm-ui/ui-components/src/repos/RepositoryEntry.tsx | 2 +- scm-ui/ui-extensions/src/extensionPoints.ts | 2 +- scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx index 261b7ef4a9..661fa9ff79 100644 --- a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx @@ -182,7 +182,7 @@ class RepositoryEntry extends React.Component { <ExtensionPoint name="repository.card.beforeTitle" props={{ repository }} /> <strong>{repository.name}</strong> {repositoryFlags} - <ExtensionPoint name="repository.card.flags" props={{ repository }} renderAll={true} /> + <ExtensionPoint name="repository.flags" props={{ repository }} renderAll={true} /> ); }; diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index 8e10ff8ff0..99afb9ccc5 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -57,4 +57,4 @@ export type RepositoryCreatorExtension = { export type RepositoryCreator = ExtensionPointDefinition<"repos.creator", RepositoryCreatorExtension>; -export type RepositoryCardFlags = ExtensionPointDefinition<"repository.card.flags", { repository: Repository }>; +export type RepositoryFlags = ExtensionPointDefinition<"repository.flags", { repository: Repository }>; diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index 69a5e6d21f..18cadad2a9 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -248,7 +248,10 @@ const RepositoryRoot = () => { afterTitle={ <> - {repositoryFlags.map((flag) => flag)} + + {repositoryFlags} + + } > From 42745c9e3468cdef1762a4e5a400f2156bd528b7 Mon Sep 17 00:00:00 2001 From: Florian Scholdei Date: Thu, 20 May 2021 08:30:20 +0200 Subject: [PATCH 11/14] Notifications for health checks (#1664) Add list of emergency contacts to global configuration. This user will receive e-mails and notification if some serious system error occurs like repository health check failed. --- docs/de/user/admin/settings.md | 3 + docs/en/user/admin/settings.md | 3 + .../changelog/health_check_notifications.yaml | 2 + .../sonia/scm/config/ScmConfiguration.java | 21 + scm-ui/ui-api/src/config.test.ts | 1 + scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-api/src/userSuggestions.ts | 52 +++ .../AutocompleteAddEntryToTableField.tsx | 2 +- scm-ui/ui-types/src/Config.ts | 1 + .../ui-webapp/public/locales/de/config.json | 8 +- .../ui-webapp/public/locales/en/config.json | 8 +- .../src/admin/components/form/ConfigForm.tsx | 304 +++++++-------- .../admin/components/form/GeneralSettings.tsx | 358 ++++++++++-------- .../form/NamespaceStrategySelect.tsx | 2 +- .../src/groups/containers/CreateGroup.tsx | 31 +- .../src/groups/containers/EditGroup.tsx | 32 +- .../sonia/scm/api/v2/resources/ConfigDto.java | 1 + .../scm/api/v2/resources/UpdateConfigDto.java | 2 + .../sonia/scm/repository/HealthChecker.java | 51 ++- .../main/resources/locales/de/plugins.json | 3 +- .../main/resources/locales/en/plugins.json | 3 +- ...ConfigDtoToScmConfigurationMapperTest.java | 3 + ...ScmConfigurationToConfigDtoMapperTest.java | 9 +- .../scm/repository/HealthCheckerTest.java | 39 +- 24 files changed, 543 insertions(+), 397 deletions(-) create mode 100644 gradle/changelog/health_check_notifications.yaml create mode 100644 scm-ui/ui-api/src/userSuggestions.ts diff --git a/docs/de/user/admin/settings.md b/docs/de/user/admin/settings.md index f89180e4b5..452780e96d 100644 --- a/docs/de/user/admin/settings.md +++ b/docs/de/user/admin/settings.md @@ -41,6 +41,9 @@ Ist der Benutzer Konverter aktiviert, werden alle internen Benutzer beim Einlogg #### Fallback E-Mail Domain Name Dieser Domain Name wird genutzt, wenn für einen User eine E-Mail-Adresse benötigt wird, für den keine hinterlegt ist. Diese Domain wird nicht zum Versenden von E-Mails genutzt und auch keine anderweitige Verbindung aufgebaut. +#### Notfallkontakte +Die folgenden Benutzer werden über administrative Vorfälle informiert (z. B. fehlgeschlagene Integritätsprüfungen). + #### Anmeldeversuche Es lässt sich konfigurieren wie häufig sich ein Benutzer falsch anmelden darf, bevor dessen Benutzerkonto gesperrt wird. Der Zähler für fehlerhafte Anmeldeversuche wird nach einem erfolgreichen Login zurückgesetzt. Man kann dieses Feature abschalten, indem man "-1" in die Konfiguration einträgt. diff --git a/docs/en/user/admin/settings.md b/docs/en/user/admin/settings.md index 527716f113..d49e89b6b1 100644 --- a/docs/en/user/admin/settings.md +++ b/docs/en/user/admin/settings.md @@ -41,6 +41,9 @@ Internal users will automatically be converted to external on their first login #### Fallback Mail Domain Name This domain name will be used to create email addresses for users without one when needed. It will not be used to send mails nor will be accessed otherwise. +#### Emergency Contacts +The following users will be notified of administrative incidents (e.g. failed health checks). + #### Login Attempt Limit It can be configured how many failed login attempts a user can have before the account gets disabled. The counter for failed login attempts is reset after a successful login. This feature can be deactivated by setting the value "-1". diff --git a/gradle/changelog/health_check_notifications.yaml b/gradle/changelog/health_check_notifications.yaml new file mode 100644 index 0000000000..c24a34e63c --- /dev/null +++ b/gradle/changelog/health_check_notifications.yaml @@ -0,0 +1,2 @@ +- type: added + description: Notifications for health checks ([#1664](https://github.com/scm-manager/scm-manager/pull/1664)) diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java index f5e0754bbb..dfc448032f 100644 --- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java +++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java @@ -214,6 +214,14 @@ public class ScmConfiguration implements Configuration { @XmlElement(name = "mail-domain-name") private String mailDomainName = DEFAULT_MAIL_DOMAIN_NAME; + /** + * List of users that will be notified of administrative incidents. + * + * @since 2.19.0 + */ + @XmlElement(name = "emergency-contacts") + private Set emergencyContacts; + /** * Fires the {@link ScmConfigurationChangedEvent}. */ @@ -253,6 +261,7 @@ public void load(ScmConfiguration other) { this.loginInfoUrl = other.loginInfoUrl; this.releaseFeedUrl = other.releaseFeedUrl; this.mailDomainName = other.mailDomainName; + this.emergencyContacts = other.emergencyContacts; this.enabledUserConverter = other.enabledUserConverter; this.enabledApiKeys = other.enabledApiKeys; } @@ -456,6 +465,14 @@ public boolean isSkipFailedAuthenticators() { return skipFailedAuthenticators; } + public Set getEmergencyContacts() { + if (emergencyContacts == null) { + emergencyContacts = Sets.newHashSet(); + } + + return emergencyContacts; + } + /** * Enables the anonymous access at protocol level. * @@ -621,6 +638,10 @@ public void setLoginInfoUrl(String loginInfoUrl) { this.loginInfoUrl = loginInfoUrl; } + public void setEmergencyContacts(Set emergencyContacts) { + this.emergencyContacts = emergencyContacts; + } + @Override // Only for permission checks, don't serialize to XML @XmlTransient diff --git a/scm-ui/ui-api/src/config.test.ts b/scm-ui/ui-api/src/config.test.ts index 9ba7376238..8b357b8354 100644 --- a/scm-ui/ui-api/src/config.test.ts +++ b/scm-ui/ui-api/src/config.test.ts @@ -48,6 +48,7 @@ describe("Test config hooks", () => { loginInfoUrl: "", mailDomainName: "", namespaceStrategy: "", + emergencyContacts: [], pluginUrl: "", proxyExcludes: [], proxyPassword: null, diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 974bd58233..f3c3b420f5 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -32,6 +32,7 @@ export * from "./base"; export * from "./login"; export * from "./groups"; export * from "./users"; +export * from "./userSuggestions"; export * from "./repositories"; export * from "./namespaces"; export * from "./branches"; diff --git a/scm-ui/ui-api/src/userSuggestions.ts b/scm-ui/ui-api/src/userSuggestions.ts new file mode 100644 index 0000000000..f2e7221add --- /dev/null +++ b/scm-ui/ui-api/src/userSuggestions.ts @@ -0,0 +1,52 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import {DisplayedUser, Link, SelectValue} from "@scm-manager/ui-types"; +import { useIndexLinks } from "./base"; +import { apiClient } from "./apiclient"; + +export const useUserSuggestions = () => { + const indexLinks = useIndexLinks(); + const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users"); + if (!autocompleteLink) { + return []; + } + const url = autocompleteLink.href + "?q="; + return (inputValue: string): never[] | Promise => { + // Prevent violate input condition of api call because parameter length is too short + if (inputValue.length < 2) { + return []; + } + return apiClient + .get(url + inputValue) + .then(response => response.json()) + .then(json => { + return json.map((element: DisplayedUser) => { + return { + value: element, + label: `${element.displayName} (${element.id})` + }; + }); + }); + }; +}; diff --git a/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx b/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx index 70366a4716..48666dc0a5 100644 --- a/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx +++ b/scm-ui/ui-components/src/forms/AutocompleteAddEntryToTableField.tsx @@ -32,7 +32,7 @@ type Props = { addEntry: (p: SelectValue) => void; disabled?: boolean; buttonLabel: string; - fieldLabel: string; + fieldLabel?: string; helpText?: string; loadSuggestions: (p: string) => Promise; placeholder?: string; diff --git a/scm-ui/ui-types/src/Config.ts b/scm-ui/ui-types/src/Config.ts index ede3fd1558..808c8e7a59 100644 --- a/scm-ui/ui-types/src/Config.ts +++ b/scm-ui/ui-types/src/Config.ts @@ -50,5 +50,6 @@ export type Config = HalRepresentation & { loginInfoUrl: string; releaseFeedUrl: string; mailDomainName: string; + emergencyContacts: string[]; enabledApiKeys: boolean; }; diff --git a/scm-ui/ui-webapp/public/locales/de/config.json b/scm-ui/ui-webapp/public/locales/de/config.json index 32605873b3..cf0799becc 100644 --- a/scm-ui/ui-webapp/public/locales/de/config.json +++ b/scm-ui/ui-webapp/public/locales/de/config.json @@ -61,7 +61,13 @@ "enabled-user-converter": "Benutzer Konverter aktivieren", "enabled-api-keys": "API Schlüssel aktivieren", "namespace-strategy": "Namespace Strategie", - "login-info-url": "Login Info URL" + "login-info-url": "Login Info URL", + "emergencyContacts": { + "label": "Notfallkontakte", + "helpText": "Liste der Benutzer, die über administrative Vorfälle informiert werden.", + "addButton": "Kontakt hinzufügen", + "autocompletePlaceholder": "Nutzer zum Benachrichtigen hinzufügen" + } }, "validation": { "date-format-invalid": "Das Datumsformat ist ungültig", diff --git a/scm-ui/ui-webapp/public/locales/en/config.json b/scm-ui/ui-webapp/public/locales/en/config.json index 032a4bef6c..47722eab64 100644 --- a/scm-ui/ui-webapp/public/locales/en/config.json +++ b/scm-ui/ui-webapp/public/locales/en/config.json @@ -61,7 +61,13 @@ "enabled-user-converter": "Enabled User Converter", "enabled-api-keys": "Enabled API Keys", "namespace-strategy": "Namespace Strategy", - "login-info-url": "Login Info URL" + "login-info-url": "Login Info URL", + "emergencyContacts": { + "label": "Emergency Contacts", + "helpText": "List of users notified of administrative incidents.", + "addButton": "Add Contact", + "autocompletePlaceholder": "Add User to Notify" + } }, "validation": { "date-format-invalid": "The date format is not valid", diff --git a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx index f2d0f92068..db3df04dfa 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/ConfigForm.tsx @@ -21,8 +21,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; +import React, { FC, useState, useEffect, FormEvent } from "react"; +import { useTranslation } from "react-i18next"; import { Config, NamespaceStrategies } from "@scm-manager/ui-types"; import { Level, Notification, SubmitButton } from "@scm-manager/ui-components"; import ProxySettings from "./ProxySettings"; @@ -30,7 +30,7 @@ import GeneralSettings from "./GeneralSettings"; import BaseUrlSettings from "./BaseUrlSettings"; import LoginAttempt from "./LoginAttempt"; -type Props = WithTranslation & { +type Props = { submitForm: (p: Config) => void; config?: Config; loading?: boolean; @@ -39,185 +39,157 @@ type Props = WithTranslation & { namespaceStrategies?: NamespaceStrategies; }; -type State = { - config: Config; - showNotification: boolean; - error: { +const ConfigForm: FC = ({ + submitForm, + config, + loading, + configReadPermission, + configUpdatePermission, + namespaceStrategies +}) => { + const [t] = useTranslation("config"); + const [innerConfig, setInnerConfig] = useState({ + proxyPassword: null, + proxyPort: 0, + proxyServer: "", + proxyUser: null, + enableProxy: false, + realmDescription: "", + disableGroupingGrid: false, + dateFormat: "", + anonymousAccessEnabled: false, + anonymousMode: "OFF", + baseUrl: "", + forceBaseUrl: false, + loginAttemptLimit: 0, + proxyExcludes: [], + skipFailedAuthenticators: false, + pluginUrl: "", + loginAttemptLimitTimeout: 0, + enabledXsrfProtection: true, + enabledUserConverter: false, + namespaceStrategy: "", + loginInfoUrl: "", + releaseFeedUrl: "", + mailDomainName: "", + emergencyContacts: [], + enabledApiKeys: true, + _links: {} + }); + const [showNotification, setShowNotification] = useState(false); + const [changed, setChanged] = useState(false); + const [error, setError] = useState<{ loginAttemptLimitTimeout: boolean; loginAttemptLimit: boolean; - }; - changed: boolean; -}; - -class ConfigForm extends React.Component { - constructor(props: Props) { - super(props); + }>({ + loginAttemptLimitTimeout: false, + loginAttemptLimit: false + }); - this.state = { - config: { - proxyPassword: null, - proxyPort: 0, - proxyServer: "", - proxyUser: null, - enableProxy: false, - realmDescription: "", - disableGroupingGrid: false, - dateFormat: "", - anonymousMode: "OFF", - baseUrl: "", - mailDomainName: "", - forceBaseUrl: false, - loginAttemptLimit: 0, - proxyExcludes: [], - skipFailedAuthenticators: false, - pluginUrl: "", - loginAttemptLimitTimeout: 0, - enabledXsrfProtection: true, - enabledUserConverter: false, - namespaceStrategy: "", - loginInfoUrl: "", - _links: {} - }, - showNotification: false, - error: { - loginAttemptLimitTimeout: false, - loginAttemptLimit: false - }, - changed: false - }; - } - - componentDidMount() { - const { config, configUpdatePermission } = this.props; + useEffect(() => { if (config) { - this.setState({ - ...this.state, - config: { - ...config - } - }); + setInnerConfig(config); } if (!configUpdatePermission) { - this.setState({ - ...this.state, - showNotification: true - }); + setShowNotification(true); } - } + }, [config, configUpdatePermission]); - submit = (event: Event) => { + const submit = (event: FormEvent) => { event.preventDefault(); - this.setState({ - changed: false - }); - this.props.submitForm(this.state.config); + setChanged(false); + submitForm(innerConfig); }; - render() { - const { loading, t, namespaceStrategies, configReadPermission, configUpdatePermission } = this.props; - const config = this.state.config; + const onChange = (isValid: boolean, changedValue: any, name: string) => { + setInnerConfig({ ...innerConfig, [name]: changedValue }); + setError({ ...error, [name]: !isValid }); + setChanged(true); + }; - let noPermissionNotification = null; + const hasError = () => { + return error.loginAttemptLimit || error.loginAttemptLimitTimeout; + }; - if (!configReadPermission) { - return ; - } + const onClose = () => { + setShowNotification(false); + }; - if (this.state.showNotification) { - noPermissionNotification = ( - this.onClose()} - /> - ); - } + let noPermissionNotification = null; - return ( -
- {noPermissionNotification} - this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- this.onChange(isValid, changedValue, name)} - hasUpdatePermission={configUpdatePermission} - /> -
- - } - /> - - ); + if (!configReadPermission) { + return ; } - onChange = (isValid: boolean, changedValue: any, name: string) => { - this.setState({ - ...this.state, - config: { - ...this.state.config, - [name]: changedValue - }, - error: { - ...this.state.error, - [name]: !isValid - }, - changed: true - }); - }; - - hasError = () => { - return this.state.error.loginAttemptLimit || this.state.error.loginAttemptLimitTimeout; - }; + if (showNotification) { + noPermissionNotification = ( + onClose()} + /> + ); + } - onClose = () => { - this.setState({ - ...this.state, - showNotification: false - }); - }; -} + return ( +
+ {noPermissionNotification} + onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ onChange(isValid, changedValue, name)} + hasUpdatePermission={configUpdatePermission} + /> +
+ + } + /> + + ); +}; -export default withTranslation("config")(ConfigForm); +export default ConfigForm; diff --git a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx index 390a8726a7..ccdad61c83 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/GeneralSettings.tsx @@ -21,13 +21,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { WithTranslation, withTranslation } from "react-i18next"; -import { Checkbox, InputField, Select } from "@scm-manager/ui-components"; -import { NamespaceStrategies, AnonymousMode } from "@scm-manager/ui-types"; +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useUserSuggestions } from "@scm-manager/ui-api"; +import { NamespaceStrategies, AnonymousMode, SelectValue } from "@scm-manager/ui-types"; +import { + Checkbox, + InputField, + MemberNameTagGroup, + AutocompleteAddEntryToTableField, + Select +} from "@scm-manager/ui-components"; import NamespaceStrategySelect from "./NamespaceStrategySelect"; -type Props = WithTranslation & { +type Props = { realmDescription: string; loginInfoUrl: string; disableGroupingGrid: boolean; @@ -40,176 +47,207 @@ type Props = WithTranslation & { enabledXsrfProtection: boolean; enabledUserConverter: boolean; enabledApiKeys: boolean; + emergencyContacts: string[]; namespaceStrategy: string; namespaceStrategies?: NamespaceStrategies; onChange: (p1: boolean, p2: any, p3: string) => void; hasUpdatePermission: boolean; }; -class GeneralSettings extends React.Component { - render() { - const { - t, - realmDescription, - loginInfoUrl, - pluginUrl, - releaseFeedUrl, - mailDomainName, - enabledXsrfProtection, - enabledUserConverter, - enabledApiKeys, - anonymousMode, - namespaceStrategy, - hasUpdatePermission, - namespaceStrategies - } = this.props; +const GeneralSettings: FC = ({ + realmDescription, + loginInfoUrl, + anonymousMode, + pluginUrl, + releaseFeedUrl, + mailDomainName, + enabledXsrfProtection, + enabledUserConverter, + enabledApiKeys, + emergencyContacts, + namespaceStrategy, + namespaceStrategies, + onChange, + hasUpdatePermission +}) => { + const { t } = useTranslation("config"); + const userSuggestions = useUserSuggestions(); - return ( -
-
-
- -
-
- -
-
-
-
- -
-
- -
-
-
-
- -
-
- +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+
+ ); +}; + +export default GeneralSettings; diff --git a/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx b/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx index 847d6b6e8d..e5a17fa47a 100644 --- a/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx +++ b/scm-ui/ui-webapp/src/admin/components/form/NamespaceStrategySelect.tsx @@ -27,7 +27,7 @@ import { NamespaceStrategies } from "@scm-manager/ui-types"; import { Select } from "@scm-manager/ui-components"; type Props = WithTranslation & { - namespaceStrategies: NamespaceStrategies; + namespaceStrategies?: NamespaceStrategies; label: string; value?: string; disabled?: boolean; diff --git a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx index a8a01f7bdb..5a7f3f521b 100644 --- a/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/CreateGroup.tsx @@ -22,46 +22,25 @@ * SOFTWARE. */ import React, { FC } from "react"; +import { Redirect } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { DisplayedUser, Link } from "@scm-manager/ui-types"; -import { apiClient, Page } from "@scm-manager/ui-components"; +import { useCreateGroup, useUserSuggestions } from "@scm-manager/ui-api"; +import { Page } from "@scm-manager/ui-components"; import GroupForm from "../components/GroupForm"; -import { useCreateGroup, useIndexLinks } from "@scm-manager/ui-api"; -import { Redirect } from "react-router-dom"; const CreateGroup: FC = () => { const [t] = useTranslation("groups"); const { isLoading, create, error, group } = useCreateGroup(); - const indexLinks = useIndexLinks(); + const userSuggestions = useUserSuggestions(); if (group) { return ; } - // TODO: Replace with react-query hook - const loadUserAutocompletion = (inputValue: string) => { - const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users"); - if (!autocompleteLink) { - return []; - } - const url = autocompleteLink.href + "?q="; - return apiClient - .get(url + inputValue) - .then(response => response.json()) - .then(json => { - return json.map((element: DisplayedUser) => { - return { - value: element, - label: `${element.displayName} (${element.id})` - }; - }); - }); - }; - return (
- +
); diff --git a/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx index 58d20c1116..e1ec8455b0 100644 --- a/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/EditGroup.tsx @@ -22,49 +22,29 @@ * SOFTWARE. */ import React, { FC } from "react"; +import { Redirect } from "react-router-dom"; +import { Group } from "@scm-manager/ui-types"; +import { useUpdateGroup, useUserSuggestions } from "@scm-manager/ui-api"; +import { ErrorNotification } from "@scm-manager/ui-components"; import GroupForm from "../components/GroupForm"; -import { DisplayedUser, Group, Link } from "@scm-manager/ui-types"; -import { apiClient, ErrorNotification } from "@scm-manager/ui-components"; import DeleteGroup from "./DeleteGroup"; -import { useIndexLinks, useUpdateGroup } from "@scm-manager/ui-api"; -import { Redirect } from "react-router-dom"; type Props = { group: Group; }; const EditGroup: FC = ({ group }) => { - const indexLinks = useIndexLinks(); const { error, isLoading, update, isUpdated } = useUpdateGroup(); - const autocompleteLink = (indexLinks.autocomplete as Link[]).find(i => i.name === "users"); + const userSuggestions = useUserSuggestions(); if (isUpdated) { return ; } - // TODO: Replace with react-query hook - const loadUserAutocompletion = (inputValue: string) => { - if (!autocompleteLink) { - return []; - } - const url = autocompleteLink.href + "?q="; - return apiClient - .get(url + inputValue) - .then(response => response.json()) - .then(json => { - return json.map((element: DisplayedUser) => { - return { - value: element, - label: `${element.displayName} (${element.id})` - }; - }); - }); - }; - return (
- +
); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java index 6aa9a2a321..82c9bdb4a8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java @@ -62,6 +62,7 @@ public class ConfigDto extends HalRepresentation implements UpdateConfigDto { private String loginInfoUrl; private String releaseFeedUrl; private String mailDomainName; + private Set emergencyContacts; @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java index dbe07a2620..f62c51db13 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UpdateConfigDto.java @@ -74,4 +74,6 @@ interface UpdateConfigDto { String getReleaseFeedUrl(); String getMailDomainName(); + + Set getEmergencyContacts(); } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java b/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java index 24339f0fbd..b3ea7a7552 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java @@ -25,9 +25,14 @@ package sonia.scm.repository; import com.google.inject.Inject; +import org.apache.shiro.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.notifications.Notification; +import sonia.scm.notifications.NotificationSender; +import sonia.scm.notifications.Type; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -59,15 +64,22 @@ final class HealthChecker { private final ExecutorService healthCheckExecutor = Executors.newSingleThreadExecutor(); + private final ScmConfiguration scmConfiguration; + private final NotificationSender notificationSender; + @Inject HealthChecker(Set checks, RepositoryManager repositoryManager, RepositoryServiceFactory repositoryServiceFactory, - RepositoryPostProcessor repositoryPostProcessor) { + RepositoryPostProcessor repositoryPostProcessor, + ScmConfiguration scmConfiguration, + NotificationSender notificationSender) { this.checks = checks; this.repositoryManager = repositoryManager; this.repositoryServiceFactory = repositoryServiceFactory; this.repositoryPostProcessor = repositoryPostProcessor; + this.scmConfiguration = scmConfiguration; + this.notificationSender = notificationSender; } void lightCheck(String id) { @@ -155,13 +167,14 @@ private HealthCheckResult gatherLightChecks(Repository repository) { private void doFullCheck(Repository repository) { withLockedRepository(repository, () -> - runInExecutorAndWait(repository, () -> { - HealthCheckResult lightCheckResult = gatherLightChecks(repository); - HealthCheckResult fullCheckResult = gatherFullChecks(repository); - HealthCheckResult result = lightCheckResult.merge(fullCheckResult); - - storeResult(repository, result); - }) + runInExecutorAndWait(repository, () -> { + HealthCheckResult lightCheckResult = gatherLightChecks(repository); + HealthCheckResult fullCheckResult = gatherFullChecks(repository); + HealthCheckResult result = lightCheckResult.merge(fullCheckResult); + + notifyCurrentUser(repository, result); + storeResult(repository, result); + }) ); } @@ -204,10 +217,32 @@ private void storeResult(Repository repository, HealthCheckResult result) { logger.trace("store health check results for repository {}", repository); repositoryPostProcessor.setCheckResults(repository, result.getFailures()); + + notifyEmergencyContacts(repository); } } public boolean checkRunning(String repositoryId) { return checksRunning.contains(repositoryId); } + + private void notifyCurrentUser(Repository repository, HealthCheckResult result) { + if (!(repository.isHealthy() && result.isHealthy())) { + String currentUser = SecurityUtils.getSubject().getPrincipal().toString(); + if (!scmConfiguration.getEmergencyContacts().contains(currentUser)) { + notificationSender.send(getHealthCheckFailedNotification(repository)); + } + } + } + + private void notifyEmergencyContacts(Repository repository) { + Set emergencyContacts = scmConfiguration.getEmergencyContacts(); + for (String user : emergencyContacts) { + notificationSender.send(getHealthCheckFailedNotification(repository), user); + } + } + + private Notification getHealthCheckFailedNotification(Repository repository) { + return new Notification(Type.ERROR, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "healthCheckFailed"); + } } diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index ccabef40ff..8ec998c15a 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -427,6 +427,7 @@ }, "notifications": { "exportFinished": "Der Repository Export wurde abgeschlossen.", - "exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator." + "exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator.", + "healthCheckFailed": "Der Repository Health Check ist fehlgeschlagen." } } diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 1ac1b8f043..723f26dee1 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -371,6 +371,7 @@ }, "notifications": { "exportFinished": "The repository export has been finished.", - "exportFailed": "The repository export has failed. Try it again or contact your administrator." + "exportFailed": "The repository export has failed. Try it again or contact your administrator.", + "healthCheckFailed": "The repository health check has failed." } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java index 632841b78f..acba81b3e8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java @@ -44,6 +44,7 @@ public class ConfigDtoToScmConfigurationMapperTest { private ConfigDtoToScmConfigurationMapperImpl mapper; private final String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedUsers = {"trillian", "arthur"}; @Before public void init() { @@ -76,6 +77,7 @@ public void shouldMapFields() { assertEquals("username", config.getNamespaceStrategy()); assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl()); assertEquals("hitchhiker.mail", config.getMailDomainName()); + assertTrue("emergencyContacts", config.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers))); } @Test @@ -115,6 +117,7 @@ private ConfigDto createDefaultDto() { configDto.setNamespaceStrategy("username"); configDto.setLoginInfoUrl("https://scm-manager.org/login-info"); configDto.setMailDomainName("hitchhiker.mail"); + configDto.setEmergencyContacts(Sets.newSet(expectedUsers)); configDto.setEnabledUserConverter(false); return configDto; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java index 49769bf1b3..2c5b8608cc 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java @@ -49,11 +49,10 @@ public class ScmConfigurationToConfigDtoMapperTest { - private URI baseUri = URI.create("http://example.com/base/"); + private final URI baseUri = URI.create("http://example.com/base/"); - private String[] expectedUsers = {"trillian", "arthur"}; - private String[] expectedGroups = {"admin", "plebs"}; - private String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedExcludes = {"ex", "clude"}; + private final String[] expectedUsers = {"trillian", "arthur"}; @SuppressWarnings("unused") // Is injected private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @@ -107,6 +106,7 @@ public void shouldMapFields() { assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl()); assertEquals("https://www.scm-manager.org/download/rss.xml", dto.getReleaseFeedUrl()); assertEquals("scm-manager.local", dto.getMailDomainName()); + assertTrue("emergencyContacts", dto.getEmergencyContacts().containsAll(Arrays.asList(expectedUsers))); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref()); assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref()); @@ -161,6 +161,7 @@ private ScmConfiguration createConfiguration() { config.setNamespaceStrategy("username"); config.setLoginInfoUrl("https://scm-manager.org/login-info"); config.setReleaseFeedUrl("https://www.scm-manager.org/download/rss.xml"); + config.setEmergencyContacts(Sets.newSet(expectedUsers)); return config; } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java index c3cb9cb287..69bf829477 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java @@ -24,6 +24,7 @@ package sonia.scm.repository; +import com.google.common.collect.ImmutableSet; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.Subject; import org.apache.shiro.util.ThreadContext; @@ -35,6 +36,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.notifications.NotificationSender; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.FullHealthCheckCommandBuilder; import sonia.scm.repository.api.RepositoryService; @@ -47,11 +50,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -74,6 +80,10 @@ class HealthCheckerTest { private RepositoryService repositoryService; @Mock private RepositoryPostProcessor postProcessor; + @Mock + private ScmConfiguration scmConfiguration; + @Mock + private NotificationSender notificationSender; @Mock private Subject subject; @@ -82,7 +92,7 @@ class HealthCheckerTest { @BeforeEach void initializeChecker() { - this.checker = new HealthChecker(of(healthCheck1, healthCheck2), repositoryManager, repositoryServiceFactory, postProcessor); + this.checker = new HealthChecker(of(healthCheck1, healthCheck2), repositoryManager, repositoryServiceFactory, postProcessor, scmConfiguration, notificationSender); } @BeforeEach @@ -182,6 +192,7 @@ class ForFullChecks { void setUpRepository() { lenient().when(repositoryServiceFactory.create(repository)).thenReturn(repositoryService); lenient().when(repositoryService.getFullCheckCommand()).thenReturn(fullHealthCheckCommand); + lenient().when(subject.getPrincipal()).thenReturn("trillian"); } @Test @@ -240,6 +251,32 @@ void shouldComputeFullChecks() throws IOException { return true; })); } + + @Test + void shouldNotifyCurrentUserOnlyOnce() throws IOException { + when(scmConfiguration.getEmergencyContacts()).thenReturn(ImmutableSet.of("trillian")); + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(repositoryService.isSupported(Command.FULL_HEALTH_CHECK)).thenReturn(true); + when(fullHealthCheckCommand.check()).thenReturn(HealthCheckResult.unhealthy(createFailure("error"))); + + checker.fullCheck(repositoryId); + + verify(notificationSender, never()).send(any()); + verify(notificationSender,times(1)).send(any(), eq("trillian")); + } + + @Test + void shouldNotifyEmergencyContacts() throws IOException { + when(scmConfiguration.getEmergencyContacts()).thenReturn(ImmutableSet.of("trillian", "Arthur")); + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(repositoryService.isSupported(Command.FULL_HEALTH_CHECK)).thenReturn(true); + when(fullHealthCheckCommand.check()).thenReturn(HealthCheckResult.unhealthy(createFailure("error"))); + + checker.fullCheck(repositoryId); + + verify(notificationSender).send(any(), eq("trillian")); + verify(notificationSender).send(any(), eq("Arthur")); + } } } From d7d0d2375c6136a86329289b848461f69b1a9e63 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Thu, 20 May 2021 14:14:58 +0200 Subject: [PATCH 12/14] Update jgit to v5.11.1.202105131744-r-scm1 (#1661) Update jgit to v5.11.1.202105131744-r-scm1 Co-authored-by: Eduard Heimbuch --- scm-plugins/scm-git-plugin/build.gradle | 2 +- .../src/main/java/sonia/scm/repository/GitConfig.java | 9 ++++++++- .../scm/repository/spi/GitWorkingCopyInitializer.java | 8 ++++++++ .../spi/GitModifyCommand_withEmptyRepositoryTest.java | 4 ++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/scm-plugins/scm-git-plugin/build.gradle b/scm-plugins/scm-git-plugin/build.gradle index cf9c37a989..d77a1e6675 100644 --- a/scm-plugins/scm-git-plugin/build.gradle +++ b/scm-plugins/scm-git-plugin/build.gradle @@ -27,7 +27,7 @@ plugins { id 'org.scm-manager.smp' version '0.7.5' } -def jgitVersion = '5.10.0.202012080955-r-scm2' +def jgitVersion = '5.11.1.202105131744-r-scm1' dependencies { // required by scm-it diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java index f699093f73..2efac8451a 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitConfig.java @@ -26,6 +26,8 @@ //~--- JDK imports ------------------------------------------------------------ +import com.google.common.base.Strings; + import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; @@ -39,6 +41,8 @@ @XmlAccessorType(XmlAccessType.FIELD) public class GitConfig extends RepositoryConfig { + private static final String FALLBACK_BRANCH = "main"; + @SuppressWarnings("WeakerAccess") // This might be needed for permission checking public static final String PERMISSION = "git"; @@ -49,7 +53,7 @@ public class GitConfig extends RepositoryConfig { private boolean nonFastForwardDisallowed; @XmlElement(name = "default-branch") - private String defaultBranch = "main"; + private String defaultBranch = FALLBACK_BRANCH; public String getGcExpression() { return gcExpression; @@ -68,6 +72,9 @@ public void setNonFastForwardDisallowed(boolean nonFastForwardDisallowed) { } public String getDefaultBranch() { + if (Strings.isNullOrEmpty(defaultBranch)) { + return FALLBACK_BRANCH; + } return defaultBranch; } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java index 77d9399825..45df640830 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitWorkingCopyInitializer.java @@ -27,6 +27,7 @@ import com.google.common.base.Stopwatch; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.TransportException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; @@ -75,6 +76,13 @@ public ParentAndClone initialize(File target, String ini } return new ParentAndClone<>(null, clone, target); + } catch (TransportException e) { + String message = e.getMessage(); + if (initialBranch != null && message.contains(initialBranch) && message.contains("not found")) { + throw notFound(entity("Branch", initialBranch).in(context.getRepository())); + } else { + throw new InternalRepositoryException(context.getRepository(), "could not clone working copy of repository", e); + } } catch (GitAPIException | IOException e) { throw new InternalRepositoryException(context.getRepository(), "could not clone working copy of repository", e); } finally { diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java index 97ab9a9776..74989cca8c 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitModifyCommand_withEmptyRepositoryTest.java @@ -60,14 +60,14 @@ public void shouldCreateNewFileInEmptyRepository() throws IOException, GitAPIExc } @Test - public void shouldCreateCommitOnMasterByDefault() throws IOException, GitAPIException { + public void shouldCreateCommitOnMainByDefault() throws IOException, GitAPIException { createContext().getGlobalConfig().setDefaultBranch(""); executeModifyCommand(); try (Git git = new Git(createContext().open())) { List branches = git.branchList().call(); - assertThat(branches).extracting("name").containsExactly("refs/heads/master"); + assertThat(branches).extracting("name").containsExactly("refs/heads/main"); } } From a04f34156a2f1b7bf25ae43ce19c59bfa38c0607 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 20 May 2021 16:07:50 +0200 Subject: [PATCH 13/14] Fix cache invalidation for repository import/mirror --- .../WrappedRepositoryHookEvent.java | 23 +++++++------------ ...PostReceiveRepositoryHookEventFactory.java | 6 +++-- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java b/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java index 2b6811457b..ee615a62ea 100644 --- a/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java +++ b/scm-core/src/main/java/sonia/scm/repository/WrappedRepositoryHookEvent.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; /** @@ -30,17 +30,14 @@ * @author Sebastian Sdorra * @since 1.23 */ -public class WrappedRepositoryHookEvent extends RepositoryHookEvent -{ +public class WrappedRepositoryHookEvent extends RepositoryHookEvent { /** * Constructs a new WrappedRepositoryHookEvent. * - * * @param wrappedEvent event to wrap */ - protected WrappedRepositoryHookEvent(RepositoryHookEvent wrappedEvent) - { + protected WrappedRepositoryHookEvent(RepositoryHookEvent wrappedEvent) { super(wrappedEvent.getContext(), wrappedEvent.getRepository(), wrappedEvent.getType()); } @@ -50,28 +47,24 @@ protected WrappedRepositoryHookEvent(RepositoryHookEvent wrappedEvent) /** * Returns a wrapped instance of the {@link RepositoryHookEvent}- * - * * @param event event to wrap - * * @return wrapper */ - public static WrappedRepositoryHookEvent wrap(RepositoryHookEvent event) - { + public static WrappedRepositoryHookEvent wrap(RepositoryHookEvent event) { WrappedRepositoryHookEvent wrappedEvent = null; - switch (event.getType()) - { - case POST_RECEIVE : + switch (event.getType()) { + case POST_RECEIVE: wrappedEvent = new PostReceiveRepositoryHookEvent(event); break; - case PRE_RECEIVE : + case PRE_RECEIVE: wrappedEvent = new PreReceiveRepositoryHookEvent(event); break; - default : + default: throw new IllegalArgumentException("unsupported hook event type"); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java index d044a25813..b9d7d982c5 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/PostReceiveRepositoryHookEventFactory.java @@ -28,8 +28,10 @@ import org.eclipse.jgit.transport.FetchResult; import sonia.scm.ContextEntry; import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.PostReceiveRepositoryHookEvent; import sonia.scm.repository.RepositoryHookEvent; import sonia.scm.repository.Tag; +import sonia.scm.repository.WrappedRepositoryHookEvent; import sonia.scm.repository.api.ImportFailedException; import javax.inject.Inject; @@ -51,12 +53,12 @@ class PostReceiveRepositoryHookEventFactory { } void fireForFetch(Git git, FetchResult result) { - RepositoryHookEvent event; + PostReceiveRepositoryHookEvent event; try { List branches = getBranchesFromFetchResult(result); List tags = getTagsFromFetchResult(result); GitLazyChangesetResolver changesetResolver = new GitLazyChangesetResolver(context.getRepository(), git); - event = eventFactory.createEvent(context, branches, tags, changesetResolver); + event = new PostReceiveRepositoryHookEvent(WrappedRepositoryHookEvent.wrap(eventFactory.createEvent(context, branches, tags, changesetResolver))); } catch (IOException e) { throw new ImportFailedException( ContextEntry.ContextBuilder.entity(context.getRepository()).build(), From 7d27e8ba35cfd66c95e321e91b9a98aa33c46ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 20 May 2021 16:30:15 +0200 Subject: [PATCH 14/14] Enhance read only check interface with verb --- .../java/sonia/scm/repository/ReadOnlyCheck.java | 3 +++ .../scm/repository/RepositoryPermissionGuard.java | 13 ++++++++++--- .../sonia/scm/repository/ReadOnlyCheckTest.java | 9 +++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java index 6e69581e8b..31f302d0ee 100644 --- a/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java +++ b/scm-core/src/main/java/sonia/scm/repository/ReadOnlyCheck.java @@ -75,4 +75,7 @@ default void check(String repositoryId) { } } + default boolean isReadOnly(String permission, String repositoryId) { + return isReadOnly(repositoryId); + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java index 9192b6bdf3..ad45ebb1ba 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryPermissionGuard.java @@ -62,16 +62,23 @@ public PermissionActionCheckInterceptor intercept(String permission) if (READ_ONLY_VERBS.contains(permission)) { return new PermissionActionCheckInterceptor() {}; } else { - return new WriteInterceptor(); + return new WriteInterceptor(permission); } } private static class WriteInterceptor implements PermissionActionCheckInterceptor { + + private final String permission; + + private WriteInterceptor(String permission) { + this.permission = permission; + } + @Override public void check(Subject subject, String id, Runnable delegate) { delegate.run(); for (ReadOnlyCheck check : readOnlyChecks) { - if (check.isReadOnly(id)) { + if (check.isReadOnly(permission, id)) { throw new AuthorizationException(check.getReason()); } } @@ -83,7 +90,7 @@ public boolean isPermitted(Subject subject, String id, BooleanSupplier delegate) } private boolean isWritable(String id) { - return readOnlyChecks.stream().noneMatch(c -> c.isReadOnly(id)); + return readOnlyChecks.stream().noneMatch(c -> c.isReadOnly(permission, id)); } } } diff --git a/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java b/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java index 7c322c0113..50031ceaa4 100644 --- a/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java +++ b/scm-core/src/test/java/sonia/scm/repository/ReadOnlyCheckTest.java @@ -31,7 +31,7 @@ class ReadOnlyCheckTest { - private final ReadOnlyCheck check = new TesingReadOnlyCheck(); + private final ReadOnlyCheck check = new TestingReadOnlyCheck(); private final Repository repository = new Repository("42", "git", "hitchhiker", "hog"); @@ -55,7 +55,12 @@ void shouldNotThrowException() { assertDoesNotThrow(() -> check.check("21")); } - private class TesingReadOnlyCheck implements ReadOnlyCheck { + @Test + void shouldDelegateToNormalCheck() { + assertThat(check.isReadOnly("any", "42")).isTrue(); + } + + private class TestingReadOnlyCheck implements ReadOnlyCheck { @Override public String getReason() {