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());