Merge with develop

This commit is contained in:
Sebastian Sdorra
2020-09-02 07:45:44 +02:00
64 changed files with 1134 additions and 485 deletions

View File

@@ -8,11 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add support for scroll anchors in url hash of diff page ([#1304](https://github.com/scm-manager/scm-manager/pull/1304))
## [2.4.1] - 2020-09-01
### Added
- Add "sonia.scm.restart-migration.wait" to set wait in milliseconds before restarting scm-server after migration ([#1308](https://github.com/scm-manager/scm-manager/pull/1308))
### Fixed
- Fix detection of markdown files for files having content does not start with '#' ([#1306](https://github.com/scm-manager/scm-manager/pull/1306))
- Fix broken markdown rendering ([#1303](https://github.com/scm-manager/scm-manager/pull/1303))
- JWT token timeout is now handled properly ([#1297](https://github.com/scm-manager/scm-manager/pull/1297))
- Fix text-overflow in danger zone ([#1298](https://github.com/scm-manager/scm-manager/pull/1298))
- Fix plugin installation error if previously a plugin was installed with the same dependency which is still pending. ([#1300](https://github.com/scm-manager/scm-manager/pull/1300))
- Fix layout overflow on changesets with multiple tags ([#1314](https://github.com/scm-manager/scm-manager/pull/1314))
- Make checkbox accessible from keyboard ([#1309](https://github.com/scm-manager/scm-manager/pull/1309))
- Fix logging of large stacktrace for unknown language ([#1313](https://github.com/scm-manager/scm-manager/pull/1313))
- Fix incorrect word breaking behaviour in markdown ([#1317](https://github.com/scm-manager/scm-manager/pull/1317))
- Remove obsolete revision encoding on sources ([#1315](https://github.com/scm-manager/scm-manager/pull/1315))
- Map generic JaxRS 'web application exceptions' to appropriate response instead of "internal server error" ([#1318](https://github.com/scm-manager/scm-manager/pull/1312))
## [2.4.0] - 2020-08-14
### Added
@@ -284,3 +296,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[2.3.0]: https://www.scm-manager.org/download/2.3.0
[2.3.1]: https://www.scm-manager.org/download/2.3.1
[2.4.0]: https://www.scm-manager.org/download/2.4.0
[2.4.1]: https://www.scm-manager.org/download/2.4.1

View File

@@ -5,5 +5,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "2.4.0"
"version": "2.4.1"
}

View File

@@ -166,6 +166,11 @@ public class AuthenticationFilter extends HttpFilter {
HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription());
}
protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException {
throw tokenExpiredException;
}
/**
* Iterates all {@link WebTokenGenerator} and creates an
* {@link AuthenticationToken} from the given request.
@@ -211,7 +216,7 @@ public class AuthenticationFilter extends HttpFilter {
processChain(request, response, chain, subject);
} catch (TokenExpiredException ex) {
// Rethrow to be caught by TokenExpiredFilter
throw ex;
handleTokenExpiredException(request, response, chain, ex);
} catch (AuthenticationException ex) {
logger.warn("authentication failed", ex);
handleUnauthorized(request, response, chain);

View File

@@ -25,6 +25,7 @@
package sonia.scm.web.filter;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser;
@@ -59,4 +60,15 @@ public class HttpProtocolServletAuthenticationFilterBase extends AuthenticationF
HttpUtil.sendUnauthorized(request, response);
}
}
@Override
protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException {
UserAgent userAgent = userAgentParser.parse(request);
if (userAgent.isBrowser()) {
// we can proceed the filter chain because the HttpProtocolServlet will render the ui if the client is a browser
chain.doFilter(request, response);
} else {
super.handleTokenExpiredException(request, response, chain, tokenExpiredException);
}
}
}

View File

@@ -30,6 +30,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil;
import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser;
@@ -43,6 +44,7 @@ import java.io.IOException;
import java.util.Collections;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -93,4 +95,23 @@ class HttpProtocolServletAuthenticationFilterBaseTest {
verify(filterChain).doFilter(request, response);
}
@Test
void shouldIgnoreTokenExpiredExceptionForBrowserCall() throws IOException, ServletException {
when(userAgentParser.parse(request)).thenReturn(browser);
authenticationFilter.handleTokenExpiredException(request, response, filterChain, new TokenExpiredException("Nothing ever expired so much"));
verify(filterChain).doFilter(request, response);
}
@Test
void shouldRethrowTokenExpiredExceptionForApiCall() {
when(userAgentParser.parse(request)).thenReturn(nonBrowser);
final TokenExpiredException tokenExpiredException = new TokenExpiredException("Nothing ever expired so much");
assertThrows(TokenExpiredException.class,
() -> authenticationFilter.handleTokenExpiredException(request, response, filterChain, tokenExpiredException));
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-git-plugin",
"private": true,
"version": "2.4.0",
"version": "2.4.1",
"license": "MIT",
"main": "./src/main/js/index.ts",
"scripts": {
@@ -20,6 +20,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
"@scm-manager/ui-plugins": "^2.4.0"
"@scm-manager/ui-plugins": "^2.4.1"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-hg-plugin",
"private": true,
"version": "2.4.0",
"version": "2.4.1",
"license": "MIT",
"main": "./src/main/js/index.ts",
"scripts": {
@@ -19,6 +19,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
"@scm-manager/ui-plugins": "^2.4.0"
"@scm-manager/ui-plugins": "^2.4.1"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-legacy-plugin",
"private": true,
"version": "2.4.0",
"version": "2.4.1",
"license": "MIT",
"main": "./src/main/js/index.tsx",
"scripts": {
@@ -19,6 +19,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
"@scm-manager/ui-plugins": "^2.4.0"
"@scm-manager/ui-plugins": "^2.4.1"
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "@scm-manager/scm-svn-plugin",
"private": true,
"version": "2.4.0",
"version": "2.4.1",
"license": "MIT",
"main": "./src/main/js/index.ts",
"scripts": {
@@ -19,6 +19,6 @@
},
"prettier": "@scm-manager/prettier-config",
"dependencies": {
"@scm-manager/ui-plugins": "^2.4.0"
"@scm-manager/ui-plugins": "^2.4.1"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/e2e-tests",
"version": "2.4.0",
"version": "2.4.1",
"description": "End to end Tests for SCM-Manager",
"main": "index.js",
"author": "Eduard Heimbuch <eduard.heimbuch@cloudogu.com>",

View File

@@ -29,6 +29,7 @@ import { withI18next } from "storybook-addon-i18next";
import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss";
import React from "react";
import { MemoryRouter } from "react-router-dom";
import withRedux from "./withRedux";
let i18n = i18next;
@@ -70,4 +71,6 @@ addDecorator(
})
);
addDecorator(withRedux);
configure(require.context("../src", true, /\.stories\.tsx?$/), module);

View File

@@ -0,0 +1,41 @@
/*
* 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 {createStore} from "redux";
import { Provider } from 'react-redux'
const reducer = (state, action) => {
return state;
};
const withRedux = (storyFn) => {
return React.createElement(Provider, {
store: createStore(reducer, {}),
children: storyFn()
});
}
export default withRedux;

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-components",
"version": "2.4.0",
"version": "2.4.1",
"description": "UI Components for SCM-Manager and its plugins",
"main": "src/index.ts",
"files": [

View File

@@ -21,14 +21,24 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ReactNode } from "react";
import React, { ComponentType, ReactNode } from "react";
import ErrorNotification from "./ErrorNotification";
import { MissingLinkError } from "./errors";
import { withContextPath } from "./urls";
import { withRouter, RouteComponentProps } from "react-router-dom";
import ErrorPage from "./ErrorPage";
import { WithTranslation, withTranslation } from "react-i18next";
import { compose } from "redux";
import { connect } from "react-redux";
type Props = {
type ExportedProps = {
fallback?: React.ComponentType<any>;
children: ReactNode;
loginLink?: string;
};
type Props = WithTranslation & RouteComponentProps & ExportedProps;
type ErrorInfo = {
componentStack: string;
};
@@ -44,16 +54,44 @@ class ErrorBoundary extends React.Component<Props, State> {
this.state = {};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Catch errors in any components below and re-render with error message
this.setState({
error,
errorInfo
});
componentDidUpdate(prevProps: Readonly<Props>) {
// we must reset the error if the url has changed
if (this.state.error && prevProps.location !== this.props.location) {
this.setState({ error: undefined, errorInfo: undefined });
}
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState(
{
error,
errorInfo
},
() => this.redirectToLogin(error)
);
}
redirectToLogin = (error: Error) => {
const { loginLink } = this.props;
if (error instanceof MissingLinkError) {
if (loginLink) {
window.location.assign(withContextPath("/login"));
}
}
};
renderError = () => {
const { t } = this.props;
const { error } = this.state;
let FallbackComponent = this.props.fallback;
if (error instanceof MissingLinkError) {
return (
<ErrorPage error={error} title={t("errorNotification.prefix")} subtitle={t("errorNotification.forbidden")} />
);
}
if (!FallbackComponent) {
FallbackComponent = ErrorNotification;
}
@@ -69,4 +107,17 @@ class ErrorBoundary extends React.Component<Props, State> {
return this.props.children;
}
}
export default ErrorBoundary;
const mapStateToProps = (state: any) => {
const loginLink = state.indexResources?.links?.login?.href;
return {
loginLink
};
};
export default compose<ComponentType<ExportedProps>>(
withRouter,
withTranslation("commons"),
connect(mapStateToProps)
)(ErrorBoundary);

View File

@@ -21,16 +21,26 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import React, { FC } from "react";
import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
import { BackendError, ForbiddenError, UnauthorizedError } from "./errors";
import Notification from "./Notification";
import BackendErrorNotification from "./BackendErrorNotification";
import { useLocation } from "react-router-dom";
import { withContextPath } from "./urls";
type Props = WithTranslation & {
error?: Error;
};
const LoginLink: FC = () => {
const [t] = useTranslation("commons");
const location = useLocation();
const from = encodeURIComponent(location.pathname);
return <a href={withContextPath(`/login?from=${from}`)}>{t("errorNotification.loginLink")}</a>;
};
class ErrorNotification extends React.Component<Props> {
render() {
const { t, error } = this.props;
@@ -40,8 +50,7 @@ class ErrorNotification extends React.Component<Props> {
} else if (error instanceof UnauthorizedError) {
return (
<Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")}{" "}
<a href="javascript:window.location.reload(true)">{t("errorNotification.loginLink")}</a>
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")} <LoginLink />
</Notification>
);
} else if (error instanceof ForbiddenError) {

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
import React, { Component } from "react";
import { Route, Redirect, withRouter, RouteComponentProps, RouteProps } from "react-router-dom";
import { Redirect, Route, RouteComponentProps, RouteProps, withRouter } from "react-router-dom";
type Props = RouteComponentProps &
RouteProps & {
@@ -30,7 +30,16 @@ type Props = RouteComponentProps &
};
class ProtectedRoute extends Component<Props> {
renderRoute = (Component: any, authenticated?: boolean) => {
constructor(props: Props) {
super(props);
this.state = {
error: undefined
};
}
renderRoute = (Component: any) => {
const { authenticated } = this.props;
return (routeProps: any) => {
if (authenticated) {
return <Component {...routeProps} />;
@@ -50,8 +59,8 @@ class ProtectedRoute extends Component<Props> {
};
render() {
const { component, authenticated, ...routeProps } = this.props;
return <Route {...routeProps} render={this.renderRoute(component, authenticated)} />;
const { component, ...routeProps } = this.props;
return <Route {...routeProps} render={this.renderRoute(component)} />;
}
}

View File

@@ -22,12 +22,12 @@
* SOFTWARE.
*/
import React, { ReactNode } from "react";
import classNames from "classnames";
type Props = {
message: string;
className?: string;
location: string;
multiline?: boolean;
children: ReactNode;
};
@@ -37,9 +37,17 @@ class Tooltip extends React.Component<Props> {
};
render() {
const { className, message, location, children } = this.props;
const { className, message, location, multiline, children } = this.props;
let classes = `tooltip has-tooltip-${location}`;
if (multiline) {
classes += " has-tooltip-multiline";
}
if (className) {
classes += " " + className;
}
return (
<span className={classNames("tooltip", "has-tooltip-" + location, className)} data-tooltip={message}>
<span className={classes} data-tooltip={message}>
{children}
</span>
);

View File

@@ -44140,14 +44140,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-square has-text-black far"
/>
</span>
Not checked
</label>
</div>
@@ -44158,14 +44164,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-check-square has-text-link fa"
/>
</span>
Checked
</label>
</div>
@@ -44176,14 +44188,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-minus-square has-text-link far"
/>
</span>
Indeterminate
</label>
</div>
@@ -44201,15 +44219,21 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
disabled={true}
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-check-square has-text-grey-light fa"
/>
</span>
Checked but disabled
</label>
</div>
@@ -44227,14 +44251,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-square has-text-black far"
/>
</span>
Classic helpText
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
@@ -44253,14 +44283,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-check-square has-text-link fa"
/>
</span>
Long helpText
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
@@ -47632,14 +47668,20 @@ exports[`Storyshots Modal|Modal With long tooltips 1`] = `
<div
className="control"
onClick={[Function]}
onKeyDown={[Function]}
>
<label
className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
>
<i
className="is-outlined fa-check-square has-text-link fa"
/>
</span>
Checkbox
<span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"

View File

@@ -125,23 +125,15 @@ const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
function handleFailure(response: Response) {
if (!response.ok) {
if (isBackendError(response)) {
return response.json().then((content: BackendErrorContent) => {
if (content.errorCode === TOKEN_EXPIRED_ERROR_CODE) {
window.location.replace(`${contextPath}/login`);
// Throw error because if redirect is not instantaneous, we want to display something senseful
throw new UnauthorizedError("Unauthorized", 401);
} else {
throw createBackendError(content, response.status);
}
});
} else {
if (response.status === 401) {
throw new UnauthorizedError("Unauthorized", 401);
} else if (response.status === 403) {
throw new ForbiddenError("Forbidden", 403);
}
} else if (isBackendError(response)) {
return response.json().then((content: BackendErrorContent) => {
throw createBackendError(content, response.status);
});
} else {
throw new Error("server returned status code " + response.status);
}
}
@@ -163,24 +155,35 @@ export function createUrlWithIdentifiers(url: string): string {
return createUrl(url) + "?X-SCM-Client=WUI&X-SCM-Session-ID=" + sessionId;
}
type ErrorListener = (error: Error) => void;
class ApiClient {
get(url: string): Promise<Response> {
return fetch(createUrl(url), applyFetchOptions({})).then(handleFailure);
}
errorListeners: ErrorListener[] = [];
post(url: string, payload?: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) {
get = (url: string): Promise<Response> => {
return fetch(createUrl(url), applyFetchOptions({}))
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
post = (
url: string,
payload?: any,
contentType = "application/json",
additionalHeaders: Record<string, string> = {}
) => {
return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload);
}
};
postText(url: string, payload: string, additionalHeaders: Record<string, string> = {}) {
postText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
return this.httpRequestWithTextBody("POST", url, additionalHeaders, payload);
}
};
putText(url: string, payload: string, additionalHeaders: Record<string, string> = {}) {
putText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
return this.httpRequestWithTextBody("PUT", url, additionalHeaders, payload);
}
};
postBinary(url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) {
postBinary = (url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) => {
const formData = new FormData();
fileAppender(formData);
@@ -190,35 +193,39 @@ class ApiClient {
headers: additionalHeaders
};
return this.httpRequestWithBinaryBody(options, url);
}
};
put(url: string, payload: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) {
return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload);
}
head(url: string) {
head = (url: string) => {
let options: RequestInit = {
method: "HEAD"
};
options = applyFetchOptions(options);
return fetch(createUrl(url), options).then(handleFailure);
}
return fetch(createUrl(url), options)
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
delete(url: string): Promise<Response> {
delete = (url: string): Promise<Response> => {
let options: RequestInit = {
method: "DELETE"
};
options = applyFetchOptions(options);
return fetch(createUrl(url), options).then(handleFailure);
}
return fetch(createUrl(url), options)
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
httpRequestWithJSONBody(
httpRequestWithJSONBody = (
method: string,
url: string,
contentType: string,
additionalHeaders: Record<string, string>,
payload?: any
): Promise<Response> {
): Promise<Response> => {
const options: RequestInit = {
method: method,
headers: additionalHeaders
@@ -227,23 +234,23 @@ class ApiClient {
options.body = JSON.stringify(payload);
}
return this.httpRequestWithBinaryBody(options, url, contentType);
}
};
httpRequestWithTextBody(
httpRequestWithTextBody = (
method: string,
url: string,
additionalHeaders: Record<string, string> = {},
payload: string
) {
) => {
const options: RequestInit = {
method: method,
headers: additionalHeaders
};
options.body = payload;
return this.httpRequestWithBinaryBody(options, url, "text/plain");
}
};
httpRequestWithBinaryBody(options: RequestInit, url: string, contentType?: string) {
httpRequestWithBinaryBody = (options: RequestInit, url: string, contentType?: string) => {
options = applyFetchOptions(options);
if (contentType) {
if (!options.headers) {
@@ -253,8 +260,10 @@ class ApiClient {
options.headers["Content-Type"] = contentType;
}
return fetch(createUrl(url), options).then(handleFailure);
}
return fetch(createUrl(url), options)
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
subscribe(url: string, argument: SubscriptionArgument): Cancel {
const es = new EventSource(createUrlWithIdentifiers(url), {
@@ -284,6 +293,15 @@ class ApiClient {
return () => es.close();
}
onError = (errorListener: ErrorListener) => {
this.errorListeners.push(errorListener);
};
private notifyAndRethrow = (error: Error): never => {
this.errorListeners.forEach(errorListener => errorListener(error));
throw error;
};
}
export const apiClient = new ApiClient();

View File

@@ -89,6 +89,10 @@ export class ConflictError extends BackendError {
}
}
export class MissingLinkError extends Error {
name = "MissingLinkError";
}
export function createBackendError(content: BackendErrorContent, statusCode: number) {
switch (statusCode) {
case 404:

View File

@@ -45,6 +45,13 @@ export default class Checkbox extends React.Component<Props> {
}
};
onKeyDown = (event: React.KeyboardEvent) => {
const SPACE = 32;
if (event.keyCode === SPACE) {
this.onCheckboxChange();
}
};
renderHelp = () => {
const { title, helpText } = this.props;
if (helpText && !title) {
@@ -64,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
return (
<div className="field">
{this.renderLabelWithHelp()}
<div className="control" onClick={this.onCheckboxChange}>
<div className="control" onClick={this.onCheckboxChange} onKeyDown={this.onKeyDown}>
{/*
we have to ignore the next line,
because jsx label does not the custom disabled attribute

View File

@@ -58,10 +58,13 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label,
color = "black";
}
// We need a tabIndex to make the checkbox accessible from keyboard.
// We also add the gwt-Anchor css class to support the key-jump browser extension
// https://github.com/KennethSundqvist/key-jump-chrome-extension/blob/master/src/content.js#L365
return (
<>
<span tabIndex={0} className="gwt-Anchor">
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
</>
</span>
);
};

View File

@@ -26,8 +26,10 @@ import { WithTranslation, withTranslation } from "react-i18next";
import PrimaryNavigationLink from "./PrimaryNavigationLink";
import { Links } from "@scm-manager/ui-types";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import {withContextPath} from "../urls";
import { withRouter, RouteComponentProps } from "react-router-dom";
type Props = WithTranslation & {
type Props = RouteComponentProps & WithTranslation & {
links: Links;
};
@@ -64,11 +66,17 @@ class PrimaryNavigation extends React.Component<Props> {
};
appendLogin = (navigationItems: ReactNode[], append: Appender) => {
const { t, links } = this.props;
const { t, links, location } = this.props;
const from = location.pathname;
const loginPath = "/login";
const to = `${loginPath}?from=${encodeURIComponent(from)}`;
const props = {
links,
label: t("primary-navigation.login")
label: t("primary-navigation.login"),
loginUrl: withContextPath(loginPath),
from
};
if (binder.hasExtension("primary-navigation.login", props)) {
@@ -76,7 +84,7 @@ class PrimaryNavigation extends React.Component<Props> {
<ExtensionPoint key="primary-navigation.login" name="primary-navigation.login" props={props} />
);
} else {
append("/login", "/login", "primary-navigation.login", "login");
append(to, "/login", "primary-navigation.login", "login");
}
};
@@ -128,4 +136,4 @@ class PrimaryNavigation extends React.Component<Props> {
}
}
export default withTranslation("commons")(PrimaryNavigation);
export default withTranslation("commons")(withRouter(PrimaryNavigation));

View File

@@ -36,7 +36,7 @@ class ChangesetTagsCollapsed extends React.Component<Props> {
const { tags, t } = this.props;
const message = tags.map(tag => tag.name).join(", ");
return (
<Tooltip location="top" message={message}>
<Tooltip location="top" message={message} multiline={true}>
<ChangesetTagBase icon="tags" label={tags.length + " " + t("changeset.tags")} />
</Tooltip>
);

View File

@@ -1,12 +1,12 @@
{
"name": "@scm-manager/ui-plugins",
"version": "2.4.0",
"version": "2.4.1",
"license": "MIT",
"bin": {
"ui-plugins": "./bin/ui-plugins.js"
},
"dependencies": {
"@scm-manager/ui-components": "^2.4.0",
"@scm-manager/ui-components": "^2.4.1",
"@scm-manager/ui-extensions": "^2.1.0",
"classnames": "^2.2.6",
"query-string": "^5.0.1",
@@ -23,7 +23,7 @@
"@scm-manager/jest-preset": "^2.1.0",
"@scm-manager/prettier-config": "^2.1.0",
"@scm-manager/tsconfig": "^2.1.0",
"@scm-manager/ui-scripts": "^2.1.0",
"@scm-manager/ui-scripts": "^2.4.1",
"@scm-manager/ui-tests": "^2.1.0",
"@scm-manager/ui-types": "^2.4.0",
"@types/classnames": "^2.2.9",

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-scripts",
"version": "2.1.0",
"version": "2.4.1",
"description": "Build scripts for SCM-Manager",
"main": "src/index.js",
"author": "Sebastian Sdorra <sebastian.sdorra@cloudogu.com>",
@@ -14,7 +14,7 @@
"babel-loader": "^8.0.6",
"css-loader": "^3.2.0",
"file-loader": "^4.2.0",
"mini-css-extract-plugin": "^0.9.0",
"mini-css-extract-plugin": "^0.10.0",
"mustache": "^3.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.3",
"react-refresh": "^0.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-styles",
"version": "2.4.0",
"version": "2.4.1",
"description": "Styles for SCM-Manager",
"main": "src/scm.scss",
"license": "MIT",

View File

@@ -15,11 +15,11 @@ $family-monospace: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro
}
.is-word-break {
-webkit-hyphens: auto;
-moz-hyphens: auto;
-ms-hyphens: auto;
hyphens: auto;
word-break: break-all;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
word-break: break-word;
}
.has-rounded-border {

View File

@@ -1,9 +1,9 @@
{
"name": "@scm-manager/ui-webapp",
"version": "2.4.0",
"version": "2.4.1",
"private": true,
"dependencies": {
"@scm-manager/ui-components": "^2.4.0",
"@scm-manager/ui-components": "^2.4.1",
"@scm-manager/ui-extensions": "^2.1.0",
"classnames": "^2.2.5",
"history": "^4.10.1",
@@ -39,6 +39,7 @@
"@types/react-dom": "^16.9.2",
"@types/react-redux": "5.0.7",
"@types/react-router-dom": "^5.1.0",
"@types/redux-logger": "^3.0.8",
"@types/styled-components": "^5.1.0",
"@types/systemjs": "^0.20.6",
"fetch-mock": "^7.5.1",

View File

@@ -29,12 +29,13 @@ import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Links } from "@scm-manager/ui-types";
import {
CustomQueryFlexWrappedColumns,
NavLink,
Page,
CustomQueryFlexWrappedColumns,
PrimaryContentColumn,
SecondaryNavigationColumn,
SecondaryNavigation,
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation
} from "@scm-manager/ui-components";
import { getAvailablePluginsLink, getInstalledPluginsLink, getLinks } from "../../modules/indexResource";
@@ -44,7 +45,6 @@ import GlobalConfig from "./GlobalConfig";
import RepositoryRoles from "../roles/containers/RepositoryRoles";
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole";
import { StateMenuContextProvider } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {

View File

@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Config, NamespaceStrategies } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Title } from "@scm-manager/ui-components";
import { getConfigLink } from "../../modules/indexResource";
import { mustGetConfigLink } from "../../modules/indexResource";
import {
fetchConfig,
getConfig,
@@ -186,7 +186,7 @@ const mapStateToProps = (state: any) => {
const config = getConfig(state);
const configUpdatePermission = getConfigUpdatePermission(state);
const configLink = getConfigLink(state);
const configLink = mustGetConfigLink(state);
const namespaceStrategies = getNamespaceStrategies(state);
return {

View File

@@ -25,7 +25,7 @@ import * as React from "react";
import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next";
import { compose } from "redux";
import { PendingPlugins, PluginCollection } from "@scm-manager/ui-types";
import { PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types";
import {
Button,
ButtonGroup,
@@ -45,16 +45,15 @@ import {
} from "../modules/plugins";
import PluginsList from "../components/PluginList";
import {
getAvailablePluginsLink,
getInstalledPluginsLink,
getPendingPluginsLink
getPendingPluginsLink,
mustGetAvailablePluginsLink,
mustGetInstalledPluginsLink
} from "../../../modules/indexResource";
import PluginTopActions from "../components/PluginTopActions";
import PluginBottomActions from "../components/PluginBottomActions";
import ExecutePendingActionModal from "../components/ExecutePendingActionModal";
import CancelPendingActionModal from "../components/CancelPendingActionModal";
import UpdateAllActionModal from "../components/UpdateAllActionModal";
import { Plugin } from "@scm-manager/ui-types";
import ShowPendingModal from "../components/ShowPendingModal";
type Props = WithTranslation & {
@@ -319,8 +318,8 @@ const mapStateToProps = (state: any) => {
const collection = getPluginCollection(state);
const loading = isFetchPluginsPending(state);
const error = getFetchPluginsFailure(state);
const availablePluginsLink = getAvailablePluginsLink(state);
const installedPluginsLink = getInstalledPluginsLink(state);
const availablePluginsLink = mustGetAvailablePluginsLink(state);
const installedPluginsLink = mustGetInstalledPluginsLink(state);
const pendingPluginsLink = getPendingPluginsLink(state);
const pendingPlugins = getPendingPlugins(state);

View File

@@ -29,7 +29,7 @@ import { History } from "history";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { RepositoryRole } from "@scm-manager/ui-types";
import { ErrorPage, Loading, Title } from "@scm-manager/ui-components";
import { getRepositoryRolesLink } from "../../../modules/indexResource";
import { mustGetRepositoryRolesLink } from "../../../modules/indexResource";
import { fetchRoleByName, getFetchRoleFailure, getRoleByName, isFetchRolePending } from "../modules/roles";
import PermissionRoleDetail from "../components/PermissionRoleDetails";
import EditRepositoryRole from "./EditRepositoryRole";
@@ -107,7 +107,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const role = getRoleByName(state, roleName);
const loading = isFetchRolePending(state, roleName);
const error = getFetchRoleFailure(state, roleName);
const repositoryRolesLink = getRepositoryRolesLink(state);
const repositoryRolesLink = mustGetRepositoryRolesLink(state);
return {
repositoryRolesLink,
roleName,

View File

@@ -64,6 +64,15 @@ class Index extends Component<Props, State> {
this.props.fetchIndexResources();
}
componentDidUpdate() {
const { indexResources, loading, error } = this.props;
const { pluginsLoaded } = this.state;
if (!indexResources && !loading && !error && pluginsLoaded) {
this.props.fetchIndexResources();
this.setState({ pluginsLoaded: false });
}
}
pluginLoaderCallback = () => {
this.setState({
pluginsLoaded: true

View File

@@ -0,0 +1,62 @@
/*
* 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 { from } from "./Login";
describe("from tests", () => {
it("should use default location", () => {
const path = from("", {});
expect(path).toBe("/");
});
it("should use default location without params", () => {
const path = from();
expect(path).toBe("/");
});
it("should use default location with null params", () => {
const path = from("", null);
expect(path).toBe("/");
});
it("should use location from query parameter", () => {
const path = from("from=/repos", {});
expect(path).toBe("/repos");
});
it("should use location from state", () => {
const path = from("", { from: "/users" });
expect(path).toBe("/users");
});
it("should prefer location from query parameter", () => {
const path = from("from=/groups", { from: "/users" });
expect(path).toBe("/groups");
});
it("should decode query param", () => {
const path = from(`from=${encodeURIComponent("/admin/plugins/installed")}`);
expect(path).toBe("/admin/plugins/installed");
});
});

View File

@@ -30,6 +30,7 @@ import { getLoginFailure, getMe, isAnonymous, isLoginPending, login } from "../m
import { getLoginInfoLink, getLoginLink } from "../modules/indexResource";
import LoginInfo from "../components/LoginInfo";
import { Me } from "@scm-manager/ui-types";
import { parse } from "query-string";
type Props = RouteComponentProps & {
authenticated: boolean;
@@ -47,6 +48,18 @@ const HeroSection = styled.section`
padding-top: 2em;
`;
interface FromObject {
from?: string;
}
/**
* @visibleForTesting
*/
export const from = (queryString?: string, stateParams?: FromObject | null): string => {
const queryParams = parse(queryString || "");
return queryParams?.from || stateParams?.from || "/";
};
class Login extends React.Component<Props> {
handleLogin = (username: string, password: string): void => {
const { link, login } = this.props;
@@ -54,12 +67,8 @@ class Login extends React.Component<Props> {
};
renderRedirect = () => {
const { from } = this.props.location.state || {
from: {
pathname: "/"
}
};
return <Redirect to={from} />;
const to = from(window.location.search, this.props.location.state);
return <Redirect to={to} />;
};
render() {

View File

@@ -31,7 +31,7 @@ import Users from "../users/containers/Users";
import Login from "../containers/Login";
import Logout from "../containers/Logout";
import { ProtectedRoute } from "@scm-manager/ui-components";
import { ProtectedRoute, ErrorBoundary } from "@scm-manager/ui-components";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import CreateUser from "../users/containers/CreateUser";
@@ -68,6 +68,7 @@ class Main extends React.Component<Props> {
url = "/login";
}
return (
<ErrorBoundary>
<div className="main">
<Switch>
<Redirect exact from="/" to={url} />
@@ -80,13 +81,13 @@ class Main extends React.Component<Props> {
<ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} />
<Redirect exact strict from="/users" to="/users/" />
<ProtectedRoute exact path="/users/" component={Users} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/users/create" component={CreateUser} />
<ProtectedRoute path="/users/create" component={CreateUser} authenticated={authenticated} />
<ProtectedRoute exact path="/users/:page" component={Users} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/user/:name" component={SingleUser} />
<ProtectedRoute path="/user/:name" component={SingleUser} authenticated={authenticated} />
<Redirect exact strict from="/groups" to="/groups/" />
<ProtectedRoute exact path="/groups/" component={Groups} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/group/:name" component={SingleGroup} />
<ProtectedRoute authenticated={authenticated} path="/groups/create" component={CreateGroup} />
<ProtectedRoute path="/group/:name" component={SingleGroup} authenticated={authenticated} />
<ProtectedRoute path="/groups/create" component={CreateGroup} authenticated={authenticated} />
<ProtectedRoute exact path="/groups/:page" component={Groups} authenticated={authenticated} />
<ProtectedRoute path="/admin" component={Admin} authenticated={authenticated} />
<ProtectedRoute path="/me" component={Profile} authenticated={authenticated} />
@@ -94,13 +95,14 @@ class Main extends React.Component<Props> {
name="main.route"
renderAll={true}
props={{
authenticated,
me,
links
links,
authenticated
}}
/>
</Switch>
</div>
</ErrorBoundary>
);
}
}

View File

@@ -24,14 +24,14 @@
import thunk from "redux-thunk";
import logger from "redux-logger";
import { applyMiddleware, combineReducers, compose, createStore } from "redux";
import { AnyAction, applyMiddleware, combineReducers, compose, createStore } from "redux";
import users from "./users/modules/users";
import repos from "./repos/modules/repos";
import repositoryTypes from "./repos/modules/repositoryTypes";
import changesets from "./repos/modules/changesets";
import sources from "./repos/sources/modules/sources";
import groups from "./groups/modules/groups";
import auth from "./modules/auth";
import auth, { isAuthenticated } from "./modules/auth";
import pending from "./modules/pending";
import failure from "./modules/failure";
import permissions from "./repos/permissions/modules/permissions";
@@ -40,13 +40,16 @@ import roles from "./admin/roles/modules/roles";
import namespaceStrategies from "./admin/modules/namespaceStrategies";
import indexResources from "./modules/indexResource";
import plugins from "./admin/plugins/modules/plugins";
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
import branches from "./repos/branches/modules/branches";
const EMPTY_STATE = {} as any;
function createReduxStore() {
// @ts-ignore __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ is defined by react dev tools
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const reducer = combineReducers({
const appReducer = combineReducers({
pending,
failure,
indexResources,
@@ -65,7 +68,39 @@ function createReduxStore() {
plugins
});
return createStore(reducer, composeEnhancers(applyMiddleware(thunk, logger)));
// We assume that an UnauthorizedError means that the access token is expired.
// If the token is expired we want to show an error with the login link.
// This error should be displayed with the state (e.g. navigation) of the previous logged in user.
// But if the user navigates away, we want to reset the state to an anonymous one.
const reducer = (state: any, action: AnyAction) => {
// Reset the state if the token is expired and a new action is dispatched (e.g. navigation).
// We exclude failures, because the fetch which had triggered the unauthorized error
// will likely end with an failure action.
if (state.tokenExpired && !action.type.includes("FAILURE")) {
// reset state by passing an empty state down to the app reducer
// we do not use the captured action, because the data is derived from the old state
return appReducer(EMPTY_STATE, { type: "_" });
}
// If the user is authenticated and response is an unauthorized error,
// we assume that the token is expired.
if (action.type === "API_CLIENT_UNAUTHORIZED" && isAuthenticated(state)) {
return { ...state, tokenExpired: true };
}
// Keep the tokenExpired after calling appReducer,
// this is required because the appReducer would remove any unknown property.
return { ...appReducer(state, action), tokenExpired: state.tokenExpired };
};
const store = createStore(reducer, EMPTY_STATE, composeEnhancers(applyMiddleware(thunk, logger)));
apiClient.onError(error => {
if (error instanceof UnauthorizedError) {
store.dispatch({ type: "API_CLIENT_UNAUTHORIZED", error });
}
});
return store;
}
export default createReduxStore;

View File

@@ -27,11 +27,10 @@ import { compose } from "redux";
import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { DisplayedUser, Group } from "@scm-manager/ui-types";
import { Page } from "@scm-manager/ui-components";
import { getGroupsLink, getUserAutoCompleteLink } from "../../modules/indexResource";
import { apiClient, Page } from "@scm-manager/ui-components";
import { getUserAutoCompleteLink, mustGetGroupsLink } from "../../modules/indexResource";
import { createGroup, createGroupReset, getCreateGroupFailure, isCreateGroupPending } from "../modules/groups";
import GroupForm from "../components/GroupForm";
import { apiClient } from "@scm-manager/ui-components";
type Props = WithTranslation & {
createGroup: (link: string, group: Group, callback?: () => void) => void;
@@ -97,7 +96,7 @@ const mapDispatchToProps = (dispatch: any) => {
const mapStateToProps = (state: any) => {
const loading = isCreateGroupPending(state);
const error = getCreateGroupFailure(state);
const createLink = getGroupsLink(state);
const createLink = mustGetGroupsLink(state);
const autocompleteLink = getUserAutoCompleteLink(state);
return {
createLink,

View File

@@ -35,7 +35,7 @@ import {
PageActions,
urls
} from "@scm-manager/ui-components";
import { getGroupsLink } from "../../modules/indexResource";
import { mustGetGroupsLink } from "../../modules/indexResource";
import {
fetchGroupsByPage,
getFetchGroupsFailure,
@@ -128,7 +128,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const page = urls.getPageFromMatch(match);
const canAddGroups = isPermittedToCreateGroups(state);
const list = selectListAsCollection(state);
const groupLink = getGroupsLink(state);
const groupLink = mustGetGroupsLink(state);
return {
groups,

View File

@@ -39,7 +39,7 @@ import {
SubNavigation,
StateMenuContextProvider
} from "@scm-manager/ui-components";
import { getGroupsLink } from "../../modules/indexResource";
import { getGroupsLink, mustGetGroupsLink } from "../../modules/indexResource";
import { fetchGroupByName, getFetchGroupFailure, getGroupByName, isFetchGroupPending } from "../modules/groups";
import { Details } from "./../components/table";
import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks";
@@ -138,7 +138,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const group = getGroupByName(state, name);
const loading = isFetchGroupPending(state, name);
const error = getFetchGroupFailure(state, name);
const groupLink = getGroupsLink(state);
const groupLink = mustGetGroupsLink(state);
return {
name,

View File

@@ -117,7 +117,7 @@ const indexResourcesAuthenticated = {
describe("index resource", () => {
describe("fetch index resource", () => {
const index_url = "/api/v2/";
const indexUrl = "/api/v2/";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
@@ -126,7 +126,7 @@ describe("index resource", () => {
});
it("should successfully fetch index resources when unauthenticated", () => {
fetchMock.getOnce(index_url, indexResourcesUnauthenticated);
fetchMock.getOnce(indexUrl, indexResourcesUnauthenticated);
const expectedActions = [
{
@@ -145,7 +145,7 @@ describe("index resource", () => {
});
it("should successfully fetch index resources when authenticated", () => {
fetchMock.getOnce(index_url, indexResourcesAuthenticated);
fetchMock.getOnce(indexUrl, indexResourcesAuthenticated);
const expectedActions = [
{
@@ -164,7 +164,7 @@ describe("index resource", () => {
});
it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => {
fetchMock.getOnce(index_url, {
fetchMock.getOnce(indexUrl, {
status: 500
});
@@ -176,6 +176,50 @@ describe("index resource", () => {
expect(actions[1].payload).toBeDefined();
});
});
it("should retry to fetch index resource on unauthorized error", () => {
fetchMock.getOnce(indexUrl, { status: 401 }).getOnce(indexUrl, indexResourcesAuthenticated, {
overwriteRoutes: false
});
const expectedActions = [
{
type: FETCH_INDEXRESOURCES_PENDING
},
{
type: FETCH_INDEXRESOURCES_SUCCESS,
payload: indexResourcesAuthenticated
}
];
const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should only retry to fetch index resource on unauthorized error once", () => {
fetchMock
.getOnce(indexUrl, { status: 401 })
.getOnce(
indexUrl,
{ status: 401 },
{
overwriteRoutes: false
}
)
.getOnce(indexUrl, indexResourcesAuthenticated, {
overwriteRoutes: false
});
const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING);
expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
});
describe("index resources reducer", () => {

View File

@@ -24,7 +24,7 @@
import * as types from "./types";
import { apiClient } from "@scm-manager/ui-components";
import { apiClient, MissingLinkError, UnauthorizedError } from "@scm-manager/ui-components";
import { Action, IndexResources, Link } from "@scm-manager/ui-types";
import { isPending } from "./pending";
import { getFailure } from "./failure";
@@ -46,14 +46,27 @@ export const callFetchIndexResources = (): Promise<IndexResources> => {
export function fetchIndexResources() {
return function(dispatch: any) {
/*
* The index resource returns a 401 if the access token expired.
* This only happens once because the error response automatically invalidates the cookie.
* In this event, we have to try the request once again.
*/
let shouldRetry = true;
dispatch(fetchIndexResourcesPending());
return callFetchIndexResources()
const run = (): Promise<any> =>
callFetchIndexResources()
.then(resources => {
dispatch(fetchIndexResourcesSuccess(resources));
})
.catch(err => {
if (err instanceof UnauthorizedError && shouldRetry) {
shouldRetry = false;
return run();
} else {
dispatch(fetchIndexResourcesFailure(err));
}
});
return run();
};
}
@@ -123,6 +136,14 @@ export function getLink(state: object, name: string) {
}
}
export function mustGetLink(state: object, name: string) {
const link = getLink(state, name);
if (link) {
return link;
}
throw new MissingLinkError(`No link in state for link name: '${name}'`);
}
export function getLinkCollection(state: object, name: string): Link[] {
// @ts-ignore Right types not available
if (state.indexResources.links && state.indexResources.links[name]) {
@@ -145,10 +166,18 @@ export function getAvailablePluginsLink(state: object) {
return getLink(state, "availablePlugins");
}
export function mustGetAvailablePluginsLink(state: object) {
return mustGetLink(state, "availablePlugins");
}
export function getInstalledPluginsLink(state: object) {
return getLink(state, "installedPlugins");
}
export function mustGetInstalledPluginsLink(state: object) {
return mustGetLink(state, "installedPlugins");
}
export function getPendingPluginsLink(state: object) {
return getLink(state, "pendingPlugins");
}
@@ -169,10 +198,18 @@ export function getUsersLink(state: object) {
return getLink(state, "users");
}
export function mustGetUsersLink(state: object) {
return mustGetLink(state, "users");
}
export function getRepositoryRolesLink(state: object) {
return getLink(state, "repositoryRoles");
}
export function mustGetRepositoryRolesLink(state: object) {
return mustGetLink(state, "repositoryRoles");
}
export function getRepositoryVerbsLink(state: object) {
return getLink(state, "repositoryVerbs");
}
@@ -181,10 +218,18 @@ export function getGroupsLink(state: object) {
return getLink(state, "groups");
}
export function mustGetGroupsLink(state: object) {
return mustGetLink(state, "groups");
}
export function getConfigLink(state: object) {
return getLink(state, "config");
}
export function mustGetConfigLink(state: object) {
return mustGetLink(state, "config");
}
export function getRepositoriesLink(state: object) {
return getLink(state, "repositories");
}

View File

@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
import classNames from "classnames";
import styled from "styled-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Changeset, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types";
import { Changeset, ParentChangeset, Repository } from "@scm-manager/ui-types";
import {
AvatarImage,
AvatarWrapper,
@@ -36,14 +36,15 @@ import {
ChangesetDiff,
ChangesetId,
changesets,
ChangesetTag,
ChangesetTags,
DateFromNow,
FileControlFactory,
Icon,
Level
Level,
SignatureIcon
} from "@scm-manager/ui-components";
import ContributorTable from "./ContributorTable";
import { Link as ReactLink } from "react-router-dom";
import { FileControlFactory, SignatureIcon } from "@scm-manager/ui-components";
type Props = WithTranslation & {
changeset: Changeset;
@@ -59,16 +60,6 @@ const RightMarginP = styled.p`
margin-right: 1em;
`;
const TagsWrapper = styled.div`
& .tag {
margin-left: 0.25rem;
}
`;
const SignedIcon = styled(SignatureIcon)`
padding-left: 1rem;
`;
const BottomMarginLevel = styled(Level)`
margin-bottom: 1rem !important;
`;
@@ -224,7 +215,9 @@ class ChangesetDetails extends React.Component<Props, State> {
)}
</ChangesetSummary>
</div>
<div className="media-right">{this.renderTags()}</div>
<div className="media-right">
<ChangesetTags changeset={changeset} />
</div>
</article>
<p>
{description.message.split("\n").map((item, key) => {
@@ -264,24 +257,6 @@ class ChangesetDetails extends React.Component<Props, State> {
);
}
getTags = () => {
return this.props.changeset._embedded.tags || [];
};
renderTags = () => {
const tags = this.getTags();
if (tags.length > 0) {
return (
<TagsWrapper className="level-item">
{tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />;
})}
</TagsWrapper>
);
}
return null;
};
collapseDiffs = () => {
this.setState(state => ({
collapsed: !state.collapsed

View File

@@ -87,7 +87,7 @@ class SourcesView extends React.Component<Props, State> {
const basePath = this.createBasePath();
if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />;
} else if (contentType.includes("markdown")) {
} else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) {
return <SwitchableMarkdownViewer file={file} basePath={basePath} />;
} else if (language) {
return <SourcecodeViewer file={file} language={language} />;

View File

@@ -28,7 +28,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history";
import { User } from "@scm-manager/ui-types";
import { Page } from "@scm-manager/ui-components";
import { getUsersLink } from "../../modules/indexResource";
import { mustGetUsersLink } from "../../modules/indexResource";
import { createUser, createUserReset, getCreateUserFailure, isCreateUserPending } from "../modules/users";
import UserForm from "../components/UserForm";
@@ -84,7 +84,7 @@ const mapDispatchToProps = (dispatch: any) => {
const mapStateToProps = (state: any) => {
const loading = isCreateUserPending(state);
const error = getCreateUserFailure(state);
const usersLink = getUsersLink(state);
const usersLink = mustGetUsersLink(state);
return {
usersLink,
loading,

View File

@@ -43,10 +43,9 @@ import EditUser from "./EditUser";
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
import { WithTranslation, withTranslation } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource";
import { mustGetUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
import AddPublicKey from "../components/publicKeys/AddPublicKey";
import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
type Props = RouteComponentProps &
@@ -148,7 +147,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const user = getUserByName(state, name);
const loading = isFetchUserPending(state, name);
const error = getFetchUserFailure(state, name);
const usersLink = getUsersLink(state);
const usersLink = mustGetUsersLink(state);
return {
usersLink,
name,

View File

@@ -35,7 +35,7 @@ import {
PageActions,
urls
} from "@scm-manager/ui-components";
import { getUsersLink } from "../../modules/indexResource";
import { mustGetUsersLink } from "../../modules/indexResource";
import {
fetchUsersByPage,
getFetchUsersFailure,
@@ -129,7 +129,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const page = urls.getPageFromMatch(match);
const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state);
const usersLink = getUsersLink(state);
const usersLink = mustGetUsersLink(state);
return {
users,

View File

@@ -315,7 +315,7 @@
<dependency>
<groupId>com.github.sdorra</groupId>
<artifactId>spotter-core</artifactId>
<version>2.1.2</version>
<version>3.0.0</version>
</dependency>
<dependency>

View File

@@ -0,0 +1,63 @@
/*
* 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;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import sonia.scm.api.v2.resources.ErrorDto;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.Collections;
@Provider
public class WebApplicationExceptionMapper implements ExceptionMapper<WebApplicationException> {
private static final Logger LOG = LoggerFactory.getLogger(WebApplicationExceptionMapper.class);
private static final String ERROR_CODE = "FVS9JY1T21";
@Override
public Response toResponse(WebApplicationException exception) {
LOG.trace("caught web application exception", exception);
ErrorDto errorDto = new ErrorDto();
errorDto.setMessage(exception.getMessage());
errorDto.setContext(Collections.emptyList());
errorDto.setErrorCode(ERROR_CODE);
errorDto.setTransactionId(MDC.get("transaction_id"));
Response originalResponse = exception.getResponse();
return Response.fromResponse(originalResponse)
.entity(errorDto)
.type(VndMediaType.ERROR_TYPE)
.build();
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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;
import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.ContentTypeDetector;
import com.github.sdorra.spotter.Language;
public final class ContentTypeResolver {
private static final ContentTypeDetector PATH_BASED = ContentTypeDetector.builder()
.defaultPathBased().boost(Language.MARKDOWN)
.bestEffortMatch();
private static final ContentTypeDetector PATH_AND_CONTENT_BASED = ContentTypeDetector.builder()
.defaultPathAndContentBased().boost(Language.MARKDOWN)
.bestEffortMatch();
private ContentTypeResolver() {
}
public static ContentType resolve(String path) {
return PATH_BASED.detect(path);
}
public static ContentType resolve(String path, byte[] contentPrefix) {
return PATH_AND_CONTENT_BASED.detect(path, contentPrefix);
}
}

View File

@@ -25,7 +25,6 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.ContentTypes;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@@ -34,6 +33,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.NotFoundException;
import sonia.scm.api.v2.ContentTypeResolver;
import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -204,7 +204,7 @@ public class ContentResource {
}
private void appendContentHeader(String path, byte[] head, Response.ResponseBuilder responseBuilder) {
ContentType contentType = ContentTypes.detect(path, head);
ContentType contentType = ContentTypeResolver.resolve(path, head);
responseBuilder.header("Content-Type", contentType.getRaw());
contentType.getLanguage().ifPresent(
language -> responseBuilder.header(ProgrammingLanguages.HEADER, ProgrammingLanguages.getValue(language))

View File

@@ -24,10 +24,10 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentTypes;
import com.github.sdorra.spotter.Language;
import com.google.inject.Inject;
import de.otto.edison.hal.Links;
import sonia.scm.api.v2.ContentTypeResolver;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.DiffFile;
import sonia.scm.repository.api.DiffLine;
@@ -120,7 +120,7 @@ class DiffResultToDiffResultDtoMapper {
dto.setOldRevision(file.getOldRevision());
Optional<Language> language = ContentTypes.detect(path).getLanguage();
Optional<Language> language = ContentTypeResolver.resolve(path).getLanguage();
language.ifPresent(value -> dto.setLanguage(ProgrammingLanguages.getValue(value)));
List<DiffResultDto.HunkDto> hunks = new ArrayList<>();

View File

@@ -40,7 +40,6 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import java.io.IOException;
import java.net.URLDecoder;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
@@ -88,7 +87,7 @@ public class SourceRootResource {
browseCommand.setPath(path);
browseCommand.setOffset(offset);
if (revision != null && !revision.isEmpty()) {
browseCommand.setRevision(URLDecoder.decode(revision, "UTF-8"));
browseCommand.setRevision(revision);
}
BrowserResult browserResult = browseCommand.getBrowserResult();

View File

@@ -31,9 +31,8 @@ import javax.inject.Singleton;
@Singleton
public class DefaultRestarter implements Restarter {
private ScmEventBus eventBus;
private RestartStrategy strategy;
private final ScmEventBus eventBus;
private final RestartStrategy strategy;
@Inject
public DefaultRestarter() {

View File

@@ -61,6 +61,7 @@ import static java.util.Comparator.comparing;
class MigrationWizardServlet extends HttpServlet {
private static final Logger LOG = LoggerFactory.getLogger(MigrationWizardServlet.class);
static final String PROPERTY_WAIT_TIME = "sonia.scm.restart-migration.wait";
private final XmlRepositoryV1UpdateStep repositoryV1UpdateStep;
private final DefaultMigrationStrategyDAO migrationStrategyDao;
@@ -129,7 +130,7 @@ class MigrationWizardServlet extends HttpServlet {
repositoryLineEntries.stream()
.forEach(
entry-> {
entry -> {
String id = entry.getId();
String protocol = entry.getType();
String originalName = entry.getOriginalName();
@@ -145,7 +146,7 @@ class MigrationWizardServlet extends HttpServlet {
if (restarter.isSupported()) {
respondWithTemplate(resp, model, "templates/repository-migration-restart.mustache");
restarter.restart(MigrationWizardServlet.class, "wrote migration data");
new Thread(this::restart).start();
} else {
respondWithTemplate(resp, model, "templates/repository-migration-manual-restart.mustache");
LOG.error("Restarting is not supported on this platform.");
@@ -153,6 +154,19 @@ class MigrationWizardServlet extends HttpServlet {
}
}
private void restart() {
String wait = System.getProperty(PROPERTY_WAIT_TIME);
if (!Strings.isNullOrEmpty(wait)) {
try {
Thread.sleep(Long.parseLong(wait));
} catch (InterruptedException e) {
LOG.error("error on waiting before restart", e);
Thread.currentThread().interrupt();
}
}
restarter.restart(MigrationWizardServlet.class, "wrote migration data");
}
private List<RepositoryLineEntry> getRepositoryLineEntries() {
List<V1Repository> repositoriesWithoutMigrationStrategies =
repositoryV1UpdateStep.getRepositoriesWithoutMigrationStrategies();

View File

@@ -31,14 +31,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting;
import com.google.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.NotFoundException;
import sonia.scm.SCMContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContextProvider;
import sonia.scm.Stage;
import sonia.scm.lifecycle.RestartEvent;
import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
import sonia.scm.filter.WebElement;
import sonia.scm.lifecycle.RestartEvent;
import sonia.scm.plugin.PluginLoader;
import javax.inject.Inject;
@@ -51,11 +51,6 @@ import java.net.URL;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Function;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
/**
@@ -63,86 +58,90 @@ import static sonia.scm.NotFoundException.notFound;
*/
@Singleton
@WebElement(value = I18nServlet.PATTERN, regex = true)
@Slf4j
public class I18nServlet extends HttpServlet {
private static final Logger LOG = LoggerFactory.getLogger(I18nServlet.class);
public static final String PLUGINS_JSON = "plugins.json";
public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON;
public static final String CACHE_NAME = "sonia.cache.plugins.translations";
private final SCMContextProvider context;
private final ClassLoader classLoader;
private final Cache<String, JsonNode> cache;
private static ObjectMapper objectMapper = new ObjectMapper();
private final ObjectMapper objectMapper = new ObjectMapper();
@Inject
public I18nServlet(PluginLoader pluginLoader, CacheManager cacheManager) {
public I18nServlet(SCMContextProvider context, PluginLoader pluginLoader, CacheManager cacheManager) {
this.context = context;
this.classLoader = pluginLoader.getUberClassLoader();
this.cache = cacheManager.getCache(CACHE_NAME);
}
@Subscribe(async = false)
public void handleRestartEvent(RestartEvent event) {
log.debug("Clear cache on restart event with reason {}", event.getReason());
LOG.debug("Clear cache on restart event with reason {}", event.getReason());
cache.clear();
}
private JsonNode getCollectedJson(String path,
Function<String, Optional<JsonNode>> jsonFileProvider,
BiConsumer<String, JsonNode> createdJsonFileConsumer) {
return Optional.ofNullable(jsonFileProvider.apply(path)
.orElseGet(() -> {
Optional<JsonNode> createdFile = collectJsonFile(path);
createdFile.ifPresent(map -> createdJsonFileConsumer.accept(path, map));
return createdFile.orElse(null);
}
)).orElseThrow(() -> notFound(entity("jsonprovider", path)));
}
@VisibleForTesting
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse response) {
protected void doGet(HttpServletRequest request, HttpServletResponse response) {
String path = request.getServletPath();
try {
Optional<JsonNode> json = findJson(path);
if (json.isPresent()) {
write(response, json.get());
} else {
LOG.debug("could not find translation at {}", path);
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
}
} catch (IOException ex) {
LOG.error("Error on getting the translation of the plugins", ex);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
private void write(HttpServletResponse response, JsonNode jsonNode) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.setHeader("Cache-Control", "no-cache");
try (PrintWriter out = response.getWriter()) {
String path = req.getServletPath();
Function<String, Optional<JsonNode>> jsonFileProvider = usedPath -> Optional.empty();
BiConsumer<String, JsonNode> createdJsonFileConsumer = (usedPath, jsonNode) -> log.debug("A json File is created from the path {}", usedPath);
try (PrintWriter writer = response.getWriter()) {
objectMapper.writeValue(writer, jsonNode);
}
}
public Optional<JsonNode> findJson(String path) throws IOException {
if (isProductionStage()) {
log.debug("In Production Stage get the plugin translations from the cache");
jsonFileProvider = usedPath -> Optional.ofNullable(
cache.get(usedPath));
createdJsonFileConsumer = createdJsonFileConsumer
.andThen((usedPath, jsonNode) -> log.debug("Put the created json File in the cache with the key {}", usedPath))
.andThen(cache::put);
return findJsonCached(path);
}
objectMapper.writeValue(out, getCollectedJson(path, jsonFileProvider, createdJsonFileConsumer));
} catch (IOException e) {
log.error("Error on getting the translation of the plugins", e);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
} catch (NotFoundException e) {
log.error("Plugin translations are not found", e);
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return collectJsonFile(path);
}
private Optional<JsonNode> findJsonCached(String path) throws IOException {
JsonNode jsonNode = cache.get(path);
if (jsonNode != null) {
LOG.debug("return json node from cache for path {}", path);
return Optional.of(jsonNode);
}
LOG.debug("collect json for path {}", path);
Optional<JsonNode> collected = collectJsonFile(path);
collected.ifPresent(node -> cache.put(path, node));
return collected;
}
@VisibleForTesting
protected boolean isProductionStage() {
return SCMContext.getContext().getStage() == Stage.PRODUCTION;
return context.getStage() == Stage.PRODUCTION;
}
/**
* Return a collected Json File as JsonNode from the given path from all plugins in the class path
*
* @param path the searched resource path
* @return a collected Json File as JsonNode from the given path from all plugins in the class path
*/
@VisibleForTesting
protected Optional<JsonNode> collectJsonFile(String path) {
log.debug("Collect plugin translations from path {} for every plugin", path);
private Optional<JsonNode> collectJsonFile(String path) throws IOException {
LOG.debug("Collect plugin translations from path {} for every plugin", path);
JsonNode mergedJsonNode = null;
try {
Enumeration<URL> resources = classLoader.getResources(path.replaceFirst("/", ""));
while (resources.hasMoreElements()) {
URL url = resources.nextElement();
@@ -153,32 +152,15 @@ public class I18nServlet extends HttpServlet {
mergedJsonNode = jsonNode;
}
}
} catch (IOException e) {
log.error("Error on loading sources from {}", path, e);
return Optional.empty();
}
return Optional.ofNullable(mergedJsonNode);
}
/**
* Merge the <code>updateNode</code> into the <code>mainNode</code> and return it.
*
* This is not a deep merge.
*
* @param mainNode the main node
* @param updateNode the update node
* @return the merged mainNode
*/
@VisibleForTesting
protected JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
private JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
Iterator<String> fieldNames = updateNode.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
JsonNode jsonNode = mainNode.get(fieldName);
if (jsonNode != null) {
mergeNode(updateNode, fieldName, jsonNode);
} else {

View File

@@ -268,6 +268,10 @@
"BxS5wX2v71": {
"displayName": "Inkorrekter Schlüssel",
"description": "Der bereitgestellte Schlüssel ist kein korrekt formartierter öffentlicher Schlüssel."
},
"FVS9JY1T21": {
"displayName": "Fehler bei der Anfrage",
"description": "Bei der Anfrage trat ein Fehler auf. Prüfen Sie bitte den Status der HTTP Antwort und die konkrete Meldung."
}
},
"namespaceStrategies": {

View File

@@ -268,6 +268,10 @@
"BxS5wX2v71": {
"displayName": "Invalid key",
"description": "The provided key is not a valid public key."
},
"FVS9JY1T21": {
"displayName": "Error in the request",
"description": "While processing the request there was an error. Please check the http return status and the concrete error message."
}
},
"namespaceStrategies": {

View File

@@ -0,0 +1,65 @@
/*
* 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;
import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.Language;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class ContentTypeResolverTest {
@Test
void shouldResolveMarkdown() {
String content = String.join("\n",
"% Markdown content",
"% Which does not start with markdown"
);
ContentType contentType = ContentTypeResolver.resolve("somedoc.md", content.getBytes(StandardCharsets.UTF_8));
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
}
@Test
void shouldResolveMarkdownWithoutContent() {
ContentType contentType = ContentTypeResolver.resolve("somedoc.md");
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
}
@Test
void shouldResolveMarkdownEvenWithDotsInFilename() {
ContentType contentType = ContentTypeResolver.resolve("somedoc.1.1.md");
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
}
@Test
void shouldResolveDockerfile() {
ContentType contentType = ContentTypeResolver.resolve("Dockerfile");
assertThat(contentType.getLanguage()).contains(Language.DOCKERFILE);
}
}

View File

@@ -23,7 +23,6 @@
*/
package sonia.scm.lifecycle;
import com.github.legman.Subscribe;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@@ -32,8 +31,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.event.ScmEventBus;
import javax.swing.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
@@ -72,7 +69,7 @@ class DefaultRestarterTest {
@Test
void shouldThrowRestartNotSupportedException() {
DefaultRestarter restarter = new DefaultRestarter(eventBus,null);
DefaultRestarter restarter = new DefaultRestarter(eventBus, null);
assertThrows(
RestartNotSupportedException.class, () -> restarter.restart(DefaultRestarterTest.class, "test")
);

View File

@@ -24,6 +24,7 @@
package sonia.scm.update;
import com.google.common.base.Stopwatch;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,13 +38,19 @@ import sonia.scm.update.repository.XmlRepositoryV1UpdateStep;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static sonia.scm.update.MigrationWizardServlet.PROPERTY_WAIT_TIME;
@ExtendWith(MockitoExtension.class)
class MigrationWizardServletTest {
@@ -262,4 +269,58 @@ class MigrationWizardServletTest {
verify(migrationStrategyDao).set("id", "git", "name", MigrationStrategy.COPY, "namespace", "name");
}
@Test
void shouldRestartAfterValidMigration() throws InterruptedException {
when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn(
Collections.singletonList(new V1Repository("id", "git", "name"))
);
doReturn("namespace").when(request).getParameter("namespace-id");
doReturn("name").when(request).getParameter("name-id");
doReturn("COPY").when(request).getParameter("strategy-id");
final CountDownLatch countDownLatch = new CountDownLatch(1);
when(restarter.isSupported()).thenReturn(true);
doAnswer(ic -> {
countDownLatch.countDown();
return null;
}).when(restarter).restart(any(), any());
servlet.doPost(request, response);
boolean restarted = countDownLatch.await(500L, TimeUnit.MILLISECONDS);
assertThat(restarted).isTrue();
}
@Test
void shouldRestartAfterValidMigrationWithSystemProperty() throws InterruptedException {
System.setProperty(PROPERTY_WAIT_TIME, "100");
Stopwatch stopwatch = Stopwatch.createStarted();
when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn(
Collections.singletonList(new V1Repository("id", "git", "name"))
);
doReturn("namespace").when(request).getParameter("namespace-id");
doReturn("name").when(request).getParameter("name-id");
doReturn("COPY").when(request).getParameter("strategy-id");
final CountDownLatch countDownLatch = new CountDownLatch(1);
when(restarter.isSupported()).thenReturn(true);
doAnswer(ic -> {
stopwatch.stop();
countDownLatch.countDown();
return null;
}).when(restarter).restart(any(), any());
servlet.doPost(request, response);
boolean restarted = countDownLatch.await(750L, TimeUnit.MILLISECONDS);
assertThat(stopwatch.elapsed()).isGreaterThanOrEqualTo(Duration.ofMillis(100L));
assertThat(restarted).isTrue();
System.clearProperty(PROPERTY_WAIT_TIME);
}
}

View File

@@ -26,44 +26,38 @@ package sonia.scm.web.i18n;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets;
import com.google.common.io.Files;
import com.github.legman.EventBus;
import org.apache.commons.lang3.StringUtils;
import org.assertj.core.util.Lists;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockSettings;
import org.mockito.internal.creation.MockSettingsImpl;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.lifecycle.RestartEvent;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.SCMContextProvider;
import sonia.scm.Stage;
import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager;
import sonia.scm.event.ScmEventBus;
import sonia.scm.lifecycle.RestartEventFactory;
import sonia.scm.plugin.PluginLoader;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.Silent.class)
public class I18nServletTest {
@ExtendWith(MockitoExtension.class)
class I18nServletTest {
private static final String GIT_PLUGIN_JSON = json(
"{",
@@ -76,6 +70,7 @@ public class I18nServletTest {
"}",
"}"
);
private static final String HG_PLUGIN_JSON = json(
"{",
"'scm-hg-plugin': {",
@@ -87,7 +82,8 @@ public class I18nServletTest {
"}",
"}"
);
private static String SVN_PLUGIN_JSON = json(
private static final String SVN_PLUGIN_JSON = json(
"{",
"'scm-svn-plugin': {",
"'information': {",
@@ -101,59 +97,49 @@ public class I18nServletTest {
return String.join("\n", parts ).replaceAll("'", "\"");
}
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Mock
private SCMContextProvider context;
@Mock
private PluginLoader pluginLoader;
@Mock
private ClassLoader classLoader;
@Mock
private CacheManager cacheManager;
@Mock
private ClassLoader classLoader;
private Cache<String, JsonNode> cache;
private I18nServlet servlet;
@Mock
private Cache cache;
private Enumeration<URL> resources;
@Before
@SuppressWarnings("unchecked")
public void init() throws IOException {
resources = Collections.enumeration(Lists.newArrayList(
createFileFromString(SVN_PLUGIN_JSON).toURI().toURL(),
createFileFromString(GIT_PLUGIN_JSON).toURI().toURL(),
createFileFromString(HG_PLUGIN_JSON).toURI().toURL()
));
@BeforeEach
void init() {
when(pluginLoader.getUberClassLoader()).thenReturn(classLoader);
when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache);
MockSettings settings = new MockSettingsImpl<>();
settings.useConstructor(pluginLoader, cacheManager);
settings.defaultAnswer(InvocationOnMock::callRealMethod);
servlet = mock(I18nServlet.class, settings);
when(cacheManager.<String, JsonNode>getCache(I18nServlet.CACHE_NAME)).thenReturn(cache);
servlet = new I18nServlet(context, pluginLoader, cacheManager);
}
@Test
public void shouldCleanCacheOnRestartEvent() {
ScmEventBus.getInstance().register(servlet);
ScmEventBus.getInstance().post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources"));
void shouldCleanCacheOnRestartEvent() {
EventBus eventBus = new EventBus("forTestingOnly");
eventBus.register(servlet);
eventBus.post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources"));
verify(cache).clear();
}
@Test
@SuppressWarnings("unchecked")
public void shouldFailWith404OnMissingResources() throws IOException {
void shouldFailWith404OnMissingResources() throws IOException {
String path = "/locales/de/plugins.json";
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
PrintWriter writer = mock(PrintWriter.class);
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenThrow(IOException.class);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(
I18nServlet.class.getClassLoader().getResources("something/not/available")
);
servlet.doGet(request, response);
@@ -161,98 +147,96 @@ public class I18nServletTest {
}
@Test
@SuppressWarnings("unchecked")
public void shouldFailWith500OnIOException() throws IOException {
void shouldFailWith500OnIOException() throws IOException {
stage(Stage.DEVELOPMENT);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn("/locales/de/plugins.json");
HttpServletResponse response = mock(HttpServletResponse.class);
doThrow(IOException.class).when(response).getWriter();
when(classLoader.getResources("locales/de/plugins.json")).thenThrow(new IOException("failed"));
servlet.doGet(request, response);
verify(response).setStatus(500);
}
private void stage(Stage stage) {
when(context.getStage()).thenReturn(stage);
}
@Test
@SuppressWarnings("unchecked")
public void inDevelopmentStageShouldNotUseCache() throws IOException {
String path = "/locales/de/plugins.json";
when(servlet.isProductionStage()).thenReturn(false);
void inDevelopmentStageShouldNotUseCache(@TempDir Path temp) throws IOException {
stage(Stage.DEVELOPMENT);
mockResources(temp, "locales/de/plugins.json");
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn("/locales/de/plugins.json");
HttpServletResponse response = mock(HttpServletResponse.class);
File file = temporaryFolder.newFile();
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
String json = doGetString(request, response);
servlet.doGet(request, response);
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
assertJson(json);
verify(cache, never()).get(any());
}
@Test
@SuppressWarnings("unchecked")
public void inProductionStageShouldUseCache() throws IOException {
String path = "/locales/de/plugins.json";
when(servlet.isProductionStage()).thenReturn(true);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
File file = temporaryFolder.newFile();
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
private String doGetString(HttpServletRequest request, HttpServletResponse response) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PrintWriter writer = new PrintWriter(baos);
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
servlet.doGet(request, response);
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
assertJson(json);
verify(cache).get(path);
verify(cache).put(eq(path), any());
writer.flush();
return baos.toString(StandardCharsets.UTF_8.name());
}
private void mockResources(Path directory, String resourcePath) throws IOException {
Enumeration<URL> resources = Collections.enumeration(
Arrays.asList(
toURL(directory, "git.json", GIT_PLUGIN_JSON),
toURL(directory, "hg.json", HG_PLUGIN_JSON),
toURL(directory, "svn.json", SVN_PLUGIN_JSON)
)
);
when(classLoader.getResources(resourcePath)).thenReturn(resources);
}
private URL toURL(Path directory, String name, String content) throws IOException {
Path file = directory.resolve(name);
java.nio.file.Files.write(file, content.getBytes(StandardCharsets.UTF_8));
return file.toUri().toURL();
}
@Test
void shouldGetFromCacheInProductionStage() throws IOException {
String path = "/locales/de/plugins.json";
stage(Stage.PRODUCTION);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn(path);
HttpServletResponse response = mock(HttpServletResponse.class);
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(GIT_PLUGIN_JSON);
when(cache.get(path)).thenReturn(jsonNode);
String json = doGetString(request, response);
assertThat(json).contains("scm-git-plugin").doesNotContain("scm-hg-plugin");
verifyHeaders(response);
}
@Test
@SuppressWarnings("unchecked")
public void inProductionStageShouldGetFromCache() throws IOException {
void shouldStoreToCacheInProductionStage(@TempDir Path temp) throws IOException {
String path = "/locales/de/plugins.json";
when(servlet.isProductionStage()).thenReturn(true);
mockResources(temp, "locales/de/plugins.json");
stage(Stage.PRODUCTION);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
File file = temporaryFolder.newFile();
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
ObjectMapper objectMapper = new ObjectMapper();
JsonNode node = objectMapper.readTree(GIT_PLUGIN_JSON);
node = servlet.merge(node, objectMapper.readTree(HG_PLUGIN_JSON));
node = servlet.merge(node, objectMapper.readTree(SVN_PLUGIN_JSON));
when(cache.get(path)).thenReturn(node);
HttpServletResponse response = mock(HttpServletResponse.class);
servlet.doGet(request, response);
String json = doGetString(request, response);
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
verify(servlet, never()).collectJsonFile(path);
verify(cache, never()).put(eq(path), any());
verify(cache).get(path);
assertJson(json);
verify(cache).put(any(String.class), any(JsonNode.class));
verifyHeaders(response);
}
@Test
@SuppressWarnings("unchecked")
public void shouldCollectJsonFile() throws IOException {
String path = "locales/de/plugins.json";
when(classLoader.getResources(path)).thenReturn(resources);
Optional<JsonNode> jsonNodeOptional = servlet.collectJsonFile("/" + path);
assertJson(jsonNodeOptional.orElse(null));
assertJson(json);
}
private void verifyHeaders(HttpServletResponse response) {
@@ -261,22 +245,11 @@ public class I18nServletTest {
verify(response).setHeader("Cache-Control", "no-cache");
}
public void assertJson(JsonNode actual) throws IOException {
assertJson(actual.toString());
}
private void assertJson(String actual) throws IOException {
private void assertJson(String actual) {
assertThat(actual)
.isNotEmpty()
.contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1)))
.contains(StringUtils.deleteWhitespace(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1)))
.contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1)));
}
private File createFileFromString(String json) throws IOException {
File file = temporaryFolder.newFile();
Files.write(json.getBytes(Charsets.UTF_8), file);
return file;
}
}

View File

@@ -3397,6 +3397,13 @@
"@types/prop-types" "*"
csstype "^2.2.0"
"@types/redux-logger@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.8.tgz#1fb6d26917bb198792bb1cf57feb31cae1532c5d"
integrity sha512-zM+cxiSw6nZtRbxpVp9SE3x/X77Z7e7YAfHD1NkxJyJbAGSXJGF0E9aqajZfPOa/sTYnuwutmlCldveExuCeLw==
dependencies:
redux "^4.0.0"
"@types/sinonjs__fake-timers@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e"
@@ -4287,6 +4294,11 @@ babel-code-frame@^6.22.0:
esutils "^2.0.2"
js-tokens "^3.0.2"
babel-core@7.0.0-bridge.0:
version "7.0.0-bridge.0"
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece"
integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==
babel-eslint@^10.0.3:
version "10.1.0"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
@@ -7786,7 +7798,7 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fault@^1.0.0, fault@^1.0.2:
fault@^1.0.0:
version "1.0.4"
resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13"
integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==
@@ -8362,7 +8374,7 @@ gitconfiglocal@^1.0.0:
dependencies:
ini "^1.3.2"
gitdiff-parser@^0.1.2:
gitdiff-parser@^0.1.2, "gitdiff-parser@https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d":
version "0.1.2"
resolved "https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d"
@@ -11350,7 +11362,7 @@ lower-case@^2.0.1:
dependencies:
tslib "^1.10.0"
lowlight@^1.13.0:
lowlight@1.13.1, lowlight@^1.13.0, lowlight@~1.11.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.13.1.tgz#c4f0e03906ebd23fedf2d258f6ab2f6324cf90eb"
integrity sha512-kQ71/T6RksEVz9AlPq07/2m+SU/1kGvt9k39UtvHX760u4SaWakaYH7hYgH5n6sTsCWk4MVYzUzLU59aN5CSmQ==
@@ -11358,14 +11370,6 @@ lowlight@^1.13.0:
fault "^1.0.0"
highlight.js "~9.16.0"
lowlight@~1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.11.0.tgz#1304d83005126d4e8b1dc0f07981e9b689ec2efc"
integrity sha512-xrGGN6XLL7MbTMdPD6NfWPwY43SNkjf/d0mecSx/CW36fUZTjRHEq0/Cdug3TWKtRXLWi7iMl1eP0olYxj/a4A==
dependencies:
fault "^1.0.2"
highlight.js "~9.13.0"
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -11715,20 +11719,20 @@ mini-create-react-context@^0.4.0:
"@babel/runtime" "^7.5.5"
tiny-warning "^1.0.3"
mini-css-extract-plugin@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
mini-css-extract-plugin@^0.10.0:
version "0.10.1"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.10.1.tgz#2592c891f965e15750da6a6c0b60740b5b0cb62d"
integrity sha512-9B10gZixtNjHerADBrMxPXM5G0uL0CRGMcLRV67I8nd1SKbwJrI0okKUzD+PxKsUZ9Dxt8/hPvtzF0DrRnrOyA==
dependencies:
loader-utils "^1.1.0"
normalize-url "1.9.1"
schema-utils "^1.0.0"
webpack-sources "^1.1.0"
mini-css-extract-plugin@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e"
integrity sha512-lp3GeY7ygcgAmVIcRPBVhIkf8Us7FZjA+ILpal44qLdSu11wmjKQ3d9k15lfD7pO4esu9eUIAW7qiYIBppv40A==
mini-css-extract-plugin@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
dependencies:
loader-utils "^1.1.0"
normalize-url "1.9.1"