Merge with 2.0.0-m3

This commit is contained in:
René Pfeuffer
2018-12-03 08:28:47 +01:00
29 changed files with 895 additions and 188 deletions

2
Jenkinsfile vendored
View File

@@ -15,7 +15,7 @@ node('docker') {
disableConcurrentBuilds() disableConcurrentBuilds()
]) ])
timeout(activity: true, time: 20, unit: 'MINUTES') { timeout(activity: true, time: 30, unit: 'MINUTES') {
catchError { catchError {

View File

@@ -6,6 +6,8 @@ import static java.util.Collections.unmodifiableList;
public abstract class ExceptionWithContext extends RuntimeException { public abstract class ExceptionWithContext extends RuntimeException {
private static final long serialVersionUID = 4327413456580409224L;
private final List<ContextEntry> context; private final List<ContextEntry> context;
public ExceptionWithContext(List<ContextEntry> context, String message) { public ExceptionWithContext(List<ContextEntry> context, String message) {

View File

@@ -7,6 +7,8 @@ import static java.util.stream.Collectors.joining;
public class NotFoundException extends ExceptionWithContext { public class NotFoundException extends ExceptionWithContext {
private static final long serialVersionUID = 1710455380886499111L;
private static final String CODE = "AGR7UzkhA1"; private static final String CODE = "AGR7UzkhA1";
public NotFoundException(Class type, String id) { public NotFoundException(Class type, String id) {

View File

@@ -0,0 +1,136 @@
package sonia.scm;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import static java.util.Collections.unmodifiableCollection;
/**
* Use this exception to handle invalid input values that cannot be handled using
* <a href="https://docs.oracle.com/javaee/7/tutorial/bean-validation001.htm#GIRCZ">JEE bean validation</a>.
* Use the {@link Builder} to conditionally create a new exception:
* <pre>
* Builder
* .doThrow()
* .violation("name or alias must not be empty if not anonymous", "myParameter", "name")
* .violation("name or alias must not be empty if not anonymous", "myParameter", "alias")
* .when(myParameter.getName() == null && myParameter.getAlias() == null && !myParameter.isAnonymous())
* .andThrow()
* .violation("name must be empty if anonymous", "myParameter", "name")
* .when(myParameter.getName() != null && myParameter.isAnonymous());
* </pre>
* Mind that using this way you do not have to use if-else constructs.
*/
public class ScmConstraintViolationException extends RuntimeException implements Serializable {
private static final long serialVersionUID = 6904534307450229887L;
private final Collection<ScmConstraintViolation> violations;
private final String furtherInformation;
private ScmConstraintViolationException(Collection<ScmConstraintViolation> violations, String furtherInformation) {
this.violations = violations;
this.furtherInformation = furtherInformation;
}
/**
* The violations that caused this exception.
*/
public Collection<ScmConstraintViolation> getViolations() {
return unmodifiableCollection(violations);
}
/**
* An optional URL for more informations about this constraint violation.
*/
public String getUrl() {
return furtherInformation;
}
/**
* Builder to conditionally create constraint violations.
*/
public static class Builder {
private final Collection<ScmConstraintViolation> violations = new ArrayList<>();
private String furtherInformation;
/**
* Use this to create a new builder instance.
*/
public static Builder doThrow() {
return new Builder();
}
/**
* Resets this builder to check for further violations.
* @return this builder instance.
*/
public Builder andThrow() {
this.violations.clear();
this.furtherInformation = null;
return this;
}
/**
* Describes the violation with a custom message and the affected property. When more than one property is affected,
* you can call this method multiple times.
* @param message The message describing the violation.
* @param pathElements The affected property denoted by the path to reach this property,
* eg. "someParameter", "complexProperty", "attribute"
* @return this builder instance.
*/
public Builder violation(String message, String... pathElements) {
this.violations.add(new ScmConstraintViolation(message, pathElements));
return this;
}
/**
* Use this to specify a URL with further information about this violation and hints how to solve this.
* This is optional.
* @return this builder instance.
*/
public Builder withFurtherInformation(String furtherInformation) {
this.furtherInformation = furtherInformation;
return this;
}
/**
* When the given condition is <code>true</code>, a exception will be thrown. Otherwise this simply resets this
* builder and does nothing else.
* @param condition The condition that indicates a violation of this constraint.
* @return this builder instance.
*/
public Builder when(boolean condition) {
if (condition && !this.violations.isEmpty()) {
throw new ScmConstraintViolationException(violations, furtherInformation);
}
return andThrow();
}
}
/**
* A single constraint violation.
*/
public static class ScmConstraintViolation implements Serializable {
private static final long serialVersionUID = -6900317468157084538L;
private final String message;
private final String path;
private ScmConstraintViolation(String message, String... pathElements) {
this.message = message;
this.path = String.join(".", pathElements);
}
public String getMessage() {
return message;
}
public String getPropertyPath() {
return path;
}
}
}

View File

@@ -1,8 +1,12 @@
package sonia.scm.repository; package sonia.scm.repository;
import com.google.common.base.CharMatcher;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import static com.google.common.base.Preconditions.checkArgument;
/** /**
* A Location Resolver for File based Repository Storage. * A Location Resolver for File based Repository Storage.
* <p> * <p>
@@ -19,6 +23,8 @@ public class InitialRepositoryLocationResolver {
private static final String DEFAULT_REPOSITORY_PATH = "repositories"; private static final String DEFAULT_REPOSITORY_PATH = "repositories";
private static final CharMatcher ID_MATCHER = CharMatcher.anyOf("/\\.");
/** /**
* Returns the initial path to repository. * Returns the initial path to repository.
* *
@@ -26,7 +32,10 @@ public class InitialRepositoryLocationResolver {
* *
* @return initial path of repository * @return initial path of repository
*/ */
@SuppressWarnings("squid:S2083") // path traversal is prevented with ID_MATCHER
public Path getPath(String repositoryId) { public Path getPath(String repositoryId) {
// avoid path traversal attacks
checkArgument(ID_MATCHER.matchesNoneOf(repositoryId), "repository id contains invalid characters");
return Paths.get(DEFAULT_REPOSITORY_PATH, repositoryId); return Paths.get(DEFAULT_REPOSITORY_PATH, repositoryId);
} }

View File

@@ -1,5 +1,6 @@
package sonia.scm.repository; package sonia.scm.repository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@@ -12,12 +13,33 @@ import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith({MockitoExtension.class}) @ExtendWith({MockitoExtension.class})
class InitialRepositoryLocationResolverTest { class InitialRepositoryLocationResolverTest {
private InitialRepositoryLocationResolver resolver = new InitialRepositoryLocationResolver();
@Test @Test
void shouldComputeInitialPath() { void shouldComputeInitialPath() {
InitialRepositoryLocationResolver resolver = new InitialRepositoryLocationResolver();
Path path = resolver.getPath("42"); Path path = resolver.getPath("42");
assertThat(path).isRelative(); assertThat(path).isRelative();
assertThat(path.toString()).isEqualTo("repositories" + File.separator + "42"); assertThat(path.toString()).isEqualTo("repositories" + File.separator + "42");
} }
@Test
void shouldThrowIllegalArgumentExceptionIfIdHasASlash() {
Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath("../../../passwd"));
}
@Test
void shouldThrowIllegalArgumentExceptionIfIdHasABackSlash() {
Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath("..\\..\\..\\users.ntlm"));
}
@Test
void shouldThrowIllegalArgumentExceptionIfIdIsDotDot() {
Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath(".."));
}
@Test
void shouldThrowIllegalArgumentExceptionIfIdIsDot() {
Assertions.assertThrows(IllegalArgumentException.class, () -> resolver.getPath("."));
}
} }

View File

@@ -61,7 +61,8 @@ class PathDatabase {
private void ensureParentDirectoryExists() { private void ensureParentDirectoryExists() {
Path parent = storePath.getParent(); Path parent = storePath.getParent();
if (!Files.exists(parent)) { // Files.exists is slow on java 8
if (!parent.toFile().exists()) {
try { try {
Files.createDirectories(parent); Files.createDirectories(parent);
} catch (IOException ex) { } catch (IOException ex) {

View File

@@ -47,12 +47,11 @@ import sonia.scm.store.StoreConstants;
import javax.inject.Inject; import javax.inject.Inject;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Clock; import java.time.Clock;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** /**
* @author Sebastian Sdorra * @author Sebastian Sdorra
@@ -69,49 +68,52 @@ public class XmlRepositoryDAO implements PathBasedRepositoryDAO {
private final InitialRepositoryLocationResolver locationResolver; private final InitialRepositoryLocationResolver locationResolver;
private final FileSystem fileSystem; private final FileSystem fileSystem;
@VisibleForTesting private final Map<String, Path> pathById;
Clock clock = Clock.systemUTC(); private final Map<String, Repository> byId;
private final Map<NamespaceAndName, Repository> byNamespaceAndName;
private final Clock clock;
private Long creationTime; private Long creationTime;
private Long lastModified; private Long lastModified;
private Map<String, Path> pathById;
private Map<String, Repository> byId;
private Map<NamespaceAndName, Repository> byNamespaceAndName;
@Inject @Inject
public XmlRepositoryDAO(SCMContextProvider context, InitialRepositoryLocationResolver locationResolver, FileSystem fileSystem) { public XmlRepositoryDAO(SCMContextProvider context, InitialRepositoryLocationResolver locationResolver, FileSystem fileSystem) {
this(context, locationResolver, fileSystem, Clock.systemUTC());
}
XmlRepositoryDAO(SCMContextProvider context, InitialRepositoryLocationResolver locationResolver, FileSystem fileSystem, Clock clock) {
this.context = context; this.context = context;
this.locationResolver = locationResolver; this.locationResolver = locationResolver;
this.fileSystem = fileSystem; this.fileSystem = fileSystem;
this.clock = clock;
this.creationTime = clock.millis(); this.creationTime = clock.millis();
this.pathById = new LinkedHashMap<>(); this.pathById = new ConcurrentHashMap<>();
this.byId = new LinkedHashMap<>(); this.byId = new ConcurrentHashMap<>();
this.byNamespaceAndName = new LinkedHashMap<>(); this.byNamespaceAndName = new ConcurrentHashMap<>();
pathDatabase = new PathDatabase(createStorePath()); pathDatabase = new PathDatabase(resolveStorePath());
read(); read();
} }
private void read() { private void read() {
Path storePath = createStorePath(); Path storePath = resolveStorePath();
if (!Files.exists(storePath)) { // Files.exists is slow on java 8
return; if (storePath.toFile().exists()) {
pathDatabase.read(this::onLoadDates, this::onLoadRepository);
} }
pathDatabase.read(this::loadDates, this::loadRepository);
} }
private void loadDates(Long creationTime, Long lastModified) { private void onLoadDates(Long creationTime, Long lastModified) {
this.creationTime = creationTime; this.creationTime = creationTime;
this.lastModified = lastModified; this.lastModified = lastModified;
} }
private void loadRepository(String id, Path repositoryPath) { private void onLoadRepository(String id, Path repositoryPath) {
Path metadataPath = createMetadataPath(context.resolve(repositoryPath)); Path metadataPath = resolveMetadataPath(context.resolve(repositoryPath));
Repository repository = metadataStore.read(metadataPath); Repository repository = metadataStore.read(metadataPath);
@@ -121,7 +123,7 @@ public class XmlRepositoryDAO implements PathBasedRepositoryDAO {
} }
@VisibleForTesting @VisibleForTesting
Path createStorePath() { Path resolveStorePath() {
return context.getBaseDirectory() return context.getBaseDirectory()
.toPath() .toPath()
.resolve(StoreConstants.CONFIG_DIRECTORY_NAME) .resolve(StoreConstants.CONFIG_DIRECTORY_NAME)
@@ -130,7 +132,7 @@ public class XmlRepositoryDAO implements PathBasedRepositoryDAO {
@VisibleForTesting @VisibleForTesting
Path createMetadataPath(Path repositoryPath) { Path resolveMetadataPath(Path repositoryPath) {
return repositoryPath.resolve(StoreConstants.REPOSITORY_METADATA.concat(StoreConstants.FILE_EXTENSION)); return repositoryPath.resolve(StoreConstants.REPOSITORY_METADATA.concat(StoreConstants.FILE_EXTENSION));
} }
@@ -159,7 +161,7 @@ public class XmlRepositoryDAO implements PathBasedRepositoryDAO {
try { try {
fileSystem.create(resolvedPath.toFile()); fileSystem.create(resolvedPath.toFile());
Path metadataPath = createMetadataPath(resolvedPath); Path metadataPath = resolveMetadataPath(resolvedPath);
metadataStore.write(metadataPath, repository); metadataStore.write(metadataPath, repository);
synchronized (this) { synchronized (this) {
@@ -227,7 +229,7 @@ public class XmlRepositoryDAO implements PathBasedRepositoryDAO {
} }
Path repositoryPath = context.resolve(getPath(repository.getId())); Path repositoryPath = context.resolve(getPath(repository.getId()));
Path metadataPath = createMetadataPath(repositoryPath); Path metadataPath = resolveMetadataPath(repositoryPath);
metadataStore.write(metadataPath, clone); metadataStore.write(metadataPath, clone);
} }

View File

@@ -13,6 +13,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.Reader; import java.io.Reader;
import java.io.Writer; import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -44,7 +45,7 @@ public final class XmlStreams {
} }
public static XMLStreamReader createReader(Path path) throws IOException, XMLStreamException { public static XMLStreamReader createReader(Path path) throws IOException, XMLStreamException {
return createReader(Files.newBufferedReader(path, Charsets.UTF_8)); return createReader(Files.newBufferedReader(path, StandardCharsets.UTF_8));
} }
public static XMLStreamReader createReader(File file) throws IOException, XMLStreamException { public static XMLStreamReader createReader(File file) throws IOException, XMLStreamException {
@@ -57,7 +58,7 @@ public final class XmlStreams {
public static IndentXMLStreamWriter createWriter(Path path) throws IOException, XMLStreamException { public static IndentXMLStreamWriter createWriter(Path path) throws IOException, XMLStreamException {
return createWriter(Files.newBufferedWriter(path, Charsets.UTF_8)); return createWriter(Files.newBufferedWriter(path, StandardCharsets.UTF_8));
} }
public static IndentXMLStreamWriter createWriter(File file) throws IOException, XMLStreamException { public static IndentXMLStreamWriter createWriter(File file) throws IOException, XMLStreamException {

View File

@@ -67,11 +67,10 @@ class XmlRepositoryDAOTest {
} }
private XmlRepositoryDAO createDAO() { private XmlRepositoryDAO createDAO() {
XmlRepositoryDAO dao = new XmlRepositoryDAO(context, locationResolver, fileSystem);
Clock clock = mock(Clock.class); Clock clock = mock(Clock.class);
when(clock.millis()).then(ic -> atomicClock.incrementAndGet()); when(clock.millis()).then(ic -> atomicClock.incrementAndGet());
dao.clock = clock;
XmlRepositoryDAO dao = new XmlRepositoryDAO(context, locationResolver, fileSystem, clock);
return dao; return dao;
} }
@@ -83,8 +82,8 @@ class XmlRepositoryDAOTest {
@Test @Test
void shouldReturnCreationTimeAfterCreation() { void shouldReturnCreationTimeAfterCreation() {
long now = System.currentTimeMillis(); long now = atomicClock.get();
assertThat(dao.getCreationTime()).isBetween(now - 200, now + 200); assertThat(dao.getCreationTime()).isEqualTo(now);
} }
@Test @Test
@@ -286,7 +285,7 @@ class XmlRepositoryDAOTest {
Repository heartOfGold = createHeartOfGold(); Repository heartOfGold = createHeartOfGold();
dao.add(heartOfGold); dao.add(heartOfGold);
Path storePath = dao.createStorePath(); Path storePath = dao.resolveStorePath();
assertThat(storePath).isRegularFile(); assertThat(storePath).isRegularFile();
String content = content(storePath); String content = content(storePath);
@@ -305,7 +304,7 @@ class XmlRepositoryDAOTest {
dao.add(heartOfGold); dao.add(heartOfGold);
Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId()); Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId());
Path metadataPath = dao.createMetadataPath(repositoryDirectory); Path metadataPath = dao.resolveMetadataPath(repositoryDirectory);
assertThat(metadataPath).isRegularFile(); assertThat(metadataPath).isRegularFile();
@@ -324,7 +323,7 @@ class XmlRepositoryDAOTest {
dao.modify(heartOfGold); dao.modify(heartOfGold);
Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId()); Path repositoryDirectory = getAbsolutePathFromDao(heartOfGold.getId());
Path metadataPath = dao.createMetadataPath(repositoryDirectory); Path metadataPath = dao.resolveMetadataPath(repositoryDirectory);
String content = content(metadataPath); String content = content(metadataPath);
assertThat(content).contains("Awesome Spaceship"); assertThat(content).contains("Awesome Spaceship");

View File

@@ -367,27 +367,6 @@ public class HgRepositoryHandler
//~--- methods -------------------------------------------------------------- //~--- methods --------------------------------------------------------------
/**
* Method description
*
*
* @param file
*/
private void createNewFile(File file)
{
try
{
if (!file.createNewFile() && logger.isErrorEnabled())
{
logger.error("could not create file {}", file);
}
}
catch (IOException ex)
{
logger.error("could not create file {}".concat(file.getPath()), ex);
}
}
/** /**
* Method description * Method description
* *

View File

@@ -33,6 +33,9 @@ package sonia.scm.repository;
public final class RepositoryTestData { public final class RepositoryTestData {
public static final String NAMESPACE = "hitchhiker";
public static final String MAIL_DOMAIN = "@hitchhiker.com";
private RepositoryTestData() { private RepositoryTestData() {
} }
@@ -43,9 +46,9 @@ public final class RepositoryTestData {
public static Repository create42Puzzle(String type) { public static Repository create42Puzzle(String type) {
return new RepositoryBuilder() return new RepositoryBuilder()
.type(type) .type(type)
.contact("douglas.adams@hitchhiker.com") .contact("douglas.adams" + MAIL_DOMAIN)
.name("42Puzzle") .name("42Puzzle")
.namespace("hitchhiker") .namespace(NAMESPACE)
.description("The 42 Puzzle") .description("The 42 Puzzle")
.build(); .build();
} }
@@ -58,9 +61,9 @@ public final class RepositoryTestData {
public static Repository createHappyVerticalPeopleTransporter(String type) { public static Repository createHappyVerticalPeopleTransporter(String type) {
return new RepositoryBuilder() return new RepositoryBuilder()
.type(type) .type(type)
.contact("zaphod.beeblebrox@hitchhiker.com") .contact("zaphod.beeblebrox" + MAIL_DOMAIN)
.name("happyVerticalPeopleTransporter") .name("happyVerticalPeopleTransporter")
.namespace("hitchhiker") .namespace(NAMESPACE)
.description("Happy Vertical People Transporter") .description("Happy Vertical People Transporter")
.build(); .build();
} }
@@ -72,9 +75,9 @@ public final class RepositoryTestData {
public static Repository createHeartOfGold(String type) { public static Repository createHeartOfGold(String type) {
return new RepositoryBuilder() return new RepositoryBuilder()
.type(type) .type(type)
.contact("zaphod.beeblebrox@hitchhiker.com") .contact("zaphod.beeblebrox" + MAIL_DOMAIN)
.name("HeartOfGold") .name("HeartOfGold")
.namespace("hitchhiker") .namespace(NAMESPACE)
.description( .description(
"Heart of Gold is the first prototype ship to successfully utilise the revolutionary Infinite Improbability Drive") "Heart of Gold is the first prototype ship to successfully utilise the revolutionary Infinite Improbability Drive")
.build(); .build();
@@ -88,9 +91,9 @@ public final class RepositoryTestData {
public static Repository createRestaurantAtTheEndOfTheUniverse(String type) { public static Repository createRestaurantAtTheEndOfTheUniverse(String type) {
return new RepositoryBuilder() return new RepositoryBuilder()
.type(type) .type(type)
.contact("douglas.adams@hitchhiker.com") .contact("douglas.adams" + MAIL_DOMAIN)
.name("RestaurantAtTheEndOfTheUniverse") .name("RestaurantAtTheEndOfTheUniverse")
.namespace("hitchhiker") .namespace(NAMESPACE)
.description("The Restaurant at the End of the Universe") .description("The Restaurant at the End of the Universe")
.build(); .build();
} }

View File

@@ -0,0 +1,138 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { PagedCollection } from "@scm-manager/ui-types";
import { Button } from "./index";
type Props = {
collection: PagedCollection,
page: number,
updatePage: number => void,
// context props
t: string => string
};
class StatePaginator extends React.Component<Props> {
renderFirstButton() {
return (
<Button
className={"pagination-link"}
label={"1"}
disabled={false}
action={() => this.updateCurrentPage(1)}
/>
);
}
updateCurrentPage = (newPage: number) => {
this.props.updatePage(newPage);
};
renderPreviousButton(label?: string) {
const { page } = this.props;
const previousPage = page - 1;
return (
<Button
className={"pagination-previous"}
label={label ? label : previousPage.toString()}
disabled={!this.hasLink("prev")}
action={() => this.updateCurrentPage(previousPage)}
/>
);
}
hasLink(name: string) {
const { collection } = this.props;
return collection._links[name];
}
renderNextButton(label?: string) {
const { page } = this.props;
const nextPage = page + 1;
return (
<Button
className={"pagination-next"}
label={label ? label : nextPage.toString()}
disabled={!this.hasLink("next")}
action={() => this.updateCurrentPage(nextPage)}
/>
);
}
renderLastButton() {
const { collection } = this.props;
return (
<Button
className={"pagination-link"}
label={`${collection.pageTotal}`}
disabled={false}
action={() => this.updateCurrentPage(collection.pageTotal)}
/>
);
}
separator() {
return <span className="pagination-ellipsis">&hellip;</span>;
}
currentPage(page: number) {
return (
<Button
className="pagination-link is-current"
label={page}
disabled={true}
action={() => this.updateCurrentPage(page)}
/>
);
}
pageLinks() {
const { collection } = this.props;
const links = [];
const page = collection.page + 1;
const pageTotal = collection.pageTotal;
if (page > 1) {
links.push(this.renderFirstButton());
}
if (page > 3) {
links.push(this.separator());
}
if (page > 2) {
links.push(this.renderPreviousButton());
}
links.push(this.currentPage(page));
if (page + 1 < pageTotal) {
links.push(this.renderNextButton());
}
if (page + 2 < pageTotal)
//if there exists pages between next and last
links.push(this.separator());
if (page < pageTotal) {
links.push(this.renderLastButton());
}
return links;
}
render() {
const { t } = this.props;
return (
<nav className="pagination is-centered" aria-label="pagination">
{this.renderPreviousButton(t("paginator.previous"))}
<ul className="pagination-list">
{this.pageLinks().map((link, index) => {
return <li key={index}>{link}</li>;
})}
</ul>
{this.renderNextButton(t("paginator.next"))}
</nav>
);
}
}
export default translate("commons")(StatePaginator);

View File

@@ -16,6 +16,8 @@ export { default as MailLink } from "./MailLink.js";
export { default as Notification } from "./Notification.js"; export { default as Notification } from "./Notification.js";
export { default as Paginator } from "./Paginator.js"; export { default as Paginator } from "./Paginator.js";
export { default as LinkPaginator } from "./LinkPaginator.js"; export { default as LinkPaginator } from "./LinkPaginator.js";
export { default as StatePaginator } from "./StatePaginator.js";
export { default as ProtectedRoute } from "./ProtectedRoute.js"; export { default as ProtectedRoute } from "./ProtectedRoute.js";
export { default as Help } from "./Help"; export { default as Help } from "./Help";
export { default as HelpIcon } from "./HelpIcon"; export { default as HelpIcon } from "./HelpIcon";

View File

@@ -55,7 +55,14 @@
"branch": "Branch" "branch": "Branch"
}, },
"content": { "content": {
"downloadButton": "Download" "historyButton": "History",
"sourcesButton": "Sources",
"downloadButton": "Download",
"path": "Path",
"branch": "Branch",
"lastModified": "Last modified",
"description": "Description",
"size": "Size"
} }
}, },
"changesets": { "changesets": {

View File

@@ -19,7 +19,6 @@ import SingleGroup from "../groups/containers/SingleGroup";
import AddGroup from "../groups/containers/AddGroup"; import AddGroup from "../groups/containers/AddGroup";
import Config from "../config/containers/Config"; import Config from "../config/containers/Config";
import ChangeUserPassword from "./ChangeUserPassword";
import Profile from "./Profile"; import Profile from "./Profile";
type Props = { type Props = {

View File

@@ -35,7 +35,7 @@ import PermissionsNavLink from "../components/PermissionsNavLink";
import Sources from "../sources/containers/Sources"; import Sources from "../sources/containers/Sources";
import RepositoryNavLink from "../components/RepositoryNavLink"; import RepositoryNavLink from "../components/RepositoryNavLink";
import { getRepositoriesLink } from "../../modules/indexResource"; import { getRepositoriesLink } from "../../modules/indexResource";
import {ExtensionPoint} from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = { type Props = {
namespace: string, namespace: string,
@@ -172,9 +172,10 @@ class RepositoryRoot extends React.Component<Props> {
/> />
)} )}
/> />
<ExtensionPoint name="repository.route" <ExtensionPoint
props={extensionProps} name="repository.route"
renderAll={true} props={extensionProps}
renderAll={true}
/> />
</Switch> </Switch>
</div> </div>
@@ -197,9 +198,10 @@ class RepositoryRoot extends React.Component<Props> {
label={t("repository-root.sources")} label={t("repository-root.sources")}
activeOnlyWhenExact={false} activeOnlyWhenExact={false}
/> />
<ExtensionPoint name="repository.navigation" <ExtensionPoint
props={extensionProps} name="repository.navigation"
renderAll={true} props={extensionProps}
renderAll={true}
/> />
<PermissionsNavLink <PermissionsNavLink
permissionUrl={`${url}/permissions`} permissionUrl={`${url}/permissions`}

View File

@@ -0,0 +1,72 @@
// @flow
import React from "react";
import { translate } from "react-i18next";
import { Button } from "@scm-manager/ui-components";
type Props = {
t: string => string,
historyIsSelected: boolean,
showHistory: boolean => void
};
class ButtonGroup extends React.Component<Props> {
showHistory = () => {
this.props.showHistory(true);
};
showSources = () => {
this.props.showHistory(false);
};
render() {
const { t, historyIsSelected } = this.props;
let sourcesColor = "";
let historyColor = "";
if (historyIsSelected) {
historyColor = "info is-selected";
} else {
sourcesColor = "info is-selected";
}
const sourcesLabel = (
<>
<span className="icon">
<i className="fas fa-code" />
</span>
<span className="is-hidden-mobile">
{t("sources.content.sourcesButton")}
</span>
</>
);
const historyLabel = (
<>
<span className="icon">
<i className="fas fa-history" />
</span>
<span className="is-hidden-mobile">
{t("sources.content.historyButton")}
</span>
</>
);
return (
<div className="buttons has-addons">
<Button
label={sourcesLabel}
color={sourcesColor}
action={this.showSources}
/>
<Button
label={historyLabel}
color={historyColor}
action={this.showHistory}
/>
</div>
);
}
}
export default translate("repos")(ButtonGroup);

View File

@@ -1,22 +1,16 @@
// @flow // @flow
import React from "react"; import React from "react";
import { translate } from "react-i18next"; import { translate } from "react-i18next";
import { getSources } from "../modules/sources"; import type { File, Repository } from "@scm-manager/ui-types";
import type { Repository, File } from "@scm-manager/ui-types"; import { DateFromNow } from "@scm-manager/ui-components";
import {
ErrorNotification,
Loading,
DateFromNow
} from "@scm-manager/ui-components";
import { connect } from "react-redux";
import ImageViewer from "../components/content/ImageViewer";
import SourcecodeViewer from "../components/content/SourcecodeViewer";
import DownloadViewer from "../components/content/DownloadViewer";
import FileSize from "../components/FileSize"; import FileSize from "../components/FileSize";
import injectSheet from "react-jss"; import injectSheet from "react-jss";
import classNames from "classnames"; import classNames from "classnames";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import ButtonGroup from "../components/content/ButtonGroup";
import { getContentType } from "./contentType"; import SourcesView from "./SourcesView";
import HistoryView from "./HistoryView";
import { getSources } from "../modules/sources";
import { connect } from "react-redux";
type Props = { type Props = {
loading: boolean, loading: boolean,
@@ -30,11 +24,8 @@ type Props = {
}; };
type State = { type State = {
contentType: string,
language: string,
loaded: boolean,
collapsed: boolean, collapsed: boolean,
error?: Error showHistory: boolean
}; };
const styles = { const styles = {
@@ -43,6 +34,13 @@ const styles = {
}, },
pointer: { pointer: {
cursor: "pointer" cursor: "pointer"
},
marginInHeader: {
marginRight: "0.5em"
},
isVerticalCenter: {
display: "flex",
alignItems: "center"
} }
}; };
@@ -51,57 +49,53 @@ class Content extends React.Component<Props, State> {
super(props); super(props);
this.state = { this.state = {
contentType: "", collapsed: true,
language: "", showHistory: false
loaded: false,
collapsed: true
}; };
} }
componentDidMount() {
const { file } = this.props;
getContentType(file._links.self.href)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
contentType: result.type,
language: result.language,
loaded: true
});
}
})
.catch(err => {});
}
toggleCollapse = () => { toggleCollapse = () => {
this.setState(prevState => ({ this.setState(prevState => ({
collapsed: !prevState.collapsed collapsed: !prevState.collapsed
})); }));
}; };
setShowHistoryState(showHistory: boolean) {
this.setState({
...this.state,
showHistory
});
}
showHeader() { showHeader() {
const { file, classes } = this.props; const { file, classes } = this.props;
const collapsed = this.state.collapsed; const { showHistory, collapsed } = this.state;
const icon = collapsed ? "fa-angle-right" : "fa-angle-down"; const icon = collapsed ? "fa-angle-right" : "fa-angle-down";
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
const selector = file._links.history ? (
<ButtonGroup
file={file}
historyIsSelected={showHistory}
showHistory={(changeShowHistory: boolean) =>
this.setShowHistoryState(changeShowHistory)
}
/>
) : null;
return ( return (
<span className={classes.pointer} onClick={this.toggleCollapse}> <span className={classes.pointer}>
<article className="media"> <article className={classNames("media", classes.isVerticalCenter)}>
<div className="media-left"> <div className="media-content" onClick={this.toggleCollapse}>
<i className={classNames("fa", icon)} /> <i
className={classNames(
"fa is-medium",
icon,
classes.marginInHeader
)}
/>
<span>{file.name}</span>
</div> </div>
<div className="media-content"> <div className="media-right">{selector}</div>
<div className="content">{file.name}</div>
</div>
<p className="media-right">{fileSize}</p>
</article> </article>
</span> </span>
); );
@@ -109,7 +103,7 @@ class Content extends React.Component<Props, State> {
showMoreInformation() { showMoreInformation() {
const collapsed = this.state.collapsed; const collapsed = this.state.collapsed;
const { classes, file, revision } = this.props; const { classes, file, revision, t } = this.props;
const date = <DateFromNow date={file.lastModified} />; const date = <DateFromNow date={file.lastModified} />;
const description = file.description ? ( const description = file.description ? (
<p> <p>
@@ -123,25 +117,30 @@ class Content extends React.Component<Props, State> {
})} })}
</p> </p>
) : null; ) : null;
const fileSize = file.directory ? "" : <FileSize bytes={file.length} />;
if (!collapsed) { if (!collapsed) {
return ( return (
<div className={classNames("panel-block", classes.toCenterContent)}> <div className={classNames("panel-block", classes.toCenterContent)}>
<table className="table"> <table className="table">
<tbody> <tbody>
<tr> <tr>
<td>Path</td> <td>{t("sources.content.path")}</td>
<td>{file.path}</td> <td>{file.path}</td>
</tr> </tr>
<tr> <tr>
<td>Branch</td> <td>{t("sources.content.branch")}</td>
<td>{revision}</td> <td>{revision}</td>
</tr> </tr>
<tr> <tr>
<td>Last modified</td> <td>{t("sources.content.size")}</td>
<td>{fileSize}</td>
</tr>
<tr>
<td>{t("sources.content.lastModified")}</td>
<td>{date}</td> <td>{date}</td>
</tr> </tr>
<tr> <tr>
<td>Description</td> <td>{t("sources.content.description")}</td>
<td>{description}</td> <td>{description}</td>
</tr> </tr>
</tbody> </tbody>
@@ -152,40 +151,22 @@ class Content extends React.Component<Props, State> {
return null; return null;
} }
showContent() {
const { file, revision } = this.props;
const { contentType, language } = this.state;
if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />;
} else if (language) {
return <SourcecodeViewer file={file} language={language} />;
} else if (contentType.startsWith("text/")) {
return <SourcecodeViewer file={file} language="none" />;
} else {
return (
<ExtensionPoint
name="repos.sources.view"
props={{ file, contentType, revision }}
>
<DownloadViewer file={file} />
</ExtensionPoint>
);
}
}
render() { render() {
const { file, classes } = this.props; const { file, revision, repository, path, classes } = this.props;
const { loaded, error } = this.state; const { showHistory } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const header = this.showHeader(); const header = this.showHeader();
const content = this.showContent(); const content =
showHistory && file._links.history ? (
<HistoryView file={file} repository={repository} />
) : (
<SourcesView
revision={revision}
file={file}
repository={repository}
path={path}
/>
);
const moreInformation = this.showMoreInformation(); const moreInformation = this.showMoreInformation();
return ( return (

View File

@@ -0,0 +1,109 @@
// @flow
import React from "react";
import type {
File,
Changeset,
Repository,
PagedCollection
} from "@scm-manager/ui-types";
import {
ErrorNotification,
Loading,
StatePaginator
} from "@scm-manager/ui-components";
import { getHistory } from "./history";
import ChangesetList from "../../components/changesets/ChangesetList";
type Props = {
file: File,
repository: Repository
};
type State = {
loaded: boolean,
changesets: Changeset[],
page: number,
pageCollection?: PagedCollection,
error?: Error
};
class HistoryView extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loaded: false,
page: 1,
changesets: []
};
}
componentDidMount() {
const { file } = this.props;
this.updateHistory(file._links.history.href);
}
updateHistory(link: string) {
getHistory(link)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
loaded: true,
changesets: result.changesets,
pageCollection: result.pageCollection,
page: result.pageCollection.page
});
}
})
.catch(err => {});
}
updatePage(page: number) {
const { file } = this.props;
const internalPage = page - 1;
this.updateHistory(
file._links.history.href + "?page=" + internalPage.toString()
);
}
showHistory() {
const { repository } = this.props;
const { changesets, page, pageCollection } = this.state;
const currentPage = page + 1;
return (
<>
<ChangesetList repository={repository} changesets={changesets} />
<StatePaginator
page={currentPage}
collection={pageCollection}
updatePage={(newPage: number) => this.updatePage(newPage)}
/>
</>
);
}
render() {
const { file } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const history = this.showHistory();
return <>{history}</>;
}
}
export default HistoryView;

View File

@@ -0,0 +1,97 @@
// @flow
import React from "react";
import SourcecodeViewer from "../components/content/SourcecodeViewer";
import ImageViewer from "../components/content/ImageViewer";
import DownloadViewer from "../components/content/DownloadViewer";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { getContentType } from "./contentType";
import type { File, Repository } from "@scm-manager/ui-types";
import { ErrorNotification, Loading } from "@scm-manager/ui-components";
type Props = {
repository: Repository,
file: File,
revision: string,
path: string
};
type State = {
contentType: string,
language: string,
loaded: boolean,
error?: Error
};
class SourcesView extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
contentType: "",
language: "",
loaded: false
};
}
componentDidMount() {
const { file } = this.props;
getContentType(file._links.self.href)
.then(result => {
if (result.error) {
this.setState({
...this.state,
error: result.error,
loaded: true
});
} else {
this.setState({
...this.state,
contentType: result.type,
language: result.language,
loaded: true
});
}
})
.catch(err => {});
}
showSources() {
const { file, revision } = this.props;
const { contentType, language } = this.state;
if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />;
} else if (language) {
return <SourcecodeViewer file={file} language={language} />;
} else if (contentType.startsWith("text/")) {
return <SourcecodeViewer file={file} language="none" />;
} else {
return (
<ExtensionPoint
name="repos.sources.view"
props={{ file, contentType, revision }}
>
<DownloadViewer file={file} />
</ExtensionPoint>
);
}
}
render() {
const { file } = this.props;
const { loaded, error } = this.state;
if (!file || !loaded) {
return <Loading />;
}
if (error) {
return <ErrorNotification error={error} />;
}
const sources = this.showSources();
return <>{sources}</>;
}
}
export default SourcesView;

View File

@@ -0,0 +1,22 @@
//@flow
import { apiClient } from "@scm-manager/ui-components";
export function getHistory(url: string) {
return apiClient
.get(url)
.then(response => response.json())
.then(result => {
return {
changesets: result._embedded.changesets,
pageCollection: {
_embedded: result._embedded,
_links: result._links,
page: result.page,
pageTotal: result.pageTotal
}
};
})
.catch(err => {
return { error: err };
});
}

View File

@@ -0,0 +1,53 @@
//@flow
import fetchMock from "fetch-mock";
import { getHistory } from "./history";
describe("get content type", () => {
const FILE_URL = "/repositories/scmadmin/TestRepo/history/file";
afterEach(() => {
fetchMock.reset();
fetchMock.restore();
});
const history = {
page: 0,
pageTotal: 10,
_links: {
self: {
href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10"
},
first: {
href: "/repositories/scmadmin/TestRepo/history/file?page=0&pageSize=10"
},
next: {
href: "/repositories/scmadmin/TestRepo/history/file?page=1&pageSize=10"
},
last: {
href: "/repositories/scmadmin/TestRepo/history/file?page=9&pageSize=10"
}
},
_embedded: {
changesets: [
{
id: "1234"
},
{
id: "2345"
}
]
}
};
it("should return history", done => {
fetchMock.get("/api/v2" + FILE_URL, history);
getHistory(FILE_URL).then(content => {
expect(content.changesets).toEqual(history._embedded.changesets);
expect(content.pageCollection.page).toEqual(history.page);
expect(content.pageCollection.pageTotal).toEqual(history.pageTotal);
expect(content.pageCollection._links).toEqual(history._links);
done();
});
});
});

View File

@@ -1,7 +1,7 @@
package sonia.scm.api.v2; package sonia.scm.api.v2;
import org.jboss.resteasy.api.validation.ResteasyViolationException; import org.jboss.resteasy.api.validation.ResteasyViolationException;
import sonia.scm.api.v2.resources.ViolationExceptionToErrorDtoMapper; import sonia.scm.api.v2.resources.ResteasyViolationExceptionToErrorDtoMapper;
import javax.inject.Inject; import javax.inject.Inject;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
@@ -10,12 +10,12 @@ import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider; import javax.ws.rs.ext.Provider;
@Provider @Provider
public class ValidationExceptionMapper implements ExceptionMapper<ResteasyViolationException> { public class ResteasyValidationExceptionMapper implements ExceptionMapper<ResteasyViolationException> {
private final ViolationExceptionToErrorDtoMapper mapper; private final ResteasyViolationExceptionToErrorDtoMapper mapper;
@Inject @Inject
public ValidationExceptionMapper(ViolationExceptionToErrorDtoMapper mapper) { public ResteasyValidationExceptionMapper(ResteasyViolationExceptionToErrorDtoMapper mapper) {
this.mapper = mapper; this.mapper = mapper;
} }

View File

@@ -0,0 +1,30 @@
package sonia.scm.api.v2;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.api.v2.resources.ScmViolationExceptionToErrorDtoMapper;
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;
@Provider
public class ScmConstraintValidationExceptionMapper implements ExceptionMapper<ScmConstraintViolationException> {
private final ScmViolationExceptionToErrorDtoMapper mapper;
@Inject
public ScmConstraintValidationExceptionMapper(ScmViolationExceptionToErrorDtoMapper mapper) {
this.mapper = mapper;
}
@Override
public Response toResponse(ScmConstraintViolationException exception) {
return Response
.status(Response.Status.BAD_REQUEST)
.type(MediaType.APPLICATION_JSON_TYPE)
.entity(mapper.map(exception))
.build();
}
}

View File

@@ -39,7 +39,8 @@ public class MapperModule extends AbstractModule {
bind(ReducedObjectModelToDtoMapper.class).to(Mappers.getMapper(ReducedObjectModelToDtoMapper.class).getClass()); bind(ReducedObjectModelToDtoMapper.class).to(Mappers.getMapper(ReducedObjectModelToDtoMapper.class).getClass());
bind(ViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ViolationExceptionToErrorDtoMapper.class).getClass()); bind(ResteasyViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ResteasyViolationExceptionToErrorDtoMapper.class).getClass());
bind(ScmViolationExceptionToErrorDtoMapper.class).to(Mappers.getMapper(ScmViolationExceptionToErrorDtoMapper.class).getClass());
bind(ExceptionWithContextToErrorDtoMapper.class).to(Mappers.getMapper(ExceptionWithContextToErrorDtoMapper.class).getClass()); bind(ExceptionWithContextToErrorDtoMapper.class).to(Mappers.getMapper(ExceptionWithContextToErrorDtoMapper.class).getClass());
// no mapstruct required // no mapstruct required

View File

@@ -12,7 +12,7 @@ import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Mapper @Mapper
public abstract class ViolationExceptionToErrorDtoMapper { public abstract class ResteasyViolationExceptionToErrorDtoMapper {
@Mapping(target = "errorCode", ignore = true) @Mapping(target = "errorCode", ignore = true)
@Mapping(target = "transactionId", ignore = true) @Mapping(target = "transactionId", ignore = true)

View File

@@ -0,0 +1,53 @@
package sonia.scm.api.v2.resources;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.slf4j.MDC;
import sonia.scm.ScmConstraintViolationException;
import sonia.scm.ScmConstraintViolationException.ScmConstraintViolation;
import java.util.List;
import java.util.stream.Collectors;
@Mapper
public abstract class ScmViolationExceptionToErrorDtoMapper {
@Mapping(target = "errorCode", ignore = true)
@Mapping(target = "transactionId", ignore = true)
@Mapping(target = "context", ignore = true)
public abstract ErrorDto map(ScmConstraintViolationException exception);
@AfterMapping
void setTransactionId(@MappingTarget ErrorDto dto) {
dto.setTransactionId(MDC.get("transaction_id"));
}
@AfterMapping
void mapViolations(ScmConstraintViolationException exception, @MappingTarget ErrorDto dto) {
List<ErrorDto.ConstraintViolationDto> violations =
exception.getViolations()
.stream()
.map(this::createViolationDto)
.collect(Collectors.toList());
dto.setViolations(violations);
}
private ErrorDto.ConstraintViolationDto createViolationDto(ScmConstraintViolation violation) {
ErrorDto.ConstraintViolationDto constraintViolationDto = new ErrorDto.ConstraintViolationDto();
constraintViolationDto.setMessage(violation.getMessage());
constraintViolationDto.setPath(violation.getPropertyPath());
return constraintViolationDto;
}
@AfterMapping
void setErrorCode(@MappingTarget ErrorDto dto) {
dto.setErrorCode("3zR9vPNIE1");
}
@AfterMapping
void setMessage(@MappingTarget ErrorDto dto) {
dto.setMessage("input violates conditions (see violation list)");
}
}

View File

@@ -43,7 +43,6 @@ import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.ExpectedException; import org.junit.rules.ExpectedException;
import org.junit.rules.TemporaryFolder;
import org.mockito.Mockito; import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock; import org.mockito.invocation.InvocationOnMock;
import sonia.scm.AlreadyExistsException; import sonia.scm.AlreadyExistsException;
@@ -70,20 +69,9 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.Stack; import java.util.Stack;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.*;
import static org.hamcrest.Matchers.hasProperty; import static org.junit.Assert.*;
import static org.hamcrest.Matchers.is; import static org.mockito.Mockito.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
//~--- JDK imports ------------------------------------------------------------ //~--- JDK imports ------------------------------------------------------------
@@ -109,9 +97,6 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
@Rule @Rule
public ExpectedException thrown = ExpectedException.none(); public ExpectedException thrown = ExpectedException.none();
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private ScmConfiguration configuration; private ScmConfiguration configuration;
private String mockedNamespace = "default_namespace"; private String mockedNamespace = "default_namespace";