mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-24 08:19:10 +01:00
merge with 2.0.0-m3
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
package sonia.scm.protocolcommand;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public class CommandContext {
|
||||
|
||||
private String command;
|
||||
private String[] args;
|
||||
|
||||
private InputStream inputStream;
|
||||
private OutputStream outputStream;
|
||||
private OutputStream errorStream;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package sonia.scm.protocolcommand;
|
||||
|
||||
public interface CommandInterpreter {
|
||||
|
||||
String[] getParsedArgs();
|
||||
|
||||
ScmCommandProtocol getProtocolHandler();
|
||||
|
||||
RepositoryContextResolver getRepositoryContextResolver();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package sonia.scm.protocolcommand;
|
||||
|
||||
import sonia.scm.plugin.ExtensionPoint;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ExtensionPoint
|
||||
public interface CommandInterpreterFactory {
|
||||
Optional<CommandInterpreter> canHandle(String command);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package sonia.scm.protocolcommand;
|
||||
|
||||
import sonia.scm.repository.Repository;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class RepositoryContext {
|
||||
private Repository repository;
|
||||
private Path directory;
|
||||
|
||||
public RepositoryContext(Repository repository, Path directory) {
|
||||
this.repository = repository;
|
||||
this.directory = directory;
|
||||
}
|
||||
|
||||
public Repository getRepository() {
|
||||
return repository;
|
||||
}
|
||||
|
||||
public Path getDirectory() {
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package sonia.scm.protocolcommand;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface RepositoryContextResolver {
|
||||
|
||||
RepositoryContext resolve(String[] args);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package sonia.scm.protocolcommand;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface ScmCommandProtocol {
|
||||
|
||||
void handle(CommandContext context, RepositoryContext repositoryContext) throws IOException;
|
||||
|
||||
}
|
||||
@@ -307,8 +307,8 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per
|
||||
this.permissions.add(newPermission);
|
||||
}
|
||||
|
||||
public void removePermission(RepositoryPermission permission) {
|
||||
this.permissions.remove(permission);
|
||||
public boolean removePermission(RepositoryPermission permission) {
|
||||
return this.permissions.remove(permission);
|
||||
}
|
||||
|
||||
public void setPublicReadable(boolean publicReadable) {
|
||||
|
||||
@@ -45,6 +45,7 @@ import javax.xml.bind.annotation.XmlElement;
|
||||
import javax.xml.bind.annotation.XmlRootElement;
|
||||
import java.io.Serializable;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -55,6 +56,8 @@ import static java.util.Collections.unmodifiableSet;
|
||||
|
||||
/**
|
||||
* Permissions controls the access to {@link Repository}.
|
||||
* This object should be immutable, but could not be due to mapstruct. Do not modify instances of this because this
|
||||
* would change the hash code and therefor make it undeletable in a repository.
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
*/
|
||||
@@ -64,22 +67,26 @@ public class RepositoryPermission implements PermissionObject, Serializable
|
||||
{
|
||||
|
||||
private static final long serialVersionUID = -2915175031430884040L;
|
||||
public static final String REPOSITORY_MODIFIED_EXCEPTION_TEXT = "repository permission must not be modified";
|
||||
|
||||
private boolean groupPermission = false;
|
||||
private Boolean groupPermission;
|
||||
private String name;
|
||||
@XmlElement(name = "verb")
|
||||
private Set<String> verbs;
|
||||
|
||||
/**
|
||||
* Constructs a new {@link RepositoryPermission}.
|
||||
* This constructor is used by JAXB and mapstruct.
|
||||
* This constructor exists for mapstruct and JAXB, only -- <b>do not use this in "normal" code</b>.
|
||||
*
|
||||
* @deprecated Do not use this for "normal" code.
|
||||
* Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public RepositoryPermission() {}
|
||||
|
||||
public RepositoryPermission(String name, Collection<String> verbs, boolean groupPermission)
|
||||
{
|
||||
this.name = name;
|
||||
this.verbs = unmodifiableSet(new LinkedHashSet<>(verbs));
|
||||
this.verbs = new LinkedHashSet<>(verbs);
|
||||
this.groupPermission = groupPermission;
|
||||
}
|
||||
|
||||
@@ -163,7 +170,7 @@ public class RepositoryPermission implements PermissionObject, Serializable
|
||||
*/
|
||||
public Collection<String> getVerbs()
|
||||
{
|
||||
return verbs == null? emptyList(): verbs;
|
||||
return verbs == null ? emptyList() : Collections.unmodifiableSet(verbs);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,35 +188,50 @@ public class RepositoryPermission implements PermissionObject, Serializable
|
||||
//~--- set methods ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sets true if the permission is a group permission.
|
||||
* Use this for creation only. This will throw an {@link IllegalStateException} when modified.
|
||||
* @throws IllegalStateException when modified after the value has been set once.
|
||||
*
|
||||
*
|
||||
* @param groupPermission true if the permission is a group permission
|
||||
* @deprecated Do not use this for "normal" code.
|
||||
* Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public void setGroupPermission(boolean groupPermission)
|
||||
{
|
||||
if (this.groupPermission != null) {
|
||||
throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
|
||||
}
|
||||
this.groupPermission = groupPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name of the user or group.
|
||||
* Use this for creation only. This will throw an {@link IllegalStateException} when modified.
|
||||
* @throws IllegalStateException when modified after the value has been set once.
|
||||
*
|
||||
*
|
||||
* @param name name of the user or group
|
||||
* @deprecated Do not use this for "normal" code.
|
||||
* Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public void setName(String name)
|
||||
{
|
||||
if (this.name != null) {
|
||||
throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
|
||||
}
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the verb of the permission.
|
||||
* Use this for creation only. This will throw an {@link IllegalStateException} when modified.
|
||||
* @throws IllegalStateException when modified after the value has been set once.
|
||||
*
|
||||
*
|
||||
* @param verbs verbs of the permission
|
||||
* @deprecated Do not use this for "normal" code.
|
||||
* Use {@link RepositoryPermission#RepositoryPermission(String, Collection, boolean)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public void setVerbs(Collection<String> verbs)
|
||||
{
|
||||
if (this.verbs != null) {
|
||||
throw new IllegalStateException(REPOSITORY_MODIFIED_EXCEPTION_TEXT);
|
||||
}
|
||||
this.verbs = unmodifiableSet(new LinkedHashSet<>(verbs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,7 @@ class RepositoryPermissionTest {
|
||||
@Test
|
||||
void shouldBeEqualWithRedundantVerbs() {
|
||||
RepositoryPermission permission1 = new RepositoryPermission("name1", asList("one", "two"), false);
|
||||
RepositoryPermission permission2 = new RepositoryPermission("name1", asList("one", "two"), false);
|
||||
permission2.setVerbs(asList("one", "two", "two"));
|
||||
RepositoryPermission permission2 = new RepositoryPermission("name1", asList("one", "two", "two"), false);
|
||||
|
||||
assertThat(permission1).isEqualTo(permission2);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
|
||||
import sonia.scm.repository.GitRepositoryHandler;
|
||||
import sonia.scm.repository.spi.HookEventFacade;
|
||||
import sonia.scm.web.CollectingPackParserListener;
|
||||
import sonia.scm.web.GitReceiveHook;
|
||||
|
||||
public abstract class BaseReceivePackFactory<T> implements ReceivePackFactory<T> {
|
||||
|
||||
private final GitRepositoryHandler handler;
|
||||
private final GitReceiveHook hook;
|
||||
|
||||
protected BaseReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
|
||||
this.handler = handler;
|
||||
this.hook = new GitReceiveHook(hookEventFacade, handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final ReceivePack create(T connection, Repository repository) throws ServiceNotAuthorizedException, ServiceNotEnabledException {
|
||||
ReceivePack receivePack = createBasicReceivePack(connection, repository);
|
||||
receivePack.setAllowNonFastForwards(isNonFastForwardAllowed());
|
||||
|
||||
receivePack.setPreReceiveHook(hook);
|
||||
receivePack.setPostReceiveHook(hook);
|
||||
// apply collecting listener, to be able to check which commits are new
|
||||
CollectingPackParserListener.set(receivePack);
|
||||
|
||||
return receivePack;
|
||||
}
|
||||
|
||||
protected abstract ReceivePack createBasicReceivePack(T request, Repository repository)
|
||||
throws ServiceNotEnabledException, ServiceNotAuthorizedException;
|
||||
|
||||
private boolean isNonFastForwardAllowed() {
|
||||
return ! handler.getConfig().isNonFastForwardDisallowed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import sonia.scm.protocolcommand.CommandInterpreter;
|
||||
import sonia.scm.protocolcommand.RepositoryContextResolver;
|
||||
import sonia.scm.protocolcommand.ScmCommandProtocol;
|
||||
|
||||
class GitCommandInterpreter implements CommandInterpreter {
|
||||
private final GitRepositoryContextResolver gitRepositoryContextResolver;
|
||||
private final GitCommandProtocol gitCommandProtocol;
|
||||
private final String[] args;
|
||||
|
||||
GitCommandInterpreter(GitRepositoryContextResolver gitRepositoryContextResolver, GitCommandProtocol gitCommandProtocol, String[] args) {
|
||||
this.gitRepositoryContextResolver = gitRepositoryContextResolver;
|
||||
this.gitCommandProtocol = gitCommandProtocol;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getParsedArgs() {
|
||||
return args;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ScmCommandProtocol getProtocolHandler() {
|
||||
return gitCommandProtocol;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RepositoryContextResolver getRepositoryContextResolver() {
|
||||
return gitRepositoryContextResolver;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.protocolcommand.CommandInterpreter;
|
||||
import sonia.scm.protocolcommand.CommandInterpreterFactory;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.util.Optional;
|
||||
|
||||
import static java.util.Optional.empty;
|
||||
import static java.util.Optional.of;
|
||||
|
||||
@Extension
|
||||
public class GitCommandInterpreterFactory implements CommandInterpreterFactory {
|
||||
private final GitCommandProtocol gitCommandProtocol;
|
||||
private final GitRepositoryContextResolver gitRepositoryContextResolver;
|
||||
|
||||
@Inject
|
||||
public GitCommandInterpreterFactory(GitCommandProtocol gitCommandProtocol, GitRepositoryContextResolver gitRepositoryContextResolver) {
|
||||
this.gitCommandProtocol = gitCommandProtocol;
|
||||
this.gitRepositoryContextResolver = gitRepositoryContextResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<CommandInterpreter> canHandle(String command) {
|
||||
try {
|
||||
String[] args = GitCommandParser.parse(command);
|
||||
if (args[0].startsWith("git")) {
|
||||
return of(new GitCommandInterpreter(gitRepositoryContextResolver, gitCommandProtocol, args));
|
||||
} else {
|
||||
return empty();
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
class GitCommandParser {
|
||||
|
||||
private GitCommandParser() {
|
||||
}
|
||||
|
||||
static String[] parse(String command) {
|
||||
List<String> strs = parseDelimitedString(command, " ", true);
|
||||
String[] args = strs.toArray(new String[strs.size()]);
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
String argVal = args[i];
|
||||
if (argVal.startsWith("'") && argVal.endsWith("'")) {
|
||||
args[i] = argVal.substring(1, argVal.length() - 1);
|
||||
argVal = args[i];
|
||||
}
|
||||
if (argVal.startsWith("\"") && argVal.endsWith("\"")) {
|
||||
args[i] = argVal.substring(1, argVal.length() - 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length != 2) {
|
||||
throw new IllegalArgumentException("Invalid git command line (no arguments): " + command);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private static List<String> parseDelimitedString(String value, String delim, boolean trim) {
|
||||
if (value == null) {
|
||||
value = "";
|
||||
}
|
||||
|
||||
List<String> list = new ArrayList<>();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int expecting = 7;
|
||||
boolean isEscaped = false;
|
||||
|
||||
for(int i = 0; i < value.length(); ++i) {
|
||||
char c = value.charAt(i);
|
||||
boolean isDelimiter = delim.indexOf(c) >= 0;
|
||||
if (!isEscaped && c == '\\') {
|
||||
isEscaped = true;
|
||||
} else {
|
||||
if (isEscaped) {
|
||||
sb.append(c);
|
||||
} else if (isDelimiter && (expecting & 2) != 0) {
|
||||
if (trim) {
|
||||
String str = sb.toString();
|
||||
list.add(str.trim());
|
||||
} else {
|
||||
list.add(sb.toString());
|
||||
}
|
||||
|
||||
sb.delete(0, sb.length());
|
||||
expecting = 7;
|
||||
} else if (c == '"' && (expecting & 4) != 0) {
|
||||
sb.append(c);
|
||||
expecting = 9;
|
||||
} else if (c == '"' && (expecting & 8) != 0) {
|
||||
sb.append(c);
|
||||
expecting = 7;
|
||||
} else {
|
||||
if ((expecting & 1) == 0) {
|
||||
throw new IllegalArgumentException("Invalid delimited string: " + value);
|
||||
}
|
||||
|
||||
sb.append(c);
|
||||
}
|
||||
|
||||
isEscaped = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (sb.length() > 0) {
|
||||
if (trim) {
|
||||
String str = sb.toString();
|
||||
list.add(str.trim());
|
||||
} else {
|
||||
list.add(sb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.lib.RepositoryCache;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.eclipse.jgit.transport.RemoteConfig;
|
||||
import org.eclipse.jgit.transport.UploadPack;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
|
||||
import org.eclipse.jgit.util.FS;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.protocolcommand.CommandContext;
|
||||
import sonia.scm.protocolcommand.RepositoryContext;
|
||||
import sonia.scm.protocolcommand.ScmCommandProtocol;
|
||||
import sonia.scm.repository.RepositoryPermissions;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.io.IOException;
|
||||
|
||||
@Extension
|
||||
public class GitCommandProtocol implements ScmCommandProtocol {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GitCommandProtocol.class);
|
||||
|
||||
private ScmUploadPackFactory uploadPackFactory;
|
||||
private ScmReceivePackFactory receivePackFactory;
|
||||
|
||||
@Inject
|
||||
public GitCommandProtocol(ScmUploadPackFactory uploadPackFactory, ScmReceivePackFactory receivePackFactory) {
|
||||
this.uploadPackFactory = uploadPackFactory;
|
||||
this.receivePackFactory = receivePackFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(CommandContext commandContext, RepositoryContext repositoryContext) throws IOException {
|
||||
String subCommand = commandContext.getArgs()[0];
|
||||
|
||||
if (RemoteConfig.DEFAULT_UPLOAD_PACK.equals(subCommand)) {
|
||||
LOG.trace("got upload pack");
|
||||
upload(commandContext, repositoryContext);
|
||||
} else if (RemoteConfig.DEFAULT_RECEIVE_PACK.equals(subCommand)) {
|
||||
LOG.trace("got receive pack");
|
||||
receive(commandContext, repositoryContext);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown git command: " + commandContext.getCommand());
|
||||
}
|
||||
}
|
||||
|
||||
private void receive(CommandContext commandContext, RepositoryContext repositoryContext) throws IOException {
|
||||
RepositoryPermissions.push(repositoryContext.getRepository()).check();
|
||||
try (Repository repository = open(repositoryContext)) {
|
||||
ReceivePack receivePack = receivePackFactory.create(repositoryContext, repository);
|
||||
receivePack.receive(commandContext.getInputStream(), commandContext.getOutputStream(), commandContext.getErrorStream());
|
||||
} catch (ServiceNotEnabledException | ServiceNotAuthorizedException e) {
|
||||
throw new IOException("error creating receive pack for ssh", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void upload(CommandContext commandContext, RepositoryContext repositoryContext) throws IOException {
|
||||
RepositoryPermissions.pull(repositoryContext.getRepository()).check();
|
||||
try (Repository repository = open(repositoryContext)) {
|
||||
UploadPack uploadPack = uploadPackFactory.create(repositoryContext, repository);
|
||||
uploadPack.upload(commandContext.getInputStream(), commandContext.getOutputStream(), commandContext.getErrorStream());
|
||||
}
|
||||
}
|
||||
|
||||
private Repository open(RepositoryContext repositoryContext) throws IOException {
|
||||
RepositoryCache.FileKey key = RepositoryCache.FileKey.lenient(repositoryContext.getDirectory().toFile(), FS.DETECTED);
|
||||
return key.open(true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import com.google.common.base.Splitter;
|
||||
import sonia.scm.protocolcommand.RepositoryContext;
|
||||
import sonia.scm.protocolcommand.RepositoryContextResolver;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryLocationResolver;
|
||||
import sonia.scm.repository.RepositoryManager;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Iterator;
|
||||
|
||||
public class GitRepositoryContextResolver implements RepositoryContextResolver {
|
||||
|
||||
private RepositoryManager repositoryManager;
|
||||
private RepositoryLocationResolver locationResolver;
|
||||
|
||||
@Inject
|
||||
public GitRepositoryContextResolver(RepositoryManager repositoryManager, RepositoryLocationResolver locationResolver) {
|
||||
this.repositoryManager = repositoryManager;
|
||||
this.locationResolver = locationResolver;
|
||||
}
|
||||
|
||||
public RepositoryContext resolve(String[] args) {
|
||||
NamespaceAndName namespaceAndName = extractNamespaceAndName(args);
|
||||
Repository repository = repositoryManager.get(namespaceAndName);
|
||||
Path path = locationResolver.getPath(repository.getId()).resolve("data");
|
||||
return new RepositoryContext(repository, path);
|
||||
}
|
||||
|
||||
private NamespaceAndName extractNamespaceAndName(String[] args) {
|
||||
String path = args[args.length - 1];
|
||||
Iterator<String> it = Splitter.on('/').omitEmptyStrings().split(path).iterator();
|
||||
String type = it.next();
|
||||
if ("repo".equals(type)) {
|
||||
String ns = it.next();
|
||||
String name = it.next();
|
||||
return new NamespaceAndName(ns, name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import sonia.scm.protocolcommand.RepositoryContext;
|
||||
import sonia.scm.repository.GitRepositoryHandler;
|
||||
import sonia.scm.repository.spi.HookEventFacade;
|
||||
|
||||
public class ScmReceivePackFactory extends BaseReceivePackFactory<RepositoryContext> {
|
||||
|
||||
@Inject
|
||||
public ScmReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
|
||||
super(handler, hookEventFacade);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ReceivePack createBasicReceivePack(RepositoryContext repositoryContext, Repository repository) {
|
||||
return new ReceivePack(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.UploadPack;
|
||||
import org.eclipse.jgit.transport.resolver.UploadPackFactory;
|
||||
import sonia.scm.protocolcommand.RepositoryContext;
|
||||
|
||||
public class ScmUploadPackFactory implements UploadPackFactory<RepositoryContext> {
|
||||
@Override
|
||||
public UploadPack create(RepositoryContext repositoryContext, Repository repository) {
|
||||
return new UploadPack(repository);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.transport.PushResult;
|
||||
import org.eclipse.jgit.transport.RemoteRefUpdate;
|
||||
import org.eclipse.jgit.transport.ScmTransportProtocol;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.repository.GitRepositoryHandler;
|
||||
@@ -62,7 +63,7 @@ public abstract class AbstractGitPushOrPullCommand extends AbstractGitCommand
|
||||
{
|
||||
|
||||
/** Field description */
|
||||
private static final String SCHEME = "scm://";
|
||||
private static final String SCHEME = ScmTransportProtocol.NAME + "://";
|
||||
|
||||
/**
|
||||
* the logger for AbstractGitPushOrPullCommand
|
||||
@@ -167,7 +168,7 @@ public abstract class AbstractGitPushOrPullCommand extends AbstractGitCommand
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IllegalArgumentException("repository or url is requiered");
|
||||
throw new IllegalArgumentException("repository or url is required");
|
||||
}
|
||||
|
||||
return url;
|
||||
|
||||
@@ -35,7 +35,6 @@ package sonia.scm.web;
|
||||
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.inject.Inject;
|
||||
import org.eclipse.jgit.http.server.resolver.DefaultReceivePackFactory;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
@@ -43,6 +42,7 @@ import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
|
||||
import sonia.scm.protocolcommand.git.BaseReceivePackFactory;
|
||||
import sonia.scm.repository.GitRepositoryHandler;
|
||||
import sonia.scm.repository.spi.HookEventFacade;
|
||||
|
||||
@@ -56,42 +56,20 @@ import javax.servlet.http.HttpServletRequest;
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
*/
|
||||
public class GitReceivePackFactory implements ReceivePackFactory<HttpServletRequest>
|
||||
public class GitReceivePackFactory extends BaseReceivePackFactory<HttpServletRequest>
|
||||
{
|
||||
|
||||
private final GitRepositoryHandler handler;
|
||||
|
||||
private ReceivePackFactory wrapped;
|
||||
|
||||
private final GitReceiveHook hook;
|
||||
private ReceivePackFactory<HttpServletRequest> wrapped;
|
||||
|
||||
@Inject
|
||||
public GitReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
|
||||
this.handler = handler;
|
||||
this.hook = new GitReceiveHook(hookEventFacade, handler);
|
||||
this.wrapped = new DefaultReceivePackFactory();
|
||||
super(handler, hookEventFacade);
|
||||
this.wrapped = new DefaultReceivePackFactory();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReceivePack create(HttpServletRequest request, Repository repository)
|
||||
protected ReceivePack createBasicReceivePack(HttpServletRequest request, Repository repository)
|
||||
throws ServiceNotEnabledException, ServiceNotAuthorizedException {
|
||||
ReceivePack receivePack = wrapped.create(request, repository);
|
||||
receivePack.setAllowNonFastForwards(isNonFastForwardAllowed());
|
||||
|
||||
receivePack.setPreReceiveHook(hook);
|
||||
receivePack.setPostReceiveHook(hook);
|
||||
// apply collecting listener, to be able to check which commits are new
|
||||
CollectingPackParserListener.set(receivePack);
|
||||
|
||||
return receivePack;
|
||||
}
|
||||
|
||||
private boolean isNonFastForwardAllowed() {
|
||||
return ! handler.getConfig().isNonFastForwardDisallowed();
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void setWrapped(ReceivePackFactory wrapped) {
|
||||
this.wrapped = wrapped;
|
||||
return wrapped.create(request, repository);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,13 +29,15 @@
|
||||
*
|
||||
*/
|
||||
|
||||
package sonia.scm.web;
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import org.eclipse.jgit.api.Git;
|
||||
import org.eclipse.jgit.api.errors.GitAPIException;
|
||||
import org.eclipse.jgit.lib.Repository;
|
||||
import org.eclipse.jgit.transport.ReceivePack;
|
||||
import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
|
||||
import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -45,21 +47,20 @@ import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import sonia.scm.repository.GitConfig;
|
||||
import sonia.scm.repository.GitRepositoryHandler;
|
||||
import sonia.scm.web.CollectingPackParserListener;
|
||||
import sonia.scm.web.GitReceiveHook;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.hamcrest.Matchers.instanceOf;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GitReceivePackFactory}.
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class GitReceivePackFactoryTest {
|
||||
public class BaseReceivePackFactoryTest {
|
||||
|
||||
@Mock
|
||||
private GitRepositoryHandler handler;
|
||||
@@ -67,12 +68,11 @@ public class GitReceivePackFactoryTest {
|
||||
private GitConfig config;
|
||||
|
||||
@Mock
|
||||
private ReceivePackFactory wrappedReceivePackFactory;
|
||||
private ReceivePackFactory<Object> wrappedReceivePackFactory;
|
||||
|
||||
private GitReceivePackFactory factory;
|
||||
private BaseReceivePackFactory<Object> factory;
|
||||
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
private Object request = new Object();
|
||||
|
||||
private Repository repository;
|
||||
|
||||
@@ -89,8 +89,12 @@ public class GitReceivePackFactoryTest {
|
||||
ReceivePack receivePack = new ReceivePack(repository);
|
||||
when(wrappedReceivePackFactory.create(request, repository)).thenReturn(receivePack);
|
||||
|
||||
factory = new GitReceivePackFactory(handler, null);
|
||||
factory.setWrapped(wrappedReceivePackFactory);
|
||||
factory = new BaseReceivePackFactory<Object>(handler, null) {
|
||||
@Override
|
||||
protected ReceivePack createBasicReceivePack(Object request, Repository repository) throws ServiceNotEnabledException, ServiceNotAuthorizedException {
|
||||
return wrappedReceivePackFactory.create(request, repository);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Repository createRepositoryForTesting() throws GitAPIException, IOException {
|
||||
@@ -105,6 +109,7 @@ public class GitReceivePackFactoryTest {
|
||||
assertThat(receivePack.getPreReceiveHook(), instanceOf(GitReceiveHook.class));
|
||||
assertThat(receivePack.getPostReceiveHook(), instanceOf(GitReceiveHook.class));
|
||||
assertTrue(receivePack.isAllowNonFastForwards());
|
||||
verify(wrappedReceivePackFactory).create(request, repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -0,0 +1,45 @@
|
||||
package sonia.scm.protocolcommand.git;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.protocolcommand.RepositoryContext;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryLocationResolver;
|
||||
import sonia.scm.repository.RepositoryManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GitRepositoryContextResolverTest {
|
||||
|
||||
private static final Repository REPOSITORY = new Repository("id", "git", "space", "X");
|
||||
|
||||
@Mock
|
||||
RepositoryManager repositoryManager;
|
||||
@Mock
|
||||
RepositoryLocationResolver locationResolver;
|
||||
|
||||
@InjectMocks
|
||||
GitRepositoryContextResolver resolver;
|
||||
|
||||
@Test
|
||||
void shouldResolveCorrectRepository() throws IOException {
|
||||
when(repositoryManager.get(new NamespaceAndName("space", "X"))).thenReturn(REPOSITORY);
|
||||
Path repositoryPath = File.createTempFile("test", "scm").toPath();
|
||||
when(locationResolver.getPath("id")).thenReturn(repositoryPath);
|
||||
|
||||
RepositoryContext context = resolver.resolve(new String[] {"git", "repo/space/X/something/else"});
|
||||
|
||||
assertThat(context.getRepository()).isSameAs(REPOSITORY);
|
||||
assertThat(context.getDirectory()).isEqualTo(repositoryPath.resolve("data"));
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,16 @@ public class SimpleGitWorkdirFactoryTest extends AbstractGitCommandTestBase {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
// keep this so that it will not be garbage collected (Transport keeps this in a week reference)
|
||||
private ScmTransportProtocol proto;
|
||||
|
||||
@Before
|
||||
public void bindScmProtocol() {
|
||||
HookContextFactory hookContextFactory = new HookContextFactory(mock(PreProcessorUtil.class));
|
||||
HookEventFacade hookEventFacade = new HookEventFacade(of(mock(RepositoryManager.class)), hookContextFactory);
|
||||
GitRepositoryHandler gitRepositoryHandler = mock(GitRepositoryHandler.class);
|
||||
Transport.register(new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler)));
|
||||
proto = new ScmTransportProtocol(of(hookEventFacade), of(gitRepositoryHandler));
|
||||
Transport.register(proto);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { BackendError } from "./errors";
|
||||
import Notification from "./Notification";
|
||||
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
type Props = { error: BackendError, t: string => string };
|
||||
|
||||
class BackendErrorNotification extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<div className="content">
|
||||
<p className="subtitle">{this.renderErrorName()}</p>
|
||||
<p>{this.renderErrorDescription()}</p>
|
||||
<p>{this.renderViolations()}</p>
|
||||
{this.renderMetadata()}
|
||||
</div>
|
||||
</Notification>
|
||||
);
|
||||
}
|
||||
|
||||
renderErrorName = () => {
|
||||
const { error, t } = this.props;
|
||||
const translation = t("errors." + error.errorCode + ".displayName");
|
||||
if (translation === error.errorCode) {
|
||||
return error.message;
|
||||
}
|
||||
return translation;
|
||||
};
|
||||
|
||||
renderErrorDescription = () => {
|
||||
const { error, t } = this.props;
|
||||
const translation = t("errors." + error.errorCode + ".description");
|
||||
if (translation === error.errorCode) {
|
||||
return "";
|
||||
}
|
||||
return translation;
|
||||
};
|
||||
|
||||
renderViolations = () => {
|
||||
const { error, t } = this.props;
|
||||
if (error.violations) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<strong>{t("errors.violations")}</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{error.violations.map((violation, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<strong>{violation.path}:</strong> {violation.message}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderMetadata = () => {
|
||||
const { error, t } = this.props;
|
||||
return (
|
||||
<>
|
||||
{this.renderContext()}
|
||||
{this.renderMoreInformationLink()}
|
||||
<div className="level is-size-7">
|
||||
<div className="left">
|
||||
{t("errors.transactionId")} {error.transactionId}
|
||||
</div>
|
||||
<div className="right">
|
||||
{t("errors.errorCode")} {error.errorCode}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
renderContext = () => {
|
||||
const { error, t} = this.props;
|
||||
if (error.context) {
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
<strong>{t("errors.context")}</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{error.context.map((context, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
<strong>{context.type}:</strong> {context.id}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderMoreInformationLink = () => {
|
||||
const { error, t } = this.props;
|
||||
if (error.url) {
|
||||
return (
|
||||
<p>
|
||||
{t("errors.moreInfo")}{" "}
|
||||
<a href={error.url} target="_blank">
|
||||
{error.errorCode}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default translate("plugins")(BackendErrorNotification);
|
||||
@@ -1,28 +1,41 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { BackendError, ForbiddenError, UnauthorizedError } from "./errors";
|
||||
import Notification from "./Notification";
|
||||
import {UNAUTHORIZED_ERROR} from "./apiclient";
|
||||
import BackendErrorNotification from "./BackendErrorNotification";
|
||||
|
||||
type Props = {
|
||||
t: string => string,
|
||||
error?: Error
|
||||
};
|
||||
|
||||
class ErrorNotification extends React.Component<Props> {
|
||||
|
||||
class ErrorNotification extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, error } = this.props;
|
||||
if (error) {
|
||||
if (error === UNAUTHORIZED_ERROR) {
|
||||
if (error instanceof BackendError) {
|
||||
return <BackendErrorNotification error={error} />
|
||||
} else if (error instanceof UnauthorizedError) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {t("error-notification.timeout")}
|
||||
{" "}
|
||||
<a href="javascript:window.location.reload(true)">{t("error-notification.loginLink")}</a>
|
||||
<strong>{t("error-notification.prefix")}:</strong>{" "}
|
||||
{t("error-notification.timeout")}{" "}
|
||||
<a href="javascript:window.location.reload(true)">
|
||||
{t("error-notification.loginLink")}
|
||||
</a>
|
||||
</Notification>
|
||||
);
|
||||
} else {
|
||||
} else if (error instanceof ForbiddenError) {
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong>{" "}
|
||||
{t("error-notification.forbidden")}
|
||||
</Notification>
|
||||
)
|
||||
} else
|
||||
{
|
||||
return (
|
||||
<Notification type="danger">
|
||||
<strong>{t("error-notification.prefix")}:</strong> {error.message}
|
||||
@@ -34,4 +47,4 @@ class ErrorNotification extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export default translate("commons")(ErrorNotification);
|
||||
export default translate("commons")(ErrorNotification);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import ErrorNotification from "./ErrorNotification";
|
||||
import { BackendError, ForbiddenError } from "./errors";
|
||||
|
||||
type Props = {
|
||||
error: Error,
|
||||
@@ -10,18 +11,26 @@ type Props = {
|
||||
|
||||
class ErrorPage extends React.Component<Props> {
|
||||
render() {
|
||||
const { title, subtitle, error } = this.props;
|
||||
const { title, error } = this.props;
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="box column is-4 is-offset-4 container">
|
||||
<h1 className="title">{title}</h1>
|
||||
<p className="subtitle">{subtitle}</p>
|
||||
{this.renderSubtitle()}
|
||||
<ErrorNotification error={error} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
renderSubtitle = () => {
|
||||
const { error, subtitle } = this.props;
|
||||
if (error instanceof BackendError || error instanceof ForbiddenError) {
|
||||
return null;
|
||||
}
|
||||
return <p className="subtitle">{subtitle}</p>
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorPage;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// @flow
|
||||
import {contextPath} from "./urls";
|
||||
|
||||
export const NOT_FOUND_ERROR = new Error("not found");
|
||||
export const UNAUTHORIZED_ERROR = new Error("unauthorized");
|
||||
export const CONFLICT_ERROR = new Error("conflict");
|
||||
import { contextPath } from "./urls";
|
||||
import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors";
|
||||
import type { BackendErrorContent } from "./errors";
|
||||
|
||||
const fetchOptions: RequestOptions = {
|
||||
credentials: "same-origin",
|
||||
@@ -12,19 +10,24 @@ const fetchOptions: RequestOptions = {
|
||||
}
|
||||
};
|
||||
|
||||
function handleStatusCode(response: Response) {
|
||||
if (!response.ok) {
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw UNAUTHORIZED_ERROR;
|
||||
case 404:
|
||||
throw NOT_FOUND_ERROR;
|
||||
case 409:
|
||||
throw CONFLICT_ERROR;
|
||||
default:
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
|
||||
|
||||
function handleFailure(response: Response) {
|
||||
if (!response.ok) {
|
||||
if (isBackendError(response)) {
|
||||
return response.json()
|
||||
.then((content: BackendErrorContent) => {
|
||||
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);
|
||||
}
|
||||
|
||||
throw new Error("server returned status code " + response.status);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -42,7 +45,7 @@ export function createUrl(url: string) {
|
||||
|
||||
class ApiClient {
|
||||
get(url: string): Promise<Response> {
|
||||
return fetch(createUrl(url), fetchOptions).then(handleStatusCode);
|
||||
return fetch(createUrl(url), fetchOptions).then(handleFailure);
|
||||
}
|
||||
|
||||
post(url: string, payload: any, contentType: string = "application/json") {
|
||||
@@ -58,7 +61,7 @@ class ApiClient {
|
||||
method: "HEAD"
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
|
||||
delete(url: string): Promise<Response> {
|
||||
@@ -66,7 +69,7 @@ class ApiClient {
|
||||
method: "DELETE"
|
||||
};
|
||||
options = Object.assign(options, fetchOptions);
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
|
||||
httpRequestWithJSONBody(
|
||||
@@ -83,7 +86,7 @@ class ApiClient {
|
||||
// $FlowFixMe
|
||||
options.headers["Content-Type"] = contentType;
|
||||
|
||||
return fetch(createUrl(url), options).then(handleStatusCode);
|
||||
return fetch(createUrl(url), options).then(handleFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +1,78 @@
|
||||
// @flow
|
||||
import {apiClient, createUrl} from "./apiclient";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { apiClient, createUrl } from "./apiclient";
|
||||
import {fetchMock} from "fetch-mock";
|
||||
import { BackendError } from "./errors";
|
||||
|
||||
describe("create url", () => {
|
||||
it("should not change absolute urls", () => {
|
||||
expect(createUrl("https://www.scm-manager.org")).toBe(
|
||||
"https://www.scm-manager.org"
|
||||
);
|
||||
});
|
||||
|
||||
it("should add prefix for api", () => {
|
||||
expect(createUrl("/users")).toBe("/api/v2/users");
|
||||
expect(createUrl("users")).toBe("/api/v2/users");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("error handling tests", () => {
|
||||
|
||||
const earthNotFoundError = {
|
||||
transactionId: "42t",
|
||||
errorCode: "42e",
|
||||
message: "earth not found",
|
||||
context: [{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}]
|
||||
};
|
||||
|
||||
describe("apiClient", () => {
|
||||
afterEach(() => {
|
||||
fetchMock.reset();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
describe("create url", () => {
|
||||
it("should not change absolute urls", () => {
|
||||
expect(createUrl("https://www.scm-manager.org")).toBe(
|
||||
"https://www.scm-manager.org"
|
||||
);
|
||||
it("should create a normal error, if the content type is not scmm-error", (done) => {
|
||||
|
||||
fetchMock.getOnce("/api/v2/error", {
|
||||
status: 404
|
||||
});
|
||||
|
||||
it("should add prefix for api", () => {
|
||||
expect(createUrl("/users")).toBe("/api/v2/users");
|
||||
expect(createUrl("users")).toBe("/api/v2/users");
|
||||
});
|
||||
apiClient.get("/error")
|
||||
.catch((err: Error) => {
|
||||
expect(err.name).toEqual("Error");
|
||||
expect(err.message).toContain("404");
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
const error = {
|
||||
message: "Error!!"
|
||||
};
|
||||
|
||||
it("should append default error message for 401 if none provided", () => {
|
||||
fetchMock.mock("api/v2/foo", 401);
|
||||
return apiClient
|
||||
.get("foo")
|
||||
.catch(err => {
|
||||
expect(err.message).toEqual("unauthorized");
|
||||
});
|
||||
it("should create an backend error, if the content type is scmm-error", (done) => {
|
||||
fetchMock.getOnce("/api/v2/error", {
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.scmm-error+json;v=2"
|
||||
},
|
||||
body: earthNotFoundError
|
||||
});
|
||||
|
||||
it("should append error message for 401 if provided", () => {
|
||||
fetchMock.mock("api/v2/foo", {"status": 401, body: error});
|
||||
return apiClient
|
||||
.get("foo")
|
||||
.catch(err => {
|
||||
expect(err.message).toEqual("Error!!");
|
||||
});
|
||||
});
|
||||
apiClient.get("/error")
|
||||
.catch((err: BackendError) => {
|
||||
|
||||
it("should append default error message for 401 if none provided", () => {
|
||||
fetchMock.mock("api/v2/foo", 404);
|
||||
return apiClient
|
||||
.get("foo")
|
||||
.catch(err => {
|
||||
expect(err.message).toEqual("not found");
|
||||
});
|
||||
});
|
||||
expect(err).toBeInstanceOf(BackendError);
|
||||
|
||||
it("should append error message for 404 if provided", () => {
|
||||
fetchMock.mock("api/v2/foo", {"status": 404, body: error});
|
||||
return apiClient
|
||||
.get("foo")
|
||||
.catch(err => {
|
||||
expect(err.message).toEqual("Error!!");
|
||||
});
|
||||
});
|
||||
expect(err.message).toEqual("earth not found");
|
||||
expect(err.statusCode).toBe(404);
|
||||
|
||||
expect(err.transactionId).toEqual("42t");
|
||||
expect(err.errorCode).toEqual("42e");
|
||||
expect(err.context).toEqual([{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
72
scm-ui-components/packages/ui-components/src/errors.js
Normal file
72
scm-ui-components/packages/ui-components/src/errors.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// @flow
|
||||
type Context = { type: string, id: string }[];
|
||||
type Violation = { path: string, message: string };
|
||||
|
||||
export type BackendErrorContent = {
|
||||
transactionId: string,
|
||||
errorCode: string,
|
||||
message: string,
|
||||
url?: string,
|
||||
context: Context,
|
||||
violations: Violation[]
|
||||
};
|
||||
|
||||
export class BackendError extends Error {
|
||||
transactionId: string;
|
||||
errorCode: string;
|
||||
url: ?string;
|
||||
context: Context = [];
|
||||
statusCode: number;
|
||||
violations: Violation[];
|
||||
|
||||
constructor(content: BackendErrorContent, name: string, statusCode: number) {
|
||||
super(content.message);
|
||||
this.name = name;
|
||||
this.transactionId = content.transactionId;
|
||||
this.errorCode = content.errorCode;
|
||||
this.url = content.url;
|
||||
this.context = content.context;
|
||||
this.statusCode = statusCode;
|
||||
this.violations = content.violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
statusCode: number;
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
statusCode: number;
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends BackendError {
|
||||
constructor(content: BackendErrorContent, statusCode: number) {
|
||||
super(content, "NotFoundError", statusCode);
|
||||
}
|
||||
}
|
||||
export function createBackendError(
|
||||
content: BackendErrorContent,
|
||||
statusCode: number
|
||||
) {
|
||||
switch (statusCode) {
|
||||
case 404:
|
||||
return new NotFoundError(content, statusCode);
|
||||
default:
|
||||
return new BackendError(content, "BackendError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackendError(response: Response) {
|
||||
return (
|
||||
response.headers.get("Content-Type") ===
|
||||
"application/vnd.scmm-error+json;v=2"
|
||||
);
|
||||
}
|
||||
35
scm-ui-components/packages/ui-components/src/errors.test.js
Normal file
35
scm-ui-components/packages/ui-components/src/errors.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// @flow
|
||||
|
||||
import { BackendError, UnauthorizedError, createBackendError, NotFoundError } from "./errors";
|
||||
|
||||
describe("test createBackendError", () => {
|
||||
|
||||
const earthNotFoundError = {
|
||||
transactionId: "42t",
|
||||
errorCode: "42e",
|
||||
message: "earth not found",
|
||||
context: [{
|
||||
type: "planet",
|
||||
id: "earth"
|
||||
}]
|
||||
};
|
||||
|
||||
it("should return a default backend error", () => {
|
||||
const err = createBackendError(earthNotFoundError, 500);
|
||||
expect(err).toBeInstanceOf(BackendError);
|
||||
expect(err.name).toBe("BackendError");
|
||||
});
|
||||
|
||||
it("should return an unauthorized error for status code 403", () => {
|
||||
const err = createBackendError(earthNotFoundError, 403);
|
||||
expect(err).toBeInstanceOf(UnauthorizedError);
|
||||
expect(err.name).toBe("UnauthorizedError");
|
||||
});
|
||||
|
||||
it("should return an not found error for status code 404", () => {
|
||||
const err = createBackendError(earthNotFoundError, 404);
|
||||
expect(err).toBeInstanceOf(NotFoundError);
|
||||
expect(err.name).toBe("NotFoundError");
|
||||
});
|
||||
|
||||
});
|
||||
@@ -26,7 +26,8 @@ export { getPageFromMatch } from "./urls";
|
||||
export { default as Autocomplete} from "./Autocomplete";
|
||||
export { default as BranchSelector } from "./BranchSelector";
|
||||
|
||||
export { apiClient, NOT_FOUND_ERROR, UNAUTHORIZED_ERROR, CONFLICT_ERROR } from "./apiclient.js";
|
||||
export { apiClient } from "./apiclient.js";
|
||||
export * from "./errors";
|
||||
|
||||
export * from "./avatar";
|
||||
export * from "./buttons";
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"prefix": "Fehler",
|
||||
"loginLink": "Erneute Anmeldung",
|
||||
"timeout": "Die Session ist abgelaufen.",
|
||||
"wrong-login-credentials": "Ungültige Anmeldedaten"
|
||||
"wrong-login-credentials": "Ungültige Anmeldedaten",
|
||||
"forbidden": "Sie haben nicht die Berechtigung, diesen Datensatz zu sehen"
|
||||
},
|
||||
"loading": {
|
||||
"alt": "Lade ..."
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"config-form": {
|
||||
"submit": "Speichern",
|
||||
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
|
||||
"no-permission-notification": "Hinweis: Es fehlen Berechtigungen zum Bearbeiten der Einstellungen!"
|
||||
"no-read-permission-notification": "Hinweis: Es fehlen Berechtigungen zum Lesen der Einstellungen!",
|
||||
"no-write-permission-notification": "Hinweis: Es fehlen Berechtigungen zum Bearbeiten der Einstellungen!"
|
||||
},
|
||||
"proxy-settings": {
|
||||
"name": "Proxy Einstellungen",
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"prefix": "Error",
|
||||
"loginLink": "You can login here again.",
|
||||
"timeout": "The session has expired",
|
||||
"wrong-login-credentials": "Invalid credentials"
|
||||
"wrong-login-credentials": "Invalid credentials",
|
||||
"forbidden": "You don't have permission to view this entity"
|
||||
},
|
||||
"loading": {
|
||||
"alt": "Loading ..."
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"config-form": {
|
||||
"submit": "Submit",
|
||||
"submit-success-notification": "Configuration changed successfully!",
|
||||
"no-permission-notification": "Please note: You do not have the permission to edit the config!"
|
||||
"no-read-permission-notification": "Please note: You do not have the permission to see the config!",
|
||||
"no-write-permission-notification": "Please note: You do not have the permission to edit the config!"
|
||||
},
|
||||
"proxy-settings": {
|
||||
"name": "Proxy Settings",
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
config?: Config,
|
||||
loading?: boolean,
|
||||
t: string => string,
|
||||
configReadPermission: boolean,
|
||||
configUpdatePermission: boolean
|
||||
};
|
||||
|
||||
@@ -84,16 +85,30 @@ class ConfigForm extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, t, configUpdatePermission } = this.props;
|
||||
const {
|
||||
loading,
|
||||
t,
|
||||
configReadPermission,
|
||||
configUpdatePermission
|
||||
} = this.props;
|
||||
const config = this.state.config;
|
||||
|
||||
let noPermissionNotification = null;
|
||||
|
||||
if (!configReadPermission) {
|
||||
return (
|
||||
<Notification
|
||||
type={"danger"}
|
||||
children={t("config-form.no-read-permission-notification")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (this.state.showNotification) {
|
||||
noPermissionNotification = (
|
||||
<Notification
|
||||
type={"info"}
|
||||
children={t("config-form.no-permission-notification")}
|
||||
children={t("config-form.no-write-permission-notification")}
|
||||
onClose={() => this.onClose()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
import { translate } from "react-i18next";
|
||||
import { Title, ErrorPage, Loading } from "@scm-manager/ui-components";
|
||||
import { Title, Loading, ErrorNotification } from "@scm-manager/ui-components";
|
||||
import {
|
||||
fetchConfig,
|
||||
getFetchConfigFailure,
|
||||
@@ -35,6 +35,7 @@ type Props = {
|
||||
};
|
||||
|
||||
type State = {
|
||||
configReadPermission: boolean,
|
||||
configChanged: boolean
|
||||
};
|
||||
|
||||
@@ -43,13 +44,18 @@ class GlobalConfig extends React.Component<Props, State> {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
configReadPermission: true,
|
||||
configChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.configReset();
|
||||
this.props.fetchConfig(this.props.configLink);
|
||||
if (this.props.configLink) {
|
||||
this.props.fetchConfig(this.props.configLink);
|
||||
} else {
|
||||
this.setState({configReadPermission: false});
|
||||
}
|
||||
}
|
||||
|
||||
modifyConfig = (config: Config) => {
|
||||
@@ -73,18 +79,8 @@ class GlobalConfig extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, error, loading, config, configUpdatePermission } = this.props;
|
||||
const { t, loading } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("config.errorTitle")}
|
||||
subtitle={t("config.errorSubtitle")}
|
||||
error={error}
|
||||
configUpdatePermission={configUpdatePermission}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return <Loading />;
|
||||
}
|
||||
@@ -92,16 +88,39 @@ class GlobalConfig extends React.Component<Props, State> {
|
||||
return (
|
||||
<div>
|
||||
<Title title={t("config.title")} />
|
||||
{this.renderConfigChangedNotification()}
|
||||
<ConfigForm
|
||||
submitForm={config => this.modifyConfig(config)}
|
||||
config={config}
|
||||
loading={loading}
|
||||
configUpdatePermission={configUpdatePermission}
|
||||
/>
|
||||
{this.renderError()}
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderError = () => {
|
||||
const { error } = this.props;
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
renderContent = () => {
|
||||
const { error, loading, config, configUpdatePermission } = this.props;
|
||||
const { configReadPermission } = this.state;
|
||||
if (!error) {
|
||||
return (
|
||||
<>
|
||||
{this.renderConfigChangedNotification()}
|
||||
<ConfigForm
|
||||
submitForm={config => this.modifyConfig(config)}
|
||||
config={config}
|
||||
loading={loading}
|
||||
configUpdatePermission={configUpdatePermission}
|
||||
configReadPermission={configReadPermission}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
|
||||
@@ -15,8 +15,7 @@ import {
|
||||
InputField,
|
||||
SubmitButton,
|
||||
ErrorNotification,
|
||||
Image,
|
||||
UNAUTHORIZED_ERROR
|
||||
Image, UnauthorizedError
|
||||
} from "@scm-manager/ui-components";
|
||||
import classNames from "classnames";
|
||||
import { getLoginLink } from "../modules/indexResource";
|
||||
@@ -95,7 +94,7 @@ class Login extends React.Component<Props, State> {
|
||||
|
||||
areCredentialsInvalid() {
|
||||
const { t, error } = this.props;
|
||||
if (error === UNAUTHORIZED_ERROR) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return new Error(t("error-notification.wrong-login-credentials"));
|
||||
} else {
|
||||
return error;
|
||||
|
||||
@@ -78,13 +78,7 @@ class Groups extends React.Component<Props> {
|
||||
<GroupTable groups={groups} />
|
||||
{this.renderPaginator()}
|
||||
{this.renderCreateButton()}
|
||||
<PageActions>
|
||||
<Button
|
||||
label={t("create-group-button.label")}
|
||||
link="/groups/add"
|
||||
color="primary"
|
||||
/>
|
||||
</PageActions>
|
||||
{this.renderPageActionCreateButton()}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +93,25 @@ class Groups extends React.Component<Props> {
|
||||
|
||||
renderCreateButton() {
|
||||
if (this.props.canAddGroups) {
|
||||
return <CreateGroupButton />;
|
||||
return (
|
||||
<CreateGroupButton />
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderPageActionCreateButton() {
|
||||
if (this.props.canAddGroups) {
|
||||
return (
|
||||
<PageActions>
|
||||
<Button
|
||||
label={this.props.t("create-group-button.label")}
|
||||
link="/groups/add"
|
||||
color="primary"
|
||||
/>
|
||||
</PageActions>
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ export function fetchGroupsByLink(link: string) {
|
||||
.then(data => {
|
||||
dispatch(fetchGroupsSuccess(data));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchGroupsFailure(link, err));
|
||||
.catch(error => {
|
||||
dispatch(fetchGroupsFailure(link, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -104,8 +104,8 @@ function fetchGroup(link: string, name: string) {
|
||||
.then(data => {
|
||||
dispatch(fetchGroupSuccess(data));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchGroupFailure(name, err));
|
||||
.catch(error => {
|
||||
dispatch(fetchGroupFailure(name, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -149,12 +149,8 @@ export function createGroup(link: string, group: Group, callback?: () => void) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(
|
||||
createGroupFailure(
|
||||
err
|
||||
)
|
||||
);
|
||||
.catch(error => {
|
||||
dispatch(createGroupFailure(error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -199,13 +195,8 @@ export function modifyGroup(group: Group, callback?: () => void) {
|
||||
.then(() => {
|
||||
dispatch(fetchGroupByLink(group));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(
|
||||
modifyGroupFailure(
|
||||
group,
|
||||
err
|
||||
)
|
||||
);
|
||||
.catch(error => {
|
||||
dispatch(modifyGroupFailure(group, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -257,8 +248,8 @@ export function deleteGroup(group: Group, callback?: () => void) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(deleteGroupFailure(group, err));
|
||||
.catch(error => {
|
||||
dispatch(deleteGroupFailure(group, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -342,7 +333,7 @@ function listReducer(state: any = {}, action: any = {}) {
|
||||
...state,
|
||||
entries: groupNames,
|
||||
entry: {
|
||||
groupCreatePermission: action.payload._links.create ? true : false,
|
||||
groupCreatePermission: !!action.payload._links.create,
|
||||
page: action.payload.page,
|
||||
pageTotal: action.payload.pageTotal,
|
||||
_links: action.payload._links
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Me } from "@scm-manager/ui-types";
|
||||
import * as types from "./types";
|
||||
|
||||
import { apiClient, UNAUTHORIZED_ERROR } from "@scm-manager/ui-components";
|
||||
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
|
||||
import { isPending } from "./pending";
|
||||
import { getFailure } from "./failure";
|
||||
import {
|
||||
@@ -152,7 +152,7 @@ export const login = (
|
||||
dispatch(loginPending());
|
||||
return apiClient
|
||||
.post(loginLink, login_data)
|
||||
.then(response => {
|
||||
.then(() => {
|
||||
dispatch(fetchIndexResourcesPending());
|
||||
return callFetchIndexResources();
|
||||
})
|
||||
@@ -178,7 +178,7 @@ export const fetchMe = (link: string) => {
|
||||
dispatch(fetchMeSuccess(me));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (error === UNAUTHORIZED_ERROR) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
dispatch(fetchMeUnauthenticated());
|
||||
} else {
|
||||
dispatch(fetchMeFailure(error));
|
||||
|
||||
@@ -179,9 +179,7 @@ describe("auth actions", () => {
|
||||
});
|
||||
|
||||
it("should dispatch fetch me unauthorized", () => {
|
||||
fetchMock.getOnce("/api/v2/me", {
|
||||
status: 401
|
||||
});
|
||||
fetchMock.getOnce("/api/v2/me", 401);
|
||||
|
||||
const expectedActions = [
|
||||
{ type: FETCH_ME_PENDING },
|
||||
|
||||
@@ -73,13 +73,7 @@ class Overview extends React.Component<Props> {
|
||||
error={error}
|
||||
>
|
||||
{this.renderList()}
|
||||
<PageActions>
|
||||
<Button
|
||||
label={t("overview.createButton")}
|
||||
link="/repos/create"
|
||||
color="primary"
|
||||
/>
|
||||
</PageActions>
|
||||
{this.renderPageActionCreateButton()}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -107,6 +101,22 @@ class Overview extends React.Component<Props> {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
renderPageActionCreateButton() {
|
||||
const { showCreateButton, t } = this.props;
|
||||
if (showCreateButton) {
|
||||
return (
|
||||
<PageActions>
|
||||
<Button
|
||||
label={t("overview.createButton")}
|
||||
link="/repos/create"
|
||||
color="primary"
|
||||
/>
|
||||
</PageActions>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const getPageFromProps = props => {
|
||||
|
||||
@@ -12,13 +12,13 @@ import { Route, Switch } from "react-router-dom";
|
||||
import type { Repository } from "@scm-manager/ui-types";
|
||||
|
||||
import {
|
||||
ErrorPage,
|
||||
CollapsibleErrorPage,
|
||||
Loading,
|
||||
Navigation,
|
||||
SubNavigation,
|
||||
NavLink,
|
||||
Page,
|
||||
Section
|
||||
Section, ErrorPage
|
||||
} from "@scm-manager/ui-components";
|
||||
import { translate } from "react-i18next";
|
||||
import RepositoryDetails from "../components/RepositoryDetails";
|
||||
@@ -82,13 +82,11 @@ class RepositoryRoot extends React.Component<Props> {
|
||||
const { loading, error, indexLinks, repository, t } = this.props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={t("repositoryRoot.errorTitle")}
|
||||
subtitle={t("repositoryRoot.errorSubtitle")}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
return <ErrorPage
|
||||
title={t("repositoryRoot.errorTitle")}
|
||||
subtitle={t("repositoryRoot.errorSubtitle")}
|
||||
error={error}
|
||||
/>
|
||||
}
|
||||
|
||||
if (!repository || loading) {
|
||||
|
||||
@@ -229,8 +229,8 @@ export function modifyRepo(repository: Repository, callback?: () => void) {
|
||||
.then(() => {
|
||||
dispatch(fetchRepoByLink(repository));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(modifyRepoFailure(repository, err));
|
||||
.catch(error => {
|
||||
dispatch(modifyRepoFailure(repository, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,10 +37,7 @@ function fetchRepositoryTypes(dispatch: any) {
|
||||
.then(repositoryTypes => {
|
||||
dispatch(fetchRepositoryTypesSuccess(repositoryTypes));
|
||||
})
|
||||
.catch(err => {
|
||||
const error = new Error(
|
||||
`failed to fetch repository types: ${err.message}`
|
||||
);
|
||||
.catch(error => {
|
||||
dispatch(fetchRepositoryTypesFailure(error));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,7 +451,10 @@ function deletePermissionFromState(
|
||||
) {
|
||||
let newPermission = [];
|
||||
for (let i = 0; i < oldPermissions.length; i++) {
|
||||
if (oldPermissions[i] !== permission) {
|
||||
if (
|
||||
oldPermissions[i].name !== permission.name ||
|
||||
oldPermissions[i].groupPermission !== permission.groupPermission
|
||||
) {
|
||||
newPermission.push(oldPermissions[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SourcesView from "./SourcesView";
|
||||
import HistoryView from "./HistoryView";
|
||||
import { getSources } from "../modules/sources";
|
||||
import { connect } from "react-redux";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
loading: boolean,
|
||||
@@ -103,7 +104,7 @@ class Content extends React.Component<Props, State> {
|
||||
|
||||
showMoreInformation() {
|
||||
const collapsed = this.state.collapsed;
|
||||
const { classes, file, revision, t } = this.props;
|
||||
const { classes, file, revision, t, repository } = this.props;
|
||||
const date = <DateFromNow date={file.lastModified} />;
|
||||
const description = file.description ? (
|
||||
<p>
|
||||
@@ -120,12 +121,7 @@ class Content extends React.Component<Props, State> {
|
||||
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
|
||||
if (!collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"panel-block",
|
||||
classes.hasBackground
|
||||
)}
|
||||
>
|
||||
<div className={classNames("panel-block", classes.hasBackground)}>
|
||||
<table className={classNames("table", classes.hasBackground)}>
|
||||
<tbody>
|
||||
<tr>
|
||||
@@ -148,6 +144,11 @@ class Content extends React.Component<Props, State> {
|
||||
<td>{t("sources.content.description")}</td>
|
||||
<td className="is-word-break">{description}</td>
|
||||
</tr>
|
||||
<ExtensionPoint
|
||||
name="repos.content.metadata"
|
||||
renderAll={true}
|
||||
props={{ file, repository, revision }}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -78,13 +78,7 @@ class Users extends React.Component<Props> {
|
||||
<UserTable users={users} />
|
||||
{this.renderPaginator()}
|
||||
{this.renderCreateButton()}
|
||||
<PageActions>
|
||||
<Button
|
||||
label={t("users.createButton")}
|
||||
link="/users/add"
|
||||
color="primary"
|
||||
/>
|
||||
</PageActions>
|
||||
{this.renderPageActionCreateButton()}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -105,6 +99,23 @@ class Users extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderPageActionCreateButton() {
|
||||
const { t } = this.props;
|
||||
if (this.props.canAddUsers) {
|
||||
return (
|
||||
<PageActions>
|
||||
<Button
|
||||
label={t("users.createButton")}
|
||||
link="/users/add"
|
||||
color="primary"
|
||||
/>
|
||||
</PageActions>
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getPageFromProps = props => {
|
||||
|
||||
@@ -35,6 +35,8 @@ export const DELETE_USER_FAILURE = `${DELETE_USER}_${types.FAILURE_SUFFIX}`;
|
||||
|
||||
const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
|
||||
|
||||
// TODO i18n for error messages
|
||||
|
||||
// fetch users
|
||||
|
||||
export function fetchUsers(link: string) {
|
||||
@@ -55,8 +57,8 @@ export function fetchUsersByLink(link: string) {
|
||||
.then(data => {
|
||||
dispatch(fetchUsersSuccess(data));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchUsersFailure(link, err));
|
||||
.catch(error => {
|
||||
dispatch(fetchUsersFailure(link, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -105,8 +107,8 @@ function fetchUser(link: string, name: string) {
|
||||
.then(data => {
|
||||
dispatch(fetchUserSuccess(data));
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(fetchUserFailure(name, err));
|
||||
.catch(error => {
|
||||
dispatch(fetchUserFailure(name, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -151,7 +153,9 @@ export function createUser(link: string, user: User, callback?: () => void) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => dispatch(createUserFailure(err)));
|
||||
.catch(error =>
|
||||
dispatch(createUserFailure(error))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,8 +254,8 @@ export function deleteUser(user: User, callback?: () => void) {
|
||||
callback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
dispatch(deleteUserFailure(user, err));
|
||||
.catch(error => {
|
||||
dispatch(deleteUserFailure(user, error));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package sonia.scm.api.v2;
|
||||
|
||||
import org.jboss.resteasy.api.validation.ResteasyViolationException;
|
||||
import sonia.scm.api.v2.resources.ResteasyViolationExceptionToErrorDtoMapper;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
@@ -23,7 +23,7 @@ public class ResteasyValidationExceptionMapper implements ExceptionMapper<Restea
|
||||
public Response toResponse(ResteasyViolationException exception) {
|
||||
return Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.type(VndMediaType.ERROR_TYPE)
|
||||
.entity(mapper.map(exception))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package sonia.scm.api.v2;
|
||||
|
||||
import sonia.scm.ScmConstraintViolationException;
|
||||
import sonia.scm.api.v2.resources.ScmViolationExceptionToErrorDtoMapper;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.ext.ExceptionMapper;
|
||||
import javax.ws.rs.ext.Provider;
|
||||
@@ -23,7 +23,7 @@ public class ScmConstraintValidationExceptionMapper implements ExceptionMapper<S
|
||||
public Response toResponse(ScmConstraintViolationException exception) {
|
||||
return Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.type(MediaType.APPLICATION_JSON_TYPE)
|
||||
.type(VndMediaType.ERROR_TYPE)
|
||||
.entity(mapper.map(exception))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ public class BranchRootResource {
|
||||
.build();
|
||||
} catch (CommandNotSupportedException ex) {
|
||||
return Response.status(Response.Status.BAD_REQUEST).build();
|
||||
} catch (NotFoundException e) {
|
||||
return Response.status(Response.Status.NOT_FOUND).build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,8 @@ import org.mapstruct.Mapper;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import sonia.scm.repository.RepositoryPermission;
|
||||
|
||||
@Mapper(collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE)
|
||||
@Mapper( collectionMappingStrategy = CollectionMappingStrategy.TARGET_IMMUTABLE)
|
||||
public abstract class RepositoryPermissionDtoToRepositoryPermissionMapper {
|
||||
|
||||
public abstract RepositoryPermission map(RepositoryPermissionDto permissionDto);
|
||||
|
||||
/**
|
||||
* this method is needed to modify an existing permission object
|
||||
*
|
||||
* @param target the target permission
|
||||
* @param repositoryPermissionDto the source dto
|
||||
* @return the mapped target permission object
|
||||
*/
|
||||
public abstract void modify(@MappingTarget RepositoryPermission target, RepositoryPermissionDto repositoryPermissionDto);
|
||||
|
||||
}
|
||||
|
||||
@@ -177,7 +177,11 @@ public class RepositoryPermissionRootResource {
|
||||
.filter(filterPermission(permissionName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> notFound(entity(RepositoryPermission.class, namespace).in(Repository.class, namespace + "/" + name)));
|
||||
dtoToModelMapper.modify(existingPermission, permission);
|
||||
RepositoryPermission newPermission = dtoToModelMapper.map(permission);
|
||||
if (!repository.removePermission(existingPermission)) {
|
||||
throw new IllegalStateException(String.format("could not delete modified permission %s from repository %s/%s", existingPermission, namespace, name));
|
||||
}
|
||||
repository.addPermission(newPermission);
|
||||
manager.modify(repository);
|
||||
log.info("the permission with name: {} is updated.", permissionName);
|
||||
return Response.noContent().build();
|
||||
@@ -210,7 +214,7 @@ public class RepositoryPermissionRootResource {
|
||||
.findFirst()
|
||||
.ifPresent(repository::removePermission);
|
||||
manager.modify(repository);
|
||||
log.info("the permission with name: {} is updated.", permissionName);
|
||||
log.info("the permission with name: {} is deleted.", permissionName);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ public class RepositoryResource {
|
||||
|
||||
private Repository processUpdate(RepositoryDto repositoryDto, Repository existing) {
|
||||
Repository changedRepository = dtoToRepositoryMapper.map(repositoryDto, existing.getId());
|
||||
changedRepository.setPermissions(existing.getPermissions());
|
||||
return changedRepository;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
<permission>
|
||||
<value>group:*</value>
|
||||
</permission>
|
||||
<permission>
|
||||
<value>permission:*</value>
|
||||
</permission>
|
||||
<permission>
|
||||
<value>configuration:list</value>
|
||||
</permission>
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
"description": "Darf Gruppen administrieren."
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"*": {
|
||||
"displayName": "Berechtigungen verwalten",
|
||||
"description": "Darf Berechtigungen verwalten (benötigt zusätzlich 'Benutzer administrieren' bzw. 'Gruppen administrieren')."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"list": {
|
||||
"displayName": "Basis für Administration",
|
||||
@@ -55,15 +61,15 @@
|
||||
"verbs": {
|
||||
"repository": {
|
||||
"read": {
|
||||
"displayName": "Lesen",
|
||||
"displayName": "Repository Lesen",
|
||||
"description": "Darf das Repository im SCM-Manager sehen."
|
||||
},
|
||||
"modify": {
|
||||
"displayName": "Modifizieren",
|
||||
"displayName": "Repository Modifizieren",
|
||||
"description": "Darf die Eigenschaften des Repository verändern."
|
||||
},
|
||||
"delete": {
|
||||
"displayName": "Löschen",
|
||||
"displayName": "Repository Löschen",
|
||||
"description": "Darf das Repository löschen."
|
||||
},
|
||||
"pull": {
|
||||
@@ -87,5 +93,55 @@
|
||||
"description": "Darf im Repository Kontext alles ausführen. Dies beinhaltet alle Repository Berechtigungen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"context": "Kontext",
|
||||
"errorCode": "Fehlercode",
|
||||
"transactionId": "Transaktions-ID",
|
||||
"moreInfo": "Für mehr Informationen, siehe",
|
||||
"AGR7UzkhA1": {
|
||||
"displayName": "Nicht gefunden",
|
||||
"description": "Der gewünschte Datensatz konnte nicht gefunden werden. Möglicherweise wurde er in einer weiteren Session gelöscht."
|
||||
},
|
||||
"FtR7UznKU1": {
|
||||
"displayName": "Existiert bereits",
|
||||
"description": "Ein Datensatz mit den gegebenen Schlüsselwerten existiert bereits"
|
||||
},
|
||||
"9BR7qpDAe1": {
|
||||
"displayName": "Passwortänderung nicht erlaubt",
|
||||
"description": "Sie haben nicht die Berechtigung, das Passwort zu ändern"
|
||||
},
|
||||
"2wR7UzpPG1": {
|
||||
"displayName": "Konkurrierende Änderungen",
|
||||
"description": "Der Datensatz wurde konkurrierend von einem anderen Benutzer oder einem anderen Prozess modifiziert. Bitte laden sie die Daten erneut."
|
||||
},
|
||||
"9SR8G0kmU1": {
|
||||
"displayName": "Feature nicht unterstützt",
|
||||
"description": "Das Versionsverwaltungssystem dieses Repositories unterstützt das angefragte Feature nicht."
|
||||
},
|
||||
"CmR8GCJb31": {
|
||||
"displayName": "Interner Serverfehler",
|
||||
"description": "Im Server ist ein interner Fehler aufgetreten. Bitte wenden Sie sich an ihren Administrator für weitere Hinweise."
|
||||
},
|
||||
"92RCCCMHO1": {
|
||||
"displayName": "Eine interne URL wurde nicht gefunden",
|
||||
"description": "Ein interner Serveraufruf konnte nicht verarbeitet werden. Bitte wenden Sie sich an ihren Administrator für weitere Hinweise."
|
||||
},
|
||||
"2VRCrvpL71": {
|
||||
"displayName": "Ungültiges Datenformat",
|
||||
"description": "Die zum Server gesendeten Daten konnten nicht verarbeitet werden. Bitte prüfen Sie die eingegebenen Werte oder wenden Sie sich an ihren Administrator für weitere Hinweise."
|
||||
},
|
||||
"8pRBYDURx1": {
|
||||
"displayName": "Ungültiger Datentyp",
|
||||
"description": "Die zum Server gesendeten Daten hatten einen ungültigen Typen. Bitte wenden Sie sich an ihren Administrator für weitere Hinweise."
|
||||
},
|
||||
"1wR7ZBe7H1": {
|
||||
"displayName": "Ungültige Eingabe",
|
||||
"description": "Die eingegebenen Daten konnten nicht validiert werden. Bitte korrigieren Sie die Eingaben und senden Sie sie erneut."
|
||||
},
|
||||
"3zR9vPNIE1": {
|
||||
"displayName": "Ungültige Eingabe",
|
||||
"description": "Die eingegebenen Daten konnten nicht validiert werden. Bitte korrigieren Sie die Eingaben und senden Sie sie erneut."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
"description": "May administer all groups"
|
||||
}
|
||||
},
|
||||
"permission": {
|
||||
"*": {
|
||||
"displayName": "Administer permissions",
|
||||
"description": "May administer permissions (additionally needs 'administer users' and/or 'administer groups')."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"list": {
|
||||
"displayName": "Basic administration",
|
||||
@@ -55,23 +61,23 @@
|
||||
"verbs": {
|
||||
"repository": {
|
||||
"read": {
|
||||
"displayName": "read",
|
||||
"displayName": "read repository",
|
||||
"description": "May see the repository inside the SCM-Manager"
|
||||
},
|
||||
"modify": {
|
||||
"displayName": "modify",
|
||||
"description": "May modify the properties of the repository"
|
||||
"displayName": "modify repository metadata",
|
||||
"description": "May modify the basic properties of the repository"
|
||||
},
|
||||
"delete": {
|
||||
"displayName": "delete",
|
||||
"displayName": "delete repository",
|
||||
"description": "May delete the repository"
|
||||
},
|
||||
"pull": {
|
||||
"displayName": "pull/checkout",
|
||||
"displayName": "pull/checkout repository",
|
||||
"description": "May pull/checkout the repository"
|
||||
},
|
||||
"push": {
|
||||
"displayName": "push/commit",
|
||||
"displayName": "push/commit repository",
|
||||
"description": "May change the content of the repository (push/commit)"
|
||||
},
|
||||
"permissionRead": {
|
||||
@@ -83,9 +89,59 @@
|
||||
"description": "May modify the permissions of the repository"
|
||||
},
|
||||
"*": {
|
||||
"displayName": "overall",
|
||||
"displayName": "own repository",
|
||||
"description": "May change everything for the repository (includes all other permissions)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"context": "Context",
|
||||
"errorCode": "Error Code",
|
||||
"transactionId": "Transaction ID",
|
||||
"moreInfo": "For more information, see",
|
||||
"AGR7UzkhA1": {
|
||||
"displayName": "Not found",
|
||||
"description": "The requested entity could not be found. It may have been deleted in another session."
|
||||
},
|
||||
"FtR7UznKU1": {
|
||||
"displayName": "Already exists",
|
||||
"description": "There is already an entity with the same key values."
|
||||
},
|
||||
"9BR7qpDAe1": {
|
||||
"displayName": "Password change not allowed",
|
||||
"description": "You do not have the permission to change the password."
|
||||
},
|
||||
"2wR7UzpPG1": {
|
||||
"displayName": "Concurrent modifications",
|
||||
"description": "The entity has been modified concurrently by another user or another process. Please reload the entity."
|
||||
},
|
||||
"9SR8G0kmU1": {
|
||||
"displayName": "Feature not supported",
|
||||
"description": "The version control system for this repository does not support the requested feature."
|
||||
},
|
||||
"CmR8GCJb31": {
|
||||
"displayName": "Internal server error",
|
||||
"description": "The server encountered an internal error. Please contact your administrator for further assistance."
|
||||
},
|
||||
"92RCCCMHO1": {
|
||||
"displayName": "An internal URL could not be found",
|
||||
"description": "An internal request could not be handled by the server. Please contact your administrator for further assistance."
|
||||
},
|
||||
"2VRCrvpL71": {
|
||||
"displayName": "Illegal data format",
|
||||
"description": "The data sent to the server could not be handled. Please check the values you have entered or contact your administrator for further assistance."
|
||||
},
|
||||
"8pRBYDURx1": {
|
||||
"displayName": "Illegal data type",
|
||||
"description": "The data sent to the server had an illegal data type. Please contact your administrator for further assistance."
|
||||
},
|
||||
"1wR7ZBe7H1": {
|
||||
"displayName": "Illegal input",
|
||||
"description": "The values could not be validated. Please correct your input and try again."
|
||||
},
|
||||
"3zR9vPNIE1": {
|
||||
"displayName": "Illegal input",
|
||||
"description": "The values could not be validated. Please correct your input and try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
</appender>
|
||||
|
||||
<logger name="sonia.scm" level="TRACE" />
|
||||
|
||||
<logger name="com.cloudogu.scm" level="TRACE" />
|
||||
|
||||
<logger name="sonia.scm.security.AuthorizationCollector" level="DEBUG" />
|
||||
<logger name="sonia.scm.web.filter.AutoLoginFilter" level="DEBUG" />
|
||||
<logger name="sonia.scm.security.XsrfProtectionFilter" level="DEBUG" />
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
</appender>
|
||||
|
||||
<logger name="sonia.scm" level="INFO" />
|
||||
|
||||
<logger name="com.cloudogu.scm" level="INFO" />
|
||||
|
||||
<!-- suppress massive gzip logging -->
|
||||
<logger name="sonia.scm.filter.GZipFilter" level="WARN" />
|
||||
<logger name="sonia.scm.filter.GZipResponseStream" level="WARN" />
|
||||
|
||||
@@ -301,11 +301,18 @@ public class RepositoryPermissionRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
@Test
|
||||
public void shouldGetUpdatedPermissions() throws URISyntaxException {
|
||||
createUserWithRepositoryAndPermissions(TEST_PERMISSIONS, PERMISSION_WRITE);
|
||||
RepositoryPermission modifiedPermission = TEST_PERMISSIONS.get(0);
|
||||
// modify the type to owner
|
||||
modifiedPermission.setVerbs(new ArrayList<>(singletonList("*")));
|
||||
ImmutableList<RepositoryPermission> expectedPermissions = ImmutableList.copyOf(TEST_PERMISSIONS);
|
||||
ArrayList<RepositoryPermission> permissions = Lists
|
||||
.newArrayList(
|
||||
new RepositoryPermission("user_write", asList("*"), false),
|
||||
new RepositoryPermission("user_read", singletonList("read"), false),
|
||||
new RepositoryPermission("user_owner", singletonList("*"), false),
|
||||
new RepositoryPermission("group_read", singletonList("read"), true),
|
||||
new RepositoryPermission("group_write", asList("read", "modify"), true),
|
||||
new RepositoryPermission("group_owner", singletonList("*"), true)
|
||||
);
|
||||
createUserWithRepositoryAndPermissions(permissions, PERMISSION_WRITE);
|
||||
RepositoryPermission modifiedPermission = permissions.get(0);
|
||||
ImmutableList<RepositoryPermission> expectedPermissions = ImmutableList.copyOf(permissions);
|
||||
assertExpectedRequest(requestPUTPermission
|
||||
.content("{\"name\" : \"" + modifiedPermission.getName() + "\" , \"verbs\" : [\"*\"], \"groupPermission\" : false}")
|
||||
.path(PATH_OF_ALL_PERMISSIONS + modifiedPermission.getName())
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
</appender>
|
||||
|
||||
<logger name="sonia.scm" level="DEBUG" />
|
||||
|
||||
<logger name="com.cloudogu.scm" level="DEBUG" />
|
||||
|
||||
<!-- suppress massive gzip logging -->
|
||||
<logger name="sonia.scm.filter.GZipFilter" level="WARN" />
|
||||
<logger name="sonia.scm.filter.GZipResponseStream" level="WARN" />
|
||||
@@ -86,4 +87,4 @@
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
</configuration>
|
||||
|
||||
Reference in New Issue
Block a user