diff --git a/gradle/changelog/url_escaping_for_parenthesis.yaml b/gradle/changelog/url_escaping_for_parenthesis.yaml new file mode 100644 index 0000000000..8f18c89988 --- /dev/null +++ b/gradle/changelog/url_escaping_for_parenthesis.yaml @@ -0,0 +1,2 @@ +- type: fixed + description: Escape parenthesis for entity names to fix routing ([#1998](https://github.com/scm-manager/scm-manager/pull/1998)) diff --git a/scm-ui/ui-api/src/urls.ts b/scm-ui/ui-api/src/urls.ts index 24fd78cb8e..869fdc7923 100644 --- a/scm-ui/ui-api/src/urls.ts +++ b/scm-ui/ui-api/src/urls.ts @@ -108,3 +108,11 @@ export function matchedUrl(props: any) { const match = props.match; return matchedUrlFromMatch(match); } + +export function escapeUrlForRoute(url: string) { + return url.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); +} + +export function unescapeUrlForRoute(url: string) { + return url.replace(/\\/g, ""); +} diff --git a/scm-ui/ui-components/src/config/ConfigurationBinder.tsx b/scm-ui/ui-components/src/config/ConfigurationBinder.tsx index 8bfeaff51f..33d3418fbe 100644 --- a/scm-ui/ui-components/src/config/ConfigurationBinder.tsx +++ b/scm-ui/ui-components/src/config/ConfigurationBinder.tsx @@ -27,6 +27,7 @@ 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 { urls } from "@scm-manager/ui-api"; type GlobalRouteProps = { url: string; @@ -49,7 +50,7 @@ class ConfigurationBinder { route(path: string, Component: any) { return ( - + {Component} ); @@ -135,7 +136,7 @@ class ConfigurationBinder { const link = repository._links[linkName]; if (link) { return this.route( - url + "/settings" + to, + urls.unescapeUrlForRoute(url) + "/settings" + to, ); } diff --git a/scm-ui/ui-components/src/navigation/useActiveMatch.ts b/scm-ui/ui-components/src/navigation/useActiveMatch.ts index 91fa14b306..4b879bc55e 100644 --- a/scm-ui/ui-components/src/navigation/useActiveMatch.ts +++ b/scm-ui/ui-components/src/navigation/useActiveMatch.ts @@ -22,6 +22,7 @@ * SOFTWARE. */ +import { urls } from "@scm-manager/ui-api"; import { useLocation, useRouteMatch } from "react-router-dom"; import { RoutingProps } from "./RoutingProps"; @@ -33,8 +34,8 @@ const useActiveMatch = ({ to, activeOnlyWhenExact, activeWhenMatch }: RoutingPro } const match = useRouteMatch({ - path, - exact: activeOnlyWhenExact, + path: urls.escapeUrlForRoute(path), + exact: activeOnlyWhenExact }); const location = useLocation(); @@ -42,7 +43,7 @@ const useActiveMatch = ({ to, activeOnlyWhenExact, activeWhenMatch }: RoutingPro const isActiveWhenMatch = () => { if (activeWhenMatch) { return activeWhenMatch({ - location, + location }); } return false; diff --git a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx index 44887c8037..d85f878ab0 100644 --- a/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx +++ b/scm-ui/ui-webapp/src/admin/roles/containers/SingleRepositoryRole.tsx @@ -47,19 +47,20 @@ const SingleRepositoryRole: FC = () => { } const url = urls.matchedUrlFromMatch(match); + const escapedUrl = urls.escapeUrlForRoute(url); const extensionProps = { role, - url + url: escapedUrl }; return ( <> - <Route path={`${url}/info`}> + <Route path={`${escapedUrl}/info`}> <PermissionRoleDetail role={role} url={url} /> </Route> - <Route path={`${url}/edit`} exact> + <Route path={`${escapedUrl}/edit`} exact> <EditRepositoryRole role={role} /> </Route> <ExtensionPoint<extensionPoints.RolesRoute> name="roles.route" props={extensionProps} renderAll={true} /> diff --git a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx index 4c7d9f8e89..24cbb1a4d1 100644 --- a/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx +++ b/scm-ui/ui-webapp/src/groups/containers/SingleGroup.tsx @@ -59,6 +59,7 @@ const SingleGroup: FC = () => { } const url = urls.matchedUrlFromMatch(match); + const escapedUrl = urls.escapeUrlForRoute(url); const extensionProps = { group, @@ -70,16 +71,23 @@ const SingleGroup: FC = () => { <Page title={group.name}> <CustomQueryFlexWrappedColumns> <PrimaryContentColumn> - <Route path={url} exact> + <Route path={escapedUrl} exact> <Details group={group} /> </Route> - <Route path={`${url}/settings/general`} exact> + <Route path={`${escapedUrl}/settings/general`} exact> <EditGroup group={group} /> </Route> - <Route path={`${url}/settings/permissions`} exact> + <Route path={`${escapedUrl}/settings/permissions`} exact> <SetGroupPermissions group={group} /> </Route> - <ExtensionPoint<extensionPoints.GroupRoute> name="group.route" props={extensionProps} renderAll={true} /> + <ExtensionPoint<extensionPoints.GroupRoute> + name="group.route" + props={{ + group, + url: escapedUrl + }} + renderAll={true} + /> </PrimaryContentColumn> <SecondaryNavigationColumn> <SecondaryNavigation label={t("singleGroup.menu.navigationLabel")}> diff --git a/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx b/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx index 5c017ec85f..e3bbcefbbf 100644 --- a/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/branches/containers/BranchRoot.tsx @@ -61,10 +61,12 @@ const BranchRoot: FC<Props> = ({ repository }) => { return null; } + const escapedUrl = urls.escapeUrlForRoute(url); + return ( <Switch> - <Redirect exact from={url} to={`${url}/info`} /> - <Route path={`${url}/info`}> + <Redirect exact from={escapedUrl} to={`${url}/info`} /> + <Route path={`${escapedUrl}/info`}> <BranchView repository={repository} branch={branch} /> </Route> </Switch> diff --git a/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx b/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx index 682083d6bc..38386afbf5 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/components/CodeActionBar.tsx @@ -24,7 +24,7 @@ import React, { FC, ReactNode } from "react"; import styled from "styled-components"; import { useLocation } from "react-router-dom"; -import { BranchSelector, devices, Level } from "@scm-manager/ui-components"; +import { BranchSelector, devices, Level, urls } from "@scm-manager/ui-components"; import CodeViewSwitcher, { SwitchViewLink } from "./CodeViewSwitcher"; import { useTranslation } from "react-i18next"; import { Branch } from "@scm-manager/ui-types"; @@ -87,7 +87,9 @@ const CodeActionBar: FC<Props> = ({ selectedBranch, branches, onSelectBranch, sw ) } children={actions} - right={<CodeViewSwitcher currentUrl={location.pathname} switchViewLink={switchViewLink} />} + right={ + <CodeViewSwitcher currentUrl={urls.escapeUrlForRoute(location.pathname)} switchViewLink={switchViewLink} /> + } /> </ActionBar> ); diff --git a/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx b/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx index f5eafe3b38..3123e09adf 100644 --- a/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx +++ b/scm-ui/ui-webapp/src/repos/codeSection/containers/CodeOverview.tsx @@ -26,7 +26,7 @@ import { Route, useLocation } from "react-router-dom"; import Sources from "../../sources/containers/Sources"; import ChangesetsRoot from "../../containers/ChangesetsRoot"; import { Branch, Repository } from "@scm-manager/ui-types"; -import { ErrorPage, Loading } from "@scm-manager/ui-components"; +import { ErrorPage, Loading, urls } from "@scm-manager/ui-components"; import { useTranslation } from "react-i18next"; import { useBranches } from "@scm-manager/ui-api"; import FileSearch from "./FileSearch"; @@ -84,24 +84,28 @@ type RoutingProps = { selectedBranch?: string; }; -const CodeRouting: FC<RoutingProps> = ({ repository, baseUrl, branches, selectedBranch }) => ( - <> - <Route path={`${baseUrl}/sources`} exact={true}> - <Sources repository={repository} baseUrl={baseUrl} branches={branches} /> - </Route> - <Route path={`${baseUrl}/sources/:revision/:path*`}> - <Sources repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> - </Route> - <Route path={`${baseUrl}/changesets`}> - <ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} /> - </Route> - <Route path={`${baseUrl}/branch/:branch/changesets/`}> - <ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> - </Route> - <Route path={`${baseUrl}/search/:revision/`}> - <FileSearch repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> - </Route> - </> -); +const CodeRouting: FC<RoutingProps> = ({ repository, baseUrl, branches, selectedBranch }) => { + + const escapedUrl = urls.escapeUrlForRoute(baseUrl); + return ( + <> + <Route path={`${escapedUrl}/sources`} exact={true}> + <Sources repository={repository} baseUrl={baseUrl} branches={branches} /> + </Route> + <Route path={`${escapedUrl}/sources/:revision/:path*`}> + <Sources repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> + </Route> + <Route path={`${escapedUrl}/changesets`}> + <ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} /> + </Route> + <Route path={`${escapedUrl}/branch/:branch/changesets/`}> + <ChangesetsRoot repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> + </Route> + <Route path={`${escapedUrl}/search/:revision/`}> + <FileSearch repository={repository} baseUrl={baseUrl} branches={branches} selectedBranch={selectedBranch} /> + </Route> + </> + ); +}; export default CodeOverview; diff --git a/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx index e01071ed3a..ae02fb92d1 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ChangesetsRoot.tsx @@ -43,7 +43,7 @@ const ChangesetRoot: FC<Props> = ({ repository, baseUrl, branches, selectedBranc return null; } - const url = urls.stripEndingSlash(match.url); + const url = urls.stripEndingSlash(urls.escapeUrlForRoute(match.url)); const defaultBranch = branches?.find(b => b.defaultBranch === true); const isBranchAvailable = () => { diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index ec8146da26..190df5288a 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -229,6 +229,8 @@ const RepositoryRoot = () => { /> ); + const escapedUrl = urls.escapeUrlForRoute(url); + return ( <StateMenuContextProvider> <Page @@ -247,57 +249,64 @@ const RepositoryRoot = () => { <CustomQueryFlexWrappedColumns> <PrimaryContentColumn> <Switch> - <Redirect exact from={match.url} to={redirectedUrl} /> + <Redirect exact from={urls.escapeUrlForRoute(match.url)} to={urls.escapeUrlForRoute(redirectedUrl)} /> {/* redirect pre 2.0.0-rc2 links */} - <Redirect from={`${url}/changeset/:id`} to={`${url}/code/changeset/:id`} /> - <Redirect exact from={`${url}/sources`} to={`${url}/code/sources`} /> - <Redirect from={`${url}/sources/:revision/:path*`} to={`${url}/code/sources/:revision/:path*`} /> - <Redirect exact from={`${url}/changesets`} to={`${url}/code/changesets`} /> - <Redirect from={`${url}/branch/:branch/changesets`} to={`${url}/code/branch/:branch/changesets/`} /> + <Redirect from={`${escapedUrl}/changeset/:id`} to={`${url}/code/changeset/:id`} /> + <Redirect exact from={`${escapedUrl}/sources`} to={`${url}/code/sources`} /> + <Redirect from={`${escapedUrl}/sources/:revision/:path*`} to={`${url}/code/sources/:revision/:path*`} /> + <Redirect exact from={`${escapedUrl}/changesets`} to={`${url}/code/changesets`} /> + <Redirect + from={`${escapedUrl}/branch/:branch/changesets`} + to={`${url}/code/branch/:branch/changesets/`} + /> - <Route path={`${url}/info`} exact> + <Route path={`${escapedUrl}/info`} exact> <RepositoryDetails repository={repository} /> </Route> - <Route path={`${url}/settings/general`}> + <Route path={`${escapedUrl}/settings/general`}> <EditRepo repository={repository} /> </Route> - <Route path={`${url}/settings/permissions`}> + <Route path={`${escapedUrl}/settings/permissions`}> <Permissions namespaceOrRepository={repository} /> </Route> - <Route exact path={`${url}/code/changeset/:id`}> + <Route exact path={`${escapedUrl}/code/changeset/:id`}> <ChangesetView repository={repository} fileControlFactoryFactory={fileControlFactoryFactory} /> </Route> - <Route path={`${url}/code/sourceext/:extension`} exact={true}> + <Route path={`${escapedUrl}/code/sourceext/:extension`} exact={true}> <SourceExtensions repository={repository} /> </Route> - <Route path={`${url}/code/sourceext/:extension/:revision/:path*`}> + <Route path={`${escapedUrl}/code/sourceext/:extension/:revision/:path*`}> <SourceExtensions repository={repository} baseUrl={`${url}/code/sources`} /> </Route> - <Route path={`${url}/code`}> + <Route path={`${escapedUrl}/code`}> <CodeOverview baseUrl={`${url}/code`} repository={repository} /> </Route> - <Route path={`${url}/branch/:branch`}> + <Route path={`${escapedUrl}/branch/:branch`}> <BranchRoot repository={repository} /> </Route> - <Route path={`${url}/branches`} exact={true}> + <Route path={`${escapedUrl}/branches`} exact={true}> <BranchesOverview repository={repository} baseUrl={`${url}/branch`} /> </Route> - <Route path={`${url}/branches/create`}> + <Route path={`${escapedUrl}/branches/create`}> <CreateBranch repository={repository} /> </Route> - <Route path={`${url}/tag/:tag`}> + <Route path={`${escapedUrl}/tag/:tag`}> <TagRoot repository={repository} baseUrl={`${url}/tag`} /> </Route> - <Route path={`${url}/tags`} exact={true}> + <Route path={`${escapedUrl}/tags`} exact={true}> <TagsOverview repository={repository} baseUrl={`${url}/tag`} /> </Route> - <Route path={`${url}/compare/:sourceType/:sourceName`}> + <Route path={`${escapedUrl}/compare/:sourceType/:sourceName`}> <CompareRoot repository={repository} baseUrl={`${url}/compare`} /> </Route> <ExtensionPoint<extensionPoints.RepositoryRoute> name="repository.route" - props={extensionProps} + props={{ + repository, + url: urls.escapeUrlForRoute(url), + indexLinks + }} renderAll={true} /> </Switch> diff --git a/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx b/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx index e09f9ac5e7..d57ea0a3ea 100644 --- a/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/tags/container/TagRoot.tsx @@ -52,10 +52,12 @@ const TagRoot: FC<Props> = ({ repository, baseUrl }) => { const url = urls.matchedUrlFromMatch(match); + const escapedUrl = urls.escapeUrlForRoute(url); + return ( <Switch> - <Redirect exact from={url} to={`${url}/info`} /> - <Route path={`${url}/info`}> + <Redirect exact from={escapedUrl} to={`${url}/info`} /> + <Route path={`${escapedUrl}/info`}> <TagView repository={repository} tag={tag} /> </Route> </Switch> diff --git a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx index f0c16b81b1..3e85d44c9c 100644 --- a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx +++ b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx @@ -74,30 +74,35 @@ const SingleUser: FC = () => { url }; + const escapedUrl = urls.escapeUrlForRoute(url); + return ( <StateMenuContextProvider> <Page title={user.displayName}> <CustomQueryFlexWrappedColumns> <PrimaryContentColumn> - <Route path={url} exact> + <Route path={escapedUrl} exact> <Details user={user} /> </Route> - <Route path={`${url}/settings/general`}> + <Route path={`${escapedUrl}/settings/general`}> <EditUser user={user} /> </Route> - <Route path={`${url}/settings/password`}> + <Route path={`${escapedUrl}/settings/password`}> <SetUserPassword user={user} /> </Route> - <Route path={`${url}/settings/permissions`}> + <Route path={`${escapedUrl}/settings/permissions`}> <SetUserPermissions user={user} /> </Route> - <Route path={`${url}/settings/publickeys`}> + <Route path={`${escapedUrl}/settings/publickeys`}> <SetPublicKeys user={user} /> </Route> - <Route path={`${url}/settings/apiKeys`}> + <Route path={`${escapedUrl}/settings/apiKeys`}> <SetApiKeys user={user} /> </Route> - <ExtensionPoint<extensionPoints.UserRoute> name="user.route" props={extensionProps} renderAll={true} /> + <ExtensionPoint<extensionPoints.UserRoute> name="user.route" props={{ + user, + url: escapedUrl + }} renderAll={true} /> </PrimaryContentColumn> <SecondaryNavigationColumn> <SecondaryNavigation label={t("singleUser.menu.navigationLabel")}>