From ac419daa3fccfed1299624601f2c4f8bd01ca5f5 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 12 Jan 2023 14:01:04 +0100 Subject: [PATCH] Add ConfigurationAdapterBase and extension points for trash bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new abstract class ConfigurationAdapterBase to simplify the creation of global configuration views. In addition there is some cleanup, interfaces and extension points for the repository trash bin plugin. Committed-by: Eduard Heimbuch Co-authored-by: Eduard Heimbuch Co-authored-by: René Pfeuffer Co-authored-by: Konstantin Schaper --- Jenkinsfile | 2 +- gradle/changelog/abstract_config_adapter.yaml | 2 + .../changelog/correct_revision_to_merge.yaml | 2 + .../resources/ConfigurationAdapterBase.java | 228 ++++++++++++++++++ .../lifecycle/PrivilegedStartupAction.java | 0 .../repository/FullRepositoryExporter.java | 31 +++ .../repository/FullRepositoryImporter.java | 31 +++ .../scm/repository/spi/GitMergeRebase.java | 2 +- .../repository/spi/GitMergeWithSquash.java | 4 +- .../repository/spi/GitMergeCommandTest.java | 2 +- scm-ui/ui-api/src/configLink.ts | 6 +- scm-ui/ui-buttons/package.json | 2 +- scm-ui/ui-buttons/src/Button.tsx | 4 + .../src/BackendErrorNotification.tsx | 8 +- .../ui-components/src/ErrorNotification.tsx | 18 +- .../src/config/ConfigurationBinder.tsx | 36 ++- scm-ui/ui-extensions/src/extensionPoints.ts | 5 + scm-ui/ui-forms/package.json | 5 +- scm-ui/ui-forms/src/ConfigurationForm.tsx | 54 +++++ scm-ui/ui-forms/src/Form.tsx | 7 +- scm-ui/ui-forms/src/index.ts | 1 + scm-ui/ui-forms/src/resourceHooks.ts | 3 + .../src/select/ControlledSelectField.tsx | 77 ++++++ scm-ui/ui-forms/src/select/Select.tsx | 43 ++++ scm-ui/ui-forms/src/select/SelectField.tsx | 59 +++++ scm-ui/ui-plugins/package.json | 2 + scm-ui/ui-scripts/src/createPluginConfig.js | 30 +-- scm-ui/ui-webapp/package.json | 1 + .../repos/containers/RepositoryDangerZone.tsx | 9 +- .../FullScmRepositoryExporter.java | 36 ++- .../FullScmRepositoryImporter.java | 7 +- .../lifecycle/modules/ScmServletModule.java | 24 +- .../ConfigurationAdapterBaseTest.java | 213 ++++++++++++++++ .../FullScmRepositoryExporterTest.java | 9 +- 34 files changed, 879 insertions(+), 84 deletions(-) create mode 100644 gradle/changelog/abstract_config_adapter.yaml create mode 100644 gradle/changelog/correct_revision_to_merge.yaml create mode 100644 scm-core/src/main/java/sonia/scm/api/v2/resources/ConfigurationAdapterBase.java rename {scm-webapp => scm-core}/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java (100%) create mode 100644 scm-core/src/main/java/sonia/scm/repository/FullRepositoryExporter.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/FullRepositoryImporter.java create mode 100644 scm-ui/ui-forms/src/ConfigurationForm.tsx create mode 100644 scm-ui/ui-forms/src/select/ControlledSelectField.tsx create mode 100644 scm-ui/ui-forms/src/select/Select.tsx create mode 100644 scm-ui/ui-forms/src/select/SelectField.tsx create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigurationAdapterBaseTest.java diff --git a/Jenkinsfile b/Jenkinsfile index 5273e0bf27..1ca98da669 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -88,7 +88,7 @@ pipeline { sh 'git fetch origin develop' script { withSonarQubeEnv('sonarcloud.io-scm') { - String parameters = ' -Dsonar.organization=scm-manager' + String parameters = ' -Dsonar.organization=scm-manager -Dsonar.analysis.scmm-repo=scm-manager/scm-manager' if (env.CHANGE_ID) { parameters += ' -Dsonar.pullrequest.provider=GitHub' parameters += ' -Dsonar.pullrequest.github.repository=scm-manager/scm-manager' diff --git a/gradle/changelog/abstract_config_adapter.yaml b/gradle/changelog/abstract_config_adapter.yaml new file mode 100644 index 0000000000..ee421fc767 --- /dev/null +++ b/gradle/changelog/abstract_config_adapter.yaml @@ -0,0 +1,2 @@ +- type: added + description: Add abstract configuration adapter to simply creating new global configurations diff --git a/gradle/changelog/correct_revision_to_merge.yaml b/gradle/changelog/correct_revision_to_merge.yaml new file mode 100644 index 0000000000..ec6df3e9a8 --- /dev/null +++ b/gradle/changelog/correct_revision_to_merge.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: The 'revision to merge' in merge results diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ConfigurationAdapterBase.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ConfigurationAdapterBase.java new file mode 100644 index 0000000000..4fb84d14d2 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ConfigurationAdapterBase.java @@ -0,0 +1,228 @@ +/* + * 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.api.v2.resources; + +import com.github.sdorra.ssp.PermissionCheck; +import com.google.common.annotations.Beta; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Link; +import de.otto.edison.hal.Links; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.config.ConfigurationPermissions; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; + +import javax.inject.Provider; +import javax.validation.Valid; +import javax.validation.constraints.NotNull; +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.UriInfo; + +/** + * This can be used as a base class for configuration resources. + * + * @param The class of the data access object used to persist the configuration. + * @param The class of the data transfer objects used in the rest api. + * @since 2.41.0 + */ +@Beta +@Slf4j +public abstract class ConfigurationAdapterBase implements HalEnricher { + + protected final ConfigurationStoreFactory configurationStoreFactory; + + protected final Provider scmPathInfoStoreProvider; + + private final Class daoClass; + private final DtoToDaoMapper dtoToDaoMapper; + private final DaoToDtoMapper daoToDtoMapper; + + /** + * Creates the resource. To do so, you have to provide the {@link ConfigurationStoreFactory} and + * the {@link ScmPathInfoStore} as a {@link Provider} and implementations for + * the DAO and the DTO. + *
+ * The DAO class has to have a default constructor that creates the default (initial) + * configuration. + *
+ * The DTO class should be created with @GenerateDto annotation using Conveyor. + * If the implementation is done manually, it has to provide two methods: + *
    + *
  • A static method DTO from(DAO, Links) creating the DTO instance + * for the given DAO with the provided links.
  • + *
  • A method DAO toEntity() creating the DAO from the DTO.
  • + *
+ * If either one is missing, you will see {@link IllegalDaoClassException}s on your way. + *
+ * The implementation may look like this: + *
+   *   @Path("/v2/test")
+   *     private static class TestConfigurationAdapter extends ConfigurationAdapterBase {
+   *
+   *     @Inject
+   *     public TestConfigurationResource(ConfigurationStoreFactory configurationStoreFactory, Provider scmPathInfoStoreProvider) {
+   *       super(configurationStoreFactory, scmPathInfoStoreProvider, TestConfiguration.class, TestConfigurationDto.class);
+   *     }
+   *
+   *     @Override
+   *     protected String getName() {
+   *       return "testConfig";
+   *     }
+   *   }
+   * 
+ * + * @param configurationStoreFactory The configuration store factory provided from injection. + * @param scmPathInfoStoreProvider The path info store provider provided from injection. + * @param daoClass The DAO class instance. + * @param dtoClass The DTO class instance. + */ + @SuppressWarnings("unchecked") + protected ConfigurationAdapterBase(ConfigurationStoreFactory configurationStoreFactory, + Provider scmPathInfoStoreProvider, + Class daoClass, + Class dtoClass) { + this.configurationStoreFactory = configurationStoreFactory; + this.scmPathInfoStoreProvider = scmPathInfoStoreProvider; + this.daoClass = daoClass; + this.dtoToDaoMapper = (DTO dto) -> { + try { + return (DAO) dtoClass.getDeclaredMethod("toEntity").invoke(dto); + } catch (Exception e) { + throw new IllegalDtoClassException(e); + } + }; + this.daoToDtoMapper = (DAO entity, Links.Builder linkBuilder) -> { + try { + return (DTO) dtoClass.getMethod("from", daoClass, Links.class) + .invoke(null, entity, linkBuilder.build()); + } catch (Exception e) { + throw new IllegalDtoClassException(e); + } + }; + } + + public DAO getConfiguration() { + return getConfigStore().getOptional().orElse(createDefaultDaoInstance()); + } + + protected abstract String getName(); + + protected String getStoreName() { + return toKebap(getName()); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + @Path("") + public DTO get(@Context UriInfo uriInfo) { + getReadPermission().check(); + return daoToDtoMapper.mapDaoToDto(getConfiguration(), createDtoLinks()); + } + + private DAO createDefaultDaoInstance() { + try { + return daoClass.getConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalDaoClassException(e); + } + } + + private ConfigurationStore getConfigStore() { + return configurationStoreFactory.withType(daoClass).withName(toKebap(getName())).build(); + } + + @PUT + @Consumes(MediaType.APPLICATION_JSON) + @Path("") + public void update(@NotNull @Valid DTO payload) { + getWritePermission().check(); + getConfigStore().set(dtoToDaoMapper.mapDtoToDao(payload)); + } + + private Links.Builder createDtoLinks() { + Links.Builder builder = Links.linkingTo(); + builder.single(Link.link("self", getReadLink())); + if (getWritePermission().isPermitted()) { + builder.single(Link.link("update", getUpdateLink())); + } + + return builder; + } + + private String getReadLink() { + LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStoreProvider.get().get(), this.getClass()); + return linkBuilder.method("get").parameters().href(); + } + + private String getUpdateLink() { + LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStoreProvider.get().get(), this.getClass()); + return linkBuilder.method("update").parameters().href(); + } + + @Override + public final void enrich(HalEnricherContext context, HalAppender appender) { + if (getReadPermission().isPermitted()) { + appender.appendLink(getName(), getReadLink()); + } + } + + protected PermissionCheck getReadPermission() { + return ConfigurationPermissions.read(getName()); + } + + protected PermissionCheck getWritePermission() { + return ConfigurationPermissions.write(getName()); + } + + private static class IllegalDtoClassException extends RuntimeException { + public IllegalDtoClassException(Throwable cause) { + super("Missing method #from(DAO, Links) or #toEntity() in DTO class; see JavaDoc for ConfigurationResourceBase", cause); + } + } + + private static class IllegalDaoClassException extends RuntimeException { + public IllegalDaoClassException(Throwable cause) { + super("Missing default constructor in DAO class; see JavaDoc for ConfigurationResourceBase", cause); + } + } + + private String toKebap(String other) { + return other.replaceAll("([a-z])([A-Z]+)", "$1-$2").toLowerCase(); + } + + private interface DaoToDtoMapper { + DTO mapDaoToDto(DAO entity, Links.Builder linkBuilder); + } + + private interface DtoToDaoMapper { + DAO mapDtoToDao(DTO dto); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java b/scm-core/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java similarity index 100% rename from scm-webapp/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java rename to scm-core/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java diff --git a/scm-core/src/main/java/sonia/scm/repository/FullRepositoryExporter.java b/scm-core/src/main/java/sonia/scm/repository/FullRepositoryExporter.java new file mode 100644 index 0000000000..e62fe6416e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/FullRepositoryExporter.java @@ -0,0 +1,31 @@ +/* + * 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; + +import java.io.OutputStream; + +public interface FullRepositoryExporter { + void export(Repository repository, OutputStream outputStream, String password); +} diff --git a/scm-core/src/main/java/sonia/scm/repository/FullRepositoryImporter.java b/scm-core/src/main/java/sonia/scm/repository/FullRepositoryImporter.java new file mode 100644 index 0000000000..f636ba1973 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/FullRepositoryImporter.java @@ -0,0 +1,31 @@ +/* + * 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; + +import java.io.InputStream; + +public interface FullRepositoryImporter { + Repository importFromStream(Repository repository, InputStream inputStream, String password); +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java index 65cba4f7ba..c81190c634 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeRebase.java @@ -87,7 +87,7 @@ public class GitMergeRebase extends GitMergeStrategy { .include(branchToMerge, sourceRevision) .call(); push(); - return MergeCommandResult.success(getTargetRevision().name(), branchToMerge, sourceRevision.name()); + return createSuccessResult(sourceRevision.name()); } catch (GitAPIException e) { return MergeCommandResult.failure(branchToMerge, targetBranch, result.getConflicts()); } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java index 9a4bae1434..b541576e5b 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeWithSquash.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.spi; import org.eclipse.jgit.api.Git; @@ -51,7 +51,7 @@ class GitMergeWithSquash extends GitMergeStrategy { if (result.getMergeStatus().isSuccessful()) { RevCommit revCommit = doCommit().orElseThrow(() -> new NoChangesMadeException(getRepository())); push(); - return MergeCommandResult.success(getTargetRevision().name(), revCommit.name(), extractRevisionFromRevCommit(revCommit)); + return createSuccessResult(extractRevisionFromRevCommit(revCommit)); } else { return analyseFailure(result); } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java index 68dc5f6464..5ce95f11f6 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitMergeCommandTest.java @@ -296,7 +296,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase { Repository repository = createContext().open(); assertThat(mergeCommandResult.isSuccess()).isTrue(); - assertThat(mergeCommandResult.getRevisionToMerge()).isEqualTo(mergeCommandResult.getNewHeadRevision()); + assertThat(mergeCommandResult.getRevisionToMerge()).isEqualTo("35597e9e98fe53167266583848bfef985c2adb27"); assertThat(mergeCommandResult.getTargetRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec"); Iterable commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call(); diff --git a/scm-ui/ui-api/src/configLink.ts b/scm-ui/ui-api/src/configLink.ts index 23bdbc6c18..4ba3e50201 100644 --- a/scm-ui/ui-api/src/configLink.ts +++ b/scm-ui/ui-api/src/configLink.ts @@ -51,7 +51,7 @@ export const useConfigLink = (link: string) => { const { isLoading: isUpdating, error: mutationError, - mutate, + mutateAsync, data: updateResponse, } = useMutation>( (vars: MutationVariables) => apiClient.put(vars.link, vars.configuration, vars.contentType), @@ -67,7 +67,7 @@ export const useConfigLink = (link: string) => { const update = useCallback( (configuration: C) => { if (data && !isReadOnly) { - mutate({ + return mutateAsync({ configuration, contentType: data.contentType, link: (data.configuration._links.update as Link).href, @@ -76,7 +76,7 @@ export const useConfigLink = (link: string) => { }, // eslint means we should add C to the dependency array, but C is only a type // eslint-disable-next-line react-hooks/exhaustive-deps - [mutate, data, isReadOnly] + [mutateAsync, data, isReadOnly] ); return { diff --git a/scm-ui/ui-buttons/package.json b/scm-ui/ui-buttons/package.json index 58e31de6d9..bc9261671b 100644 --- a/scm-ui/ui-buttons/package.json +++ b/scm-ui/ui-buttons/package.json @@ -1,7 +1,7 @@ { "name": "@scm-manager/ui-buttons", "version": "2.40.2-SNAPSHOT", - "private": true, + "private": false, "main": "build/index.js", "module": "build/index.mjs", "types": "build/index.d.ts", diff --git a/scm-ui/ui-buttons/src/Button.tsx b/scm-ui/ui-buttons/src/Button.tsx index 946a744f31..d07ec9899c 100644 --- a/scm-ui/ui-buttons/src/Button.tsx +++ b/scm-ui/ui-buttons/src/Button.tsx @@ -27,6 +27,7 @@ import { Link as ReactRouterLink, LinkProps as ReactRouterLinkProps } from "reac import classNames from "classnames"; import { createAttributesForTesting } from "@scm-manager/ui-components"; +/** @Beta */ export const ButtonVariants = { PRIMARY: "primary", SECONDARY: "secondary", @@ -57,6 +58,7 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes; /** * Styled html button + * @Beta */ export const Button = React.forwardRef( ({ className, variant, isLoading, testId, children, ...props }, ref) => ( @@ -75,6 +77,7 @@ type LinkButtonProps = BaseButtonProps & ReactRouterLinkProps; /** * Styled react router link + * @Beta */ export const LinkButton = React.forwardRef( ({ className, variant, isLoading, testId, children, ...props }, ref) => ( @@ -93,6 +96,7 @@ type ExternalLinkButtonProps = BaseButtonProps & AnchorHTMLAttributes( ({ className, variant, isLoading, testId, children, ...props }, ref) => ( diff --git a/scm-ui/ui-components/src/BackendErrorNotification.tsx b/scm-ui/ui-components/src/BackendErrorNotification.tsx index 7879782493..45e1a4797a 100644 --- a/scm-ui/ui-components/src/BackendErrorNotification.tsx +++ b/scm-ui/ui-components/src/BackendErrorNotification.tsx @@ -21,16 +21,16 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC } from "react"; +import React, { ComponentProps, FC } from "react"; import { BackendError } from "@scm-manager/ui-api"; import Notification from "./Notification"; import { useTranslation } from "react-i18next"; -type Props = { +type Props = Omit, "type" | "role"> & { error: BackendError; }; -const BackendErrorNotification: FC = ({ error }) => { +const BackendErrorNotification: FC = ({ error, ...props }) => { const [t] = useTranslation("plugins"); const renderErrorName = () => { @@ -141,7 +141,7 @@ const BackendErrorNotification: FC = ({ error }) => { }; return ( - +

{t("error.subtitle")} diff --git a/scm-ui/ui-components/src/ErrorNotification.tsx b/scm-ui/ui-components/src/ErrorNotification.tsx index 3997d5d581..6d18b34019 100644 --- a/scm-ui/ui-components/src/ErrorNotification.tsx +++ b/scm-ui/ui-components/src/ErrorNotification.tsx @@ -21,14 +21,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC } from "react"; +import React, { ComponentProps, FC } from "react"; import { useTranslation } from "react-i18next"; import { BackendError, ForbiddenError, UnauthorizedError, urls } from "@scm-manager/ui-api"; import Notification from "./Notification"; import BackendErrorNotification from "./BackendErrorNotification"; import { useLocation } from "react-router-dom"; -type Props = { +type Props = ComponentProps & { error?: Error | null; }; @@ -40,31 +40,31 @@ const LoginLink: FC = () => { return {t("errorNotification.loginLink")}; }; -const BasicErrorMessage: FC = ({ children }) => { +const BasicErrorMessage: FC, "type" | "role">> = ({ children, ...props }) => { const [t] = useTranslation("commons"); return ( - + {t("errorNotification.prefix")}: {children} ); }; -const ErrorNotification: FC = ({ error }) => { +const ErrorNotification: FC = ({ error, ...props }) => { const [t] = useTranslation("commons"); if (error) { if (error instanceof BackendError) { - return ; + return ; } else if (error instanceof UnauthorizedError) { return ( - + {t("errorNotification.timeout")} ); } else if (error instanceof ForbiddenError) { - return {t("errorNotification.forbidden")}; + return {t("errorNotification.forbidden")}; } else { - return {error.message}; + return {error.message}; } } return null; diff --git a/scm-ui/ui-components/src/config/ConfigurationBinder.tsx b/scm-ui/ui-components/src/config/ConfigurationBinder.tsx index 33d3418fbe..e6f8988013 100644 --- a/scm-ui/ui-components/src/config/ConfigurationBinder.tsx +++ b/scm-ui/ui-components/src/config/ConfigurationBinder.tsx @@ -21,12 +21,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; -import { binder } from "@scm-manager/ui-extensions"; +import React, { ComponentProps } from "react"; +import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import { NavLink } from "../navigation"; import { Route } from "react-router-dom"; import { WithTranslation, withTranslation } from "react-i18next"; -import { Repository, Links, Link } from "@scm-manager/ui-types"; +import { Link, Links, Repository } from "@scm-manager/ui-types"; import { urls } from "@scm-manager/ui-api"; type GlobalRouteProps = { @@ -44,8 +44,13 @@ type RepositoryNavProps = WithTranslation & { url: string }; class ConfigurationBinder { i18nNamespace = "plugins"; - navLink(to: string, labelI18nKey: string, t: any) { - return ; + navLink( + to: string, + labelI18nKey: string, + t: any, + options: Omit, "label" | "to"> = {} + ) { + return ; } route(path: string, Component: any) { @@ -86,6 +91,27 @@ class ConfigurationBinder { binder.bind("admin.route", ConfigRoute, configPredicate); } + bindAdmin( + to: string, + labelI18nKey: string, + icon: string, + linkName: string, + Component: React.ComponentType<{ link: string }> + ) { + const predicate = ({ links }: extensionPoints.AdminRoute["props"]) => links[linkName]; + + const AdminNavLink = withTranslation(this.i18nNamespace)( + ({ t, url }: WithTranslation & extensionPoints.AdminNavigation["props"]) => + this.navLink(url + to, labelI18nKey, t, { icon }) + ); + + const AdminRoute: extensionPoints.AdminRoute["type"] = ({ links, url }) => + this.route(url + to, ); + + binder.bind("admin.route", AdminRoute, predicate); + binder.bind("admin.navigation", AdminNavLink, predicate); + } + bindRepository(to: string, labelI18nKey: string, linkName: string, RepositoryComponent: any) { // create predicate based on the link name of the current repository route // if the linkname is not available, the navigation link and the route are not bound to the extension points diff --git a/scm-ui/ui-extensions/src/extensionPoints.ts b/scm-ui/ui-extensions/src/extensionPoints.ts index 6498eee69d..e1de447b67 100644 --- a/scm-ui/ui-extensions/src/extensionPoints.ts +++ b/scm-ui/ui-extensions/src/extensionPoints.ts @@ -660,3 +660,8 @@ export type FileViewActionBarOverflowMenu = ExtensionPointDefinition< >; export type LoginForm = RenderableExtensionPointDefinition<"login.form">; + +export type RepositoryDeleteButton = RenderableExtensionPointDefinition< + "repository.deleteButton", + { repository: Repository } +>; diff --git a/scm-ui/ui-forms/package.json b/scm-ui/ui-forms/package.json index 04e72c8cf9..27bc9f17ca 100644 --- a/scm-ui/ui-forms/package.json +++ b/scm-ui/ui-forms/package.json @@ -1,6 +1,6 @@ { "name": "@scm-manager/ui-forms", - "private": true, + "private": false, "version": "2.40.2-SNAPSHOT", "main": "build/index.js", "types": "build/index.d.ts", @@ -40,7 +40,8 @@ "react-query": "3" }, "dependencies": { - "@scm-manager/ui-buttons": "^2.40.2-SNAPSHOT" + "@scm-manager/ui-buttons": "^2.40.2-SNAPSHOT", + "@scm-manager/ui-api": "^2.40.2-SNAPSHOT" }, "prettier": "@scm-manager/prettier-config", "eslintConfig": { diff --git a/scm-ui/ui-forms/src/ConfigurationForm.tsx b/scm-ui/ui-forms/src/ConfigurationForm.tsx new file mode 100644 index 0000000000..48f48cf7f9 --- /dev/null +++ b/scm-ui/ui-forms/src/ConfigurationForm.tsx @@ -0,0 +1,54 @@ +/* + * 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 {useConfigLink} from "@scm-manager/ui-api"; +import {Loading} from "@scm-manager/ui-components"; +import React, {ComponentProps} from "react"; +import {HalRepresentation} from "@scm-manager/ui-types"; +import Form from "./Form"; + +type Props = Pick>, "translationPath" | "children"> & { + link: string; +}; + +/** @Beta */ +export function ConfigurationForm({link, translationPath, children}: Props) { + const {initialConfiguration, isReadOnly, update, isLoading} = useConfigLink(link); + + if (isLoading || !initialConfiguration) { + return ; + } + + return ( + + onSubmit={update} + translationPath={translationPath} + defaultValues={initialConfiguration} + readOnly={isReadOnly} + > + {children} + + ); +} + +export default ConfigurationForm; diff --git a/scm-ui/ui-forms/src/Form.tsx b/scm-ui/ui-forms/src/Form.tsx index 28431eac0e..a27086ce29 100644 --- a/scm-ui/ui-forms/src/Form.tsx +++ b/scm-ui/ui-forms/src/Form.tsx @@ -33,6 +33,7 @@ import ControlledInputField from "./input/ControlledInputField"; import ControlledCheckboxField from "./checkbox/ControlledCheckboxField"; import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField"; import { HalRepresentation } from "@scm-manager/ui-types"; +import ControlledSelectField from "./select/ControlledSelectField"; type RenderProps> = Omit< UseFormReturn, @@ -61,6 +62,7 @@ type Props, DefaultValues extends FormT submitButtonTestId?: string; }; +/** @Beta */ function Form, DefaultValues extends FormType>({ children, onSubmit, @@ -84,11 +86,11 @@ function Form, DefaultValues extends Fo useEffect(() => { if (isSubmitSuccessful) { setShowSuccessNotification(true); - reset(defaultValues as never); } - /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [isSubmitSuccessful]); + useEffect(() => reset(defaultValues as never), [defaultValues, reset]); + useEffect(() => { if (isDirty) { setShowSuccessNotification(false); @@ -158,4 +160,5 @@ export default Object.assign(Form, { Input: ControlledInputField, Checkbox: ControlledCheckboxField, SecretConfirmation: ControlledSecretConfirmationField, + Select: ControlledSelectField, }); diff --git a/scm-ui/ui-forms/src/index.ts b/scm-ui/ui-forms/src/index.ts index c590733aed..bbfc8605ed 100644 --- a/scm-ui/ui-forms/src/index.ts +++ b/scm-ui/ui-forms/src/index.ts @@ -23,4 +23,5 @@ */ export { default as Form } from "./Form"; +export { default as ConfigurationForm } from "./ConfigurationForm"; export * from "./resourceHooks"; diff --git a/scm-ui/ui-forms/src/resourceHooks.ts b/scm-ui/ui-forms/src/resourceHooks.ts index 4e8ace4621..cdb12c03af 100644 --- a/scm-ui/ui-forms/src/resourceHooks.ts +++ b/scm-ui/ui-forms/src/resourceHooks.ts @@ -66,6 +66,7 @@ const createResource = (link: string, contentType: string) => { type CreateResourceOptions = MutatingResourceOptions; +/** @Beta */ export const useCreateResource = ( link: string, [entityKey, collectionName]: QueryKeyPair, @@ -91,6 +92,7 @@ type UpdateResourceOptions = MutatingResourceOptions & { collectionName?: QueryKeyPair; }; +/** @Beta */ export const useUpdateResource = ( link: LinkOrHalLink, idFactory: (createdResource: T) => string, @@ -123,6 +125,7 @@ type DeleteResourceOptions = { collectionName?: QueryKeyPair; }; +/** @Beta */ export const useDeleteResource = ( idFactory: (createdResource: T) => string, { collectionName: [entityQueryKey, collectionName] = ["", ""] }: DeleteResourceOptions = {} diff --git a/scm-ui/ui-forms/src/select/ControlledSelectField.tsx b/scm-ui/ui-forms/src/select/ControlledSelectField.tsx new file mode 100644 index 0000000000..1226ddd5dc --- /dev/null +++ b/scm-ui/ui-forms/src/select/ControlledSelectField.tsx @@ -0,0 +1,77 @@ +/* + * 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 React, { ComponentProps } from "react"; +import { Controller, ControllerRenderProps, Path } from "react-hook-form"; +import classNames from "classnames"; +import { useScmFormContext } from "../ScmFormContext"; +import SelectField from "./SelectField"; + +type Props> = Omit< + ComponentProps, + "error" | "label" | "required" | keyof ControllerRenderProps +> & { + rules?: ComponentProps["rules"]; + name: Path; + label?: string; +}; + +function ControlledSelectField>({ + name, + label, + helpText, + rules, + className, + testId, + defaultValue, + readOnly, + ...props +}: Props) { + const { control, t, readOnly: formReadonly } = useScmFormContext(); + const labelTranslation = label || t(`${name}.label`) || ""; + const helpTextTranslation = helpText || t(`${name}.helpText`); + return ( + ( + + )} + /> + ); +} + +export default ControlledSelectField; diff --git a/scm-ui/ui-forms/src/select/Select.tsx b/scm-ui/ui-forms/src/select/Select.tsx new file mode 100644 index 0000000000..1c832ac4ba --- /dev/null +++ b/scm-ui/ui-forms/src/select/Select.tsx @@ -0,0 +1,43 @@ +/* + * 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 React, { InputHTMLAttributes } from "react"; +import classNames from "classnames"; +import { createVariantClass, Variant } from "../variants"; +import { createAttributesForTesting } from "@scm-manager/ui-components"; + +type Props = { + variant?: Variant; + testId?: string; +} & InputHTMLAttributes; + +const Select = React.forwardRef(({ variant, children, className, testId, ...props }, ref) => ( +

+ +
+)); + +export default Select; diff --git a/scm-ui/ui-forms/src/select/SelectField.tsx b/scm-ui/ui-forms/src/select/SelectField.tsx new file mode 100644 index 0000000000..1da9dcbc4e --- /dev/null +++ b/scm-ui/ui-forms/src/select/SelectField.tsx @@ -0,0 +1,59 @@ +/* + * 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 React from "react"; +import Field from "../base/Field"; +import Control from "../base/Control"; +import Label from "../base/label/Label"; +import FieldMessage from "../base/field-message/FieldMessage"; +import Help from "../base/help/Help"; +import Select from "./Select"; + +type Props = { + label: string; + helpText?: string; + error?: string; +} & React.ComponentProps; + +/** + * @see https://bulma.io/documentation/form/select/ + */ +const SelectField = React.forwardRef( + ({ label, helpText, error, className, ...props }, ref) => { + const variant = error ? "danger" : undefined; + return ( + + + + + + {error ? {error} : null} + + ); + } +); +export default SelectField; diff --git a/scm-ui/ui-plugins/package.json b/scm-ui/ui-plugins/package.json index 377aba0155..7138d860d4 100644 --- a/scm-ui/ui-plugins/package.json +++ b/scm-ui/ui-plugins/package.json @@ -8,6 +8,8 @@ "dependencies": { "@scm-manager/ui-components": "2.40.2-SNAPSHOT", "@scm-manager/ui-extensions": "2.40.2-SNAPSHOT", + "@scm-manager/ui-forms": "2.40.2-SNAPSHOT", + "@scm-manager/ui-buttons": "2.40.2-SNAPSHOT", "classnames": "^2.2.6", "query-string": "6.14.1", "react": "^17.0.1", diff --git a/scm-ui/ui-scripts/src/createPluginConfig.js b/scm-ui/ui-scripts/src/createPluginConfig.js index 61ab0e1cf8..2a0657450b 100644 --- a/scm-ui/ui-scripts/src/createPluginConfig.js +++ b/scm-ui/ui-scripts/src/createPluginConfig.js @@ -35,11 +35,11 @@ if (orgaIndex > 0) { name = name.substring(orgaIndex + 1); } -module.exports = function(mode) { +module.exports = function (mode) { return { context: root, entry: { - [name]: [path.resolve(__dirname, "webpack-public-path.js"), packageJSON.main || "src/main/js/index.js"] + [name]: [path.resolve(__dirname, "webpack-public-path.js"), packageJSON.main || "src/main/js/index.js"], }, mode, stats: "minimal", @@ -48,7 +48,7 @@ module.exports = function(mode) { node: { fs: "empty", net: "empty", - tls: "empty" + tls: "empty", }, externals: [ "react", @@ -59,11 +59,13 @@ module.exports = function(mode) { "@scm-manager/ui-types", "@scm-manager/ui-extensions", "@scm-manager/ui-components", + "@scm-manager/ui-forms", + "@scm-manager/ui-buttons", "classnames", "query-string", "redux", "react-redux", - /^@scm-manager\/scm-.*-plugin$/i + /^@scm-manager\/scm-.*-plugin$/i, ], module: { rules: [ @@ -73,29 +75,29 @@ module.exports = function(mode) { use: { loader: "babel-loader", options: { - presets: ["@scm-manager/babel-preset"] - } - } + presets: ["@scm-manager/babel-preset"], + }, + }, }, { test: /\.(css|scss|sass)$/i, - use: ["style-loader", "css-loader", "sass-loader"] + use: ["style-loader", "css-loader", "sass-loader"], }, { test: /\.(png|svg|jpg|gif|woff2?|eot|ttf)$/, - use: ["file-loader"] - } - ] + use: ["file-loader"], + }, + ], }, resolve: { - extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"] + extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"], }, output: { path: path.join(root, "target", `${name}-${packageJSON.version}`, "webapp", "assets"), filename: "[name].bundle.js", chunkFilename: `${name}.[name].chunk.js`, library: name, - libraryTarget: "amd" - } + libraryTarget: "amd", + }, }; }; diff --git a/scm-ui/ui-webapp/package.json b/scm-ui/ui-webapp/package.json index 85c99b9f22..a11a92cdfc 100644 --- a/scm-ui/ui-webapp/package.json +++ b/scm-ui/ui-webapp/package.json @@ -13,6 +13,7 @@ "@scm-manager/ui-shortcuts": "2.40.2-SNAPSHOT", "@scm-manager/ui-legacy": "2.40.2-SNAPSHOT", "@scm-manager/ui-forms": "2.40.2-SNAPSHOT", + "@scm-manager/ui-buttons": "2.40.2-SNAPSHOT", "classnames": "^2.2.5", "history": "^4.10.1", "i18next": "21", diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx index e6cec43a85..00d172caba 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx @@ -29,6 +29,7 @@ import RenameRepository from "./RenameRepository"; import DeleteRepo from "./DeleteRepo"; import ArchiveRepo from "./ArchiveRepo"; import UnarchiveRepo from "./UnarchiveRepo"; +import { ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions"; type Props = { repository: Repository; @@ -36,12 +37,18 @@ type Props = { const RepositoryDangerZone: FC = ({ repository }) => { const [t] = useTranslation("repos"); + const binder = useBinder(); const dangerZone = []; if (repository?._links?.rename || repository?._links?.renameWithNamespace) { dangerZone.push(); } - if (repository?._links?.delete) { + + if (binder.hasExtension("repository.deleteButton", { repository })) { + dangerZone.push( + name="repository.deleteButton" props={{ repository }} /> + ); + } else if (repository?._links?.delete) { dangerZone.push(); } if (repository?._links?.archive) { diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryExporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryExporter.java index 7cd03d39dc..7deec6ff15 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryExporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryExporter.java @@ -27,7 +27,7 @@ package sonia.scm.importexport; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; -import sonia.scm.ContextEntry; +import sonia.scm.repository.FullRepositoryExporter; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryExportingCheck; import sonia.scm.repository.api.ExportFailedException; @@ -36,6 +36,7 @@ import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.work.WorkdirProvider; import sonia.scm.util.Archives; import sonia.scm.util.IOUtil; +import sonia.scm.web.security.AdministrationContext; import javax.inject.Inject; import java.io.BufferedOutputStream; @@ -46,8 +47,10 @@ import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Paths; +import static sonia.scm.ContextEntry.ContextBuilder.entity; -public class FullScmRepositoryExporter { + +public class FullScmRepositoryExporter implements FullRepositoryExporter { static final String SCM_ENVIRONMENT_FILE_NAME = "scm-environment.xml"; static final String METADATA_FILE_NAME = "metadata.xml"; @@ -61,6 +64,8 @@ public class FullScmRepositoryExporter { private final RepositoryImportExportEncryption repositoryImportExportEncryption; private final ExportNotificationHandler notificationHandler; + private final AdministrationContext administrationContext; + @Inject public FullScmRepositoryExporter(EnvironmentInformationXmlGenerator environmentGenerator, RepositoryMetadataXmlGenerator metadataGenerator, @@ -68,7 +73,7 @@ public class FullScmRepositoryExporter { TarArchiveRepositoryStoreExporter storeExporter, WorkdirProvider workdirProvider, RepositoryExportingCheck repositoryExportingCheck, - RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler) { + RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler, AdministrationContext administrationContext) { this.environmentGenerator = environmentGenerator; this.metadataGenerator = metadataGenerator; this.serviceFactory = serviceFactory; @@ -77,6 +82,7 @@ public class FullScmRepositoryExporter { this.repositoryExportingCheck = repositoryExportingCheck; this.repositoryImportExportEncryption = repositoryImportExportEncryption; this.notificationHandler = notificationHandler; + this.administrationContext = administrationContext; } public void export(Repository repository, OutputStream outputStream, String password) { @@ -99,27 +105,33 @@ public class FullScmRepositoryExporter { GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(cos); TarArchiveOutputStream taos = Archives.createTarOutputStream(gzos); ) { - writeEnvironmentData(taos); + writeEnvironmentData(repository, taos); writeMetadata(repository, taos); writeStoreData(repository, taos); writeRepository(service, taos); taos.finish(); } catch (IOException e) { throw new ExportFailedException( - ContextEntry.ContextBuilder.entity(repository).build(), + entity(repository).build(), "Could not export repository with metadata", e ); } } - private void writeEnvironmentData(TarArchiveOutputStream taos) throws IOException { - byte[] envBytes = environmentGenerator.generate(); - TarArchiveEntry entry = new TarArchiveEntry(SCM_ENVIRONMENT_FILE_NAME); - entry.setSize(envBytes.length); - taos.putArchiveEntry(entry); - taos.write(envBytes); - taos.closeArchiveEntry(); + private void writeEnvironmentData(Repository repository, TarArchiveOutputStream taos) { + administrationContext.runAsAdmin(() -> { + byte[] envBytes = environmentGenerator.generate(); + TarArchiveEntry entry = new TarArchiveEntry(SCM_ENVIRONMENT_FILE_NAME); + entry.setSize(envBytes.length); + try { + taos.putArchiveEntry(entry); + taos.write(envBytes); + taos.closeArchiveEntry(); + } catch (IOException e) { + throw new ExportFailedException(entity(repository).build(), "Failed to collect instance environment for repository export", e); + } + }); } private void writeMetadata(Repository repository, TarArchiveOutputStream taos) throws IOException { diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java index 6223b62cb0..950c55190d 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java @@ -32,10 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; import sonia.scm.event.ScmEventBus; -import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryImportEvent; -import sonia.scm.repository.RepositoryManager; -import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.*; import sonia.scm.repository.api.ImportFailedException; import javax.inject.Inject; @@ -48,7 +45,7 @@ import static sonia.scm.ContextEntry.ContextBuilder.noContext; import static sonia.scm.importexport.RepositoryImportLogger.ImportType.FULL; import static sonia.scm.util.Archives.createTarInputStream; -public class FullScmRepositoryImporter { +public class FullScmRepositoryImporter implements FullRepositoryImporter { private static final Logger LOG = LoggerFactory.getLogger(FullScmRepositoryImporter.class); diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index 59a8d07f47..e789648ac3 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -57,6 +57,8 @@ import sonia.scm.group.GroupDisplayManager; import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManagerProvider; import sonia.scm.group.xml.XmlGroupDAO; +import sonia.scm.importexport.FullScmRepositoryExporter; +import sonia.scm.importexport.FullScmRepositoryImporter; import sonia.scm.initialization.DefaultInitializationFinisher; import sonia.scm.initialization.InitializationCookieIssuer; import sonia.scm.initialization.InitializationFinisher; @@ -76,24 +78,7 @@ import sonia.scm.notifications.NotificationSender; import sonia.scm.plugin.DefaultPluginManager; import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginManager; -import sonia.scm.repository.DefaultHealthCheckService; -import sonia.scm.repository.DefaultNamespaceManager; -import sonia.scm.repository.DefaultRepositoryManager; -import sonia.scm.repository.DefaultRepositoryProvider; -import sonia.scm.repository.DefaultRepositoryRoleManager; -import sonia.scm.repository.HealthCheckContextListener; -import sonia.scm.repository.HealthCheckService; -import sonia.scm.repository.NamespaceManager; -import sonia.scm.repository.NamespaceStrategy; -import sonia.scm.repository.NamespaceStrategyProvider; -import sonia.scm.repository.PermissionProvider; -import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryDAO; -import sonia.scm.repository.RepositoryManager; -import sonia.scm.repository.RepositoryManagerProvider; -import sonia.scm.repository.RepositoryProvider; -import sonia.scm.repository.RepositoryRoleDAO; -import sonia.scm.repository.RepositoryRoleManager; +import sonia.scm.repository.*; import sonia.scm.repository.api.HookContextFactory; import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.spi.HookEventFacade; @@ -297,6 +282,9 @@ class ScmServletModule extends ServletModule { bind(CentralWorkQueue.class, DefaultCentralWorkQueue.class); bind(ContentTypeResolver.class).to(DefaultContentTypeResolver.class); + + bind(FullRepositoryImporter.class).to(FullScmRepositoryImporter.class); + bind(FullRepositoryExporter.class).to(FullScmRepositoryExporter.class); } private void bind(Class clazz, Class defaultImplementation) { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigurationAdapterBaseTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigurationAdapterBaseTest.java new file mode 100644 index 0000000000..81cfa31f3b --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigurationAdapterBaseTest.java @@ -0,0 +1,213 @@ +/* + * 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.api.v2.resources; + +import de.otto.edison.hal.Embedded; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.github.sdorra.jse.ShiroExtension; +import org.github.sdorra.jse.SubjectAware; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.store.ConfigurationStore; +import sonia.scm.store.ConfigurationStoreFactory; +import sonia.scm.web.JsonMockHttpRequest; +import sonia.scm.web.JsonMockHttpResponse; +import sonia.scm.web.RestDispatcher; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.ws.rs.Path; +import javax.ws.rs.core.MediaType; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +@ExtendWith({MockitoExtension.class, ShiroExtension.class}) +@SubjectAware(value = "trillian") +class ConfigurationAdapterBaseTest { + + @Mock + private ConfigurationStore configStore; + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private ConfigurationStoreFactory configurationStoreFactory; + + @Mock + private Provider scmPathInfoStore; + + private RestDispatcher dispatcher; + private final JsonMockHttpResponse response = new JsonMockHttpResponse(); + + @BeforeEach + void initResource() { + when(configurationStoreFactory.withType(TestConfiguration.class).withName("test-config").build()).thenReturn(configStore); + ScmPathInfoStore pathInfoStore = new ScmPathInfoStore(); + pathInfoStore.set(() -> URI.create("v2/test")); + lenient().when(scmPathInfoStore.get()).thenReturn(pathInfoStore); + TestConfigurationAdapter resource = new TestConfigurationAdapter(configurationStoreFactory, scmPathInfoStore); + + dispatcher = new RestDispatcher(); + dispatcher.addSingletonResource(resource); + } + + @Test + void shouldThrowBadRequestErrorWhenUpdatingWithNullPayload() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.put("/v2/test") + .contentType(MediaType.APPLICATION_JSON); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + } + + @Test + void shouldThrowAuthorizationExceptionWithoutPermissionOnRead() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/test"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Test + void shouldThrowAuthorizationExceptionWithoutPermissionOnUpdate() throws URISyntaxException { + JsonMockHttpRequest request = JsonMockHttpRequest.put("/v2/test") + .contentType(MediaType.APPLICATION_JSON) + .json("{\"number\": 3, \"text\" : \"https://scm-manager.org/\"}"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(403); + } + + @Nested + @SubjectAware(permissions = "configuration:read,write:testConfig") + class WithPermission { + + @Test + void shouldGetDefaultDao() throws URISyntaxException { + when(configStore.getOptional()).thenReturn(Optional.empty()); + MockHttpRequest request = MockHttpRequest.get("/v2/test"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsJson().get("number").asInt()).isEqualTo(4); + assertThat(response.getContentAsJson().get("text").textValue()).isEqualTo("myTest"); + } + + @Test + void shouldGetStoredDao() throws URISyntaxException { + when(configStore.getOptional()).thenReturn(Optional.of(new TestConfiguration(3, "secret"))); + + MockHttpRequest request = MockHttpRequest.get("/v2/test"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(200); + assertThat(response.getContentAsJson().get("number").asInt()).isEqualTo(3); + assertThat(response.getContentAsJson().get("text").textValue()).isEqualTo("secret"); + } + + @Test + void shouldThrowBadRequestErrorWhenUpdatingWithInvalidPayload() throws URISyntaxException { + JsonMockHttpRequest request = JsonMockHttpRequest.put("/v2/test") + .contentType(MediaType.APPLICATION_JSON) + .json("{\"number\": 42, \"text\" : \"https://scm-manager.org/\"}"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(400); + } + } + + @AllArgsConstructor + @NoArgsConstructor + @Getter + @Setter + static class TestConfiguration { + private int number = 4; + private String text = "myTest"; + } + + @NoArgsConstructor + @Getter + @Setter + static class TestConfigurationDto extends HalRepresentation { + + @Min(2) + @Max(6) + private int number; + + @NotBlank + private String text; + + public TestConfigurationDto(int number, String text) { + super(Links.emptyLinks(), Embedded.emptyEmbedded()); + this.number = number; + this.text = text; + } + + public static TestConfigurationDto from(TestConfiguration configuration, Links links) { + return new TestConfigurationDto(configuration.number, configuration.text); + } + + public TestConfiguration toEntity() { + return new TestConfiguration(number, text); + } + } + + @Path("/v2/test") + private static class TestConfigurationAdapter extends ConfigurationAdapterBase { + + @Inject + public TestConfigurationAdapter(ConfigurationStoreFactory configurationStoreFactory, Provider scmPathInfoStoreProvider) { + super(configurationStoreFactory, scmPathInfoStoreProvider, TestConfiguration.class, TestConfigurationDto.class); + } + + @Override + protected String getName() { + return "testConfig"; + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryExporterTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryExporterTest.java index 9707be6348..0fcad86f97 100644 --- a/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryExporterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryExporterTest.java @@ -39,6 +39,8 @@ import sonia.scm.repository.api.BundleCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.work.WorkdirProvider; +import sonia.scm.web.security.AdministrationContext; +import sonia.scm.web.security.PrivilegedAction; import java.io.ByteArrayOutputStream; import java.io.File; @@ -79,6 +81,8 @@ class FullScmRepositoryExporterTest { @Mock private RepositoryExportingCheck repositoryExportingCheck; @Mock + private AdministrationContext administrationContext; + @Mock private RepositoryImportExportEncryption repositoryImportExportEncryption; @InjectMocks @@ -89,14 +93,13 @@ class FullScmRepositoryExporterTest { @BeforeEach void initRepoService() throws IOException { when(serviceFactory.create(REPOSITORY)).thenReturn(repositoryService); - when(environmentGenerator.generate()).thenReturn(new byte[0]); when(metadataGenerator.generate(REPOSITORY)).thenReturn(new byte[0]); when(repositoryExportingCheck.withExportingLock(any(), any())).thenAnswer(invocation -> invocation.getArgument(1, Supplier.class).get()); when(repositoryImportExportEncryption.optionallyEncrypt(any(), any())).thenAnswer(invocation -> invocation.getArgument(0)); } @Test - void shouldExportEverythingAsTarArchive(@TempDir Path temp) throws IOException { + void shouldExportEverythingAsTarArchive(@TempDir Path temp) { BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); when(repositoryService.getBundleCommand()).thenReturn(bundleCommandBuilder); when(repositoryService.getRepository()).thenReturn(REPOSITORY); @@ -105,7 +108,7 @@ class FullScmRepositoryExporterTest { exporter.export(REPOSITORY, baos, ""); verify(storeExporter, times(1)).export(eq(REPOSITORY), any(OutputStream.class)); - verify(environmentGenerator, times(1)).generate(); + verify(administrationContext, times(1)).runAsAdmin(any(PrivilegedAction.class)); verify(metadataGenerator, times(1)).generate(REPOSITORY); verify(bundleCommandBuilder, times(1)).bundle(any(OutputStream.class)); verify(repositoryExportingCheck).withExportingLock(eq(REPOSITORY), any());