From c8207d89da90a0e9a321489c4d6aa85eda930c0e Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Wed, 13 Apr 2022 13:06:02 +0200 Subject: [PATCH] Fix routing for entity names with parenthesis (#1998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If entities like users, groups or repository namespaces contains parenthesis the frontend router gets confused and doesn't work properly. To fix this issue we escape the chars in the url which may cause such problems because they are reserved by the http url schema. Co-authored-by: René Pfeuffer Co-authored-by: Florian Scholdei --- .../url_escaping_for_parenthesis.yaml | 2 + scm-ui/ui-api/src/urls.ts | 8 +++ .../src/config/ConfigurationBinder.tsx | 5 +- .../src/navigation/useActiveMatch.ts | 7 +-- .../roles/containers/SingleRepositoryRole.tsx | 7 +-- .../src/groups/containers/SingleGroup.tsx | 16 ++++-- .../repos/branches/containers/BranchRoot.tsx | 6 ++- .../codeSection/components/CodeActionBar.tsx | 6 ++- .../codeSection/containers/CodeOverview.tsx | 44 +++++++++-------- .../src/repos/containers/ChangesetsRoot.tsx | 2 +- .../src/repos/containers/RepositoryRoot.tsx | 49 +++++++++++-------- .../src/repos/tags/container/TagRoot.tsx | 6 ++- .../src/users/containers/SingleUser.tsx | 19 ++++--- 13 files changed, 111 insertions(+), 66 deletions(-) create mode 100644 gradle/changelog/url_escaping_for_parenthesis.yaml 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")}>