Merge with 2.0.0-m3

This commit is contained in:
René Pfeuffer
2019-03-13 12:47:13 +01:00
55 changed files with 1231 additions and 208 deletions

View File

@@ -184,8 +184,8 @@ public class ScmConfiguration implements Configuration {
@XmlElement(name = "xsrf-protection")
private boolean enabledXsrfProtection = true;
@XmlElement(name = "default-namespace-strategy")
private String defaultNamespaceStrategy = "sonia.scm.repository.DefaultNamespaceStrategy";
@XmlElement(name = "namespace-strategy")
private String namespaceStrategy = "UsernameNamespaceStrategy";
/**
@@ -227,7 +227,7 @@ public class ScmConfiguration implements Configuration {
this.loginAttemptLimit = other.loginAttemptLimit;
this.loginAttemptLimitTimeout = other.loginAttemptLimitTimeout;
this.enabledXsrfProtection = other.enabledXsrfProtection;
this.defaultNamespaceStrategy = other.defaultNamespaceStrategy;
this.namespaceStrategy = other.namespaceStrategy;
}
public Set<String> getAdminGroups() {
@@ -366,8 +366,8 @@ public class ScmConfiguration implements Configuration {
return loginAttemptLimit > 0;
}
public String getDefaultNamespaceStrategy() {
return defaultNamespaceStrategy;
public String getNamespaceStrategy() {
return namespaceStrategy;
}
@@ -501,8 +501,8 @@ public class ScmConfiguration implements Configuration {
this.enabledXsrfProtection = enabledXsrfProtection;
}
public void setDefaultNamespaceStrategy(String defaultNamespaceStrategy) {
this.defaultNamespaceStrategy = defaultNamespaceStrategy;
public void setNamespaceStrategy(String namespaceStrategy) {
this.namespaceStrategy = namespaceStrategy;
}
@Override

View File

@@ -248,7 +248,8 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
/**
* Returns true if the {@link Repository} is valid.
* <ul>
* <li>The name is not empty and contains only A-z, 0-9, _, -, /</li>
* <li>The namespace is valid</li>
* <li>The name is valid</li>
* <li>The type is not empty</li>
* <li>The contact is empty or contains a valid email address</li>
* </ul>
@@ -257,9 +258,10 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
*/
@Override
public boolean isValid() {
return ValidationUtil.isRepositoryNameValid(name) && Util.isNotEmpty(type)
&& ((Util.isEmpty(contact))
|| ValidationUtil.isMailAddressValid(contact));
return ValidationUtil.isRepositoryNameValid(namespace)
&& ValidationUtil.isRepositoryNameValid(name)
&& Util.isNotEmpty(type)
&& ((Util.isEmpty(contact)) || ValidationUtil.isMailAddressValid(contact));
}
/**

View File

@@ -35,14 +35,12 @@ package sonia.scm.util;
//~--- non-JDK imports --------------------------------------------------------
import com.google.common.base.Splitter;
import sonia.scm.Validateable;
//~--- JDK imports ------------------------------------------------------------
import java.util.regex.Pattern;
//~--- JDK imports ------------------------------------------------------------
/**
*
* @author Sebastian Sdorra
@@ -52,15 +50,16 @@ public final class ValidationUtil
/** Field description */
private static final String REGEX_MAIL =
"^[A-z0-9][\\w.-]*@[A-z0-9][\\w\\-\\.]*\\.[A-z0-9][A-z0-9-]+$";
"^[A-Za-z0-9][\\w.-]*@[A-Za-z0-9][\\w\\-\\.]*\\.[A-Za-z0-9][A-Za-z0-9-]+$";
/** Field description */
private static final String REGEX_NAME =
"^[A-z0-9\\.\\-_@]|[^ ]([A-z0-9\\.\\-_@ ]*[A-z0-9\\.\\-_@]|[^ ])?$";
"^[A-Za-z0-9\\.\\-_@]|[^ ]([A-Za-z0-9\\.\\-_@ ]*[A-Za-z0-9\\.\\-_@]|[^ ])?$";
public static final String REGEX_REPOSITORYNAME = "(?!^\\.\\.$)(?!^\\.$)(?!.*[\\\\\\[\\]])^[A-Za-z0-9\\.][A-Za-z0-9\\.\\-_]*$";
/** Field description */
private static final String REGEX_REPOSITORYNAME =
"(?!^\\.\\.$)(?!^\\.$)(?!.*[\\\\\\[\\]])^[A-z0-9\\.][A-z0-9\\.\\-_/]*$";
private static final Pattern PATTERN_REPOSITORYNAME = Pattern.compile(REGEX_REPOSITORYNAME);
//~--- constructors ---------------------------------------------------------
@@ -142,37 +141,15 @@ public final class ValidationUtil
}
/**
* Method description
* Returns {@code true} if the repository name is valid.
*
*
* @param name
* @param name repository name
* @since 1.9
*
* @return
* @return {@code true} if repository name is valid
*/
public static boolean isRepositoryNameValid(String name)
{
Pattern pattern = Pattern.compile(REGEX_REPOSITORYNAME);
boolean result = true;
if (Util.isNotEmpty(name))
{
for (String p : Splitter.on('/').split(name))
{
if (!pattern.matcher(p).matches())
{
result = false;
break;
}
}
}
else
{
result = false;
}
return result;
public static boolean isRepositoryNameValid(String name) {
return PATTERN_REPOSITORYNAME.matcher(name).matches();
}
/**

View File

@@ -46,6 +46,8 @@ public class VndMediaType {
public static final String MERGE_RESULT = PREFIX + "mergeResult" + SUFFIX;
public static final String MERGE_COMMAND = PREFIX + "mergeCommand" + SUFFIX;
public static final String NAMESPACE_STRATEGIES = PREFIX + "namespaceStrategies" + SUFFIX;
public static final String ME = PREFIX + "me" + SUFFIX;
public static final String SOURCE = PREFIX + "source" + SUFFIX;
public static final String ERROR_TYPE = PREFIX + "error" + SUFFIX;

View File

@@ -143,51 +143,21 @@ public class ValidationUtilTest
assertFalse(ValidationUtil.isNotContaining("test", "t"));
}
/**
* Method description
*
*/
@Test
public void testIsRepositoryNameValid()
{
assertTrue(ValidationUtil.isRepositoryNameValid("scm"));
assertTrue(ValidationUtil.isRepositoryNameValid("scm/main"));
assertTrue(ValidationUtil.isRepositoryNameValid("scm/plugins/git-plugin"));
assertTrue(ValidationUtil.isRepositoryNameValid("s"));
assertTrue(ValidationUtil.isRepositoryNameValid("sc"));
assertTrue(ValidationUtil.isRepositoryNameValid(".scm/plugins"));
// issue 142
assertFalse(ValidationUtil.isRepositoryNameValid("."));
assertFalse(ValidationUtil.isRepositoryNameValid("/"));
assertFalse(ValidationUtil.isRepositoryNameValid("scm/plugins/."));
assertFalse(ValidationUtil.isRepositoryNameValid("scm/../plugins"));
assertFalse(ValidationUtil.isRepositoryNameValid("scm/main/"));
assertFalse(ValidationUtil.isRepositoryNameValid("/scm/main/"));
// issue 144
assertFalse(ValidationUtil.isRepositoryNameValid("scm/./main"));
assertFalse(ValidationUtil.isRepositoryNameValid("scm//main"));
// issue 148
//J-
public void testIsRepositoryNameValid() {
String[] validPaths = {
"scm",
"scm/main",
"scm/plugins/git-plugin",
"s",
"sc",
".scm/plugins",
".hiddenrepo",
"b.",
"...",
"..c",
"d..",
"a/b..",
"a/..b",
"a..c",
"a..c"
};
// issue 142, 144 and 148
String[] invalidPaths = {
".",
"/",
@@ -228,17 +198,22 @@ public class ValidationUtilTest
"abc)abc",
"abc[abc",
"abc]abc",
"abc|abc"
"abc|abc",
"scm/main",
"scm/plugins/git-plugin",
".scm/plugins",
"a/b..",
"a/..b",
"scm/main",
"scm/plugins/git-plugin",
"scm/plugins/git-plugin"
};
//J+
for (String path : validPaths)
{
for (String path : validPaths) {
assertTrue(ValidationUtil.isRepositoryNameValid(path));
}
for (String path : invalidPaths)
{
for (String path : invalidPaths) {
assertFalse(ValidationUtil.isRepositoryNameValid(path));
}
}

View File

@@ -22,6 +22,6 @@ export type Config = {
pluginUrl: string,
loginAttemptLimitTimeout: number,
enabledXsrfProtection: boolean,
defaultNamespaceStrategy: string,
namespaceStrategy: string,
_links: Links
};

View File

@@ -0,0 +1,9 @@
// @flow
import type { Links } from "./hal";
export type NamespaceStrategies = {
current: string,
available: string[],
_links: Links
};

View File

@@ -26,3 +26,5 @@ export type { SubRepository, File } from "./Sources";
export type { SelectValue, AutocompleteObject } from "./Autocomplete";
export type { AvailableRepositoryPermissions, RepositoryRole } from "./AvailableRepositoryPermissions";
export type { NamespaceStrategies } from "./NamespaceStrategies";

View File

@@ -57,7 +57,7 @@
"skip-failed-authenticators": "Fehlgeschlagene Authentifizierer überspringen",
"plugin-url": "Plugin URL",
"enabled-xsrf-protection": "XSRF Protection aktivieren",
"default-namespace-strategy": "Default Namespace Strategie"
"namespace-strategy": "Namespace Strategie"
},
"validation": {
"date-format-invalid": "Das Datumsformat ist ungültig",
@@ -87,6 +87,6 @@
"proxyUserHelpText": "Der Benutzername für die Proxy Server Anmeldung.",
"proxyExcludesHelpText": "Glob patterns für Hostnamen, die von den Proxy-Einstellungen ausgeschlossen werden sollen.",
"enableXsrfProtectionHelpText": "Xsrf Cookie Protection aktivieren. Hinweis: Dieses Feature befindet sich noch im Experimentalstatus.",
"defaultNameSpaceStrategyHelpText": "Die Standardstrategie für Namespaces."
"nameSpaceStrategyHelpText": "Strategie für Namespaces."
}
}

View File

@@ -1,5 +1,6 @@
{
"repository": {
"namespace": "Namespace",
"name": "Name",
"type": "Typ",
"contact": "Kontakt",
@@ -8,10 +9,12 @@
"lastModified": "Zuletzt bearbeitet"
},
"validation": {
"namespace-invalid": "Der Namespace des Repository ist ungültig",
"name-invalid": "Der Name des Repository ist ungültig",
"contact-invalid": "Der Kontakt muss eine gültige E-Mail Adresse sein"
},
"help": {
"namespaceHelpText": "Der Namespace des Repository. Dieser wird Teil der URL des Repository sein.",
"nameHelpText": "Der Name des Repository. Dieser wird Teil der URL des Repository sein.",
"typeHelpText": "Der Typ des Repository (Mercurial, Git oder Subversion).",
"contactHelpText": "E-Mail Adresse der Person, die für das Repository verantwortlich ist.",

View File

@@ -57,7 +57,7 @@
"skip-failed-authenticators": "Skip Failed Authenticators",
"plugin-url": "Plugin URL",
"enabled-xsrf-protection": "Enabled XSRF Protection",
"default-namespace-strategy": "Default Namespace Strategy"
"namespace-strategy": "Namespace Strategy"
},
"validation": {
"date-format-invalid": "The date format is not valid",
@@ -87,6 +87,6 @@
"proxyUserHelpText": "The username for the proxy server authentication.",
"proxyExcludesHelpText": "Glob patterns for hostnames, which should be excluded from proxy settings.",
"enableXsrfProtectionHelpText": "Enable XSRF Cookie Protection. Note: This feature is still experimental.",
"defaultNameSpaceStrategyHelpText": "The default namespace strategy."
"nameSpaceStrategyHelpText": "The namespace strategy."
}
}

View File

@@ -1,5 +1,6 @@
{
"repository": {
"namespace": "Namespace",
"name": "Name",
"type": "Type",
"contact": "Contact",
@@ -8,10 +9,12 @@
"lastModified": "Last Modified"
},
"validation": {
"namespace-invalid": "The repository namespace is invalid",
"name-invalid": "The repository name is invalid",
"contact-invalid": "Contact must be a valid mail address"
},
"help": {
"namespaceHelpText": "The namespace of the repository. This name will be part of the repository url.",
"nameHelpText": "The name of the repository. This name will be part of the repository url.",
"typeHelpText": "The type of the repository (e.g. Mercurial, Git or Subversion).",
"contactHelpText": "Email address of the person who is responsible for this repository.",

View File

@@ -2,7 +2,7 @@
import React from "react";
import { translate } from "react-i18next";
import { SubmitButton, Notification } from "@scm-manager/ui-components";
import type { Config } from "@scm-manager/ui-types";
import type { NamespaceStrategies, Config } from "@scm-manager/ui-types";
import ProxySettings from "./ProxySettings";
import GeneralSettings from "./GeneralSettings";
import BaseUrlSettings from "./BaseUrlSettings";
@@ -13,9 +13,11 @@ type Props = {
submitForm: Config => void,
config?: Config,
loading?: boolean,
t: string => string,
configReadPermission: boolean,
configUpdatePermission: boolean
configUpdatePermission: boolean,
namespaceStrategies?: NamespaceStrategies,
// context props
t: string => string,
};
type State = {
@@ -54,7 +56,7 @@ class ConfigForm extends React.Component<Props, State> {
pluginUrl: "",
loginAttemptLimitTimeout: 0,
enabledXsrfProtection: true,
defaultNamespaceStrategy: "",
namespaceStrategy: "",
_links: {}
},
showNotification: false,
@@ -88,6 +90,7 @@ class ConfigForm extends React.Component<Props, State> {
const {
loading,
t,
namespaceStrategies,
configReadPermission,
configUpdatePermission
} = this.props;
@@ -118,6 +121,7 @@ class ConfigForm extends React.Component<Props, State> {
<form onSubmit={this.submit}>
{noPermissionNotification}
<GeneralSettings
namespaceStrategies={namespaceStrategies}
realmDescription={config.realmDescription}
enableRepositoryArchive={config.enableRepositoryArchive}
disableGroupingGrid={config.disableGroupingGrid}
@@ -126,7 +130,7 @@ class ConfigForm extends React.Component<Props, State> {
skipFailedAuthenticators={config.skipFailedAuthenticators}
pluginUrl={config.pluginUrl}
enabledXsrfProtection={config.enabledXsrfProtection}
defaultNamespaceStrategy={config.defaultNamespaceStrategy}
namespaceStrategy={config.namespaceStrategy}
onChange={(isValid, changedValue, name) =>
this.onChange(isValid, changedValue, name)
}

View File

@@ -1,7 +1,9 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Checkbox, InputField } from "@scm-manager/ui-components";
import { Checkbox, InputField} from "@scm-manager/ui-components";
import type { NamespaceStrategies } from "@scm-manager/ui-types";
import NamespaceStrategySelect from "./NamespaceStrategySelect";
type Props = {
realmDescription: string,
@@ -12,13 +14,16 @@ type Props = {
skipFailedAuthenticators: boolean,
pluginUrl: string,
enabledXsrfProtection: boolean,
defaultNamespaceStrategy: string,
t: string => string,
namespaceStrategy: string,
namespaceStrategies?: NamespaceStrategies,
onChange: (boolean, any, string) => void,
hasUpdatePermission: boolean
hasUpdatePermission: boolean,
// context props
t: string => string
};
class GeneralSettings extends React.Component<Props> {
render() {
const {
t,
@@ -30,8 +35,9 @@ class GeneralSettings extends React.Component<Props> {
skipFailedAuthenticators,
pluginUrl,
enabledXsrfProtection,
defaultNamespaceStrategy,
hasUpdatePermission
namespaceStrategy,
hasUpdatePermission,
namespaceStrategies
} = this.props;
return (
@@ -67,13 +73,14 @@ class GeneralSettings extends React.Component<Props> {
/>
</div>
<div className="column is-half">
<InputField
label={t("general-settings.default-namespace-strategy")}
onChange={this.handleDefaultNamespaceStrategyChange}
value={defaultNamespaceStrategy}
disabled={!hasUpdatePermission}
helpText={t("help.defaultNameSpaceStrategyHelpText")}
/>
<NamespaceStrategySelect
label={t("general-settings.namespace-strategy")}
onChange={this.handleNamespaceStrategyChange}
value={namespaceStrategy}
disabled={!hasUpdatePermission}
namespaceStrategies={namespaceStrategies}
helpText={t("help.nameSpaceStrategyHelpText")}
/>
</div>
</div>
<div className="columns">
@@ -146,19 +153,17 @@ class GeneralSettings extends React.Component<Props> {
handleAnonymousAccessEnabledChange = (value: string) => {
this.props.onChange(true, value, "anonymousAccessEnabled");
};
handleSkipFailedAuthenticatorsChange = (value: string) => {
this.props.onChange(true, value, "skipFailedAuthenticators");
};
handlePluginUrlChange = (value: string) => {
this.props.onChange(true, value, "pluginUrl");
};
handleEnabledXsrfProtectionChange = (value: boolean) => {
this.props.onChange(true, value, "enabledXsrfProtection");
};
handleDefaultNamespaceStrategyChange = (value: string) => {
this.props.onChange(true, value, "defaultNamespaceStrategy");
handleNamespaceStrategyChange = (value: string) => {
this.props.onChange(true, value, "namespaceStrategy");
};
}

View File

@@ -0,0 +1,63 @@
//@flow
import React from "react";
import { translate, type TFunction } from "react-i18next";
import { Select } from "@scm-manager/ui-components";
import type { NamespaceStrategies } from "@scm-manager/ui-types";
type Props = {
namespaceStrategies: NamespaceStrategies,
label: string,
value?: string,
disabled?: boolean,
helpText?: string,
onChange: (value: string, name?: string) => void,
// context props
t: TFunction
};
class NamespaceStrategySelect extends React.Component<Props> {
createNamespaceOptions = () => {
const { namespaceStrategies, t } = this.props;
let available = [];
if (namespaceStrategies && namespaceStrategies.available) {
available = namespaceStrategies.available;
}
return available.map(ns => {
const key = "namespaceStrategies." + ns;
let label = t(key);
if (label === key) {
label = ns;
}
return {
value: ns,
label: label
};
});
};
findSelected = () => {
const { namespaceStrategies, value } = this.props;
if (namespaceStrategies.available.indexOf(value) < 0) {
return namespaceStrategies.current;
}
return value;
};
render() {
const { label, helpText, disabled, onChange } = this.props;
const nsOptions = this.createNamespaceOptions();
return (
<Select
label={label}
onChange={onChange}
value={this.findSelected()}
disabled={disabled}
options={nsOptions}
helpText={helpText}
/>
);
}
}
export default translate("plugins")(NamespaceStrategySelect);

View File

@@ -14,9 +14,15 @@ import {
modifyConfigReset
} from "../modules/config";
import { connect } from "react-redux";
import type { Config } from "@scm-manager/ui-types";
import type { Config, NamespaceStrategies } from "@scm-manager/ui-types";
import ConfigForm from "../components/form/ConfigForm";
import { getConfigLink } from "../../modules/indexResource";
import {
fetchNamespaceStrategiesIfNeeded,
getFetchNamespaceStrategiesFailure,
getNamespaceStrategies,
isFetchNamespaceStrategiesPending
} from "../modules/namespaceStrategies";
type Props = {
loading: boolean,
@@ -24,11 +30,13 @@ type Props = {
config: Config,
configUpdatePermission: boolean,
configLink: string,
namespaceStrategies?: NamespaceStrategies,
// dispatch functions
modifyConfig: (config: Config, callback?: () => void) => void,
fetchConfig: (link: string) => void,
configReset: void => void,
fetchNamespaceStrategiesIfNeeded: void => void,
// context objects
t: string => string
@@ -51,6 +59,7 @@ class GlobalConfig extends React.Component<Props, State> {
componentDidMount() {
this.props.configReset();
this.props.fetchNamespaceStrategiesIfNeeded();
if (this.props.configLink) {
this.props.fetchConfig(this.props.configLink);
} else {
@@ -103,7 +112,7 @@ class GlobalConfig extends React.Component<Props, State> {
};
renderContent = () => {
const { error, loading, config, configUpdatePermission } = this.props;
const { error, loading, config, configUpdatePermission, namespaceStrategies } = this.props;
const { configReadPermission } = this.state;
if (!error) {
return (
@@ -113,6 +122,7 @@ class GlobalConfig extends React.Component<Props, State> {
submitForm={config => this.modifyConfig(config)}
config={config}
loading={loading}
namespaceStrategies={namespaceStrategies}
configUpdatePermission={configUpdatePermission}
configReadPermission={configReadPermission}
/>
@@ -133,23 +143,33 @@ const mapDispatchToProps = dispatch => {
},
configReset: () => {
dispatch(modifyConfigReset());
},
fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded());
}
};
};
const mapStateToProps = state => {
const loading = isFetchConfigPending(state) || isModifyConfigPending(state);
const error = getFetchConfigFailure(state) || getModifyConfigFailure(state);
const loading = isFetchConfigPending(state)
|| isModifyConfigPending(state)
|| isFetchNamespaceStrategiesPending(state);
const error = getFetchConfigFailure(state)
|| getModifyConfigFailure(state)
|| getFetchNamespaceStrategiesFailure(state);
const config = getConfig(state);
const configUpdatePermission = getConfigUpdatePermission(state);
const configLink = getConfigLink(state);
const namespaceStrategies = getNamespaceStrategies(state);
return {
loading,
error,
config,
configUpdatePermission,
configLink
configLink,
namespaceStrategies
};
};

View File

@@ -50,7 +50,7 @@ const config = {
"http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false",
loginAttemptLimitTimeout: 300,
enabledXsrfProtection: true,
defaultNamespaceStrategy: "sonia.scm.repository.DefaultNamespaceStrategy",
namespaceStrategy: "UsernameNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/api/v2/config" },
update: { href: "http://localhost:8081/api/v2/config" }
@@ -79,7 +79,7 @@ const configWithNullValues = {
"http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false",
loginAttemptLimitTimeout: 300,
enabledXsrfProtection: true,
defaultNamespaceStrategy: "sonia.scm.repository.DefaultNamespaceStrategy",
namespaceStrategy: "UsernameNamespaceStrategy",
_links: {
self: { href: "http://localhost:8081/api/v2/config" },
update: { href: "http://localhost:8081/api/v2/config" }

View File

@@ -0,0 +1,115 @@
// @flow
import * as types from "../../modules/types";
import type { Action, NamespaceStrategies } from "@scm-manager/ui-types";
import { apiClient } from "@scm-manager/ui-components";
import { isPending } from "../../modules/pending";
import { getFailure } from "../../modules/failure";
import { MODIFY_CONFIG_SUCCESS } from "./config";
export const FETCH_NAMESPACESTRATEGIES_TYPES =
"scm/config/FETCH_NAMESPACESTRATEGIES_TYPES";
export const FETCH_NAMESPACESTRATEGIES_TYPES_PENDING = `${FETCH_NAMESPACESTRATEGIES_TYPES}_${
types.PENDING_SUFFIX
}`;
export const FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS = `${FETCH_NAMESPACESTRATEGIES_TYPES}_${
types.SUCCESS_SUFFIX
}`;
export const FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE = `${FETCH_NAMESPACESTRATEGIES_TYPES}_${
types.FAILURE_SUFFIX
}`;
export function fetchNamespaceStrategiesIfNeeded() {
return function(dispatch: any, getState: () => Object) {
const state = getState();
if (shouldFetchNamespaceStrategies(state)) {
return fetchNamespaceStrategies(
dispatch,
state.indexResources.links.namespaceStrategies.href
);
}
};
}
function fetchNamespaceStrategies(dispatch: any, url: string) {
dispatch(fetchNamespaceStrategiesPending());
return apiClient
.get(url)
.then(response => response.json())
.then(namespaceStrategies => {
dispatch(fetchNamespaceStrategiesSuccess(namespaceStrategies));
})
.catch(error => {
dispatch(fetchNamespaceStrategiesFailure(error));
});
}
export function shouldFetchNamespaceStrategies(state: Object) {
if (
isFetchNamespaceStrategiesPending(state) ||
getFetchNamespaceStrategiesFailure(state)
) {
return false;
}
return !state.namespaceStrategies || !state.namespaceStrategies.current;
}
export function fetchNamespaceStrategiesPending(): Action {
return {
type: FETCH_NAMESPACESTRATEGIES_TYPES_PENDING
};
}
export function fetchNamespaceStrategiesSuccess(
namespaceStrategies: NamespaceStrategies
): Action {
return {
type: FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS,
payload: namespaceStrategies
};
}
export function fetchNamespaceStrategiesFailure(error: Error): Action {
return {
type: FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE,
payload: error
};
}
// reducers
export default function reducer(
state: Object = {},
action: Action = { type: "UNKNOWN" }
): Object {
if (
action.type === FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS &&
action.payload
) {
return action.payload;
} else if (action.type === MODIFY_CONFIG_SUCCESS && action.payload) {
const config = action.payload;
return {
...state,
current: config.namespaceStrategy
};
}
return state;
}
// selectors
export function getNamespaceStrategies(state: Object) {
if (state.namespaceStrategies) {
return state.namespaceStrategies;
}
return {};
}
export function isFetchNamespaceStrategiesPending(state: Object) {
return isPending(state, FETCH_NAMESPACESTRATEGIES_TYPES);
}
export function getFetchNamespaceStrategiesFailure(state: Object) {
return getFailure(state, FETCH_NAMESPACESTRATEGIES_TYPES);
}

View File

@@ -0,0 +1,199 @@
// @flow
import fetchMock from "fetch-mock";
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import {
FETCH_NAMESPACESTRATEGIES_TYPES,
FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE,
FETCH_NAMESPACESTRATEGIES_TYPES_PENDING,
FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS,
fetchNamespaceStrategiesIfNeeded,
fetchNamespaceStrategiesSuccess,
shouldFetchNamespaceStrategies,
default as reducer,
getNamespaceStrategies,
isFetchNamespaceStrategiesPending,
getFetchNamespaceStrategiesFailure
} from "./namespaceStrategies";
import { MODIFY_CONFIG_SUCCESS } from "./config";
const strategies = {
current: "UsernameNamespaceStrategy",
available: [
"UsernameNamespaceStrategy",
"CustomNamespaceStrategy",
"CurrentYearNamespaceStrategy",
"RepositoryTypeNamespaceStrategy"
],
_links: {
self: {
href: "http://localhost:8081/scm/api/v2/namespaceStrategies"
}
}
};
describe("namespace strategy caching", () => {
it("should fetch strategies, on empty state", () => {
expect(shouldFetchNamespaceStrategies({})).toBe(true);
});
it("should fetch strategies, on empty namespaceStrategies node", () => {
const state = {
namespaceStrategies: {}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(true);
});
it("should not fetch strategies, on pending state", () => {
const state = {
pending: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: true
}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(false);
});
it("should not fetch strategies, on failure state", () => {
const state = {
failure: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: new Error("no...")
}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(false);
});
it("should not fetch strategies, if they are already fetched", () => {
const state = {
namespaceStrategies: {
current: "some"
}
};
expect(shouldFetchNamespaceStrategies(state)).toBe(false);
});
});
describe("namespace strategies fetch", () => {
const URL = "http://scm.hitchhiker.com/api/v2/namespaceStrategies";
const mockStore = configureMockStore([thunk]);
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
const createStore = (initialState = {}) => {
return mockStore({
...initialState,
indexResources: {
links: {
namespaceStrategies: {
href: URL
}
}
}
});
};
it("should successfully fetch strategies", () => {
fetchMock.getOnce(URL, strategies);
const expectedActions = [
{ type: FETCH_NAMESPACESTRATEGIES_TYPES_PENDING },
{
type: FETCH_NAMESPACESTRATEGIES_TYPES_SUCCESS,
payload: strategies
}
];
const store = createStore();
return store.dispatch(fetchNamespaceStrategiesIfNeeded()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should dispatch FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE on server error", () => {
fetchMock.getOnce(URL, {
status: 500
});
const store = createStore();
return store.dispatch(fetchNamespaceStrategiesIfNeeded()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toBe(FETCH_NAMESPACESTRATEGIES_TYPES_PENDING);
expect(actions[1].type).toBe(FETCH_NAMESPACESTRATEGIES_TYPES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
it("should not dispatch any action, if the strategies are already fetched", () => {
const store = createStore({
namespaceStrategies: strategies
});
store.dispatch(fetchNamespaceStrategiesIfNeeded());
expect(store.getActions().length).toBe(0);
});
});
describe("namespace strategies reducer", () => {
it("should return unmodified state on unknown action", () => {
const state = {};
expect(reducer(state)).toBe(state);
});
it("should store the strategies on success", () => {
const newState = reducer({}, fetchNamespaceStrategiesSuccess(strategies));
expect(newState).toBe(strategies);
});
it("should clear store if config was modified", () => {
const modifyConfigAction = {
type: MODIFY_CONFIG_SUCCESS,
payload: {
namespaceStrategy: "CustomNamespaceStrategy"
}
};
const newState = reducer(strategies, modifyConfigAction);
expect(newState.current).toEqual("CustomNamespaceStrategy");
});
});
describe("namespace strategy selectors", () => {
const error = new Error("The end of the universe");
it("should return an empty object", () => {
expect(getNamespaceStrategies({})).toEqual({});
});
it("should return the namespace strategies", () => {
const state = {
namespaceStrategies: strategies
};
expect(getNamespaceStrategies(state)).toBe(strategies);
});
it("should return true, when fetch namespace strategies is pending", () => {
const state = {
pending: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: true
}
};
expect(isFetchNamespaceStrategiesPending(state)).toEqual(true);
});
it("should return false, when fetch strategies is not pending", () => {
expect(isFetchNamespaceStrategiesPending({})).toEqual(false);
});
it("should return error when fetch namespace strategies did fail", () => {
const state = {
failure: {
[FETCH_NAMESPACESTRATEGIES_TYPES]: error
}
};
expect(getFetchNamespaceStrategiesFailure(state)).toEqual(error);
});
it("should return undefined when fetch strategies did not fail", () => {
expect(getFetchNamespaceStrategiesFailure({})).toBe(undefined);
});
});

View File

@@ -15,6 +15,7 @@ import pending from "./modules/pending";
import failure from "./modules/failure";
import permissions from "./repos/permissions/modules/permissions";
import config from "./config/modules/config";
import namespaceStrategies from "./config/modules/namespaceStrategies";
import indexResources from "./modules/indexResource";
import type { BrowserHistory } from "history/createBrowserHistory";
@@ -38,7 +39,8 @@ function createReduxStore(history: BrowserHistory) {
groups,
auth,
config,
sources
sources,
namespaceStrategies
});
return createStore(

View File

@@ -8,6 +8,7 @@ import {
SubmitButton,
Textarea
} from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import type { Repository, RepositoryType } from "@scm-manager/ui-types";
import * as validator from "./repositoryValidation";
@@ -15,16 +16,20 @@ type Props = {
submitForm: Repository => void,
repository?: Repository,
repositoryTypes: RepositoryType[],
namespaceStrategy: string,
loading?: boolean,
t: string => string
};
type State = {
repository: Repository,
namespaceValidationError: boolean,
nameValidationError: boolean,
contactValidationError: boolean
};
const CUSTOM_NAMESPACE_STRATEGY = "CustomNamespaceStrategy";
class RepositoryForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
@@ -38,9 +43,9 @@ class RepositoryForm extends React.Component<Props, State> {
description: "",
_links: {}
},
namespaceValidationError: false,
nameValidationError: false,
contactValidationError: false,
descriptionValidationError: false
contactValidationError: false
};
}
@@ -59,11 +64,14 @@ class RepositoryForm extends React.Component<Props, State> {
}
isValid = () => {
const repository = this.state.repository;
const { namespaceStrategy } = this.props;
const { repository } = this.state;
return !(
this.state.namespaceValidationError ||
this.state.nameValidationError ||
this.state.contactValidationError ||
this.isFalsy(repository.name)
this.isFalsy(repository.name) ||
(namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY && this.isFalsy(repository.namespace))
);
};
@@ -127,6 +135,31 @@ class RepositoryForm extends React.Component<Props, State> {
});
}
renderNamespaceField = () => {
const { namespaceStrategy, t } = this.props;
const repository = this.state.repository;
const props = {
label: t("repository.namespace"),
helpText: t("help.namespaceHelpText"),
value: repository ? repository.namespace : "",
onChange: this.handleNamespaceChange,
errorMessage: t("validation.namespace-invalid"),
validationError: this.state.namespaceValidationError
};
if (namespaceStrategy === CUSTOM_NAMESPACE_STRATEGY) {
return <InputField {...props} />;
}
return (
<ExtensionPoint
name="repos.create.namespace"
props={props}
renderAll={false}
/>
);
};
renderCreateOnlyFields() {
if (!this.isCreateMode()) {
return null;
@@ -135,6 +168,7 @@ class RepositoryForm extends React.Component<Props, State> {
const repository = this.state.repository;
return (
<>
{this.renderNamespaceField()}
<InputField
label={t("repository.name")}
onChange={this.handleNameChange}
@@ -154,6 +188,13 @@ class RepositoryForm extends React.Component<Props, State> {
);
}
handleNamespaceChange = (namespace: string) => {
this.setState({
namespaceValidationError: !validator.isNameValid(namespace),
repository: { ...this.state.repository, namespace }
});
};
handleNameChange = (name: string) => {
this.setState({
nameValidationError: !validator.isNameValid(name),

View File

@@ -1,8 +1,10 @@
// @flow
import { validation } from "@scm-manager/ui-components";
const nameRegex = /(?!^\.\.$)(?!^\.$)(?!.*[\\\[\]])^[A-Za-z0-9\.][A-Za-z0-9\.\-_]*$/;
export const isNameValid = (name: string) => {
return validation.isNameValid(name);
return nameRegex.test(name);
};
export function isContactValid(mail: string) {

View File

@@ -11,6 +11,81 @@ describe("repository name validation", () => {
expect(validator.isNameValid("scm/manager")).toBe(false);
expect(validator.isNameValid("scm/ma/nager")).toBe(false);
});
it("should allow same names as the backend", () => {
const validPaths = [
"scm",
"s",
"sc",
".hiddenrepo",
"b.",
"...",
"..c",
"d..",
"a..c"
];
validPaths.forEach((path) =>
expect(validator.isNameValid(path)).toBe(true)
);
});
it("should deny same names as the backend", () => {
const invalidPaths = [
".",
"/",
"//",
"..",
"/.",
"/..",
"./",
"../",
"/../",
"/./",
"/...",
"/abc",
".../",
"/sdf/",
"asdf/",
"./b",
"scm/plugins/.",
"scm/../plugins",
"scm/main/",
"/scm/main/",
"scm/./main",
"scm//main",
"scm\\main",
"scm/main-$HOME",
"scm/main-${HOME}-home",
"scm/main-%HOME-home",
"scm/main-%HOME%-home",
"abc$abc",
"abc%abc",
"abc<abc",
"abc>abc",
"abc#abc",
"abc+abc",
"abc{abc",
"abc}abc",
"abc(abc",
"abc)abc",
"abc[abc",
"abc]abc",
"abc|abc",
"scm/main",
"scm/plugins/git-plugin",
".scm/plugins",
"a/b..",
"a/..b",
"scm/main",
"scm/plugins/git-plugin",
"scm/plugins/git-plugin"
];
invalidPaths.forEach((path) =>
expect(validator.isNameValid(path)).toBe(false)
);
});
});
describe("repository contact validation", () => {

View File

@@ -4,7 +4,7 @@ import { connect } from "react-redux";
import { translate } from "react-i18next";
import { Page } from "@scm-manager/ui-components";
import RepositoryForm from "../components/form";
import type { Repository, RepositoryType } from "@scm-manager/ui-types";
import type { Repository, RepositoryType, NamespaceStrategies } from "@scm-manager/ui-types";
import {
fetchRepositoryTypesIfNeeded,
getFetchRepositoryTypesFailure,
@@ -19,15 +19,21 @@ import {
} from "../modules/repos";
import type { History } from "history";
import { getRepositoriesLink } from "../../modules/indexResource";
import {
fetchNamespaceStrategiesIfNeeded,
getFetchNamespaceStrategiesFailure, getNamespaceStrategies, isFetchNamespaceStrategiesPending
} from "../../config/modules/namespaceStrategies";
type Props = {
repositoryTypes: RepositoryType[],
typesLoading: boolean,
namespaceStrategies: NamespaceStrategies,
pageLoading: boolean,
createLoading: boolean,
error: Error,
repoLink: string,
// dispatch functions
fetchNamespaceStrategiesIfNeeded: () => void,
fetchRepositoryTypesIfNeeded: () => void,
createRepo: (
link: string,
@@ -45,6 +51,7 @@ class Create extends React.Component<Props> {
componentDidMount() {
this.props.resetForm();
this.props.fetchRepositoryTypesIfNeeded();
this.props.fetchNamespaceStrategiesIfNeeded();
}
repoCreated = (repo: Repository) => {
@@ -55,9 +62,10 @@ class Create extends React.Component<Props> {
render() {
const {
typesLoading,
pageLoading,
createLoading,
repositoryTypes,
namespaceStrategies,
createRepo,
error
} = this.props;
@@ -67,13 +75,14 @@ class Create extends React.Component<Props> {
<Page
title={t("create.title")}
subtitle={t("create.subtitle")}
loading={typesLoading}
loading={pageLoading}
error={error}
showContentOnError={true}
>
<RepositoryForm
repositoryTypes={repositoryTypes}
loading={createLoading}
namespaceStrategy={namespaceStrategies.current}
submitForm={repo => {
createRepo(repoLink, repo, (repo: Repository) =>
this.repoCreated(repo)
@@ -87,14 +96,18 @@ class Create extends React.Component<Props> {
const mapStateToProps = state => {
const repositoryTypes = getRepositoryTypes(state);
const typesLoading = isFetchRepositoryTypesPending(state);
const namespaceStrategies = getNamespaceStrategies(state);
const pageLoading = isFetchRepositoryTypesPending(state)
|| isFetchNamespaceStrategiesPending(state);
const createLoading = isCreateRepoPending(state);
const error =
getFetchRepositoryTypesFailure(state) || getCreateRepoFailure(state);
const error = getFetchRepositoryTypesFailure(state)
|| getCreateRepoFailure(state)
|| getFetchNamespaceStrategiesFailure(state);
const repoLink = getRepositoriesLink(state);
return {
repositoryTypes,
typesLoading,
namespaceStrategies,
pageLoading,
createLoading,
error,
repoLink
@@ -106,6 +119,9 @@ const mapDispatchToProps = dispatch => {
fetchRepositoryTypesIfNeeded: () => {
dispatch(fetchRepositoryTypesIfNeeded());
},
fetchNamespaceStrategiesIfNeeded: () => {
dispatch(fetchNamespaceStrategiesIfNeeded());
},
createRepo: (
link: string,
repository: Repository,

View File

@@ -35,7 +35,7 @@ public class ConfigDto extends HalRepresentation {
private String pluginUrl;
private long loginAttemptLimitTimeout;
private boolean enabledXsrfProtection;
private String defaultNamespaceStrategy;
private String namespaceStrategy;
@Override
@SuppressWarnings("squid:S1185") // We want to have this method available in this package

View File

@@ -5,6 +5,7 @@ import com.webcohesion.enunciate.metadata.rs.StatusCodes;
import com.webcohesion.enunciate.metadata.rs.TypeHint;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.NamespaceStrategyValidator;
import sonia.scm.util.ScmConfigurationUtil;
import sonia.scm.web.VndMediaType;
@@ -27,12 +28,16 @@ public class ConfigResource {
private final ConfigDtoToScmConfigurationMapper dtoToConfigMapper;
private final ScmConfigurationToConfigDtoMapper configToDtoMapper;
private final ScmConfiguration configuration;
private final NamespaceStrategyValidator namespaceStrategyValidator;
@Inject
public ConfigResource(ConfigDtoToScmConfigurationMapper dtoToConfigMapper, ScmConfigurationToConfigDtoMapper configToDtoMapper, ScmConfiguration configuration) {
public ConfigResource(ConfigDtoToScmConfigurationMapper dtoToConfigMapper,
ScmConfigurationToConfigDtoMapper configToDtoMapper,
ScmConfiguration configuration, NamespaceStrategyValidator namespaceStrategyValidator) {
this.dtoToConfigMapper = dtoToConfigMapper;
this.configToDtoMapper = configToDtoMapper;
this.configuration = configuration;
this.namespaceStrategyValidator = namespaceStrategyValidator;
}
/**
@@ -78,6 +83,9 @@ public class ConfigResource {
// But to where to check? load() or store()? Leave it for now, SCMv1 legacy that can be cleaned up later.
ConfigurationPermissions.write(configuration).check();
// ensure the namespace strategy is valid
namespaceStrategyValidator.check(configDto.getNamespaceStrategy());
ScmConfiguration config = dtoToConfigMapper.map(configDto);
synchronized (ScmConfiguration.class) {
configuration.load(config);

View File

@@ -59,6 +59,9 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("permissions", resourceLinks.permissions().self()));
}
builder.single(link("availableRepositoryPermissions", resourceLinks.availableRepositoryPermissions().self()));
builder.single(link("repositoryTypes", resourceLinks.repositoryTypeCollection().self()));
builder.single(link("namespaceStrategies", resourceLinks.namespaceStrategies().self()));
} else {
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
}

View File

@@ -0,0 +1,20 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Getter;
import java.util.List;
@Getter
public class NamespaceStrategiesDto extends HalRepresentation {
private String current;
private List<String> available;
public NamespaceStrategiesDto(String current, List<String> available, Links links) {
super(links);
this.current = current;
this.available = available;
}
}

View File

@@ -0,0 +1,63 @@
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links;
import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.UriInfo;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* RESTFul WebService Endpoint for namespace strategies.
*/
@Path(NamespaceStrategyResource.PATH)
public class NamespaceStrategyResource {
static final String PATH = "v2/namespaceStrategies";
private Set<NamespaceStrategy> namespaceStrategies;
private Provider<NamespaceStrategy> namespaceStrategyProvider;
@Inject
public NamespaceStrategyResource(Set<NamespaceStrategy> namespaceStrategies, Provider<NamespaceStrategy> namespaceStrategyProvider) {
this.namespaceStrategies = namespaceStrategies;
this.namespaceStrategyProvider = namespaceStrategyProvider;
}
/**
* Returns all available namespace strategies and the current selected.
*
* @param uriInfo uri info
*
* @return available and current namespace strategies
*/
@GET
@Path("")
@Produces(VndMediaType.NAMESPACE_STRATEGIES)
public NamespaceStrategiesDto get(@Context UriInfo uriInfo) {
String currentStrategy = strategyAsString(namespaceStrategyProvider.get());
List<String> availableStrategies = collectStrategyNames();
return new NamespaceStrategiesDto(currentStrategy, availableStrategies, createLinks(uriInfo));
}
private Links createLinks(@Context UriInfo uriInfo) {
return Links.linkingTo().self(uriInfo.getAbsolutePath().toASCIIString()).build();
}
private String strategyAsString(NamespaceStrategy namespaceStrategy) {
return namespaceStrategy.getClass().getSimpleName();
}
private List<String> collectStrategyNames() {
return namespaceStrategies.stream().map(this::strategyAsString).collect(Collectors.toList());
}
}

View File

@@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.validator.constraints.Email;
import org.hibernate.validator.constraints.NotEmpty;
import sonia.scm.util.ValidationUtil;
import javax.validation.constraints.Pattern;
import java.time.Instant;
@@ -25,8 +26,9 @@ public class RepositoryDto extends HalRepresentation {
private List<HealthCheckFailureDto> healthCheckFailures;
@JsonInclude(JsonInclude.Include.NON_NULL)
private Instant lastModified;
// we could not validate the namespace, this must be done by the namespace strategy
private String namespace;
@Pattern(regexp = "^[A-z0-9\\-_]+$")
@Pattern(regexp = ValidationUtil.REGEX_REPOSITORYNAME)
private String name;
private boolean archived = false;
@NotEmpty

View File

@@ -277,6 +277,23 @@ class ResourceLinks {
}
}
public NamespaceStrategiesLinks namespaceStrategies() {
return new NamespaceStrategiesLinks(scmPathInfoStore.get());
}
static class NamespaceStrategiesLinks {
private final LinkBuilder namespaceStrategiesLinkBuilder;
NamespaceStrategiesLinks(ScmPathInfo pathInfo) {
namespaceStrategiesLinkBuilder = new LinkBuilder(pathInfo, NamespaceStrategyResource.class);
}
String self() {
return namespaceStrategiesLinkBuilder.method("get").parameters().href();
}
}
public RepositoryTypeLinks repositoryType() {
return new RepositoryTypeLinks(scmPathInfoStore.get());
}

View File

@@ -0,0 +1,29 @@
package sonia.scm.repository;
import com.google.common.annotations.VisibleForTesting;
import sonia.scm.plugin.Extension;
import javax.inject.Inject;
import java.time.Clock;
import java.time.Year;
@Extension
public class CurrentYearNamespaceStrategy implements NamespaceStrategy {
private final Clock clock;
@Inject
public CurrentYearNamespaceStrategy() {
this(Clock.systemDefaultZone());
}
@VisibleForTesting
CurrentYearNamespaceStrategy(Clock clock) {
this.clock = clock;
}
@Override
public String createNamespace(Repository repository) {
return String.valueOf(Year.now(clock).getValue());
}
}

View File

@@ -0,0 +1,20 @@
package sonia.scm.repository;
import sonia.scm.plugin.Extension;
import sonia.scm.util.ValidationUtil;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
@Extension
public class CustomNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
String namespace = repository.getNamespace();
doThrow()
.violation("invalid namespace", "namespace")
.when(!ValidationUtil.isRepositoryNameValid(namespace));
return namespace;
}
}

View File

@@ -1,24 +0,0 @@
package sonia.scm.repository;
import com.google.common.base.Strings;
import org.apache.shiro.SecurityUtils;
import sonia.scm.plugin.Extension;
/**
* The DefaultNamespaceStrategy returns the predefined namespace of the given repository, if the namespace was not set
* the username of the currently loggedin user is used.
*
* @since 2.0.0
*/
@Extension
public class DefaultNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
String namespace = repository.getNamespace();
if (Strings.isNullOrEmpty(namespace)) {
namespace = SecurityUtils.getSubject().getPrincipal().toString();
}
return namespace;
}
}

View File

@@ -52,6 +52,7 @@ import sonia.scm.util.CollectionAppender;
import sonia.scm.util.IOUtil;
import sonia.scm.util.Util;
import javax.inject.Provider;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
@@ -85,7 +86,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
private final KeyGenerator keyGenerator;
private final RepositoryDAO repositoryDAO;
private final Set<Type> types;
private NamespaceStrategy namespaceStrategy;
private final Provider<NamespaceStrategy> namespaceStrategyProvider;
private final ManagerDaoAdapter<Repository> managerDaoAdapter;
@@ -93,11 +94,11 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
public DefaultRepositoryManager(ScmConfiguration configuration,
SCMContextProvider contextProvider, KeyGenerator keyGenerator,
RepositoryDAO repositoryDAO, Set<RepositoryHandler> handlerSet,
NamespaceStrategy namespaceStrategy) {
Provider<NamespaceStrategy> namespaceStrategyProvider) {
this.configuration = configuration;
this.keyGenerator = keyGenerator;
this.repositoryDAO = repositoryDAO;
this.namespaceStrategy = namespaceStrategy;
this.namespaceStrategyProvider = namespaceStrategyProvider;
ThreadFactory factory = new ThreadFactoryBuilder()
.setNameFormat(THREAD_NAME).build();
@@ -131,7 +132,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
public Repository create(Repository repository, boolean initRepository) {
repository.setId(keyGenerator.createKey());
repository.setNamespace(namespaceStrategy.createNamespace(repository));
repository.setNamespace(namespaceStrategyProvider.get().createNamespace(repository));
logger.info("create repository {}/{} of type {} in namespace {}", repository.getNamespace(), repository.getName(), repository.getType(), repository.getNamespace());

View File

@@ -1,5 +1,7 @@
package sonia.scm.repository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.config.ScmConfiguration;
import javax.inject.Inject;
@@ -8,6 +10,8 @@ import java.util.Set;
public class NamespaceStrategyProvider implements Provider<NamespaceStrategy> {
private static final Logger LOG = LoggerFactory.getLogger(NamespaceStrategyProvider.class);
private final Set<NamespaceStrategy> strategies;
private final ScmConfiguration scmConfiguration;
@@ -19,14 +23,16 @@ public class NamespaceStrategyProvider implements Provider<NamespaceStrategy> {
@Override
public NamespaceStrategy get() {
String namespaceStrategy = scmConfiguration.getDefaultNamespaceStrategy();
String namespaceStrategy = scmConfiguration.getNamespaceStrategy();
for (NamespaceStrategy s : this.strategies) {
if (s.getClass().getCanonicalName().equals(namespaceStrategy)) {
if (s.getClass().getSimpleName().equals(namespaceStrategy)) {
return s;
}
}
return null;
LOG.warn("could not find namespace strategy {}, using default strategy", namespaceStrategy);
return new UsernameNamespaceStrategy();
}
}

View File

@@ -0,0 +1,26 @@
package sonia.scm.repository;
import javax.inject.Inject;
import java.util.Set;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
public class NamespaceStrategyValidator {
private final Set<NamespaceStrategy> strategies;
@Inject
public NamespaceStrategyValidator(Set<NamespaceStrategy> strategies) {
this.strategies = strategies;
}
public void check(String name) {
doThrow()
.violation("unknown NamespaceStrategy " + name, "namespaceStrategy")
.when(!isValid(name));
}
private boolean isValid(String name) {
return strategies.stream().anyMatch(ns -> ns.getClass().getSimpleName().equals(name));
}
}

View File

@@ -0,0 +1,11 @@
package sonia.scm.repository;
import sonia.scm.plugin.Extension;
@Extension
public class RepositoryTypeNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return repository.getType();
}
}

View File

@@ -0,0 +1,13 @@
package sonia.scm.repository;
import org.apache.shiro.SecurityUtils;
import sonia.scm.plugin.Extension;
@Extension
public class UsernameNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return SecurityUtils.getSubject().getPrincipal().toString();
}
}

View File

@@ -103,6 +103,7 @@
"errorCode": "Fehlercode",
"transactionId": "Transaktions-ID",
"moreInfo": "Für mehr Informationen, siehe",
"violations": "Ungültige Werte:",
"AGR7UzkhA1": {
"displayName": "Nicht gefunden",
"description": "Der gewünschte Datensatz konnte nicht gefunden werden. Möglicherweise wurde er in einer weiteren Session gelöscht."
@@ -147,5 +148,11 @@
"displayName": "Ungültige Eingabe",
"description": "Die eingegebenen Daten konnten nicht validiert werden. Bitte korrigieren Sie die Eingaben und senden Sie sie erneut."
}
},
"namespaceStrategies": {
"UsernameNamespaceStrategy": "Benutzername",
"CustomNamespaceStrategy": "Benutzerdefiniert",
"CurrentYearNamespaceStrategy": "Aktuelles Jahr",
"RepositoryTypeNamespaceStrategy": "Repository Typ"
}
}

View File

@@ -103,6 +103,7 @@
"errorCode": "Error Code",
"transactionId": "Transaction ID",
"moreInfo": "For more information, see",
"violations": "Violations:",
"AGR7UzkhA1": {
"displayName": "Not found",
"description": "The requested entity could not be found. It may have been deleted in another session."
@@ -147,5 +148,11 @@
"displayName": "Illegal input",
"description": "The values could not be validated. Please correct your input and try again."
}
},
"namespaceStrategies": {
"UsernameNamespaceStrategy": "Username",
"CustomNamespaceStrategy": "Custom",
"CurrentYearNamespaceStrategy": "Current year",
"RepositoryTypeNamespaceStrategy": "Repository type"
}
}

View File

@@ -51,7 +51,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
assertEquals("https://plug.ins" , config.getPluginUrl());
assertEquals(40 , config.getLoginAttemptLimitTimeout());
assertTrue(config.isEnabledXsrfProtection());
assertEquals("username", config.getDefaultNamespaceStrategy());
assertEquals("username", config.getNamespaceStrategy());
}
private ConfigDto createDefaultDto() {
@@ -76,7 +76,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
configDto.setPluginUrl("https://plug.ins");
configDto.setLoginAttemptLimitTimeout(40);
configDto.setEnabledXsrfProtection(true);
configDto.setDefaultNamespaceStrategy("username");
configDto.setNamespaceStrategy("username");
return configDto;
}

View File

@@ -13,7 +13,9 @@ import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.NamespaceStrategyValidator;
import sonia.scm.web.VndMediaType;
import javax.servlet.http.HttpServletResponse;
@@ -22,10 +24,12 @@ import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.MockitoAnnotations.initMocks;
@SubjectAware(
@@ -46,6 +50,9 @@ public class ConfigResourceTest {
@SuppressWarnings("unused") // Is injected
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@Mock
private NamespaceStrategyValidator namespaceStrategyValidator;
@InjectMocks
private ConfigDtoToScmConfigurationMapperImpl dtoToConfigMapper;
@InjectMocks
@@ -62,7 +69,7 @@ public class ConfigResourceTest {
public void prepareEnvironment() {
initMocks(this);
ConfigResource configResource = new ConfigResource(dtoToConfigMapper, configToDtoMapper, createConfiguration());
ConfigResource configResource = new ConfigResource(dtoToConfigMapper, configToDtoMapper, createConfiguration(), namespaceStrategyValidator);
dispatcher.getRegistry().addSingletonResource(configResource);
}
@@ -140,6 +147,21 @@ public class ConfigResourceTest {
assertEquals(HttpServletResponse.SC_BAD_REQUEST, response.getStatus());
}
@Test
@SubjectAware(username = "readWrite")
public void shouldValidateNamespaceStrategy() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.put("/" + ConfigResource.CONFIG_PATH_V2)
.contentType(VndMediaType.CONFIG)
.content("{ \"namespaceStrategy\": \"AwesomeStrategy\" }".getBytes(StandardCharsets.UTF_8));
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus());
verify(namespaceStrategyValidator).check("AwesomeStrategy");
}
private MockHttpRequest post(String resourceName) throws IOException, URISyntaxException {
URL url = Resources.getResource(resourceName);
byte[] configJson = Resources.toByteArray(url);

View File

@@ -0,0 +1,74 @@
package sonia.scm.api.v2.resources;
import com.google.common.collect.Lists;
import com.google.inject.util.Providers;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.repository.Repository;
import javax.inject.Provider;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.LinkedHashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class NamespaceStrategyResourceTest {
@Mock
private UriInfo uriInfo;
@Test
void shouldReturnNamespaceStrategies() {
when(uriInfo.getAbsolutePath()).thenReturn(URI.create("/namespace-strategies"));
Set<NamespaceStrategy> namespaceStrategies = allStrategies();
Provider<NamespaceStrategy> current = Providers.of(new MegaNamespaceStrategy());
NamespaceStrategyResource resource = new NamespaceStrategyResource(namespaceStrategies, current);
NamespaceStrategiesDto dto = resource.get(uriInfo);
assertThat(dto.getCurrent()).isEqualTo(MegaNamespaceStrategy.class.getSimpleName());
assertThat(dto.getAvailable()).contains(
AwesomeNamespaceStrategy.class.getSimpleName(),
SuperNamespaceStrategy.class.getSimpleName(),
MegaNamespaceStrategy.class.getSimpleName()
);
assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo("/namespace-strategies");
}
private Set<NamespaceStrategy> allStrategies() {
return strategies(new AwesomeNamespaceStrategy(), new SuperNamespaceStrategy(), new MegaNamespaceStrategy());
}
private Set<NamespaceStrategy> strategies(NamespaceStrategy... strategies) {
return new LinkedHashSet<>(Lists.newArrayList(strategies));
}
private static class AwesomeNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return "awesome";
}
}
private static class SuperNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return "super";
}
}
private static class MegaNamespaceStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return "mega";
}
}
}

View File

@@ -43,6 +43,8 @@ public class ResourceLinksMock {
when(resourceLinks.merge()).thenReturn(new ResourceLinks.MergeLinks(uriInfo));
when(resourceLinks.permissions()).thenReturn(new ResourceLinks.PermissionsLinks(uriInfo));
when(resourceLinks.availableRepositoryPermissions()).thenReturn(new ResourceLinks.AvailableRepositoryPermissionLinks(uriInfo));
when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(uriInfo));
when(resourceLinks.namespaceStrategies()).thenReturn(new ResourceLinks.NamespaceStrategiesLinks(uriInfo));
return resourceLinks;
}

View File

@@ -81,7 +81,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
assertEquals("pluginurl" , dto.getPluginUrl());
assertEquals(2 , dto.getLoginAttemptLimitTimeout());
assertTrue(dto.isEnabledXsrfProtection());
assertEquals("username", dto.getDefaultNamespaceStrategy());
assertEquals("username", dto.getNamespaceStrategy());
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("self").get().getHref());
assertEquals(expectedBaseUri.toString(), dto.getLinks().getLinkBy("update").get().getHref());
@@ -121,7 +121,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
config.setPluginUrl("pluginurl");
config.setLoginAttemptLimitTimeout(2);
config.setEnabledXsrfProtection(true);
config.setDefaultNamespaceStrategy("username");
config.setNamespaceStrategy("username");
return config;
}

View File

@@ -0,0 +1,39 @@
package sonia.scm.repository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CurrentYearNamespaceStrategyTest {
@Mock
private Clock clock;
private NamespaceStrategy namespaceStrategy;
@BeforeEach
void setupObjectUnderTest() {
namespaceStrategy = new CurrentYearNamespaceStrategy(clock);
}
@Test
void shouldReturn1985() {
LocalDateTime dateTime = LocalDateTime.of(1985, 4, 9, 21, 42);
when(clock.instant()).thenReturn(dateTime.toInstant(ZoneOffset.UTC));
when(clock.getZone()).thenReturn(ZoneId.systemDefault());
String namespace = namespaceStrategy.createNamespace(RepositoryTestData.createHeartOfGold());
assertThat(namespace).isEqualTo("1985");
}
}

View File

@@ -0,0 +1,28 @@
package sonia.scm.repository;
import org.junit.jupiter.api.Test;
import sonia.scm.ScmConstraintViolationException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CustomNamespaceStrategyTest {
private final NamespaceStrategy namespaceStrategy = new CustomNamespaceStrategy();
@Test
void shouldReturnNamespaceFromRepository() {
Repository heartOfGold = RepositoryTestData.createHeartOfGold();
assertThat(namespaceStrategy.createNamespace(heartOfGold)).isEqualTo(RepositoryTestData.NAMESPACE);
}
@Test
void shouldThrowAnValidationExceptionForAnInvalidNamespace() {
Repository repository = new Repository();
repository.setNamespace("..");
repository.setName(".");
assertThrows(ScmConstraintViolationException.class, () -> namespaceStrategy.createNamespace(repository));
}
}

View File

@@ -1,32 +0,0 @@
package sonia.scm.repository;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import org.junit.Rule;
import org.junit.Test;
import static org.junit.Assert.*;
@SubjectAware(configuration = "classpath:sonia/scm/shiro-001.ini")
public class DefaultNamespaceStrategyTest {
@Rule
public ShiroRule shiroRule = new ShiroRule();
private DefaultNamespaceStrategy namespaceStrategy = new DefaultNamespaceStrategy();
@Test
@SubjectAware(username = "trillian", password = "secret")
public void testNamespaceStrategyWithoutPreset() {
assertEquals("trillian", namespaceStrategy.createNamespace(new Repository()));
}
@Test
@SubjectAware(username = "trillian", password = "secret")
public void testNamespaceStrategyWithPreset() {
Repository repository = new Repository();
repository.setNamespace("awesome");
assertEquals("awesome", namespaceStrategy.createNamespace(repository));
}
}

View File

@@ -34,6 +34,7 @@ import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.inject.Provider;
import com.google.inject.util.Providers;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
@@ -118,7 +119,7 @@ public class DefaultRepositoryManagerPerfTest {
keyGenerator,
repositoryDAO,
handlerSet,
namespaceStrategy
Providers.of(namespaceStrategy)
);
setUpTestRepositories();

View File

@@ -37,6 +37,7 @@ import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.inject.util.Providers;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.util.ThreadContext;
import org.junit.Before;
@@ -436,7 +437,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
when(namespaceStrategy.createNamespace(Mockito.any(Repository.class))).thenAnswer(invocation -> mockedNamespace);
return new DefaultRepositoryManager(configuration, contextProvider,
keyGenerator, repositoryDAO, handlerSet, namespaceStrategy);
keyGenerator, repositoryDAO, handlerSet, Providers.of(namespaceStrategy));
}
private void createRepository(RepositoryManager m, Repository repository) {

View File

@@ -0,0 +1,69 @@
package sonia.scm.repository;
import org.junit.jupiter.api.Test;
import sonia.scm.config.ScmConfiguration;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class NamespaceStrategyProviderTest {
@Test
void shouldReturnConfiguredStrategy() {
Set<NamespaceStrategy> strategies = allStrategiesAsSet();
ScmConfiguration configuration = new ScmConfiguration();
configuration.setNamespaceStrategy("Arthur");
NamespaceStrategyProvider provider = new NamespaceStrategyProvider(strategies, configuration);
NamespaceStrategy strategy = provider.get();
assertThat(strategy).isInstanceOf(Arthur.class);
}
@Test
void shouldReturnUsernameStrategyForUnknown() {
Set<NamespaceStrategy> strategies = Collections.emptySet();
ScmConfiguration configuration = new ScmConfiguration();
configuration.setNamespaceStrategy("Arthur");
NamespaceStrategyProvider provider = new NamespaceStrategyProvider(strategies, configuration);
NamespaceStrategy strategy = provider.get();
assertThat(strategy).isInstanceOf(UsernameNamespaceStrategy.class);
}
private LinkedHashSet<NamespaceStrategy> allStrategiesAsSet() {
return new LinkedHashSet<>(Arrays.asList(new Trillian(), new Zaphod(), new Arthur()));
}
private static class Trillian implements NamespaceStrategy{
@Override
public String createNamespace(Repository repository) {
return "trillian";
}
}
private static class Zaphod implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return "zaphod";
}
}
private static class Arthur implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return "arthur";
}
}
}

View File

@@ -0,0 +1,33 @@
package sonia.scm.repository;
import com.google.common.collect.Sets;
import org.junit.jupiter.api.Test;
import sonia.scm.ScmConstraintViolationException;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.*;
class NamespaceStrategyValidatorTest {
@Test
void shouldThrowConstraintValidationException() {
NamespaceStrategyValidator validator = new NamespaceStrategyValidator(Collections.emptySet());
assertThrows(ScmConstraintViolationException.class, () -> validator.check("AwesomeStrategy"));
}
@Test
void shouldDoNotThrowAnException() {
NamespaceStrategyValidator validator = new NamespaceStrategyValidator(Sets.newHashSet(new AwesomeStrategy()));
validator.check("AwesomeStrategy");
}
public static class AwesomeStrategy implements NamespaceStrategy {
@Override
public String createNamespace(Repository repository) {
return null;
}
}
}

View File

@@ -0,0 +1,20 @@
package sonia.scm.repository;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class RepositoryTypeNamespaceStrategyTest {
private final RepositoryTypeNamespaceStrategy namespaceStrategy = new RepositoryTypeNamespaceStrategy();
@Test
void shouldReturnTypeOfRepository() {
Repository git = RepositoryTestData.create42Puzzle("git");
assertThat(namespaceStrategy.createNamespace(git)).isEqualTo("git");
Repository hg = RepositoryTestData.create42Puzzle("hg");
assertThat(namespaceStrategy.createNamespace(hg)).isEqualTo("hg");
}
}

View File

@@ -0,0 +1,42 @@
package sonia.scm.repository;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UsernameNamespaceStrategyTest {
@Mock
private Subject subject;
private final NamespaceStrategy usernameNamespaceStrategy = new UsernameNamespaceStrategy();
@BeforeEach
void setupSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void clearThreadContext() {
ThreadContext.unbindSubject();
}
@Test
void shouldReturnPrimaryPrincipal() {
when(subject.getPrincipal()).thenReturn("trillian");
String namespace = usernameNamespaceStrategy.createNamespace(RepositoryTestData.createHeartOfGold());
assertThat(namespace).isEqualTo("trillian");
}
}