mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-15 09:46:16 +01:00
merge
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
import React from "react";
|
||||
import Button, { type ButtonProps } from "./Button";
|
||||
|
||||
class SubmitButton extends React.Component<ButtonProps> {
|
||||
type SubmitButtonProps = ButtonProps & {
|
||||
scrollToTop: boolean
|
||||
}
|
||||
|
||||
class SubmitButton extends React.Component<SubmitButtonProps> {
|
||||
static defaultProps = {
|
||||
scrollToTop: true
|
||||
};
|
||||
|
||||
render() {
|
||||
const { action } = this.props;
|
||||
const { action, scrollToTop } = this.props;
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -12,9 +20,11 @@ class SubmitButton extends React.Component<ButtonProps> {
|
||||
{...this.props}
|
||||
action={(event) => {
|
||||
if (action) {
|
||||
action(event)
|
||||
action(event);
|
||||
}
|
||||
if (scrollToTop) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,6 +52,13 @@ export class NotFoundError extends BackendError {
|
||||
super(content, "NotFoundError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends BackendError {
|
||||
constructor(content: BackendErrorContent, statusCode: number) {
|
||||
super(content, "ConflictError", statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackendError(
|
||||
content: BackendErrorContent,
|
||||
statusCode: number
|
||||
@@ -59,6 +66,8 @@ export function createBackendError(
|
||||
switch (statusCode) {
|
||||
case 404:
|
||||
return new NotFoundError(content, statusCode);
|
||||
case 409:
|
||||
return new ConflictError(content, statusCode);
|
||||
default:
|
||||
return new BackendError(content, "BackendError", statusCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
content: string
|
||||
};
|
||||
|
||||
class MarkdownView extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const {content } = this.props;
|
||||
return (
|
||||
<div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MarkdownView;
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
label?: string,
|
||||
placeholder?: SelectItem[],
|
||||
value?: string,
|
||||
autofocus?: boolean,
|
||||
onChange: (value: string, name?: string) => void,
|
||||
helpText?: string
|
||||
};
|
||||
@@ -19,6 +20,12 @@ type Props = {
|
||||
class Textarea extends React.Component<Props> {
|
||||
field: ?HTMLTextAreaElement;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.autofocus && this.field) {
|
||||
this.field.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput = (event: SyntheticInputEvent<HTMLTextAreaElement>) => {
|
||||
this.props.onChange(event.target.value, this.props.name);
|
||||
};
|
||||
|
||||
@@ -11,4 +11,5 @@ export { default as Textarea } from "./Textarea.js";
|
||||
export { default as PasswordConfirmation } from "./PasswordConfirmation.js";
|
||||
export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon.js";
|
||||
export { default as DropDown } from "./DropDown.js";
|
||||
export { default as MarkdownView } from "./MarkdownView.js";
|
||||
|
||||
|
||||
@@ -37,3 +37,16 @@ export * from "./layout";
|
||||
export * from "./modals";
|
||||
export * from "./navigation";
|
||||
export * from "./repos";
|
||||
|
||||
// not sure if it is required
|
||||
export type {
|
||||
File,
|
||||
FileChangeType,
|
||||
Hunk,
|
||||
Change,
|
||||
BaseContext,
|
||||
AnnotationFactory,
|
||||
AnnotationFactoryContext,
|
||||
DiffEventHandler,
|
||||
DiffEventContext
|
||||
} from "./repos";
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import DiffFile from "./DiffFile";
|
||||
import type { DiffObjectProps } from "./DiffTypes";
|
||||
|
||||
type Props = {
|
||||
diff: any,
|
||||
sideBySide: boolean
|
||||
type Props = DiffObjectProps & {
|
||||
diff: any
|
||||
};
|
||||
|
||||
class Diff extends React.Component<Props> {
|
||||
|
||||
static defaultProps = {
|
||||
sideBySide: false
|
||||
};
|
||||
|
||||
renderFile = (file: any, i: number) => {
|
||||
const { sideBySide } = this.props;
|
||||
return <DiffFile key={i} file={file} sideBySide={sideBySide} />;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { diff } = this.props;
|
||||
const { diff, ...fileProps } = this.props;
|
||||
return (
|
||||
<>
|
||||
{diff.map(this.renderFile)}
|
||||
{diff.map((file, index) => (
|
||||
<DiffFile key={index} file={file} {...fileProps} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default Diff;
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
//@flow
|
||||
import React from "react";
|
||||
import { Hunk, Diff as DiffComponent } from "react-diff-view";
|
||||
import {
|
||||
Hunk,
|
||||
Diff as DiffComponent,
|
||||
getChangeKey,
|
||||
Change,
|
||||
DiffObjectProps,
|
||||
File
|
||||
} from "react-diff-view";
|
||||
import injectSheets from "react-jss";
|
||||
import classNames from "classnames";
|
||||
import {translate} from "react-i18next";
|
||||
import { translate } from "react-i18next";
|
||||
|
||||
const styles = {
|
||||
panel: {
|
||||
fontSize: "1rem"
|
||||
},
|
||||
header: {
|
||||
titleHeader: {
|
||||
cursor: "pointer"
|
||||
},
|
||||
title: {
|
||||
@@ -18,23 +25,24 @@ const styles = {
|
||||
},
|
||||
hunkDivider: {
|
||||
margin: ".5rem 0"
|
||||
},
|
||||
changeType: {
|
||||
marginLeft: ".75rem"
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
file: any,
|
||||
sideBySide: boolean,
|
||||
type Props = DiffObjectProps & {
|
||||
file: File,
|
||||
// context props
|
||||
classes: any,
|
||||
t: string => string
|
||||
}
|
||||
};
|
||||
|
||||
type State = {
|
||||
collapsed: boolean
|
||||
}
|
||||
};
|
||||
|
||||
class DiffFile extends React.Component<Props, State> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -43,23 +51,83 @@ class DiffFile extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
toggleCollapse = () => {
|
||||
this.setState((state) => ({
|
||||
collapsed: ! state.collapsed
|
||||
this.setState(state => ({
|
||||
collapsed: !state.collapsed
|
||||
}));
|
||||
};
|
||||
|
||||
renderHunk = (hunk: any, i: number) => {
|
||||
setCollapse = (collapsed: boolean) => {
|
||||
this.setState({
|
||||
collapsed
|
||||
});
|
||||
};
|
||||
|
||||
createHunkHeader = (hunk: Hunk, i: number) => {
|
||||
const { classes } = this.props;
|
||||
let header = null;
|
||||
if (i > 0) {
|
||||
header = <hr className={classes.hunkDivider} />;
|
||||
return <hr className={classes.hunkDivider} />;
|
||||
}
|
||||
return <Hunk key={hunk.content} hunk={hunk} header={header} />;
|
||||
return null;
|
||||
};
|
||||
|
||||
collectHunkAnnotations = (hunk: Hunk) => {
|
||||
const { annotationFactory, file } = this.props;
|
||||
if (annotationFactory) {
|
||||
return annotationFactory({
|
||||
hunk,
|
||||
file
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleClickEvent = (change: Change, hunk: Hunk) => {
|
||||
const { file, onClick } = this.props;
|
||||
const context = {
|
||||
changeId: getChangeKey(change),
|
||||
change,
|
||||
hunk,
|
||||
file
|
||||
};
|
||||
if (onClick) {
|
||||
onClick(context);
|
||||
}
|
||||
};
|
||||
|
||||
createCustomEvents = (hunk: Hunk) => {
|
||||
const { onClick } = this.props;
|
||||
if (onClick) {
|
||||
return {
|
||||
gutter: {
|
||||
onClick: (change: Change) => {
|
||||
this.handleClickEvent(change, hunk);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
renderHunk = (hunk: Hunk, i: number) => {
|
||||
return (
|
||||
<Hunk
|
||||
key={hunk.content}
|
||||
hunk={hunk}
|
||||
header={this.createHunkHeader(hunk, i)}
|
||||
widgets={this.collectHunkAnnotations(hunk)}
|
||||
customEvents={this.createCustomEvents(hunk)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderFileTitle = (file: any) => {
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return (<>{file.oldPath} <i className="fa fa-arrow-right" /> {file.newPath}</>);
|
||||
if (
|
||||
file.oldPath !== file.newPath &&
|
||||
(file.type === "copy" || file.type === "rename")
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
{file.oldPath} <i className="fa fa-arrow-right" /> {file.newPath}
|
||||
</>
|
||||
);
|
||||
} else if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
@@ -73,48 +141,61 @@ class DiffFile extends React.Component<Props, State> {
|
||||
if (key === value) {
|
||||
value = file.type;
|
||||
}
|
||||
return (
|
||||
<span className="tag is-info has-text-weight-normal">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
return <span className="tag is-info has-text-weight-normal">{value}</span>;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { file, sideBySide, classes } = this.props;
|
||||
const {
|
||||
file,
|
||||
fileControlFactory,
|
||||
fileAnnotationFactory,
|
||||
sideBySide,
|
||||
classes
|
||||
} = this.props;
|
||||
const { collapsed } = this.state;
|
||||
const viewType = sideBySide ? "split" : "unified";
|
||||
|
||||
let body = null;
|
||||
let icon = "fa fa-angle-right";
|
||||
if (!collapsed) {
|
||||
const fileAnnotations = fileAnnotationFactory
|
||||
? fileAnnotationFactory(file)
|
||||
: null;
|
||||
icon = "fa fa-angle-down";
|
||||
body = (
|
||||
<div className="panel-block is-paddingless is-size-7">
|
||||
{fileAnnotations}
|
||||
<DiffComponent viewType={viewType}>
|
||||
{ file.hunks.map(this.renderHunk) }
|
||||
{file.hunks.map(this.renderHunk)}
|
||||
</DiffComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null;
|
||||
return (
|
||||
<div className={classNames("panel", classes.panel)}>
|
||||
<div className={classNames("panel-heading", classes.header)} onClick={this.toggleCollapse}>
|
||||
<div className="panel-heading">
|
||||
<div className="level">
|
||||
<div className="level-left">
|
||||
<i className={icon} /><span className={classes.title}>{this.renderFileTitle(file)}</span>
|
||||
</div>
|
||||
<div className="level-right">
|
||||
{this.renderChangeTag(file)}
|
||||
<div
|
||||
className={classNames("level-left", classes.titleHeader)}
|
||||
onClick={this.toggleCollapse}
|
||||
>
|
||||
<i className={icon} />
|
||||
<span className={classes.title}>
|
||||
{this.renderFileTitle(file)}
|
||||
</span>
|
||||
<span className={classes.changeType}>
|
||||
{this.renderChangeTag(file)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="level-right">{fileControls}</div>
|
||||
</div>
|
||||
</div>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectSheets(styles)(translate("repos")(DiffFile));
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @flow
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
// We place the types here and not in @scm-manager/ui-types,
|
||||
// because they represent not a real scm-manager related type.
|
||||
// This types represents only the required types for the Diff related components,
|
||||
// such as every other component does with its Props.
|
||||
|
||||
export type FileChangeType = "add" | "modify" | "delete" | "copy" | "rename";
|
||||
|
||||
export type File = {
|
||||
hunks: Hunk[],
|
||||
newEndingNewLine: boolean,
|
||||
newMode?: string,
|
||||
newPath: string,
|
||||
newRevision?: string,
|
||||
oldEndingNewLine: boolean,
|
||||
oldMode?: string,
|
||||
oldPath: string,
|
||||
oldRevision?: string,
|
||||
type: FileChangeType
|
||||
};
|
||||
|
||||
export type Hunk = {
|
||||
changes: Change[],
|
||||
content: string
|
||||
};
|
||||
|
||||
export type Change = {
|
||||
content: string,
|
||||
isNormal: boolean,
|
||||
newLineNumber: number,
|
||||
oldLineNumber: number,
|
||||
type: string
|
||||
};
|
||||
|
||||
export type BaseContext = {
|
||||
hunk: Hunk,
|
||||
file: File
|
||||
};
|
||||
|
||||
export type AnnotationFactoryContext = BaseContext;
|
||||
|
||||
export type FileAnnotationFactory = (file: File) => React.Node[];
|
||||
|
||||
// key = change id, value = react component
|
||||
export type AnnotationFactory = (
|
||||
context: AnnotationFactoryContext
|
||||
) => {
|
||||
[string]: any
|
||||
};
|
||||
|
||||
export type DiffEventContext = BaseContext & {
|
||||
changeId: string,
|
||||
change: Change
|
||||
};
|
||||
|
||||
export type DiffEventHandler = (context: DiffEventContext) => void;
|
||||
|
||||
export type FileControlFactory = (file: File, setCollapseState: (boolean) => void) => ?React.Node;
|
||||
|
||||
export type DiffObjectProps = {
|
||||
sideBySide: boolean,
|
||||
onClick?: DiffEventHandler,
|
||||
fileControlFactory?: FileControlFactory,
|
||||
fileAnnotationFactory?: FileAnnotationFactory,
|
||||
annotationFactory?: AnnotationFactory
|
||||
};
|
||||
@@ -6,10 +6,10 @@ import parser from "gitdiff-parser";
|
||||
|
||||
import Loading from "../Loading";
|
||||
import Diff from "./Diff";
|
||||
import type {DiffObjectProps} from "./DiffTypes";
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
sideBySide: boolean
|
||||
type Props = DiffObjectProps & {
|
||||
url: string
|
||||
};
|
||||
|
||||
type State = {
|
||||
@@ -71,7 +71,7 @@ class LoadingDiff extends React.Component<Props, State> {
|
||||
return null;
|
||||
}
|
||||
else {
|
||||
return <Diff diff={diff} />;
|
||||
return <Diff diff={diff} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
scm-ui-components/packages/ui-components/src/repos/diffs.js
Normal file
18
scm-ui-components/packages/ui-components/src/repos/diffs.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// @flow
|
||||
import type { BaseContext, File, Hunk } from "./DiffTypes";
|
||||
|
||||
export function getPath(file: File) {
|
||||
if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
}
|
||||
|
||||
export function createHunkIdentifier(file: File, hunk: Hunk) {
|
||||
const path = getPath(file);
|
||||
return `${file.type}_${path}_${hunk.content}`;
|
||||
}
|
||||
|
||||
export function createHunkIdentifierFromContext(ctx: BaseContext) {
|
||||
return createHunkIdentifier(ctx.file, ctx.hunk);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// @flow
|
||||
import type { File, FileChangeType, Hunk } from "./DiffTypes";
|
||||
import {
|
||||
getPath,
|
||||
createHunkIdentifier,
|
||||
createHunkIdentifierFromContext
|
||||
} from "./diffs";
|
||||
|
||||
describe("tests for diff util functions", () => {
|
||||
const file = (
|
||||
type: FileChangeType,
|
||||
oldPath: string,
|
||||
newPath: string
|
||||
): File => {
|
||||
return {
|
||||
hunks: [],
|
||||
type: type,
|
||||
oldPath,
|
||||
newPath,
|
||||
newEndingNewLine: true,
|
||||
oldEndingNewLine: true
|
||||
};
|
||||
};
|
||||
|
||||
const add = (path: string) => {
|
||||
return file("add", "/dev/null", path);
|
||||
};
|
||||
|
||||
const rm = (path: string) => {
|
||||
return file("delete", path, "/dev/null");
|
||||
};
|
||||
|
||||
const modify = (path: string) => {
|
||||
return file("modify", path, path);
|
||||
};
|
||||
|
||||
const createHunk = (content: string): Hunk => {
|
||||
return {
|
||||
content,
|
||||
changes: []
|
||||
};
|
||||
};
|
||||
|
||||
describe("getPath tests", () => {
|
||||
it("should pick the new path, for type add", () => {
|
||||
const file = add("/etc/passwd");
|
||||
const path = getPath(file);
|
||||
expect(path).toBe("/etc/passwd");
|
||||
});
|
||||
|
||||
it("should pick the old path, for type delete", () => {
|
||||
const file = rm("/etc/passwd");
|
||||
const path = getPath(file);
|
||||
expect(path).toBe("/etc/passwd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHunkIdentifier tests", () => {
|
||||
it("should create identifier", () => {
|
||||
const file = modify("/etc/passwd");
|
||||
const hunk = createHunk("@@ -1,18 +1,15 @@");
|
||||
const identifier = createHunkIdentifier(file, hunk);
|
||||
expect(identifier).toBe("modify_/etc/passwd_@@ -1,18 +1,15 @@");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHunkIdentifierFromContext tests", () => {
|
||||
it("should create identifier", () => {
|
||||
const identifier = createHunkIdentifierFromContext({
|
||||
file: rm("/etc/passwd"),
|
||||
hunk: createHunk("@@ -1,42 +1,39 @@")
|
||||
});
|
||||
expect(identifier).toBe("delete_/etc/passwd_@@ -1,42 +1,39 @@");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,20 @@
|
||||
// @flow
|
||||
import * as diffs from "./diffs";
|
||||
export { diffs };
|
||||
|
||||
export * from "./changesets";
|
||||
|
||||
export { default as Diff } from "./Diff";
|
||||
export { default as LoadingDiff } from "./LoadingDiff";
|
||||
|
||||
export type {
|
||||
File,
|
||||
FileChangeType,
|
||||
Hunk,
|
||||
Change,
|
||||
BaseContext,
|
||||
AnnotationFactory,
|
||||
AnnotationFactoryContext,
|
||||
DiffEventHandler,
|
||||
DiffEventContext
|
||||
} from "./DiffTypes";
|
||||
|
||||
@@ -40,7 +40,7 @@ class Main extends React.Component<Props> {
|
||||
return (
|
||||
<div className="main">
|
||||
<Switch>
|
||||
<Redirect exact path="/" to={url}/>
|
||||
<Redirect exact from="/" to={url}/>
|
||||
<Route exact path="/login" component={Login} />
|
||||
<Route path="/logout" component={Logout} />
|
||||
<ProtectedRoute
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../modules/repos";
|
||||
|
||||
import { connect } from "react-redux";
|
||||
import { Route, Switch } from "react-router-dom";
|
||||
import {Redirect, Route, Switch} from "react-router-dom";
|
||||
import type { Repository } from "@scm-manager/ui-types";
|
||||
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ import PermissionsNavLink from "../components/PermissionsNavLink";
|
||||
import Sources from "../sources/containers/Sources";
|
||||
import RepositoryNavLink from "../components/RepositoryNavLink";
|
||||
import {getLinks, getRepositoriesLink} from "../../modules/indexResource";
|
||||
import {ExtensionPoint} from "@scm-manager/ui-extensions";
|
||||
import {binder, ExtensionPoint} from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
namespace: string,
|
||||
@@ -63,7 +63,7 @@ class RepositoryRoot extends React.Component<Props> {
|
||||
|
||||
stripEndingSlash = (url: string) => {
|
||||
if (url.endsWith("/")) {
|
||||
return url.substring(0, url.length - 2);
|
||||
return url.substring(0, url.length - 1);
|
||||
}
|
||||
return url;
|
||||
};
|
||||
@@ -101,13 +101,22 @@ class RepositoryRoot extends React.Component<Props> {
|
||||
indexLinks
|
||||
};
|
||||
|
||||
const redirectUrlFactory = binder.getExtension("repository.redirect", this.props);
|
||||
let redirectedUrl;
|
||||
if (redirectUrlFactory){
|
||||
redirectedUrl = url + redirectUrlFactory(this.props);
|
||||
}else{
|
||||
redirectedUrl = url + "/info";
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={repository.namespace + "/" + repository.name}>
|
||||
<div className="columns">
|
||||
<div className="column is-three-quarters is-clipped">
|
||||
<Switch>
|
||||
<Redirect exact from={this.props.match.url} to={redirectedUrl}/>
|
||||
<Route
|
||||
path={url}
|
||||
path={`${url}/info`}
|
||||
exact
|
||||
component={() => <RepositoryDetails repository={repository} />}
|
||||
/>
|
||||
@@ -172,8 +181,13 @@ class RepositoryRoot extends React.Component<Props> {
|
||||
<div className="column">
|
||||
<Navigation>
|
||||
<Section label={t("repositoryRoot.menu.navigationLabel")}>
|
||||
<ExtensionPoint
|
||||
name="repository.navigation.topLevel"
|
||||
props={extensionProps}
|
||||
renderAll={true}
|
||||
/>
|
||||
<NavLink
|
||||
to={url}
|
||||
to={`${url}/info`}
|
||||
icon="fas fa-info-circle"
|
||||
label={t("repositoryRoot.menu.informationNavLink")}
|
||||
/>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ package sonia.scm.api.rest;
|
||||
//~--- non-JDK imports --------------------------------------------------------
|
||||
|
||||
import org.apache.shiro.authz.AuthorizationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
@@ -50,12 +52,17 @@ public class AuthorizationExceptionMapper
|
||||
extends StatusExceptionMapper<AuthorizationException>
|
||||
{
|
||||
|
||||
/**
|
||||
* Constructs ...
|
||||
*
|
||||
*/
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthorizationExceptionMapper.class);
|
||||
|
||||
public AuthorizationExceptionMapper()
|
||||
{
|
||||
super(AuthorizationException.class, Response.Status.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response toResponse(AuthorizationException exception) {
|
||||
LOG.info("user is missing permission: {}", exception.getMessage());
|
||||
LOG.trace("AuthorizationException:", exception);
|
||||
return super.toResponse(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.webcohesion.enunciate.metadata.rs.ResponseCode;
|
||||
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.http.HttpStatus;
|
||||
import sonia.scm.ConcurrentModificationException;
|
||||
import sonia.scm.repository.NamespaceAndName;
|
||||
import sonia.scm.repository.api.MergeCommandBuilder;
|
||||
import sonia.scm.repository.api.MergeCommandResult;
|
||||
@@ -73,7 +74,7 @@ public class MergeResource {
|
||||
if (mergeCommandResult.isMergeable()) {
|
||||
return Response.noContent().build();
|
||||
} else {
|
||||
return Response.status(HttpStatus.SC_CONFLICT).build();
|
||||
throw new ConcurrentModificationException("revision", mergeCommand.getTargetRevision());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,9 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper<Reposit
|
||||
linksBuilder.single(link("incomingDiff", resourceLinks.incoming().diff(repository.getNamespace(), repository.getName())));
|
||||
}
|
||||
if (repositoryService.isSupported(Command.MERGE)) {
|
||||
linksBuilder.single(link("merge", resourceLinks.merge().merge(repository.getNamespace(), repository.getName())));
|
||||
if (RepositoryPermissions.push(repository).isPermitted()) {
|
||||
linksBuilder.single(link("merge", resourceLinks.merge().merge(repository.getNamespace(), repository.getName())));
|
||||
}
|
||||
linksBuilder.single(link("mergeDryRun", resourceLinks.merge().dryRun(repository.getNamespace(), repository.getName())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user